Coverage for gws-app/gws/base/metadata/core.py: 96%
173 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""Metadata objects: creation, merging and conversion."""
3from typing import Optional
4import gws
5import gws.lib.intl
6import gws.lib.datetimex as dtx
8from . import inspire, iso
11class LinkConfig(gws.Config):
12 """Link to a metadata document or another resource related to the object."""
14 about: Optional[str]
15 """Aspect of the object the link describes."""
16 description: Optional[str]
17 """Description of the linked resource."""
18 format: Optional[str]
19 """Format of the linked resource."""
20 formatVersion: Optional[str]
21 """Version of the format of the linked resource."""
22 function: Optional[str]
23 """Function of the link."""
24 mimeType: Optional[gws.MimeType]
25 """MIME type of the linked resource."""
26 scheme: Optional[str]
27 """Link scheme or protocol."""
28 title: Optional[str]
29 """Link title."""
30 type: Optional[str]
31 url: Optional[str]
32 """Link URL."""
35class Config(gws.Config):
36 """Metadata of an object, used in the client and in OWS services."""
38 name: Optional[str]
39 """Object name."""
40 title: Optional[str]
41 """Title of the object."""
43 abstract: Optional[str]
44 """Abstract of the object, a brief description."""
45 accessConstraints: Optional[str]
46 """Access constraints, written to AccessConstraints in service capabilities."""
47 accessConstraintsType: Optional[str]
48 """Access constraint type for the object."""
49 attribution: Optional[str]
50 """Attribution text, shown in the client and as the WMS Attribution title."""
51 attributionUrl: Optional[str]
52 """URL of the attribution, written to the WMS Attribution online resource."""
53 dateCreated: Optional[gws.DateStr]
54 """Creation date of the object."""
55 dateUpdated: Optional[gws.DateStr]
56 """Last update date of the object."""
57 fees: Optional[str]
58 """Fees for using the object, written to Fees in service capabilities."""
59 image: Optional[str]
60 """Image URL or path associated with the object."""
61 keywords: Optional[list[str]]
62 """Keywords, optionally prefixed with a vocabulary, e.g. 'gemet:river'."""
63 license: Optional[str]
64 """License text, written to the legal constraints in CSW records."""
65 licenseUrl: Optional[gws.Url]
66 """License URL."""
68 contactAddress: Optional[str]
69 """Street address of the contact."""
70 contactAddressType: Optional[str]
71 """Type of contact address, such as 'postal' or 'email'."""
72 contactArea: Optional[str]
73 """Administrative area, state or province of the contact address."""
74 contactCity: Optional[str]
75 """Contact city."""
76 contactCountry: Optional[str]
77 """Contact country."""
78 contactEmail: Optional[str]
79 """Contact email address."""
80 contactFax: Optional[str]
81 """Contact fax number."""
82 contactOrganization: Optional[str]
83 """Contact organization or institution."""
84 contactPerson: Optional[str]
85 """Contact person name."""
86 contactPhone: Optional[str]
87 """Contact phone number."""
88 contactPosition: Optional[str]
89 """Contact position or job title."""
90 contactProviderName: Optional[str]
91 """Name of the service provider, written to ServiceProvider/ProviderName."""
92 contactProviderSite: Optional[str]
93 """Website of the service provider, written to ServiceProvider/ProviderSite."""
94 contactRole: Optional[iso.CI_RoleCode]
95 """Role of the contact."""
96 contactUrl: Optional[str]
97 """Website of the contact, written to the contact OnlineResource."""
98 contactZip: Optional[str]
99 """Contact postal code."""
101 authorityIdentifier: Optional[str]
102 """Layer identifier issued by the authority, written to the WMS Identifier element."""
103 authorityName: Optional[str]
104 """Name of the authority that issues layer identifiers."""
105 authorityUrl: Optional[str]
106 """URL of the authority that issues layer identifiers."""
108 metaLinks: Optional[list[LinkConfig]]
109 """Links to metadata documents."""
110 serviceMetadataURL: Optional[str]
111 """URL of the service metadata document."""
113 catalogCitationUid: Optional[str]
114 """Identifier of the resource, written to the CI_Citation identifier in CSW records."""
115 catalogUid: Optional[str]
116 """Identifier of the metadata record."""
118 language: Optional[str]
119 """Language of the object as an ISO 639-1 code."""
121 parentIdentifier: Optional[str]
122 """Identifier of the parent metadata record."""
123 wgsExtent: Optional[gws.Extent]
124 """Geographic extent in WGS84, written to EX_Extent in CSW records."""
125 crs: Optional[gws.CrsName]
126 """Reference system of the data, written to MD_ReferenceSystem in CSW records."""
127 temporalBegin: Optional[gws.DateStr]
128 """Start of the temporal extent of the data."""
129 temporalEnd: Optional[gws.DateStr]
130 """End of the temporal extent of the data."""
132 inspireMandatoryKeyword: Optional[inspire.IM_MandatoryKeyword]
133 """INSPIRE service type, added to the keywords as the ISO serviceType keyword."""
134 inspireDegreeOfConformity: Optional[inspire.IM_DegreeOfConformity]
135 """Degree of conformity with the INSPIRE implementing rules."""
136 inspireResourceType: Optional[inspire.IM_ResourceType]
137 """INSPIRE resource type."""
138 inspireSpatialDataServiceType: Optional[inspire.IM_SpatialDataServiceType]
139 """INSPIRE spatial data service type."""
140 inspireSpatialScope: Optional[inspire.IM_SpatialScope]
141 """INSPIRE spatial scope."""
142 inspireSpatialScopeName: Optional[str]
143 """Display name of the INSPIRE spatial scope."""
144 inspireTheme: Optional[inspire.IM_Theme]
145 """INSPIRE data theme, added to the keywords as a GEMET INSPIRE theme."""
147 isoMaintenanceFrequencyCode: Optional[iso.MD_MaintenanceFrequencyCode]
148 """How often the data is updated."""
149 isoQualityConformanceExplanation: Optional[str]
150 """Explanation of the conformance result."""
151 isoQualityConformanceQualityPass: Optional[bool]
152 """The data passes the conformance test."""
153 isoQualityConformanceSpecificationDate: Optional[str]
154 """Publication date of the specification the conformance is tested against."""
155 isoQualityConformanceSpecificationTitle: Optional[str]
156 """Title of the specification the conformance is tested against."""
157 isoQualityLineageSource: Optional[str]
158 """Description of the source data."""
159 isoQualityLineageSourceScale: Optional[int]
160 """Scale denominator of the source data."""
161 isoQualityLineageStatement: Optional[str]
162 """Statement on the lineage of the data."""
163 isoRestrictionCode: Optional[iso.MD_RestrictionCode]
164 """Restrictions on access or use of the data."""
165 isoServiceFunction: Optional[iso.SV_ServiceFunction]
166 """ISO service function."""
167 isoScope: Optional[iso.MD_ScopeCode]
168 """Scope of the metadata."""
169 isoScopeName: Optional[str]
170 """Name of the scope, written to hierarchyLevelName in CSW records."""
171 isoSpatialRepresentationType: Optional[iso.MD_SpatialRepresentationTypeCode]
172 """How the data is represented spatially."""
173 isoTopicCategories: Optional[list[iso.MD_TopicCategoryCode]]
174 """ISO 19115 topic categories, added to the keywords."""
175 isoSpatialResolution: Optional[int]
176 """Spatial resolution as a scale denominator."""
179##
181_KEYWORD_CODE_SPACES = {
182 'iso': ['ISOTC211/19115', 'http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_KeywordTypeCode'],
183 'gemet': ['GEMET', 'http://www.eionet.europa.eu/gemet/2004/06/gemet-version.rdf'],
184 'inspire_themes': ['GEMET - INSPIRE themes', 'http://inspire.ec.europa.eu/theme'],
185 'gcmd': ['gcmd', 'http://gcmd.nasa.gov/Resources/valids/locations.html'],
186}
189class KeywordGroup(gws.Data):
190 """Keywords from one vocabulary, as written to OWS keyword lists."""
192 codeSpace: str
193 """Code space for the keyword group, e.g. 'iso', 'gemet', 'inspire', 'gcmd'."""
194 typeName: str
195 """Type name for the keyword group, e.g. 'isoTopicCategories', 'keywords'."""
196 keywords: list[str]
197 """List of keywords in the group."""
200def keyword_groups(md: gws.Metadata) -> list[KeywordGroup]:
201 """Group the keywords of a metadata object by code space and type name.
203 Keywords without a vocabulary prefix go to a group with an empty code
204 space and type name; keywords with a prefix (``vocabulary:keyword``) are
205 currently not included. The INSPIRE theme, the ISO topic categories and
206 the INSPIRE mandatory keyword get groups of their own.
208 Args:
209 md: Metadata object.
211 Returns:
212 Keyword groups.
213 """
214 d = {}
216 def add(kw):
217 p = kw.split(':')
218 if len(p) == 1:
219 return add2('', '', kw)
221 def add2(code_space, type_name, kw):
222 if code_space.lower() in _KEYWORD_CODE_SPACES:
223 code_space = _KEYWORD_CODE_SPACES[code_space.lower()][0]
224 key = (code_space, type_name)
225 if key not in d:
226 d[key] = KeywordGroup(codeSpace=code_space, typeName=type_name, keywords=[])
227 d[key].keywords.append(kw)
229 if md.keywords:
230 for kw in md.keywords:
231 add(kw)
232 if md.inspireTheme:
233 add2('inspire_themes', 'theme', md.inspireTheme)
234 if md.isoTopicCategories:
235 for cat in md.isoTopicCategories:
236 add2('iso', 'isoTopicCategory', cat)
237 if md.inspireMandatoryKeyword:
238 add2('iso', 'serviceType', md.inspireMandatoryKeyword)
240 return list(d.values())
243class Props(gws.Props):
244 """Represents metadata properties."""
246 abstract: str
247 attribution: str
248 dateCreated: str
249 dateUpdated: str
250 keywords: list[str]
251 language: str
252 title: str
255def new() -> gws.Metadata:
256 """Create an empty metadata object.
258 The list values ``keywords``, ``isoTopicCategories`` and ``metaLinks``
259 are empty lists, all other values are unset.
261 Returns:
262 A new metadata object.
263 """
265 return _new()
268def from_dict(d: dict) -> gws.Metadata:
269 """Create a metadata object from a dictionary.
271 Args:
272 d: Metadata values by key.
274 Returns:
275 A new metadata object.
276 """
278 return _update(_new(), d)
281def from_args(*args, **kwargs) -> gws.Metadata:
282 """Create a metadata object by merging several sources.
284 Args:
285 *args: Dicts or data objects (configs, props, metadata objects),
286 merged in order; ``None`` values are skipped.
287 **kwargs: Values merged last.
289 Returns:
290 A new metadata object.
291 """
293 return _update(_new(), *args, **kwargs)
296def from_config(c: gws.Config) -> gws.Metadata:
297 """Create a metadata object from a metadata configuration.
299 Args:
300 c: Metadata configuration.
302 Returns:
303 A new metadata object.
304 """
306 return _update(_new(), c)
309def from_props(p: gws.Props) -> gws.Metadata:
310 """Create a metadata object from metadata props.
312 Args:
313 p: Metadata props.
315 Returns:
316 A new metadata object.
317 """
319 return _update(_new(), p)
322def update(md: gws.Metadata, *args, **kwargs) -> gws.Metadata:
323 """Merge values into a metadata object in place.
325 ``keywords`` and ``isoTopicCategories`` are added to the existing values,
326 other keys replace them. ``None`` values do not override existing ones.
328 Args:
329 md: Metadata object to update.
330 *args: Dicts or data objects, merged in order.
331 **kwargs: Values merged last.
333 Returns:
334 The updated metadata object.
335 """
337 _update(md, *args, **kwargs)
338 return md
341def normalize(md: gws.Metadata) -> gws.Metadata:
342 """Return a normalized copy of a metadata object.
344 Dates are parsed to ``datetime`` objects, keyword lists are deduplicated
345 and sorted, and the derived language codes and INSPIRE theme names are
346 filled in.
348 Args:
349 md: Metadata object.
351 Returns:
352 A new metadata object.
353 """
355 nor = _new()
356 _update(nor, md)
357 return nor
360def props(md: gws.Metadata) -> gws.Props:
361 """Convert a metadata object to client props.
363 Args:
364 md: Metadata object.
366 Returns:
367 Props with the abstract, attribution, dates as ISO date strings,
368 sorted keywords, language and title. Missing values are empty strings.
369 """
371 dc = dtx.parse(md.dateCreated)
372 du = dtx.parse(md.dateUpdated)
374 return gws.Props(
375 abstract=md.abstract or '',
376 attribution=md.attribution or '',
377 dateCreated=dtx.to_iso_date_string(dc) if dc else '',
378 dateUpdated=dtx.to_iso_date_string(du) if du else '',
379 keywords=sorted(md.keywords or []),
380 language=md.language or '',
381 title=md.title or '',
382 )
385##
388def _new() -> gws.Metadata:
389 """Create a metadata object with the list values initialized."""
390 md = gws.Metadata()
391 for key, fn in _UPDATE_FNS.items():
392 fn(md, key, None)
393 return md
396def _update(md: gws.Metadata, *args, **kwargs):
397 """Merge dicts or data objects into a metadata object and fix the language fields."""
398 def add(a):
399 for key, val in a.items():
400 fn = _UPDATE_FNS.get(key)
401 if fn:
402 fn(md, key, val)
403 elif val is not None:
404 setattr(md, key, val)
406 for a in args:
407 if not a:
408 continue
409 if isinstance(a, gws.Data):
410 a = gws.u.to_dict(a)
411 add(a)
413 add(kwargs)
414 _fix_language(md)
416 return md
419def _update_set(md: gws.Metadata, key, val):
420 """Add values to a list attribute, deduplicated and sorted."""
421 s = set(getattr(md, key, None) or [])
422 s.update(val or [])
423 setattr(md, key, sorted(s))
426def _update_list(md: gws.Metadata, key, val):
427 """Replace a list attribute."""
428 setattr(md, key, val or [])
431def _update_datetime(md: gws.Metadata, key, val):
432 """Set a date attribute from a parseable value; invalid values are ignored."""
433 if val:
434 dt = dtx.parse(val)
435 if dt:
436 setattr(md, key, dt)
439def _fix_language(md: gws.Metadata):
440 """Set the three-letter language codes and the INSPIRE theme names."""
441 lang = md.language or 'en'
443 md.language3 = gws.lib.intl.locale(lang).language3
444 md.languageBib = gws.lib.intl.locale(lang).languageBib
446 if md.inspireTheme:
447 md.inspireThemeNameLocal = inspire.theme_name(md.inspireTheme, md.language) or ''
448 md.inspireThemeNameEn = inspire.theme_name(md.inspireTheme, 'en') or ''
451_UPDATE_FNS = dict(
452 keywords=_update_set,
453 isoTopicCategories=_update_set,
454 metaLinks=_update_list,
455 dateCreated=_update_datetime,
456 dateUpdated=_update_datetime,
457 temporalBegin=_update_datetime,
458 temporalEnd=_update_datetime,
459)