Coverage for gws-app/gws/base/ows/client/parseutil.py: 99%
137 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"""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.formats = el.textlist('Format')
86 if 'OUTPUTFORMAT' in op.allowedParameters:
87 op.formats.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
189 ('contactArea', 'StateOrProvince'),
190 ('contactCity', 'City'),
191 ('contactCountry', 'Country'),
192 ('contactEmail', 'ContactElectronicMailAddress'),
193 ('contactFax', 'ContactFacsimileTelephone'),
194 ('contactOrganization', 'ContactOrganization'),
195 ('contactPerson', 'ContactPerson'),
196 ('contactPhone', 'ContactVoiceTelephone'),
197 ('contactPosition', 'ContactPosition'),
198 ('contactZip', 'PostCode'),
200 # ows
202 ('contactArea', 'AdministrativeArea'),
203 ('contactCity', 'City'),
204 ('contactCountry', 'Country'),
205 ('contactEmail', 'ElectronicMailAddress'),
206 ('contactFax', 'Facsimile'),
207 ('contactOrganization', 'ProviderName'),
208 ('contactPerson', 'IndividualName'),
209 ('contactPhone', 'Voice'),
210 ('contactPosition', 'PositionName'),
211 ('contactZip', 'PostalCode'),
212]
215def _contact_metadata(el: Optional[gws.XmlElement], md: gws.Metadata):
216 """Copy contact information from an element into ``md``."""
217 if not el:
218 return
220 src = el.textdict(deep=True)
222 for dkey, skey in _contact_mapping:
223 if skey in src:
224 setattr(md, dkey, src[skey])
227##
229def wgs_extent(layer_el: gws.XmlElement) -> Optional[gws.Extent]:
230 """Read the WGS84 bounding box from a ``Layer`` or ``FeatureType`` element.
232 Extracts coordinates from ``EX_GeographicBoundingBox`` (WMS), ``WGS84BoundingBox`` (OWS)
233 or ``LatLonBoundingBox``. For the latter, assume x=longitude, y=latitude,
234 as per OGC 01-068r3, 6.5.6.
236 Args:
237 layer_el: ``Layer`` or ``FeatureType`` element.
239 Returns:
240 The extent, or ``None`` if the element has no WGS84 bounding box.
241 """
243 el = layer_el.findfirst('EX_GeographicBoundingBox', 'WGS84BoundingBox', 'LatLonBoundingBox')
244 if el:
245 bbox = _parse_bbox(el)
246 if bbox:
247 return gws.lib.extent.from_list(bbox)
250def supported_crs(layer_el: gws.XmlElement, extra_crs_ids: list[str] = None) -> list[gws.Crs]:
251 """Enumerate the supported CRSs of a ``Layer`` or ``FeatureType`` element.
253 For WMS, enumerates ``CRS``/``SRS`` and ``BoundingBox`` tags,
254 for OWS, ``DefaultCRS`` and ``OtherCRS``. Unknown CRS ids are skipped.
256 Args:
257 layer_el: ``Layer`` or ``FeatureType`` element.
258 extra_crs_ids: Additional CRS ids.
260 Returns:
261 A list of ``Crs`` objects.
262 """
264 # <Layer...
265 # <CRS>EPSG....
266 # <BoundingBox CRS="EPSG:" minx=....
267 #
268 # <FeatureType...
269 # <DefaultCRS>urn:ogc:def:crs:EPSG...
270 # <OtherCRS>urn:ogc:def:crs:EPSG...
272 crsids = set()
274 for el in layer_el.findall('BoundingBox'):
275 crsids.add(el.get('SRS') or el.get('CRS'))
277 for tag in 'DefaultSRS', 'DefaultCRS', 'OtherSRS', 'OtherCRS', 'SRS', 'CRS':
278 for el in layer_el.findall(tag):
279 if el.text:
280 crsids.add(el.text)
282 crsids.update(extra_crs_ids or [])
284 return gws.u.compact(gws.lib.crs.get(s) for s in crsids)
287##
290def parse_style(el: gws.XmlElement) -> gws.SourceStyle:
291 """Read a ``Style`` element.
293 The style is marked as default if it has ``IsDefault="true"``
294 or its name is ``default`` or ends with ``:default``.
296 Args:
297 el: ``Style`` element.
299 Returns:
300 The source style, with a lower-cased name.
301 """
303 # <Style>
304 # <Name>default...
305 # <Title>...
306 # <LegendURL
307 # <Format>...
308 # <OnlineResource...
310 st = gws.SourceStyle()
312 st.metadata = element_metadata(el)
313 st.name = st.metadata.get('name', '').lower()
314 st.legendUrl = _parse_url(el.findfirst('LegendURL'))
315 st.isDefault = (
316 el.get('IsDefault') == 'true'
317 or st.name == 'default'
318 or st.name.endswith(':default'))
319 return st
322def default_style(styles: list[gws.SourceStyle]) -> Optional[gws.SourceStyle]:
323 """Find the default style in a list of styles.
325 Args:
326 styles: Source styles.
328 Returns:
329 The first style marked as default, otherwise the first style, or ``None`` if the list is empty.
330 """
332 for s in styles:
333 if s.isDefault:
334 return s
335 return styles[0] if styles else None
338##
341def to_float(s, default=0.0):
342 """Convert a string to a float.
344 Args:
345 s: String value.
346 default: Value used when ``s`` is empty.
348 Returns:
349 The float value.
351 Raises:
352 ``ValueError``: If the string is not a number.
353 """
355 return float(s or default)
358def to_int(s, default=0):
359 """Convert a string to an int, accepting float strings.
361 Args:
362 s: String value.
363 default: Value used when ``s`` is empty.
365 Returns:
366 The value, truncated to an int.
368 Raises:
369 ``ValueError``: If the string is not a number.
370 """
372 # accept floats as well, but convert to int
373 return int(float(s or default))
376def to_float_pair(s):
377 """Convert a whitespace-separated string to a pair of floats.
379 Args:
380 s: String like ``"1.5 2.5"``.
382 Returns:
383 The first two numbers as a tuple.
385 Raises:
386 ``ValueError``: If a value is not a number.
387 ``IndexError``: If there are fewer than two values.
388 """
390 s = s.split()
391 return float(s[0]), float(s[1])
394##
397def _parse_bbox(el: gws.XmlElement):
398 """Read a bounding box element in any of the supported formats as ``[x1, y1, x2, y2]``, or ``None``."""
400 # note: bboxes are always converted to (x1, y1, x2, y2) with x1 < x2, y1 < y2
402 # <BoundingBox/LatLonBoundingBox CRS="..." minx="0" miny="1" maxx="2" maxy="3"/>
404 if el.get('minx'):
405 return [
406 to_float(el.get('minx')),
407 to_float(el.get('miny')),
408 to_float(el.get('maxx')),
409 to_float(el.get('maxy')),
410 ]
412 # <ows:BoundingBox/WGS84BoundingBox
413 # <ows:LowerCorner> 0 1
414 # <ows:UpperCorner> 2 3
416 if el.findfirst('LowerCorner'):
417 x1, y1 = to_float_pair(el.textof('LowerCorner'))
418 x2, y2 = to_float_pair(el.textof('UpperCorner'))
419 return [
420 min(x1, x2),
421 min(y1, y2),
422 max(x1, x2),
423 max(y1, y2),
424 ]
426 # <EX_GeographicBoundingBox>
427 # <westBoundLongitude> 0
428 # <eastBoundLongitude> 2
429 # <southBoundLatitude> 1
430 # <northBoundLatitude> 3
432 if el.findfirst('westBoundLongitude'):
433 x1 = to_float(el.textof('eastBoundLongitude'))
434 y1 = to_float(el.textof('southBoundLatitude'))
435 x2 = to_float(el.textof('westBoundLongitude'))
436 y2 = to_float(el.textof('northBoundLatitude'))
437 return [
438 min(x1, x2),
439 min(y1, y2),
440 max(x1, x2),
441 max(y1, y2),
442 ]
445def _parse_url(el: gws.XmlElement) -> str:
446 """Read a URL from an ``href`` attribute or a nested ``OnlineResource``."""
448 def cleanup(s):
449 return (s or '').strip(' ?&')
451 if not el:
452 return ''
454 # <ows:DCP>
455 # <ows:HTTP>
456 # <ows:Get xlink:href=... <-- we are here
458 s = el.get('href') or el.get('onlineResource')
459 if s:
460 return cleanup(s)
462 # <whatever <--
463 # <OnlineResource xlink:href=...
465 e = el.findfirst('OnlineResource')
466 if e:
467 return cleanup(e.get('href', default=e.text))
469 return ''
472def _parse_link(el: gws.XmlElement) -> Optional[gws.MetadataLink]:
473 """Read a metadata link in the simple or nested format."""
475 if not el:
476 return None
478 # see base/ows/server/templatelib.py
479 # regarding different MetadataURL formats
481 # simple
482 if el.get('href'):
483 return gws.MetadataLink(url=el.get('href'))
485 # nested
486 d = gws.u.strip({
487 'url': _parse_url(el),
488 'type': el.get('type'),
489 'format': el.textof('Format'),
490 })
492 if d:
493 return gws.MetadataLink(d)