Skip to content

Commit dbfbd81

Browse files
authored
update to latest OGC API - Records specification (#988)
* update to latest OGC API - Records specification * update tests * fix deprecation warnings, removed unused import * remove Python 3.8 from GitHub Actions * update copyright year
1 parent 6860bba commit dbfbd81

File tree

9 files changed

+127
-62
lines changed

9 files changed

+127
-62
lines changed

.github/workflows/main.yml

-4
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,6 @@ jobs:
2121
strategy:
2222
matrix:
2323
include:
24-
- python-version: "3.8"
25-
toxenv: "py38-sqlite"
26-
- python-version: "3.9"
27-
toxenv: "py39-sqlite"
2824
- python-version: "3.10"
2925
toxenv: "py310-sqlite"
3026
- python-version: "3.11"

.github/workflows/vulnerabilities.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
- name: Setup Python
2121
uses: actions/setup-python@v1
2222
with:
23-
python-version: 3.8
23+
python-version: '3.10'
2424
architecture: x64
2525
- name: Checkout pycsw
2626
uses: actions/checkout@master

pycsw/core/repository.py

+7-6
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,7 @@
3838
from time import sleep
3939

4040
from shapely.wkt import loads
41-
try:
42-
from shapely.errors import ReadingError
43-
except Exception:
44-
from shapely.geos import ReadingError
41+
from shapely.errors import ShapelyError
4542

4643
from sqlalchemy import create_engine, func, __version__, select
4744
from sqlalchemy.exc import OperationalError
@@ -306,7 +303,8 @@ def describe(self):
306303

307304
properties = {
308305
'geometry': {
309-
'$ref': 'https://geojson.org/schema/Polygon.json'
306+
'$ref': 'https://geojson.org/schema/Polygon.json',
307+
'x-ogc-role': 'primary-geometry'
310308
}
311309
}
312310

@@ -318,6 +316,9 @@ def describe(self):
318316
'title': i.name
319317
}
320318

319+
if i.name == 'identifier':
320+
properties[i.name]['x-ogc-role'] = 'id'
321+
321322
try:
322323
properties[i.name]['type'] = type_mappings[str(i.type)]
323324
except Exception as err:
@@ -636,7 +637,7 @@ def query_spatial(bbox_data_wkt, bbox_input_wkt, predicate, distance):
636637
else:
637638
raise RuntimeError(
638639
'Invalid spatial query predicate: %s' % predicate)
639-
except (AttributeError, ValueError, ReadingError, TypeError):
640+
except (AttributeError, ValueError, ShapelyError, TypeError):
640641
result = False
641642
return "true" if result else "false"
642643

pycsw/ogc/api/records.py

+76-44
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
from pycsw.core.pygeofilter_evaluate import to_filter
4646
from pycsw.core.util import bind_url, get_today_and_now, jsonify_links, load_custom_repo_mappings, str2bool, wkt2geom
4747
from pycsw.ogc.api.oapi import gen_oapi
48-
from pycsw.ogc.api.util import match_env_var, render_j2_template, to_json
48+
from pycsw.ogc.api.util import match_env_var, render_j2_template, to_json, to_rfc3339
4949

5050
LOGGER = logging.getLogger(__name__)
5151

@@ -109,7 +109,7 @@ def __init__(self, config: dict):
109109
try:
110110
self.limit = int(self.config['server']['maxrecords'])
111111
except KeyError:
112-
self.limit= 10
112+
self.limit = 10
113113
LOGGER.debug(f'limit: {self.limit}')
114114

115115
repo_filter = self.config['repository'].get('filter')
@@ -466,7 +466,7 @@ def queryables(self, headers_, args, collection='metadata:main'):
466466
headers_['Content-Type'] = 'application/schema+json'
467467

468468
if collection not in self.get_all_collections():
469-
msg = f'Invalid collection'
469+
msg = 'Invalid collection'
470470
LOGGER.exception(msg)
471471
return self.get_exception(400, headers_, 'InvalidParameterValue', msg)
472472

@@ -506,6 +506,11 @@ def items(self, headers_, json_post_data, args, collection='metadata:main'):
506506
:returns: tuple of headers, status code, content
507507
"""
508508

509+
LOGGER.debug(f'Request args: {args.keys()}')
510+
LOGGER.debug('converting request argument names to lower case')
511+
args = {k.lower(): v for k, v in args.items()}
512+
LOGGER.debug(f'Request args (lower case): {args.keys()}')
513+
509514
headers_['Content-Type'] = self.get_content_type(headers_, args)
510515

511516
reserved_query_params = [
@@ -539,7 +544,7 @@ def items(self, headers_, json_post_data, args, collection='metadata:main'):
539544
collections = []
540545

541546
if collection not in self.get_all_collections():
542-
msg = f'Invalid collection'
547+
msg = 'Invalid collection'
543548
LOGGER.exception(msg)
544549
return self.get_exception(400, headers_, 'InvalidParameterValue', msg)
545550

@@ -830,7 +835,7 @@ def item(self, headers_, args, collection, item):
830835
headers_['Content-Type'] = self.get_content_type(headers_, args)
831836

832837
if collection not in self.get_all_collections():
833-
msg = f'Invalid collection'
838+
msg = 'Invalid collection'
834839
LOGGER.exception(msg)
835840
return self.get_exception(400, headers_, 'InvalidParameterValue', msg)
836841

@@ -992,6 +997,7 @@ def get_collection_info(self, collection_name: str = 'metadata:main',
992997

993998
collection_info = {
994999
'id': id_,
1000+
'type': 'catalog',
9951001
'title': title,
9961002
'description': description,
9971003
'itemType': 'record',
@@ -1127,22 +1133,29 @@ def record2json(record, url, collection, mode='ogcapi-records'):
11271133
'id': record.identifier,
11281134
'type': 'Feature',
11291135
'geometry': None,
1130-
'time': record.date,
11311136
'properties': {},
11321137
'links': []
11331138
}
11341139

1140+
try:
1141+
dt, dt_type = to_rfc3339(record.date)
1142+
record_dict['time'] = {
1143+
dt_type: dt
1144+
}
1145+
except Exception:
1146+
record_dict['time'] = None
1147+
11351148
# todo; for keywords with a scheme use the theme property
1136-
themes = []
11371149
if record.topicategory:
1150+
themes = []
11381151
themes.append({'concepts': [record.topicategory],
11391152
'scheme': 'https://standards.iso.org/iso/19139/resources/gmxCodelists.xml#MD_TopicCategoryCode'})
1140-
record_dict['properties']['themes'] = themes
1153+
record_dict['properties']['themes'] = themes
11411154

11421155
if record.otherconstraints:
1143-
if isinstance(record.otherconstraints, str):
1156+
if isinstance(record.otherconstraints, str) and record.otherconstraints not in [None, 'None']:
11441157
record.otherconstraints = [record.otherconstraints]
1145-
record_dict['properties']['license'] = ", ".join(record.otherconstraints)
1158+
record_dict['properties']['license'] = ", ".join(record.otherconstraints)
11461159

11471160
record_dict['properties']['updated'] = record.insert_date
11481161

@@ -1168,7 +1181,7 @@ def record2json(record, url, collection, mode='ogcapi-records'):
11681181
record_dict['properties']['description'] = record.abstract
11691182

11701183
if record.format:
1171-
record_dict['properties']['formats'] = [record.format]
1184+
record_dict['properties']['formats'] = [{'name': record.format}]
11721185

11731186
if record.keywords:
11741187
record_dict['properties']['keywords'] = [x for x in record.keywords.split(',')]
@@ -1181,58 +1194,67 @@ def record2json(record, url, collection, mode='ogcapi-records'):
11811194
rcnt.append({
11821195
'name': cnt['name'],
11831196
'organization': cnt.get('organization', ''),
1184-
'positionName': cnt.get('position', ''),
1185-
'roles': [
1186-
{'name': cnt.get('role', '')}
1187-
],
1188-
'contactInfo': {
1189-
'phone': {'work': cnt.get('phone', '')},
1190-
'email': {'work': cnt.get('email', '')},
1191-
'address': {
1192-
'work': {
1193-
'deliveryPoint': cnt.get('address', ''),
1194-
'city': cnt.get('city', ''),
1195-
'administrativeArea': cnt.get('region', ''),
1196-
'postalCode': cnt.get('postcode', ''),
1197-
'country': cnt.get('country', ''),
1198-
}
1199-
},
1200-
'url': cnt.get('onlineresource', '')
1201-
}
1197+
'position': cnt.get('position', ''),
1198+
'roles': [cnt.get('role', '')],
1199+
'phones': [{
1200+
'value': cnt.get('phone', '')
1201+
}],
1202+
'emails': [{
1203+
'value': cnt.get('email', '')
1204+
}],
1205+
'addresses': [{
1206+
'deliveryPoint': [cnt.get('address', '')],
1207+
'city': cnt.get('city', ''),
1208+
'administrativeArea': cnt.get('region', ''),
1209+
'postalCode': cnt.get('postcode', ''),
1210+
'country': cnt.get('country', '')
1211+
}],
1212+
'links': [{
1213+
'href': cnt.get('onlineresource')
1214+
}]
12021215
})
12031216
except Exception as err:
12041217
LOGGER.exception(f"failed to parse contact of {record.identifier}: {err}")
12051218
except Exception as err:
12061219
LOGGER.exception(f"failed to parse contacts json of {record.identifier}: {err}")
1207-
record_dict['properties']['providers'] = rcnt
1220+
1221+
record_dict['properties']['contacts'] = rcnt
12081222

12091223
if record.themes not in [None, '', 'null']:
1210-
ogcapiThemes = []
1224+
ogcapi_themes = []
12111225
# For a scheme, prefer uri over label
12121226
# OWSlib currently uses .keywords_object for keywords with url, see https://github.com/geopython/OWSLib/pull/765
12131227
try:
12141228
for theme in json.loads(record.themes):
12151229
try:
1216-
ogcapiThemes.append({
1230+
ogcapi_themes.append({
12171231
'scheme': theme['thesaurus'].get('url', theme['thesaurus'].get('title', '')),
12181232
'concepts': [c for c in theme.get('keywords_object', []) if c not in [None, '']]
12191233
})
12201234
except Exception as err:
12211235
LOGGER.exception(f"failed to parse theme of {record.identifier}: {err}")
12221236
except Exception as err:
12231237
LOGGER.exception(f"failed to parse themes json of {record.identifier}: {err}")
1224-
record_dict['properties']['themes'] = ogcapiThemes
1238+
1239+
record_dict['properties']['themes'] = ogcapi_themes
12251240

12261241
if record.links:
12271242
rdl = record_dict['links']
12281243

12291244
for link in jsonify_links(record.links):
1245+
if link['url'] in [None, 'None']:
1246+
LOGGER.debug(f'Skipping null link: {link}')
1247+
continue
1248+
12301249
link2 = {
1231-
'href': link['url'],
1232-
'name': link.get('name'),
1233-
'description': link.get('description'),
1234-
'type': link.get('protocol')
1250+
'href': link['url']
12351251
}
1252+
if link.get('name') not in [None, 'None']:
1253+
link2['name'] = link['name']
1254+
if link.get('description') not in [None, 'None']:
1255+
link2['description'] = link['description']
1256+
if link.get('protocol') not in [None, 'None']:
1257+
link2['procotol'] = link['protocol']
12361258
if 'rel' in link:
12371259
link2['rel'] = link['rel']
12381260
elif link['protocol'] == 'WWW:LINK-1.0-http--image-thumbnail':
@@ -1289,18 +1311,28 @@ def record2json(record, url, collection, mode='ogcapi-records'):
12891311
if record.time_begin or record.time_end:
12901312
if record.time_end not in [None, '']:
12911313
if record.time_begin not in [None, '']:
1292-
record_dict['time'] = [record.time_begin, record.time_end]
1314+
begin, _ = to_rfc3339(record.time_begin)
1315+
end, _ = to_rfc3339(record.time_end)
1316+
record_dict['time'] = {
1317+
'interval': [begin, end]
1318+
}
12931319
else:
1294-
record_dict['time'] = record.time_end
1320+
end, end_type = to_rfc3339(record.time_end)
1321+
record_dict['time'] = {
1322+
end_type: end
1323+
}
12951324
else:
1296-
record_dict['time'] = record.time_begin
1325+
begin, begin_type = to_rfc3339(record.time_begin)
1326+
record_dict['time'] = {
1327+
begin_type: begin
1328+
}
12971329

12981330
if mode == 'stac-api':
1299-
record_dict['properties']['datetime'] = record.date
1331+
record_dict['properties']['datetime'] = to_rfc3339(record.date)
13001332

13011333
if None not in [record.time_begin, record.time_end]:
1302-
record_dict['properties']['start_datetime'] = record.time_begin
1303-
record_dict['properties']['end_datetime'] = record.time_end
1334+
record_dict['properties']['start_datetime'] = to_rfc3339(record.time_begin)
1335+
record_dict['properties']['end_datetime'] = to_rfc3339(record.time_end)
13041336

13051337
return record_dict
13061338

@@ -1322,7 +1354,7 @@ def build_anytext(name, value):
13221354
tokens = value.split(',')
13231355

13241356
if len(tokens) == 1 and ' ' not in value: # single term
1325-
LOGGER.debug(f'Single term with no spaces')
1357+
LOGGER.debug('Single term with no spaces')
13261358
return f"{name} ILIKE '%{value}%'"
13271359

13281360
for token in tokens:

pycsw/ogc/api/util.py

+30-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@
3939
import os
4040
import pathlib
4141
import re
42+
from typing import Union
4243

44+
from dateutil.parser import parse as dparse
4345
from jinja2 import Environment, FileSystemLoader
4446
from jinja2.exceptions import TemplateNotFound
4547
import yaml
@@ -82,12 +84,14 @@ def json_serial(obj):
8284
"""
8385
helper function to convert to JSON non-default
8486
types (source: https://stackoverflow.com/a/22238613)
87+
8588
:param obj: `object` to be evaluated
89+
8690
:returns: JSON non-default type to `str`
8791
"""
8892

8993
if isinstance(obj, (datetime, date, time)):
90-
return obj.isoformat()
94+
return obj.isoformat() + 'Z'
9195
elif isinstance(obj, bytes):
9296
try:
9397
LOGGER.debug('Returning as UTF-8 decoded bytes')
@@ -218,3 +222,28 @@ def render_j2_template(config, template, data):
218222
raise
219223

220224
return template.render(config=config, data=data, version=__version__)
225+
226+
227+
def to_rfc3339(value: str) -> Union[tuple, None]:
228+
"""
229+
Helper function to convert a date/datetime into
230+
RFC3339
231+
232+
:param value: `str` of date/datetime value
233+
234+
:returns: `tuple` of `datetime` of RFC3339 value and date type
235+
"""
236+
237+
try:
238+
dt = dparse(value) # TODO TIMEZONE)
239+
except Exception as err:
240+
msg = f'Parse error: {err}'
241+
LOGGER.error(msg)
242+
return 'date', None
243+
244+
if len(value) < 11:
245+
dt_type = 'date'
246+
else:
247+
dt_type = 'date-time'
248+
249+
return dt, dt_type

pycsw/ogc/gml/gml3.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
#
44
# Authors: Tom Kralidis <[email protected]>
55
#
6-
# Copyright (c) 2015 Tom Kralidis
6+
# Copyright (c) 2024 Tom Kralidis
77
#
88
# Permission is hereby granted, free of charge, to any person
99
# obtaining a copy of this software and associated documentation
@@ -214,11 +214,11 @@ def transform(self, src, dest):
214214

215215
geom = loads(self.wkt)
216216

217-
if geom.type == 'Point':
217+
if geom.geom_type == 'Point':
218218
newgeom = Point(transformer.transform(geom.x, geom.y))
219219
wkt2 = newgeom.wkt
220220

221-
elif geom.type == 'LineString':
221+
elif geom.geom_type == 'LineString':
222222
for vertice in list(geom.coords):
223223
newgeom = transformer.transform(vertice[0], vertice[1])
224224
vertices.append(newgeom)
@@ -227,7 +227,7 @@ def transform(self, src, dest):
227227

228228
wkt2 = linestring.wkt
229229

230-
elif geom.type == 'Polygon':
230+
elif geom.geom_type == 'Polygon':
231231
for vertice in list(geom.exterior.coords):
232232
newgeom = transformer.transform(vertice[0], vertice[1])
233233
vertices.append(newgeom)

0 commit comments

Comments
 (0)