Skip to content

Commit 5340d17

Browse files
authored
STAC model updates (#1183)
* STAC API: add created, updated, platform, instrument and gsd parsing, constrain STAC API queries to item search only * fix broker/MQTT symbols * update docs * update OARec tests
1 parent efbaf9c commit 5340d17

File tree

10 files changed

+212
-72
lines changed

10 files changed

+212
-72
lines changed

docs/metadata-model-reference.rst

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -224,15 +224,15 @@ Model Crosswalk
224224
- ``gmd:identificationInfo/gmd:MD_DataIdentification/gmd:citation/gmd:CI_Citation/gmd:date/gmd:CI_Date[gmd:dateType/gmd:CI_DateTypeCode/@codeListValue="revision"]/gmd:date/gco:Date``
225225
-
226226
- ``properties.updated``
227-
- ``properties.updated``
227+
- ``created`` or ``properties.updated``
228228
-
229229
* - ``date_creation``
230230
- ``pycsw:CreationDate``
231231
- ``apiso:CreationDate``
232232
- ``gmd:identificationInfo/gmd:MD_DataIdentification/gmd:citation/gmd:CI_Citation/gmd:date/gmd:CI_Date[gmd:dateType/gmd:CI_DateTypeCode/@codeListValue="creation"]/gmd:date/gco:Date``
233233
-
234234
- ``properties.created``
235-
- ``properties.created``
235+
- ``created`` or ``properties.created``
236236
-
237237
* - ``date_publication``
238238
- ``pycsw:PublicationDate``
@@ -304,15 +304,15 @@ Model Crosswalk
304304
- ``gmd:identificationInfo/gmd:MD_DataIdentification/gmd:spatialResolution/gmd:MD_Resolution/gmd:distance/gco:Distance``
305305
-
306306
-
307-
-
307+
- ``gsd`` or ``properties.gsd``
308308
-
309309
* - ``distanceuom``
310310
- ``pycsw:DistanceUOM``
311311
- ``apiso:DistanceUOM``
312312
- ``gmd:identificationInfo/gmd:MD_DataIdentification/gmd:spatialResolution/gmd:MD_Resolution/gmd:distance/gco:Distance/@uom``
313313
-
314314
-
315-
-
315+
- fixed to ``m``
316316
-
317317
* - ``time_begin``
318318
- ``pycsw:TempExtent_begin``
@@ -504,15 +504,15 @@ Model Crosswalk
504504
- ``gmi:acquisitionInfo/gmi:MI_AcquisitionInformation/gmi:platform/gmi:MI_Platform/gmi:identifier``
505505
-
506506
-
507-
-
507+
- ``platform`` or ``properties.platform``
508508
-
509509
* - ``instrument``
510510
- ``pycsw:Instrument``
511511
- ``apiso:Instrument``
512512
- ``gmi:acquisitionInfo/gmi:MI_AcquisitionInformation/gmi:platform/gmi:MI_Platform/gmi:instrument/gmi:MI_Instrument/gmi:identifier``
513513
-
514514
-
515-
-
515+
- ``instruments`` or ``properties.instruments``
516516
-
517517
* - ``sensortype``
518518
- ``pycsw:SensorType``

pycsw/broker/mqtt.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def __init__(self, broker_url):
4545
4646
:param publisher_def: provider definition
4747
48-
:returns: pycsw.pubsub.mqtt.MQTTClient
48+
:returns: pycsw.pubsub.mqtt.MQTTPubSubClient
4949
"""
5050

5151
super().__init__(broker_url)
@@ -79,25 +79,28 @@ def __init__(self, broker_url):
7979
def connect(self) -> None:
8080
"""
8181
Connect to an MQTT broker
82+
8283
:returns: None
8384
"""
8485

8586
self.conn.connect(self.broker_url.hostname, self.port)
8687
LOGGER.debug('Connected to broker')
8788

88-
def pub(self, topic: str, message: str, qos: int = 1) -> bool:
89+
def pub(self, channel: str, message: str, qos: int = 1) -> bool:
8990
"""
90-
Publish a message to a broker/topic
91-
:param topic: `str` of topic
91+
Publish a message to a broker/channel
92+
93+
:param channel: `str` of channel
9294
:param message: `str` of message
95+
9396
:returns: `bool` of publish result
9497
"""
9598

9699
LOGGER.debug(f'Publishing to broker {self.broker_safe_url}')
97-
LOGGER.debug(f'Topic: {topic}')
100+
LOGGER.debug(f'Channel: {channel}')
98101
LOGGER.debug(f'Message: {message}')
99102

100-
result = self.conn.publish(topic, message, qos)
103+
result = self.conn.publish(channel, message, qos)
101104
LOGGER.debug(f'Result: {result}')
102105

103106
# TODO: investigate implication

pycsw/core/metadata.py

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1504,12 +1504,11 @@ def _parse_iso(context, repos, exml):
15041504
_set(context, recobj, 'pycsw:DistanceValue', md_identification.distance[0])
15051505
if len(md_identification.uom) > 0:
15061506
_set(context, recobj, 'pycsw:DistanceUOM', md_identification.uom[0])
1507-
15081507
if len(md_identification.classification) > 0:
15091508
_set(context, recobj, 'pycsw:Classification', md_identification.classification[0])
15101509
if len(md_identification.uselimitation) > 0:
15111510
_set(context, recobj, 'pycsw:ConditionApplyingToAccessAndUse',
1512-
md_identification.uselimitation[0])
1511+
md_identification.uselimitation[0])
15131512

15141513
service_types = []
15151514
from owslib.iso import SV_ServiceIdentification
@@ -1527,14 +1526,13 @@ def _parse_iso(context, repos, exml):
15271526
_set(context, recobj, 'pycsw:SpecificationTitle', md.dataquality.specificationtitle)
15281527
if hasattr(md.dataquality, 'specificationdate'):
15291528
_set(context, recobj, 'pycsw:specificationDate',
1530-
md.dataquality.specificationdate[0].date)
1529+
md.dataquality.specificationdate[0].date)
15311530
_set(context, recobj, 'pycsw:SpecificationDateType',
1532-
md.dataquality.specificationdate[0].datetype)
1531+
md.dataquality.specificationdate[0].datetype)
15331532

15341533
if hasattr(md, 'contact') and len(md.contact) > 0:
15351534
_set(context, recobj, 'pycsw:ResponsiblePartyRole', md.contact[0].role)
15361535

1537-
15381536
if hasattr(md, 'contentinfo') and len(md.contentinfo) > 0:
15391537
for ci in md.contentinfo:
15401538
if isinstance(ci, MD_ImageDescription):
@@ -1545,9 +1543,9 @@ def _parse_iso(context, repos, exml):
15451543
if ci.processing_level is not None:
15461544
pl_keyword = 'eo:processingLevel:' + ci.processing_level
15471545
if keywords is None:
1548-
keywords = pl_keyword
1546+
keywords = pl_keyword
15491547
else:
1550-
keywords += ',' + pl_keyword
1548+
keywords += ',' + pl_keyword
15511549

15521550
_set(context, recobj, 'pycsw:Keywords', keywords)
15531551

@@ -1573,7 +1571,7 @@ def _parse_iso(context, repos, exml):
15731571
_set(context, recobj, 'pycsw:Instrument', instrument.identifier)
15741572
_set(context, recobj, 'pycsw:SensorType', instrument.type)
15751573

1576-
all_formats=[]
1574+
all_formats = []
15771575
if hasattr(md.distribution, 'format') and md.distribution.format is not None:
15781576
all_formats.append(md.distribution.format)
15791577

@@ -1616,11 +1614,11 @@ def _parse_iso(context, repos, exml):
16161614
'url': scpt.url
16171615
}
16181616
links.append(linkobj)
1619-
except Exception as err: # srv: identification does not exist
1617+
except Exception: # srv: identification does not exist
16201618
LOGGER.exception('no srv:SV_ServiceIdentification links found')
16211619

16221620
if hasattr(md_identification, 'graphicoverview'):
1623-
for thumb in md_identification.graphicoverview:
1621+
for thumb in md_identification.graphicoverview:
16241622
links.append({
16251623
'name': 'preview',
16261624
'description': 'Web image thumbnail (URL)',
@@ -1643,6 +1641,7 @@ def _parse_iso(context, repos, exml):
16431641

16441642
return recobj
16451643

1644+
16461645
def _parse_dc(context, repos, exml):
16471646

16481647
from owslib.csw import CswRecord
@@ -1830,6 +1829,15 @@ def _parse_stac_resource(context, repos, record):
18301829
stype = 'item'
18311830
title = record['properties'].get('title')
18321831
abstract = record['properties'].get('description')
1832+
_set(context, recobj, 'pycsw:CreationDate', record['properties'].get('created'))
1833+
_set(context, recobj, 'pycsw:Modified', record['properties'].get('updated'))
1834+
_set(context, recobj, 'pycsw:Platform', record['properties'].get('platform'))
1835+
instruments = record['properties'].get('instruments')
1836+
if instruments is not None:
1837+
_set(context, recobj, 'pycsw:Instrument', ','.join(instruments))
1838+
if record['properties'].get('gsd') is not None:
1839+
_set(context, recobj, 'pycsw:DistanceValue', record['properties']['gsd'])
1840+
_set(context, recobj, 'pycsw:DistanceUOM', 'm')
18331841
if record.get('geometry') is not None:
18341842
bbox_wkt = util.bbox2wktpolygon(util.geojson_geometry2bbox(record['geometry']))
18351843
elif stac_type == 'Collection':
@@ -1840,6 +1848,15 @@ def _parse_stac_resource(context, repos, record):
18401848
stype = 'collection'
18411849
title = record.get('title')
18421850
abstract = record.get('description')
1851+
_set(context, recobj, 'pycsw:CreationDate', record.get('created'))
1852+
_set(context, recobj, 'pycsw:Modified', record.get('updated'))
1853+
_set(context, recobj, 'pycsw:Platform', record.get('platform'))
1854+
instruments = record.get('instruments')
1855+
if instruments is not None:
1856+
_set(context, recobj, 'pycsw:Instrument', ','.join(instruments))
1857+
if record.get('gsd') is not None:
1858+
_set(context, recobj, 'pycsw:DistanceValue', record['gsd'])
1859+
_set(context, recobj, 'pycsw:DistanceUOM', 'm')
18431860
if 'extent' in record and 'spatial' in record['extent']:
18441861
bbox_csv = ','.join(str(t) for t in record['extent']['spatial']['bbox'][0])
18451862
bbox_wkt = util.bbox2wktpolygon(bbox_csv)
@@ -1854,6 +1871,12 @@ def _parse_stac_resource(context, repos, record):
18541871
stype = 'catalog'
18551872
title = record.get('title')
18561873
abstract = record.get('description')
1874+
_set(context, recobj, 'pycsw:CreationDate', record.get('created'))
1875+
_set(context, recobj, 'pycsw:Modified', record.get('updated'))
1876+
_set(context, recobj, 'pycsw:Platform', record.get('platform'))
1877+
instruments = record.get('instruments')
1878+
if instruments is not None:
1879+
_set(context, recobj, 'pycsw:Instrument', ','.join(instruments))
18571880

18581881
_set(context, recobj, 'pycsw:Identifier', record['id'])
18591882
_set(context, recobj, 'pycsw:Typename', typename)
@@ -1952,22 +1975,25 @@ def _parse_stac_resource(context, repos, record):
19521975

19531976
return recobj
19541977

1978+
19551979
def fgdccontact2iso(cnt, role='pointOfContact'):
19561980
"""Creates a iso format contact (owslib style) from fgdc format"""
19571981

1958-
return {'name': cnt.cntper,
1959-
'organization': cnt.cntorg,
1960-
'position': cnt.cntpos,
1961-
'phone': cnt.voice,
1962-
'address': cnt.address,
1963-
'city': cnt.city,
1964-
'region': cnt.state,
1965-
'postcode': cnt.postal,
1966-
'country': cnt.country,
1967-
'email': cnt.email,
1968-
'role': role
1982+
return {
1983+
'name': cnt.cntper,
1984+
'organization': cnt.cntorg,
1985+
'position': cnt.cntpos,
1986+
'phone': cnt.voice,
1987+
'address': cnt.address,
1988+
'city': cnt.city,
1989+
'region': cnt.state,
1990+
'postcode': cnt.postal,
1991+
'country': cnt.country,
1992+
'email': cnt.email,
1993+
'role': role
19691994
}
19701995

1996+
19711997
def caps2iso(record, caps, context):
19721998
"""Creates ISO metadata from Capabilities XML"""
19731999

pycsw/core/pygeofilter_evaluate.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,23 @@ def bbox(self, node, lhs):
8484
else:
8585
return text(f"query_spatial({geometry}, '{wkt}', 'bbox', 'false') = 'true'") # noqa
8686

87+
@handle(ast.Equal)
88+
def equal(self, node, lhs, rhs):
89+
list_props = [
90+
'dataset.keywords',
91+
'dataset.instrument'
92+
]
93+
94+
if str(lhs.prop) in list_props:
95+
LOGGER.debug(f'Overriding {lhs.prop} "=" with "ilike"')
96+
node.pattern = f'%{rhs}%'
97+
node.nocase = False
98+
node.not_ = False
99+
100+
return self.ilike(node, lhs)
101+
102+
return filters.runop(lhs, rhs, '=')
103+
87104
@handle(ast.Like)
88105
def ilike(self, node, lhs):
89106
LOGGER.debug('Overriding ILIKE filter handling')

pycsw/core/repository.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ def __init__(self, database, context, app_root=None, table='records', repo_filte
159159
'bbox': self.dataset.wkt_geometry,
160160
'date': self.dataset.date,
161161
'date_creation': self.dataset.date_creation,
162+
'date_modified': self.dataset.date_modified,
162163
'datetime': self.dataset.date,
163164
'time_begin': self.dataset.time_begin,
164165
'time_end': self.dataset.time_end,

pycsw/stac/api.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -506,18 +506,45 @@ def items(self, headers_, json_post_data, args, collection='metadata:main'):
506506

507507
if not json_post_data and not cql_ops:
508508
LOGGER.debug('No JSON POST data or CQL ops')
509+
args['type'] = 'item'
509510
elif not json_post_data and cql_ops:
510511
LOGGER.debug('No JSON POST data left')
512+
cql_ops.append({
513+
'op': '=',
514+
'args': [
515+
{'property': 'type'},
516+
'item'
517+
]
518+
})
511519
json_post_data = {
512520
'op': 'and',
513521
'args': cql_ops
514522
}
515523
else:
516524
LOGGER.debug('JSON POST data is CQL2 JSON')
517-
if cql_ops:
518-
print("JJ2", json_post_data)
519-
LOGGER.debug('Adding STAC API query parameters to CQL2 JSON')
525+
cql_ops.append({
526+
'op': '=',
527+
'args': [
528+
{'property': 'type'},
529+
'item'
530+
]
531+
})
532+
LOGGER.debug('Adding STAC API query parameters to CQL2 JSON')
533+
if json_post_data.get('op') in ['and', 'or']:
520534
json_post_data['args'].extend(cql_ops)
535+
else:
536+
op_, args_ = json_post_data.get('op'), json_post_data.get('args')
537+
if None not in [op_, args_]:
538+
cql_ops.append({
539+
'op': op_,
540+
'args': args_,
541+
})
542+
json_post_data = {
543+
'op': 'and',
544+
'args': cql_ops
545+
}
546+
else:
547+
json_post_data.update(*cql_ops)
521548

522549
headers, status, response = super().items(headers_, json_post_data, args, collection)
523550

@@ -526,7 +553,7 @@ def items(self, headers_, json_post_data, args, collection='metadata:main'):
526553
response2['features'] = []
527554

528555
LOGGER.debug('Filtering on STAC items')
529-
for record in response['features']:
556+
for record in response.get('features', []):
530557
if record.get('stac_version') is None:
531558
record['links'].extend([{
532559
'rel': 'self',
@@ -559,7 +586,7 @@ def items(self, headers_, json_post_data, args, collection='metadata:main'):
559586

560587
links2 = []
561588

562-
for link in response2['links']:
589+
for link in response2.get('links', []):
563590
if link['rel'] in ['alternate', 'collection']:
564591
continue
565592
link['href'] = link['href'].replace('collections/metadata:main/items', 'search')
@@ -574,6 +601,10 @@ def items(self, headers_, json_post_data, args, collection='metadata:main'):
574601
}
575602
])
576603

604+
if 'code' in response2:
605+
response2.pop('features')
606+
response2.pop('links')
607+
577608
return self.get_response(status, headers, response2)
578609

579610
def item(self, headers_, args, collection, item):

tests/functionaltests/suites/oarec/test_oarec_functional.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ def test_queryables(config):
108108
assert content['$id'] == 'http://localhost/pycsw/oarec/collections/metadata:main/queryables' # noqa
109109
assert content['$schema'] == 'http://json-schema.org/draft/2019-09/schema'
110110

111-
assert len(content['properties']) == 16
111+
assert len(content['properties']) == 17
112112

113113
assert 'geometry' in content['properties']
114114
assert content['properties']['geometry']['$ref'] == 'https://geojson.org/schema/Polygon.json' # noqa

tests/functionaltests/suites/oarec/test_oarec_virtual_collections_functional.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def test_queryables(config_virtual_collections):
116116
assert content['$id'] == 'http://localhost/pycsw/oarec/collections/metadata:main/queryables' # noqa
117117
assert content['$schema'] == 'http://json-schema.org/draft/2019-09/schema'
118118

119-
assert len(content['properties']) == 16
119+
assert len(content['properties']) == 17
120120

121121
assert 'geometry' in content['properties']
122122
assert content['properties']['geometry']['$ref'] == 'https://geojson.org/schema/Polygon.json' # noqa
-4 KB
Binary file not shown.

0 commit comments

Comments
 (0)