Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
e332f8b
Merge pull request #5 from KatKatKateryna/kate/yaml_validation
KatKatKateryna Sep 4, 2025
7103925
Kate/unit tests (#6)
KatKatKateryna Sep 5, 2025
e8daa77
Merge branch 'byteroad:master' into master
KatKatKateryna Sep 5, 2025
27bfa7d
Merge branch 'dev' into master
KatKatKateryna Sep 5, 2025
037de86
Merge pull request #7 from KatKatKateryna/master
KatKatKateryna Sep 5, 2025
c0217d4
redundant installation; split req_dev; more yml samples to test
KatKatKateryna Sep 5, 2025
be6e912
Merge branch 'dev' of https://github.com/KatKatKateryna/pygeoapi_conf…
KatKatKateryna Sep 5, 2025
9d9b071
validation login added to tests
KatKatKateryna Sep 5, 2025
1560fe2
data_widget fix in provider window
KatKatKateryna Sep 5, 2025
e91a2b3
test print
KatKatKateryna Sep 5, 2025
5c69fdf
split into 2 tests
KatKatKateryna Sep 5, 2025
054081f
shorten tests
KatKatKateryna Sep 5, 2025
2b2b84c
fail for wrongly passed validation
KatKatKateryna Sep 5, 2025
92ca91e
fix resource fields
KatKatKateryna Sep 19, 2025
0ef3550
updated field validation
KatKatKateryna Sep 27, 2025
2082e87
typing
KatKatKateryna Sep 27, 2025
b193cdb
automatic type matching for UI elements
KatKatKateryna Sep 27, 2025
cf10691
fixed types issues
KatKatKateryna Sep 27, 2025
3ff09fb
adjust postgres lists
KatKatKateryna Sep 27, 2025
a460950
Merge pull request #8 from KatKatKateryna/kate/resource_field_values
KatKatKateryna Sep 27, 2025
419b887
run unit tests locally
KatKatKateryna Sep 27, 2025
1addbd3
hash yaml_samples
KatKatKateryna Sep 27, 2025
5b6fa43
fix deserialization exceptions
KatKatKateryna Sep 28, 2025
917b6b2
typo
KatKatKateryna Sep 28, 2025
625b89e
test to validate file similarity
KatKatKateryna Sep 29, 2025
305067c
export diff to file
KatKatKateryna Sep 30, 2025
e057e31
don't modify and don't preview unsupported Resource types
KatKatKateryna Oct 1, 2025
44cfd45
fixed props Server: limits.on_exceed, manager, language, languages
KatKatKateryna Oct 7, 2025
2dfaa50
deserialize links with unknown languages; cast each link in the list
KatKatKateryna Oct 7, 2025
2e5e93c
typos and Readme
KatKatKateryna Oct 7, 2025
d82361c
add ogc_schemas_location and provider.linked-data
KatKatKateryna Oct 8, 2025
dfd7e1d
ignore optional server fields
KatKatKateryna Oct 8, 2025
4262ecf
standatdize server optional fields
KatKatKateryna Oct 8, 2025
171dbab
star mandatory fields
KatKatKateryna Oct 8, 2025
b8eb7aa
links language bug
KatKatKateryna Oct 8, 2025
a742886
add optional disabled fields
KatKatKateryna Oct 8, 2025
8feb4f0
remove validation from host, port, admin
KatKatKateryna Oct 8, 2025
95f62d1
revert yaml
KatKatKateryna Oct 8, 2025
71647f8
revert admin back to checkbox
KatKatKateryna Oct 8, 2025
58e9b82
ui_reshuffle
KatKatKateryna Oct 8, 2025
a69c4a3
set width
KatKatKateryna Oct 8, 2025
7d8f6cc
visual adjustments
KatKatKateryna Oct 8, 2025
b7706ad
metadata and resource mandatory fields check and other details
KatKatKateryna Oct 8, 2025
8ebe39f
typo
KatKatKateryna Oct 8, 2025
f9fade6
preserve resource order if renamed
KatKatKateryna Oct 8, 2025
125549a
passing tests
KatKatKateryna Oct 8, 2025
7db97be
warning before saving file
KatKatKateryna Oct 9, 2025
7dd9639
small details
KatKatKateryna Oct 9, 2025
df2fbe8
move all diff conditions to one function
KatKatKateryna Oct 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release-upload.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@ jobs:
with:
files: build/*.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28 changes: 28 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Run unit tests on PR branch

on:
pull_request:

jobs:
test-on-pr:
runs-on: windows-latest

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements_dev.txt

- name: Run unit tests (headless PyQt)
env:
QT_QPA_PLATFORM: offscreen
run: |
python -m pytest -s --import-mode=importlib -q
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
*.yml
*.pyc
*.pyc
.venv_workflow*
tests/yaml_samples/saved_*
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ copy this folder to your QGIS plugin directory. Something like:

Modify the user interface by opening pygeoapiconfig_dialog_base.ui in [Qt Creator](https://doc.qt.io/qtcreator/).

## Run unit tests locally
Run the following command from the root folder:
`python tests\run_tests_locally.py`

The YAML files to test against are stored under tests/yaml_samples and names as follows: 'organisation_repository_commit_filename'.

## Screenshot

![screenshot](/screenshot.png)
Expand Down
77 changes: 48 additions & 29 deletions models/ConfigData.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
MetadataConfig,
ResourceConfigTemplate,
)
from .top_level.utils import InlineList, bbox_from_list
from .top_level.utils import InlineList
from .top_level.providers import ProviderTemplate
from .top_level.providers.records import ProviderTypes
from .top_level.ResourceConfigTemplate import ResourceTypesEnum


@dataclass(kw_only=True)
Expand All @@ -23,7 +24,9 @@ class ConfigData:
server: ServerConfig = field(default_factory=lambda: ServerConfig())
logging: LoggingConfig = field(default_factory=lambda: LoggingConfig())
metadata: MetadataConfig = field(default_factory=lambda: MetadataConfig())
resources: dict[str, ResourceConfigTemplate] = field(default_factory=lambda: {})
resources: dict[str, ResourceConfigTemplate | dict] = field(
default_factory=lambda: {}
)

def set_data_from_yaml(self, dict_content: dict):
"""Parse YAML file content and overwride .config_data properties where available."""
Expand Down Expand Up @@ -69,30 +72,38 @@ def set_data_from_yaml(self, dict_content: dict):
resource_instance_name = next(iter(res_config))
resource_data = res_config[resource_instance_name]

# Create a new ResourceConfigTemplate instance and update with available values
new_resource_item = ResourceConfigTemplate(
instance_name=resource_instance_name
)
defaults_resource, wrong_types_resource, all_missing_props_resource = (
update_dataclass_from_dict(
# only cast to ResourceConfigTemplate, if it's supported Resource type (e.g. 'collection, stac-collection')
if resource_data.get("type") in [e.value for e in ResourceTypesEnum]:

# Create a new ResourceConfigTemplate instance and update with available values
new_resource_item = ResourceConfigTemplate.init_with_name(
instance_name=resource_instance_name
)
(
defaults_resource,
wrong_types_resource,
all_missing_props_resource,
) = update_dataclass_from_dict(
new_resource_item,
resource_data,
f"resources.{resource_instance_name}",
)
)
default_fields.extend(defaults_resource)
wrong_types.extend(wrong_types_resource)
all_missing_props.extend(all_missing_props_resource)

# Exceptional check: verify that all list items of BBox are integers, and len(list)=4 or 6
if not new_resource_item.validate_reassign_bbox():
wrong_types.append(
f"resources.{resource_instance_name}.extents.spatial.bbox"
)

# reorder providers to move read-only to the end of the list
# this is needed to not accidentally match read-only providers when deleting a provider
new_resource_item.providers.sort(key=lambda x: isinstance(x, dict))
default_fields.extend(defaults_resource)
wrong_types.extend(wrong_types_resource)
all_missing_props.extend(all_missing_props_resource)

# Exceptional check: verify that all list items of BBox are integers, and len(list)=4 or 6
if not new_resource_item.validate_reassign_bbox():
wrong_types.append(
f"resources.{resource_instance_name}.extents.spatial.bbox"
)

# reorder providers to move read-only to the end of the list
# this is needed to not accidentally match read-only providers when deleting a provider
new_resource_item.providers.sort(key=lambda x: isinstance(x, dict))
else:
# keep as dict if unsopported resource type (e.g. 'process')
new_resource_item = resource_data

self.resources[resource_instance_name] = new_resource_item

Expand Down Expand Up @@ -135,8 +146,12 @@ def asdict_enum_safe(self, obj):
result = {}
for f in fields(obj):
value = getattr(obj, f.name)

key = f.name
if key == "linked__data":
key = "linked-data"
if value is not None:
result[f.name] = self.asdict_enum_safe(value)
result[key] = self.asdict_enum_safe(value)
return result
elif isinstance(obj, Enum):
return obj.value
Expand All @@ -155,7 +170,9 @@ def asdict_enum_safe(self, obj):
def add_new_resource(self) -> str:
"""Add a placeholder resource."""
new_name = "new_resource"
self.resources[new_name] = ResourceConfigTemplate(instance_name=new_name)
self.resources[new_name] = ResourceConfigTemplate.init_with_name(
instance_name=new_name
)
return new_name

def delete_resource(self, dialog):
Expand All @@ -173,7 +190,7 @@ def set_validate_new_provider_data(

# initialize provider; assign ui_dict data to the provider instance
new_provider = ProviderTemplate.init_provider_from_type(provider_type)
new_provider.assign_ui_dict_to_provider_data(values)
new_provider.assign_ui_dict_to_provider_data_on_save(values)

# if incomplete data, remove Provider from ConfigData and show Warning
invalid_props = new_provider.get_invalid_properties()
Expand All @@ -192,9 +209,11 @@ def validate_config_data(self) -> int:
invalid_props.extend(self.server.get_invalid_properties())
invalid_props.extend(self.metadata.get_invalid_properties())
for key, resource in self.resources.items():
invalid_res_props = [
f"resources.{key}.{prop}" for prop in resource.get_invalid_properties()
]
invalid_props.extend(invalid_res_props)
if not isinstance(resource, dict):
invalid_res_props = [
f"resources.{key}.{prop}"
for prop in resource.get_invalid_properties()
]
invalid_props.extend(invalid_res_props)

return invalid_props
3 changes: 1 addition & 2 deletions models/top_level/LoggingConfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,4 @@ class LoggingConfig:
logfile: str | None = None
logformat: str | None = None
dateformat: str | None = None
# TODO: Not currently used in the UI
# rotation: LoggingRotationConfig | None = None
rotation: dict | None = None
50 changes: 24 additions & 26 deletions models/top_level/MetadataConfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

# records
class MetadataKeywordTypeEnum(Enum):
NONE = ""
DISCIPLINE = "discipline"
TEMPORAL = "temporal"
PLACE = "place"
Expand All @@ -13,6 +14,7 @@ class MetadataKeywordTypeEnum(Enum):


class MetadataRoleEnum(Enum):
NONE = ""
AUTHOR = "author"
COAUTHOR = "coAuthor"
COLLABORATOR = "collaborator"
Expand Down Expand Up @@ -41,45 +43,39 @@ class MetadataIdentificationConfig:
title: str | dict = field(default_factory=lambda: "")
description: str | dict = field(default_factory=lambda: "")
keywords: list | dict = field(default_factory=lambda: [])
keywords_type: MetadataKeywordTypeEnum = field(
default_factory=lambda: MetadataKeywordTypeEnum.THEME
)
terms_of_service: str = field(
default="https://creativecommons.org/licenses/by/4.0/"
)
url: str = field(default="https://example.org")
keywords_type: MetadataKeywordTypeEnum | None = None
terms_of_service: str | None = None


@dataclass(kw_only=True)
class MetadataLicenseConfig:
name: str = field(default="CC-BY 4.0 license")
url: str = field(default="https://creativecommons.org/licenses/by/4.0/")
url: str | None = None


@dataclass(kw_only=True)
class MetadataProviderConfig:
name: str = field(default="Organization Name")
url: str = field(default="https://pygeoapi.io")
url: str | None = None


@dataclass(kw_only=True)
class MetadataContactConfig:
name: str = field(default="Lastname, Firstname")
position: str = field(default="Position Title")
address: str = field(default="Mailing Address")
city: str = field(default="City")
stateorprovince: str = field(default="Administrative Area")
postalcode: str = field(default="Zip or Postal Code")
country: str = field(default="Country")
phone: str = field(default="+xx-xxx-xxx-xxxx")
fax: str = field(default="+xx-xxx-xxx-xxxx")
email: str = field(default="you@example.org")
url: str = field(default="Contact URL")
hours: str = field(default="Mo-Fr 08:00-17:00")
instructions: str = field(default="During hours of service. Off on weekends.")
role: MetadataRoleEnum = field(
default_factory=lambda: MetadataRoleEnum.POINTOFCONTACT
)
position: str | None = None
address: str | None = None
city: str | None = None
stateorprovince: str | None = None
postalcode: str | None = None
country: str | None = None
phone: str | None = None
fax: str | None = None
email: str | None = None
url: str | None = None
hours: str | None = None
instructions: str | None = None
role: MetadataRoleEnum | None = None


@dataclass(kw_only=True)
Expand Down Expand Up @@ -109,15 +105,17 @@ def get_invalid_properties(self):
all_invalid_fields.append("metadata.identification.description")
if len(self.identification.keywords) == 0:
all_invalid_fields.append("metadata.identification.keywords")
if len(self.identification.url) == 0:
all_invalid_fields.append("metadata.identification.url")
if len(self.license.name) == 0:
all_invalid_fields.append("metadata.license.name")
if len(self.provider.name) == 0:
all_invalid_fields.append("metadata.provider.name")
if len(self.contact.name) == 0:
all_invalid_fields.append("metadata.contact.name")

parsed_url = urlparse(self.identification.url)
if not all([parsed_url.scheme, parsed_url.netloc]):
all_invalid_fields.append("metadata.identification.url")
# parsed_url = urlparse(self.identification.url)
# if not all([parsed_url.scheme, parsed_url.netloc]):
# all_invalid_fields.append("metadata.identification.url")

return all_invalid_fields
Loading