Coverage for gws-app/gws/base/metadata/core.py: 96%
175 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +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, ``language3`` and ``languageBib`` are set for the default
260 language ``en``, all other values are unset.
262 Returns:
263 A new metadata object.
264 """
266 md = _new()
267 _fix_language(md)
268 return md
271def from_dict(d: dict) -> gws.Metadata:
272 """Create a metadata object from a dictionary.
274 Args:
275 d: Metadata values by key.
277 Returns:
278 A new metadata object.
279 """
281 return _update(_new(), d)
284def from_args(*args, **kwargs) -> gws.Metadata:
285 """Create a metadata object by merging several sources.
287 Args:
288 *args: Dicts or data objects (configs, props, metadata objects),
289 merged in order; ``None`` values are skipped.
290 **kwargs: Values merged last.
292 Returns:
293 A new metadata object.
294 """
296 return _update(_new(), *args, **kwargs)
299def from_config(c: gws.Config) -> gws.Metadata:
300 """Create a metadata object from a metadata configuration.
302 Args:
303 c: Metadata configuration.
305 Returns:
306 A new metadata object.
307 """
309 return _update(_new(), c)
312def from_props(p: gws.Props) -> gws.Metadata:
313 """Create a metadata object from metadata props.
315 Args:
316 p: Metadata props.
318 Returns:
319 A new metadata object.
320 """
322 return _update(_new(), p)
325def update(md: gws.Metadata, *args, **kwargs) -> gws.Metadata:
326 """Merge values into a metadata object in place.
328 ``keywords`` and ``isoTopicCategories`` are added to the existing values,
329 other keys replace them. ``None`` values do not override existing ones.
331 Args:
332 md: Metadata object to update.
333 *args: Dicts or data objects, merged in order.
334 **kwargs: Values merged last.
336 Returns:
337 The updated metadata object.
338 """
340 _update(md, *args, **kwargs)
341 return md
344def normalize(md: gws.Metadata) -> gws.Metadata:
345 """Return a normalized copy of a metadata object.
347 Dates are parsed to ``datetime`` objects, keyword lists are deduplicated
348 and sorted, and the derived language codes and INSPIRE theme names are
349 filled in.
351 Args:
352 md: Metadata object.
354 Returns:
355 A new metadata object.
356 """
358 nor = _new()
359 _update(nor, md)
360 return nor
363def props(md: gws.Metadata) -> gws.Props:
364 """Convert a metadata object to client props.
366 Args:
367 md: Metadata object.
369 Returns:
370 Props with the abstract, attribution, dates as ISO date strings,
371 sorted keywords, language and title. Missing values are empty strings.
372 """
374 dc = dtx.parse(md.dateCreated)
375 du = dtx.parse(md.dateUpdated)
377 return gws.Props(
378 abstract=md.abstract or '',
379 attribution=md.attribution or '',
380 dateCreated=dtx.to_iso_date_string(dc) if dc else '',
381 dateUpdated=dtx.to_iso_date_string(du) if du else '',
382 keywords=sorted(md.keywords or []),
383 language=md.language or '',
384 title=md.title or '',
385 )
388##
391def _new() -> gws.Metadata:
392 """Create a metadata object with the list values initialized."""
393 md = gws.Metadata()
394 for key, fn in _UPDATE_FNS.items():
395 fn(md, key, None)
396 return md
399def _update(md: gws.Metadata, *args, **kwargs):
400 """Merge dicts or data objects into a metadata object and fix the language fields."""
401 def add(a):
402 for key, val in a.items():
403 fn = _UPDATE_FNS.get(key)
404 if fn:
405 fn(md, key, val)
406 elif val is not None:
407 setattr(md, key, val)
409 for a in args:
410 if not a:
411 continue
412 if isinstance(a, gws.Data):
413 a = gws.u.to_dict(a)
414 add(a)
416 add(kwargs)
417 _fix_language(md)
419 return md
422def _update_set(md: gws.Metadata, key, val):
423 """Add values to a list attribute, deduplicated and sorted."""
424 s = set(getattr(md, key, None) or [])
425 s.update(val or [])
426 setattr(md, key, sorted(s))
429def _update_list(md: gws.Metadata, key, val):
430 """Replace a list attribute."""
431 setattr(md, key, val or [])
434def _update_datetime(md: gws.Metadata, key, val):
435 """Set a date attribute from a parseable value; invalid values are ignored."""
436 if val:
437 dt = dtx.parse(val)
438 if dt:
439 setattr(md, key, dt)
442def _fix_language(md: gws.Metadata):
443 """Set the three-letter language codes and the INSPIRE theme names."""
444 lang = md.language or 'en'
446 md.language3 = gws.lib.intl.locale(lang).language3
447 md.languageBib = gws.lib.intl.locale(lang).languageBib
449 if md.inspireTheme:
450 md.inspireThemeNameLocal = inspire.theme_name(md.inspireTheme, md.language) or ''
451 md.inspireThemeNameEn = inspire.theme_name(md.inspireTheme, 'en') or ''
454_UPDATE_FNS = dict(
455 keywords=_update_set,
456 isoTopicCategories=_update_set,
457 metaLinks=_update_list,
458 dateCreated=_update_datetime,
459 dateUpdated=_update_datetime,
460 temporalBegin=_update_datetime,
461 temporalEnd=_update_datetime,
462)