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

1"""Metadata objects: creation, merging and conversion.""" 

2 

3from typing import Optional 

4import gws 

5import gws.lib.intl 

6import gws.lib.datetimex as dtx 

7 

8from . import inspire, iso 

9 

10 

11class LinkConfig(gws.Config): 

12 """Link to a metadata document or another resource related to the object.""" 

13 

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.""" 

33 

34 

35class Config(gws.Config): 

36 """Metadata of an object, used in the client and in OWS services.""" 

37 

38 name: Optional[str] 

39 """Object name.""" 

40 title: Optional[str] 

41 """Title of the object.""" 

42 

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.""" 

67 

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.""" 

100 

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.""" 

107 

108 metaLinks: Optional[list[LinkConfig]] 

109 """Links to metadata documents.""" 

110 serviceMetadataURL: Optional[str] 

111 """URL of the service metadata document.""" 

112 

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.""" 

117 

118 language: Optional[str] 

119 """Language of the object as an ISO 639-1 code.""" 

120 

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.""" 

131 

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.""" 

146 

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.""" 

177 

178 

179## 

180 

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} 

187 

188 

189class KeywordGroup(gws.Data): 

190 """Keywords from one vocabulary, as written to OWS keyword lists.""" 

191 

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.""" 

198 

199 

200def keyword_groups(md: gws.Metadata) -> list[KeywordGroup]: 

201 """Group the keywords of a metadata object by code space and type name. 

202 

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. 

207 

208 Args: 

209 md: Metadata object. 

210 

211 Returns: 

212 Keyword groups. 

213 """ 

214 d = {} 

215 

216 def add(kw): 

217 p = kw.split(':') 

218 if len(p) == 1: 

219 return add2('', '', kw) 

220 

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) 

228 

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) 

239 

240 return list(d.values()) 

241 

242 

243class Props(gws.Props): 

244 """Represents metadata properties.""" 

245 

246 abstract: str 

247 attribution: str 

248 dateCreated: str 

249 dateUpdated: str 

250 keywords: list[str] 

251 language: str 

252 title: str 

253 

254 

255def new() -> gws.Metadata: 

256 """Create an empty metadata object. 

257 

258 The list values ``keywords``, ``isoTopicCategories`` and ``metaLinks`` 

259 are empty lists, all other values are unset. 

260 

261 Returns: 

262 A new metadata object. 

263 """ 

264 

265 return _new() 

266 

267 

268def from_dict(d: dict) -> gws.Metadata: 

269 """Create a metadata object from a dictionary. 

270 

271 Args: 

272 d: Metadata values by key. 

273 

274 Returns: 

275 A new metadata object. 

276 """ 

277 

278 return _update(_new(), d) 

279 

280 

281def from_args(*args, **kwargs) -> gws.Metadata: 

282 """Create a metadata object by merging several sources. 

283 

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. 

288 

289 Returns: 

290 A new metadata object. 

291 """ 

292 

293 return _update(_new(), *args, **kwargs) 

294 

295 

296def from_config(c: gws.Config) -> gws.Metadata: 

297 """Create a metadata object from a metadata configuration. 

298 

299 Args: 

300 c: Metadata configuration. 

301 

302 Returns: 

303 A new metadata object. 

304 """ 

305 

306 return _update(_new(), c) 

307 

308 

309def from_props(p: gws.Props) -> gws.Metadata: 

310 """Create a metadata object from metadata props. 

311 

312 Args: 

313 p: Metadata props. 

314 

315 Returns: 

316 A new metadata object. 

317 """ 

318 

319 return _update(_new(), p) 

320 

321 

322def update(md: gws.Metadata, *args, **kwargs) -> gws.Metadata: 

323 """Merge values into a metadata object in place. 

324 

325 ``keywords`` and ``isoTopicCategories`` are added to the existing values, 

326 other keys replace them. ``None`` values do not override existing ones. 

327 

328 Args: 

329 md: Metadata object to update. 

330 *args: Dicts or data objects, merged in order. 

331 **kwargs: Values merged last. 

332 

333 Returns: 

334 The updated metadata object. 

335 """ 

336 

337 _update(md, *args, **kwargs) 

338 return md 

339 

340 

341def normalize(md: gws.Metadata) -> gws.Metadata: 

342 """Return a normalized copy of a metadata object. 

343 

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. 

347 

348 Args: 

349 md: Metadata object. 

350 

351 Returns: 

352 A new metadata object. 

353 """ 

354 

355 nor = _new() 

356 _update(nor, md) 

357 return nor 

358 

359 

360def props(md: gws.Metadata) -> gws.Props: 

361 """Convert a metadata object to client props. 

362 

363 Args: 

364 md: Metadata object. 

365 

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 """ 

370 

371 dc = dtx.parse(md.dateCreated) 

372 du = dtx.parse(md.dateUpdated) 

373 

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 ) 

383 

384 

385## 

386 

387 

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 

394 

395 

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) 

405 

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) 

412 

413 add(kwargs) 

414 _fix_language(md) 

415 

416 return md 

417 

418 

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)) 

424 

425 

426def _update_list(md: gws.Metadata, key, val): 

427 """Replace a list attribute.""" 

428 setattr(md, key, val or []) 

429 

430 

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) 

437 

438 

439def _fix_language(md: gws.Metadata): 

440 """Set the three-letter language codes and the INSPIRE theme names.""" 

441 lang = md.language or 'en' 

442 

443 md.language3 = gws.lib.intl.locale(lang).language3 

444 md.languageBib = gws.lib.intl.locale(lang).languageBib 

445 

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 '' 

449 

450 

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)