Coverage for gws-app/gws/base/ows/client/parseutil.py: 99%
137 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"""Parse utilities for OWS capabilities documents."""
3from typing import Optional
5import re
7import gws
8import gws.base.metadata
9import gws.lib.crs
10import gws.lib.extent
11import gws.lib.net
14def service_operations(caps_el: gws.XmlElement) -> list[gws.OwsOperation]:
15 """Read the operations from a capabilities document.
17 Supports both the OWS Common format (``OperationsMetadata/Operation``)
18 and the WMS format (``Capability/Request``).
20 Args:
21 caps_el: Root element of the capabilities document.
23 Returns:
24 A list of operations, empty if none are found.
25 """
27 # <ows:OperationsMetadata>
28 # <ows:Operation name="GetCapabilities">...
30 els = caps_el.findall('OperationsMetadata/Operation')
31 if els:
32 return [_parse_operation(e) for e in els]
34 # <Capability>
35 # <Request>
36 # <GetCapabilities>...
38 el = caps_el.find('Capability/Request')
39 if el:
40 return [_parse_operation(e) for e in el]
42 return []
45def _parse_operation(el: gws.XmlElement) -> gws.OwsOperation:
46 """Read an operation with its allowed parameters, URLs and formats."""
47 op = gws.OwsOperation(verb=el.get('name') or el.tag)
49 # @TODO Range
50 # @TODO Constraint
52 # <Parameter name="Format">
53 # <AllowedValues>
54 # <Value>image/gif</Value>
55 # ...
57 # <Parameter name="AcceptVersions">
58 # <Value>1.0.0</Value>
60 op.allowedParameters = {}
61 for param_el in el.findall('Parameter'):
62 values = param_el.textlist('Value') + param_el.textlist('AllowedValues/Value')
63 if values:
64 op.allowedParameters[param_el.get('name').upper()] = values
66 # <Operation name="GetMap">
67 # <DCP> <HTTP>
68 # <Get xlink:href="...."/>
69 # <Post xlink:href="..."/>
70 # </HTTP> </DCP>
71 #
72 #
73 # <GetMap>
74 # <Format>image/png</Format>
75 # <DCPType> <HTTP> <Get>
76 # <OnlineResource xlink:type="simple" xlink:href="..."/>
77 # </Get> </HTTP> </DCPType>
78 # </GetMap>
80 op.postUrl = _parse_url(el.findfirst('DCP/HTTP/Post', 'DCPType/HTTP/Post'))
82 u = _parse_url(el.findfirst('DCP/HTTP/Get', 'DCPType/HTTP/Get'))
83 op.url, op.params = gws.lib.net.extract_params(u)
85 op.mimeTypes = el.textlist('Format')
86 if 'OUTPUTFORMAT' in op.allowedParameters:
87 op.mimeTypes.extend(op.allowedParameters['OUTPUTFORMAT'])
89 return op
92##
95def service_metadata(caps_el: gws.XmlElement) -> gws.Metadata:
96 """Read the service metadata from a capabilities document.
98 Reads the WMS ``Service`` element or the OWS ``ServiceIdentification``
99 and ``ServiceProvider`` elements, including contact information.
101 Args:
102 caps_el: Root element of the capabilities document.
104 Returns:
105 Normalized metadata.
106 """
108 # wms
109 #
110 # <Capabilities
111 # <Service...
112 # <Name>...
113 # <Title>...
114 # <ContactInformation>...
115 #
116 # ows
117 #
118 # <Capabilities
119 # <ows:ServiceIdentification>
120 # <ows:Title>....
121 # <ows:ServiceProvider>
122 # <ows:ProviderName>...
123 # <ows:ServiceContact>...
125 md = gws.base.metadata.new()
127 _element_metadata(caps_el.findfirst('Service', 'ServiceIdentification'), md)
128 _contact_metadata(caps_el.findfirst('Service/ContactInformation', 'ServiceProvider/ServiceContact'), md)
130 md.contactProviderName = caps_el.textof('ServiceProvider/ProviderName')
131 md.contactProviderSite = caps_el.textof('ServiceProvider/ProviderSite')
133 # <Capabilities
134 # <ServiceMetadataURL
136 service_link = _parse_link(caps_el.find('ServiceMetadataURL'))
137 if service_link:
138 md.serviceMetadataURL = service_link.url
140 return gws.base.metadata.normalize(md)
143def element_metadata(el: gws.XmlElement) -> gws.Metadata:
144 """Read the metadata of an element, such as a ``Layer`` or ``FeatureType``.
146 Args:
147 el: XML element.
149 Returns:
150 Normalized metadata.
151 """
153 # <whatever, e.g. Layer or FeatureType
154 # <Name...
155 # <Title...
157 md = gws.base.metadata.new()
158 _element_metadata(el, md)
159 return gws.base.metadata.normalize(md)
162def _element_metadata(el: Optional[gws.XmlElement], md: gws.Metadata):
163 """Copy common metadata fields (title, abstract, keywords etc.) from an element into ``md``."""
164 if not el:
165 return
167 md.abstract = el.textof('Abstract')
168 md.accessConstraints = el.textof('AccessConstraints')
169 md.attribution = el.textof('Attribution/Title')
170 md.fees = el.textof('Fees')
171 md.keywords = el.textlist('Keywords', 'KeywordList', deep=True)
172 md.name = el.textof('Name', 'Identifier')
173 md.title = el.textof('Title')
174 md.metaLinks = gws.u.compact(_parse_link(e) for e in el.findall('MetadataURL'))
176 e = el.find('AuthorityURL')
177 if e:
178 md.authorityUrl = _parse_url(e)
179 md.authorityName = e.get('name')
181 e = el.find('Identifier')
182 if e:
183 md.authorityIdentifier = e.text
186_contact_mapping = [
187 # wms
188 ('contactArea', 'StateOrProvince'),
189 ('contactCity', 'City'),
190 ('contactCountry', 'Country'),
191 ('contactEmail', 'ContactElectronicMailAddress'),
192 ('contactFax', 'ContactFacsimileTelephone'),
193 ('contactOrganization', 'ContactOrganization'),
194 ('contactPerson', 'ContactPerson'),
195 ('contactPhone', 'ContactVoiceTelephone'),
196 ('contactPosition', 'ContactPosition'),
197 ('contactZip', 'PostCode'),
198 # ows
199 ('contactArea', 'AdministrativeArea'),
200 ('contactCity', 'City'),
201 ('contactCountry', 'Country'),
202 ('contactEmail', 'ElectronicMailAddress'),
203 ('contactFax', 'Facsimile'),
204 ('contactOrganization', 'ProviderName'),
205 ('contactPerson', 'IndividualName'),
206 ('contactPhone', 'Voice'),
207 ('contactPosition', 'PositionName'),
208 ('contactZip', 'PostalCode'),
209]
212def _contact_metadata(el: Optional[gws.XmlElement], md: gws.Metadata):
213 """Copy contact information from an element into ``md``."""
214 if not el:
215 return
217 src = el.textdict(deep=True)
219 for dkey, skey in _contact_mapping:
220 if skey in src:
221 setattr(md, dkey, src[skey])
224##
227def wgs_extent(layer_el: gws.XmlElement) -> Optional[gws.Extent]:
228 """Read the WGS84 bounding box from a ``Layer`` or ``FeatureType`` element.
230 Extracts coordinates from ``EX_GeographicBoundingBox`` (WMS), ``WGS84BoundingBox`` (OWS)
231 or ``LatLonBoundingBox``. For the latter, assume x=longitude, y=latitude,
232 as per OGC 01-068r3, 6.5.6.
234 Args:
235 layer_el: ``Layer`` or ``FeatureType`` element.
237 Returns:
238 The extent, or ``None`` if the element has no WGS84 bounding box.
239 """
241 el = layer_el.findfirst('EX_GeographicBoundingBox', 'WGS84BoundingBox', 'LatLonBoundingBox')
242 if el:
243 bbox = _parse_bbox(el)
244 if bbox:
245 return gws.lib.extent.from_list(bbox)
248def supported_crs(layer_el: gws.XmlElement, extra_crs_ids: list[str] = None) -> list[gws.Crs]:
249 """Enumerate the supported CRSs of a ``Layer`` or ``FeatureType`` element.
251 For WMS, enumerates ``CRS``/``SRS`` and ``BoundingBox`` tags,
252 for OWS, ``DefaultCRS`` and ``OtherCRS``. Unknown CRS ids are skipped.
254 Args:
255 layer_el: ``Layer`` or ``FeatureType`` element.
256 extra_crs_ids: Additional CRS ids.
258 Returns:
259 A list of ``Crs`` objects.
260 """
262 # <Layer...
263 # <CRS>EPSG....
264 # <BoundingBox CRS="EPSG:" minx=....
265 #
266 # <FeatureType...
267 # <DefaultCRS>urn:ogc:def:crs:EPSG...
268 # <OtherCRS>urn:ogc:def:crs:EPSG...
270 crsids = set()
272 for el in layer_el.findall('BoundingBox'):
273 crsids.add(el.get('SRS') or el.get('CRS'))
275 for tag in 'DefaultSRS', 'DefaultCRS', 'OtherSRS', 'OtherCRS', 'SRS', 'CRS':
276 for el in layer_el.findall(tag):
277 if el.text:
278 crsids.add(el.text)
280 crsids.update(extra_crs_ids or [])
282 return [gws.lib.crs.require(s) for s in crsids if gws.lib.crs.is_valid(s)]
285##
288def parse_style(el: gws.XmlElement) -> gws.SourceStyle:
289 """Read a ``Style`` element.
291 The style is marked as default if it has ``IsDefault="true"``
292 or its name is ``default`` or ends with ``:default``.
294 Args:
295 el: ``Style`` element.
297 Returns:
298 The source style, with a lower-cased name.
299 """
301 # <Style>
302 # <Name>default...
303 # <Title>...
304 # <LegendURL
305 # <Format>...
306 # <OnlineResource...
308 st = gws.SourceStyle()
310 st.metadata = element_metadata(el)
311 st.name = st.metadata.get('name', '').lower()
312 st.legendUrl = _parse_url(el.findfirst('LegendURL'))
313 st.isDefault = el.get('IsDefault') == 'true' or st.name == 'default' or st.name.endswith(':default')
314 return st
317def default_style(styles: list[gws.SourceStyle]) -> Optional[gws.SourceStyle]:
318 """Find the default style in a list of styles.
320 Args:
321 styles: Source styles.
323 Returns:
324 The first style marked as default, otherwise the first style, or ``None`` if the list is empty.
325 """
327 for s in styles:
328 if s.isDefault:
329 return s
330 return styles[0] if styles else None
333##
336def to_float(s, default=0.0):
337 """Convert a string to a float.
339 Args:
340 s: String value.
341 default: Value used when ``s`` is empty.
343 Returns:
344 The float value.
346 Raises:
347 ``ValueError``: If the string is not a number.
348 """
350 return float(s or default)
353def to_int(s, default=0):
354 """Convert a string to an int, accepting float strings.
356 Args:
357 s: String value.
358 default: Value used when ``s`` is empty.
360 Returns:
361 The value, truncated to an int.
363 Raises:
364 ``ValueError``: If the string is not a number.
365 """
367 # accept floats as well, but convert to int
368 return int(float(s or default))
371def to_float_pair(s):
372 """Convert a whitespace-separated string to a pair of floats.
374 Args:
375 s: String like ``"1.5 2.5"``.
377 Returns:
378 The first two numbers as a tuple.
380 Raises:
381 ``ValueError``: If a value is not a number.
382 ``IndexError``: If there are fewer than two values.
383 """
385 s = s.split()
386 return float(s[0]), float(s[1])
389##
392def _parse_bbox(el: gws.XmlElement):
393 """Read a bounding box element in any of the supported formats as ``[x1, y1, x2, y2]``, or ``None``."""
395 # note: bboxes are always converted to (x1, y1, x2, y2) with x1 < x2, y1 < y2
397 # <BoundingBox/LatLonBoundingBox CRS="..." minx="0" miny="1" maxx="2" maxy="3"/>
399 if el.get('minx'):
400 return [
401 to_float(el.get('minx')),
402 to_float(el.get('miny')),
403 to_float(el.get('maxx')),
404 to_float(el.get('maxy')),
405 ]
407 # <ows:BoundingBox/WGS84BoundingBox
408 # <ows:LowerCorner> 0 1
409 # <ows:UpperCorner> 2 3
411 if el.findfirst('LowerCorner'):
412 x1, y1 = to_float_pair(el.textof('LowerCorner'))
413 x2, y2 = to_float_pair(el.textof('UpperCorner'))
414 return [
415 min(x1, x2),
416 min(y1, y2),
417 max(x1, x2),
418 max(y1, y2),
419 ]
421 # <EX_GeographicBoundingBox>
422 # <westBoundLongitude> 0
423 # <eastBoundLongitude> 2
424 # <southBoundLatitude> 1
425 # <northBoundLatitude> 3
427 if el.findfirst('westBoundLongitude'):
428 x1 = to_float(el.textof('eastBoundLongitude'))
429 y1 = to_float(el.textof('southBoundLatitude'))
430 x2 = to_float(el.textof('westBoundLongitude'))
431 y2 = to_float(el.textof('northBoundLatitude'))
432 return [
433 min(x1, x2),
434 min(y1, y2),
435 max(x1, x2),
436 max(y1, y2),
437 ]
440def _parse_url(el: gws.XmlElement) -> str:
441 """Read a URL from an ``href`` attribute or a nested ``OnlineResource``."""
443 def cleanup(s):
444 return (s or '').strip(' ?&')
446 if not el:
447 return ''
449 # <ows:DCP>
450 # <ows:HTTP>
451 # <ows:Get xlink:href=... <-- we are here
453 s = el.get('href') or el.get('onlineResource')
454 if s:
455 return cleanup(s)
457 # <whatever <--
458 # <OnlineResource xlink:href=...
460 e = el.findfirst('OnlineResource')
461 if e:
462 return cleanup(e.get('href', default=e.text))
464 return ''
467def _parse_link(el: gws.XmlElement) -> Optional[gws.MetadataLink]:
468 """Read a metadata link in the simple or nested format."""
470 if not el:
471 return None
473 # see base/ows/server/templatelib.py
474 # regarding different MetadataURL formats
476 # simple
477 if el.get('href'):
478 return gws.MetadataLink(url=el.get('href'))
480 # nested
481 d = gws.u.strip(
482 {
483 'url': _parse_url(el),
484 'type': el.get('type'),
485 'format': el.textof('Format'),
486 }
487 )
489 if d:
490 return gws.MetadataLink(d)