Coverage for gws-app/gws/base/ows/client/featureinfo.py: 94%
135 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
1"""Parse WMS/WFS FeatureInfo responses."""
3import gws
4import gws.base.shape
5import gws.lib.gml
6import gws.lib.xmlx as xmlx
9class Error(gws.Error):
10 pass
13def parse(text: str, default_crs: gws.Crs = None, always_xy=False) -> list[gws.FeatureRecord]:
14 gws.debug.time_start('featureinfo:parse')
15 recs = _parse(text.strip(), default_crs, always_xy)
16 gws.debug.time_end()
17 return recs
20def _parse(text, default_crs, always_xy):
21 if not text.strip():
22 return []
24 if text.startswith('<'):
25 try:
26 xml_el = xmlx.from_string(text, gws.XmlOptions(removeNamespaces=True))
27 except xmlx.Error as exc:
28 raise Error(f'XML error') from exc
30 parser = _XML_FORMATS.get(xml_el.lcName)
31 if not parser:
32 raise Error(f'XML format error for {xml_el.name!r}')
34 recs = parser(xml_el, default_crs, always_xy)
35 gws.log.debug(f'parsed with {parser.__name__} count={len(recs)}')
36 return recs
38 raise Error(f'unknown format in {text[:100]!r}')
41##
44def _parse_msgmloutput(xml_el: gws.XmlElement, default_crs, always_xy):
45 # msGMLOutput (MapServer)
46 #
47 # <msGMLOutput
48 # <LAYER_1>
49 # <gml:name>LAYER_NAME
50 # <FEATURE_1>
51 # <gml:boundedBy>
52 # ...
53 # </gml:boundedBy>
54 # <GEOMETRY>
55 # <gml:Point...
56 # </GEOMETRY>
57 # <attr>....</attr>
58 # <attr>....</attr>
59 #
61 recs = []
63 for layer_el in xml_el:
64 layer_name = layer_el.lcName
65 for el in layer_el:
66 if el.lcName == 'name':
67 layer_name = el.text
68 else:
69 rec = _record_from_gml(el, default_crs, always_xy)
70 rec.meta = {'layerName': layer_name}
71 recs.append(rec)
73 return recs
76def _parse_featurecollection(xml_el: gws.XmlElement, default_crs, always_xy):
77 # FeatureCollection (OGC)
78 #
79 # <FeatureCollection
80 # <wfs:member>
81 # <FEATURE gml:id=...
82 # <attr>....</attr>
83 # <attr> <nested>....</attr>
84 # <GEOMETRY>
85 # <gml:Point...
86 #
88 recs = []
90 for member_el in xml_el:
91 if member_el.lcName in {'member', 'featuremember'}:
92 if len(member_el) == 1:
93 # <wfs:member><my:feature><attr...
94 recs.append(_record_from_gml(member_el[0], default_crs, always_xy))
95 elif len(member_el) > 1:
96 # <wfs:member><attr...
97 recs.append(_record_from_gml(member_el, default_crs, always_xy))
98 elif member_el.lcName == 'featuremembers':
99 # WFS 1.1.0: features directly inside <gml:featureMembers>
100 for feature_el in member_el:
101 recs.append(_record_from_gml(feature_el, default_crs, always_xy))
103 return recs
106def _parse_getfeatureinforesponse(xml_el: gws.XmlElement, default_crs, always_xy):
107 # GetFeatureInfoResponse (geoserver/qgis)
108 #
109 # <GetFeatureInfoResponse>
110 # <Layer name="....">
111 # <Feature id="...">
112 # <Attribute name="..." value="..."/>
113 # <Attribute name="geometry" value="<wkt>"/>
114 #
115 # For qgis raster layers, "Attribute" is directly under "Layer":
116 #
117 # <GetFeatureInfoResponse>
118 # <Layer name="....">
119 # <Attribute name="..." value="..."/>
121 def attr(rec, el):
122 key = el.get('name').lower()
123 val = el.get('value', '').strip()
124 if key == 'geometry':
125 rec.shape = gws.base.shape.from_wkt(val, default_crs)
126 elif len(val) > 0:
127 rec.attributes[key] = val
129 recs = []
131 for layer_el in xml_el:
132 layer_name = layer_el.get('name')
134 raster_rec = gws.FeatureRecord(
135 attributes={},
136 uid='',
137 meta={'layerName': layer_name},
138 )
140 for sub_el in layer_el:
141 if sub_el.lcName == 'feature':
142 rec = gws.FeatureRecord(
143 attributes={},
144 uid=_get_uid(sub_el),
145 meta={'layerName': layer_name},
146 )
147 for el in sub_el:
148 if el.lcName == 'attribute':
149 attr(rec, el)
150 recs.append(rec)
152 if sub_el.lcName == 'attribute':
153 attr(raster_rec, sub_el)
155 if raster_rec.attributes:
156 recs.append(raster_rec)
158 return recs
161def _parse_featureinforesponse(xml_el: gws.XmlElement, default_crs, always_xy):
162 # FeatureInfoResponse (Arcgis)
163 #
164 # https://webhelp.esri.com/arcims/9.3/General/mergedProjects/wms_connect/wms_connector/get_featureinfo.htm
165 #
166 # <FeatureInfoResponse...
167 # <fields objectid="15111" shape="polygon"...
168 # <fields objectid="15111" shape="polygon"...
170 recs = []
172 for fields_el in xml_el:
173 if fields_el.lcName == 'fields':
174 rec = gws.FeatureRecord(
175 attributes={},
176 uid=_get_uid(fields_el),
177 )
178 for key, val in fields_el.attrib.items():
179 key = key.lower()
180 if key != 'shape':
181 rec.attributes[key] = val
182 recs.append(rec)
184 return recs
187def _parse_geobak(xml_el: gws.XmlElement, default_crs, always_xy):
188 # GeoBAK (https://www.egovernment.sachsen.de/geodaten.html)
189 #
190 # <geobak_20:Sachdatenabfrage...
191 # <geobak_20:Kartenebene>....
192 # <geobak_20:Inhalt>
193 # <geobak_20:Datensatz>
194 # <geobak_20:Attribut>
195 # <geobak_20:Name>...
196 # <geobak_20:Wert>...
197 # <geobak_20:Inhalt>
198 # <geobak_20:Datensatz>
199 # ...
201 recs = []
202 layer_name = ''
204 for el in xml_el:
205 if el.lcName == 'kartenebene':
206 layer_name = el.text
207 continue
209 if el.lcName == 'inhalt':
210 rec = gws.FeatureRecord(attributes={}, meta={'layerName': layer_name})
211 for attr_el in el[0]:
212 key = attr_el[0].text.strip().lower()
213 val = attr_el[1].text.strip()
214 if key != 'shape' and val.lower() != 'null':
215 rec.attributes[key] = val
216 recs.append(rec)
218 return recs
221def _parse_osiris(xml_el: gws.XmlElement, default_crs, always_xy):
222 # OSIRIS (https://www.gis-consult.de/produkte-und-loesungen/gc-osiris/)
223 #
224 # <OSIRIS_Objektmenge ...
225 # <OSIRIS_Objekt fid="..."
226 # <gml:description>...
227 # <gml:name>...
228 # <gml:boundedBy>...
230 recs = []
232 for obj_el in xml_el:
233 if obj_el.lcName == 'osiris_objekt':
234 rec = _record_from_gml(obj_el, default_crs, always_xy)
235 recs.append(rec)
237 return recs
240##
242_DEEP_ATTRIBUTE_DELIMITER = '.'
245def _record_from_gml(feature_el, default_crs, always_xy) -> gws.FeatureRecord:
246 # like GDAL does:
247 # "When reading a feature, the driver will by default only take into account
248 # the last recognized GML geometry found..." (https://gdal.org/drivers/vector/gml.html)
250 rec = gws.FeatureRecord(
251 attributes={},
252 uid=_get_uid(feature_el),
253 meta={'layerName': feature_el.lcName},
254 )
256 bbox = None
258 for el in feature_el:
259 if el.lcName == 'boundedby':
260 # <gml:boundedBy directly under feature
261 bbox = gws.lib.gml.parse_envelope(el[0], default_crs, always_xy)
262 elif gws.lib.gml.is_geometry_element(el):
263 # <gml:Polygon etc directly under feature
264 rec.shape = gws.lib.gml.parse_shape(el, default_crs, always_xy)
265 elif len(el) == 1 and gws.lib.gml.is_geometry_element(el[0]):
266 # <gml:Polygon etc in a wrapper tag
267 rec.shape = gws.lib.gml.parse_shape(el[0], default_crs, always_xy)
268 elif len(el) > 0:
269 # sub-feature
270 sub = _record_from_gml(el, default_crs, always_xy)
271 for k, v in sub.attributes.items():
272 rec.attributes[el.lcName + _DEEP_ATTRIBUTE_DELIMITER + k] = v
273 else:
274 # attribute <attr>text</attr>
275 s = el.text.strip()
276 if s:
277 rec.attributes[el.lcName] = s
279 if not rec.shape and bbox:
280 rec.shape = gws.base.shape.from_bounds(bbox)
282 return rec
285_UIDS = ['id', 'fid', 'objectid', 'ID', 'FID', 'OBJECTID']
288def _get_uid(el):
289 for u in _UIDS:
290 if u in el.attrib:
291 return el.get(u)
292 return ''
295##
298_XML_FORMATS = {
299 'msgmloutput': _parse_msgmloutput,
300 'featurecollection': _parse_featurecollection,
301 'getfeatureinforesponse': _parse_getfeatureinforesponse,
302 'featureinforesponse': _parse_featureinforesponse,
303 'sachdatenabfrage': _parse_geobak,
304 'osiris_objektmenge': _parse_osiris,
305}