Skip to content

Commit a50f08b

Browse files
committed
2 parents 9747eb1 + 17934ed commit a50f08b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+54438
-1024
lines changed

.github/workflows/release-upload.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,4 @@ jobs:
3838
with:
3939
files: build/*.zip
4040
env:
41-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
41+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/unit-tests.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Run unit tests on PR branch
2+
3+
on:
4+
pull_request:
5+
6+
jobs:
7+
test-on-pr:
8+
runs-on: windows-latest
9+
10+
steps:
11+
- uses: actions/checkout@v4
12+
13+
- name: Set up Python
14+
uses: actions/setup-python@v5
15+
with:
16+
python-version: '3.12'
17+
18+
- name: Install dependencies
19+
run: |
20+
python -m pip install --upgrade pip
21+
pip install -r requirements.txt
22+
pip install -r requirements_dev.txt
23+
24+
- name: Run unit tests (headless PyQt)
25+
env:
26+
QT_QPA_PLATFORM: offscreen
27+
run: |
28+
python -m pytest -s --import-mode=importlib -q

.gitignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
*.yml
2-
*.pyc
1+
*.pyc
2+
.venv_workflow*
3+
tests/yaml_samples/saved_*

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ copy this folder to your QGIS plugin directory. Something like:
2020

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

23+
## Run unit tests locally
24+
Run the following command from the root folder:
25+
`python tests\run_tests_locally.py`
26+
27+
The YAML files to test against are stored under tests/yaml_samples and names as follows: 'organisation_repository_commit_filename'.
28+
2329
## Screenshot
2430

2531
![screenshot](/screenshot.png)

models/ConfigData.py

Lines changed: 66 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from dataclasses import dataclass, field, fields, is_dataclass
2+
from datetime import datetime, timezone
23
from enum import Enum
34

45
from .utils import update_dataclass_from_dict
@@ -8,9 +9,10 @@
89
MetadataConfig,
910
ResourceConfigTemplate,
1011
)
11-
from .top_level.utils import InlineList, bbox_from_list
12+
from .top_level.utils import InlineList
1213
from .top_level.providers import ProviderTemplate
1314
from .top_level.providers.records import ProviderTypes
15+
from .top_level.ResourceConfigTemplate import ResourceTypesEnum
1416

1517

1618
@dataclass(kw_only=True)
@@ -23,7 +25,9 @@ class ConfigData:
2325
server: ServerConfig = field(default_factory=lambda: ServerConfig())
2426
logging: LoggingConfig = field(default_factory=lambda: LoggingConfig())
2527
metadata: MetadataConfig = field(default_factory=lambda: MetadataConfig())
26-
resources: dict[str, ResourceConfigTemplate] = field(default_factory=lambda: {})
28+
resources: dict[str, ResourceConfigTemplate | dict] = field(
29+
default_factory=lambda: {}
30+
)
2731

2832
def set_data_from_yaml(self, dict_content: dict):
2933
"""Parse YAML file content and overwride .config_data properties where available."""
@@ -69,30 +73,38 @@ def set_data_from_yaml(self, dict_content: dict):
6973
resource_instance_name = next(iter(res_config))
7074
resource_data = res_config[resource_instance_name]
7175

72-
# Create a new ResourceConfigTemplate instance and update with available values
73-
new_resource_item = ResourceConfigTemplate(
74-
instance_name=resource_instance_name
75-
)
76-
defaults_resource, wrong_types_resource, all_missing_props_resource = (
77-
update_dataclass_from_dict(
76+
# only cast to ResourceConfigTemplate, if it's supported Resource type (e.g. 'collection, stac-collection')
77+
if resource_data.get("type") in [e.value for e in ResourceTypesEnum]:
78+
79+
# Create a new ResourceConfigTemplate instance and update with available values
80+
new_resource_item = ResourceConfigTemplate.init_with_name(
81+
instance_name=resource_instance_name
82+
)
83+
(
84+
defaults_resource,
85+
wrong_types_resource,
86+
all_missing_props_resource,
87+
) = update_dataclass_from_dict(
7888
new_resource_item,
7989
resource_data,
8090
f"resources.{resource_instance_name}",
8191
)
82-
)
83-
default_fields.extend(defaults_resource)
84-
wrong_types.extend(wrong_types_resource)
85-
all_missing_props.extend(all_missing_props_resource)
86-
87-
# Exceptional check: verify that all list items of BBox are integers, and len(list)=4 or 6
88-
if not new_resource_item.validate_reassign_bbox():
89-
wrong_types.append(
90-
f"resources.{resource_instance_name}.extents.spatial.bbox"
91-
)
92-
93-
# reorder providers to move read-only to the end of the list
94-
# this is needed to not accidentally match read-only providers when deleting a provider
95-
new_resource_item.providers.sort(key=lambda x: isinstance(x, dict))
92+
default_fields.extend(defaults_resource)
93+
wrong_types.extend(wrong_types_resource)
94+
all_missing_props.extend(all_missing_props_resource)
95+
96+
# Exceptional check: verify that all list items of BBox are integers, and len(list)=4 or 6
97+
if not new_resource_item.validate_reassign_bbox():
98+
wrong_types.append(
99+
f"resources.{resource_instance_name}.extents.spatial.bbox"
100+
)
101+
102+
# reorder providers to move read-only to the end of the list
103+
# this is needed to not accidentally match read-only providers when deleting a provider
104+
new_resource_item.providers.sort(key=lambda x: isinstance(x, dict))
105+
else:
106+
# keep as dict if unsopported resource type (e.g. 'process')
107+
new_resource_item = resource_data
96108

97109
self.resources[resource_instance_name] = new_resource_item
98110

@@ -129,33 +141,52 @@ def all_missing_props(self):
129141
return self._all_missing_props
130142
return []
131143

132-
def asdict_enum_safe(self, obj):
144+
def datetime_to_string(self, data: datetime):
145+
# normalize to UTC and format with Z
146+
if data.tzinfo is None:
147+
data = data.replace(tzinfo=timezone.utc)
148+
else:
149+
data = data.astimezone(timezone.utc)
150+
return data.strftime("%Y-%m-%dT%H:%M:%SZ")
151+
152+
def asdict_enum_safe(self, obj, datetime_to_str=False):
133153
"""Overwriting dataclass 'asdict' fuction to replace Enums with strings."""
134154
if is_dataclass(obj):
135155
result = {}
136156
for f in fields(obj):
137157
value = getattr(obj, f.name)
158+
159+
key = f.name
160+
if key == "linked__data":
161+
key = "linked-data"
138162
if value is not None:
139-
result[f.name] = self.asdict_enum_safe(value)
163+
result[key] = self.asdict_enum_safe(value, datetime_to_str)
140164
return result
141165
elif isinstance(obj, Enum):
142166
return obj.value
143167
elif isinstance(obj, InlineList):
144168
return obj
145169
elif isinstance(obj, list):
146-
return [self.asdict_enum_safe(v) for v in obj]
170+
return [self.asdict_enum_safe(v, datetime_to_str) for v in obj]
147171
elif isinstance(obj, dict):
148172
return {
149-
self.asdict_enum_safe(k): self.asdict_enum_safe(v)
173+
self.asdict_enum_safe(k, datetime_to_str): self.asdict_enum_safe(
174+
v, datetime_to_str
175+
)
150176
for k, v in obj.items()
151177
}
152178
else:
153-
return obj
179+
if isinstance(obj, datetime) and datetime_to_str:
180+
return self.datetime_to_string(obj)
181+
else:
182+
return obj
154183

155184
def add_new_resource(self) -> str:
156185
"""Add a placeholder resource."""
157186
new_name = "new_resource"
158-
self.resources[new_name] = ResourceConfigTemplate(instance_name=new_name)
187+
self.resources[new_name] = ResourceConfigTemplate.init_with_name(
188+
instance_name=new_name
189+
)
159190
return new_name
160191

161192
def delete_resource(self, dialog):
@@ -173,7 +204,7 @@ def set_validate_new_provider_data(
173204

174205
# initialize provider; assign ui_dict data to the provider instance
175206
new_provider = ProviderTemplate.init_provider_from_type(provider_type)
176-
new_provider.assign_ui_dict_to_provider_data(values)
207+
new_provider.assign_ui_dict_to_provider_data_on_save(values)
177208

178209
# if incomplete data, remove Provider from ConfigData and show Warning
179210
invalid_props = new_provider.get_invalid_properties()
@@ -192,9 +223,11 @@ def validate_config_data(self) -> int:
192223
invalid_props.extend(self.server.get_invalid_properties())
193224
invalid_props.extend(self.metadata.get_invalid_properties())
194225
for key, resource in self.resources.items():
195-
invalid_res_props = [
196-
f"resources.{key}.{prop}" for prop in resource.get_invalid_properties()
197-
]
198-
invalid_props.extend(invalid_res_props)
226+
if not isinstance(resource, dict):
227+
invalid_res_props = [
228+
f"resources.{key}.{prop}"
229+
for prop in resource.get_invalid_properties()
230+
]
231+
invalid_props.extend(invalid_res_props)
199232

200233
return invalid_props

models/top_level/LoggingConfig.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,4 @@ class LoggingConfig:
3434
logfile: str | None = None
3535
logformat: str | None = None
3636
dateformat: str | None = None
37-
# TODO: Not currently used in the UI
38-
# rotation: LoggingRotationConfig | None = None
37+
rotation: dict | None = None

models/top_level/MetadataConfig.py

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
# records
77
class MetadataKeywordTypeEnum(Enum):
8+
NONE = ""
89
DISCIPLINE = "discipline"
910
TEMPORAL = "temporal"
1011
PLACE = "place"
@@ -13,6 +14,7 @@ class MetadataKeywordTypeEnum(Enum):
1314

1415

1516
class MetadataRoleEnum(Enum):
17+
NONE = ""
1618
AUTHOR = "author"
1719
COAUTHOR = "coAuthor"
1820
COLLABORATOR = "collaborator"
@@ -41,45 +43,39 @@ class MetadataIdentificationConfig:
4143
title: str | dict = field(default_factory=lambda: "")
4244
description: str | dict = field(default_factory=lambda: "")
4345
keywords: list | dict = field(default_factory=lambda: [])
44-
keywords_type: MetadataKeywordTypeEnum = field(
45-
default_factory=lambda: MetadataKeywordTypeEnum.THEME
46-
)
47-
terms_of_service: str = field(
48-
default="https://creativecommons.org/licenses/by/4.0/"
49-
)
5046
url: str = field(default="https://example.org")
47+
keywords_type: MetadataKeywordTypeEnum | None = None
48+
terms_of_service: str | None = None
5149

5250

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

5856

5957
@dataclass(kw_only=True)
6058
class MetadataProviderConfig:
6159
name: str = field(default="Organization Name")
62-
url: str = field(default="https://pygeoapi.io")
60+
url: str | None = None
6361

6462

6563
@dataclass(kw_only=True)
6664
class MetadataContactConfig:
6765
name: str = field(default="Lastname, Firstname")
68-
position: str = field(default="Position Title")
69-
address: str = field(default="Mailing Address")
70-
city: str = field(default="City")
71-
stateorprovince: str = field(default="Administrative Area")
72-
postalcode: str = field(default="Zip or Postal Code")
73-
country: str = field(default="Country")
74-
phone: str = field(default="+xx-xxx-xxx-xxxx")
75-
fax: str = field(default="+xx-xxx-xxx-xxxx")
76-
email: str = field(default="[email protected]")
77-
url: str = field(default="Contact URL")
78-
hours: str = field(default="Mo-Fr 08:00-17:00")
79-
instructions: str = field(default="During hours of service. Off on weekends.")
80-
role: MetadataRoleEnum = field(
81-
default_factory=lambda: MetadataRoleEnum.POINTOFCONTACT
82-
)
66+
position: str | None = None
67+
address: str | None = None
68+
city: str | None = None
69+
stateorprovince: str | None = None
70+
postalcode: str | None = None
71+
country: str | None = None
72+
phone: str | None = None
73+
fax: str | None = None
74+
email: str | None = None
75+
url: str | None = None
76+
hours: str | None = None
77+
instructions: str | None = None
78+
role: MetadataRoleEnum | None = None
8379

8480

8581
@dataclass(kw_only=True)
@@ -109,15 +105,17 @@ def get_invalid_properties(self):
109105
all_invalid_fields.append("metadata.identification.description")
110106
if len(self.identification.keywords) == 0:
111107
all_invalid_fields.append("metadata.identification.keywords")
108+
if len(self.identification.url) == 0:
109+
all_invalid_fields.append("metadata.identification.url")
112110
if len(self.license.name) == 0:
113111
all_invalid_fields.append("metadata.license.name")
114112
if len(self.provider.name) == 0:
115113
all_invalid_fields.append("metadata.provider.name")
116114
if len(self.contact.name) == 0:
117115
all_invalid_fields.append("metadata.contact.name")
118116

119-
parsed_url = urlparse(self.identification.url)
120-
if not all([parsed_url.scheme, parsed_url.netloc]):
121-
all_invalid_fields.append("metadata.identification.url")
117+
# parsed_url = urlparse(self.identification.url)
118+
# if not all([parsed_url.scheme, parsed_url.netloc]):
119+
# all_invalid_fields.append("metadata.identification.url")
122120

123121
return all_invalid_fields

0 commit comments

Comments
 (0)