Coverage for gws-app/gws/base/ows/client/featureinfo.py: 94%
135 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"""Parser for WMS/WFS feature info responses."""
3import gws
4import gws.lib.shape
5import gws.lib.gml
6import gws.lib.xmlx as xmlx
9class Error(gws.Error):
10 """Feature info parse error."""
12 pass
15def parse(text: str, default_crs: gws.Crs = None, always_xy=False) -> list[gws.FeatureRecord]:
16 """Parse a GetFeatureInfo or GetFeature response into feature records.
18 The format is detected from the name of the XML root element.
19 An empty response yields an empty list.
21 Args:
22 text: Response text.
23 default_crs: CRS for geometries that do not specify one.
24 always_xy: If true, coordinates are read in XY order regardless of the CRS axis order.
26 Returns:
27 A list of feature records.
29 Raises:
30 ``Error``: If the response is not valid XML or the format is not recognized.
31 """
33 gws.debug.time_start('featureinfo:parse')
34 recs = _parse(text.strip(), default_crs, always_xy)
35 gws.debug.time_end()
36 return recs
39def _parse(text, default_crs, always_xy):
40 """Detect the response format and run the matching parser."""
41 if not text.strip():
42 return []
44 if text.startswith('<'):
45 try:
46 xml_el = xmlx.from_string(text, gws.XmlOptions(removeNamespaces=True))
47 except xmlx.Error as exc:
48 raise Error(f'XML error') from exc
50 parser = _XML_FORMATS.get(xml_el.name.lower())
51 if not parser:
52 raise Error(f'XML format error for {xml_el.name!r}')
54 recs = parser(xml_el, default_crs, always_xy)
55 gws.log.debug(f'parsed with {parser.__name__} count={len(recs)}')
56 return recs
58 raise Error(f'unknown format in {text[:100]!r}')
61##
64def _parse_msgmloutput(xml_el: gws.XmlElement, default_crs, always_xy):
65 # msGMLOutput (MapServer)
66 #
67 # <msGMLOutput
68 # <LAYER_1>
69 # <gml:name>LAYER_NAME
70 # <FEATURE_1>
71 # <gml:boundedBy>
72 # ...
73 # </gml:boundedBy>
74 # <GEOMETRY>
75 # <gml:Point...
76 # </GEOMETRY>
77 # <attr>....</attr>
78 # <attr>....</attr>
79 #
81 recs = []
83 for layer_el in xml_el:
84 layer_name = layer_el.name.lower()
85 for el in layer_el:
86 if el.isa('name'):
87 layer_name = el.text
88 else:
89 rec = _record_from_gml(el, default_crs, always_xy)
90 rec.meta = {'layerName': layer_name}
91 recs.append(rec)
93 return recs
96def _parse_featurecollection(xml_el: gws.XmlElement, default_crs, always_xy):
97 # FeatureCollection (OGC)
98 #
99 # <FeatureCollection
100 # <wfs:member>
101 # <FEATURE gml:id=...
102 # <attr>....</attr>
103 # <attr> <nested>....</attr>
104 # <GEOMETRY>
105 # <gml:Point...
106 #
108 recs = []
110 for member_el in xml_el:
111 if member_el.isa('member', 'featureMember'):
112 if len(member_el) == 1:
113 # <wfs:member><my:feature><attr...
114 recs.append(_record_from_gml(member_el[0], default_crs, always_xy))
115 elif len(member_el) > 1:
116 # <wfs:member><attr...
117 recs.append(_record_from_gml(member_el, default_crs, always_xy))
118 elif member_el.isa('featureMembers'):
119 # WFS 1.1.0: features directly inside <gml:featureMembers>
120 for feature_el in member_el:
121 recs.append(_record_from_gml(feature_el, default_crs, always_xy))
123 return recs
126def _parse_getfeatureinforesponse(xml_el: gws.XmlElement, default_crs, always_xy):
127 # GetFeatureInfoResponse (geoserver/qgis)
128 #
129 # <GetFeatureInfoResponse>
130 # <Layer name="....">
131 # <Feature id="...">
132 # <Attribute name="..." value="..."/>
133 # <Attribute name="geometry" value="<wkt>"/>
134 #
135 # For qgis raster layers, "Attribute" is directly under "Layer":
136 #
137 # <GetFeatureInfoResponse>
138 # <Layer name="....">
139 # <Attribute name="..." value="..."/>
141 def attr(rec, el):
142 key = el.get('name').lower()
143 val = el.get('value', '').strip()
144 if key == 'geometry':
145 rec.shape = gws.lib.shape.from_wkt(val, default_crs)
146 elif len(val) > 0:
147 rec.attributes[key] = val
149 recs = []
151 for layer_el in xml_el:
152 layer_name = layer_el.get('name')
154 raster_rec = gws.FeatureRecord(
155 attributes={},
156 uid='',
157 meta={'layerName': layer_name},
158 )
160 for sub_el in layer_el:
161 if sub_el.isa('Feature'):
162 rec = gws.FeatureRecord(
163 attributes={},
164 uid=_get_uid(sub_el),
165 meta={'layerName': layer_name},
166 )
167 for el in sub_el:
168 if el.isa('Attribute'):
169 attr(rec, el)
170 recs.append(rec)
172 if sub_el.isa('Attribute'):
173 attr(raster_rec, sub_el)
175 if raster_rec.attributes:
176 recs.append(raster_rec)
178 return recs
181def _parse_featureinforesponse(xml_el: gws.XmlElement, default_crs, always_xy):
182 # FeatureInfoResponse (Arcgis)
183 #
184 # https://webhelp.esri.com/arcims/9.3/General/mergedProjects/wms_connect/wms_connector/get_featureinfo.htm
185 #
186 # <FeatureInfoResponse...
187 # <fields objectid="15111" shape="polygon"...
188 # <fields objectid="15111" shape="polygon"...
190 recs = []
192 for fields_el in xml_el:
193 if fields_el.isa('FIELDS'):
194 rec = gws.FeatureRecord(
195 attributes={},
196 uid=_get_uid(fields_el),
197 )
198 for key, val in fields_el.attrib.items():
199 key = key.lower()
200 if key != 'shape':
201 rec.attributes[key] = val
202 recs.append(rec)
204 return recs
207def _parse_geobak(xml_el: gws.XmlElement, default_crs, always_xy):
208 # GeoBAK (https://www.egovernment.sachsen.de/geodaten.html)
209 #
210 # <geobak_20:Sachdatenabfrage...
211 # <geobak_20:Kartenebene>....
212 # <geobak_20:Inhalt>
213 # <geobak_20:Datensatz>
214 # <geobak_20:Attribut>
215 # <geobak_20:Name>...
216 # <geobak_20:Wert>...
217 # <geobak_20:Inhalt>
218 # <geobak_20:Datensatz>
219 # ...
221 recs = []
222 layer_name = ''
224 for el in xml_el:
225 if el.isa('Kartenebene'):
226 layer_name = el.text
227 continue
229 if el.isa('Inhalt'):
230 rec = gws.FeatureRecord(attributes={}, meta={'layerName': layer_name})
231 for attr_el in el[0]:
232 key = attr_el[0].text.strip().lower()
233 val = attr_el[1].text.strip()
234 if key != 'shape' and val.lower() != 'null':
235 rec.attributes[key] = val
236 recs.append(rec)
238 return recs
241def _parse_osiris(xml_el: gws.XmlElement, default_crs, always_xy):
242 # OSIRIS (https://www.gis-consult.de/produkte-und-loesungen/gc-osiris/)
243 #
244 # <OSIRIS_Objektmenge ...
245 # <OSIRIS_Objekt fid="..."
246 # <gml:description>...
247 # <gml:name>...
248 # <gml:boundedBy>...
250 recs = []
252 for obj_el in xml_el:
253 if obj_el.isa('OSIRIS_Objekt'):
254 rec = _record_from_gml(obj_el, default_crs, always_xy)
255 recs.append(rec)
257 return recs
260##
262_DEEP_ATTRIBUTE_DELIMITER = '.'
265def _record_from_gml(feature_el, default_crs, always_xy) -> gws.FeatureRecord:
266 """Create a record from a GML feature element; nested elements become dotted attribute names."""
268 # like GDAL does:
269 # "When reading a feature, the driver will by default only take into account
270 # the last recognized GML geometry found..." (https://gdal.org/drivers/vector/gml.html)
272 rec = gws.FeatureRecord(
273 attributes={},
274 uid=_get_uid(feature_el),
275 meta={'layerName': feature_el.name.lower()},
276 )
278 bbox = None
280 for el in feature_el:
281 if el.isa('boundedBy'):
282 # <gml:boundedBy directly under feature
283 bbox = gws.lib.gml.parse_envelope(el[0], default_crs, always_xy)
284 elif gws.lib.gml.is_geometry_element(el):
285 # <gml:Polygon etc directly under feature
286 rec.shape = gws.lib.gml.parse_shape(el, default_crs, always_xy)
287 elif len(el) == 1 and gws.lib.gml.is_geometry_element(el[0]):
288 # <gml:Polygon etc in a wrapper tag
289 rec.shape = gws.lib.gml.parse_shape(el[0], default_crs, always_xy)
290 elif len(el) > 0:
291 # sub-feature
292 sub = _record_from_gml(el, default_crs, always_xy)
293 for k, v in sub.attributes.items():
294 rec.attributes[el.name.lower() + _DEEP_ATTRIBUTE_DELIMITER + k] = v
295 else:
296 # attribute <attr>text</attr>
297 s = el.text.strip()
298 if s:
299 rec.attributes[el.name.lower()] = s
301 if not rec.shape and bbox:
302 rec.shape = gws.lib.shape.from_bounds(bbox)
304 return rec
307_UIDS = ['id', 'fid', 'objectid', 'ID', 'FID', 'OBJECTID']
310def _get_uid(el):
311 """Return the feature id from the first known id attribute, or an empty string."""
312 for u in _UIDS:
313 if u in el.attrib:
314 return el.get(u)
315 return ''
318##
321_XML_FORMATS = {
322 'msgmloutput': _parse_msgmloutput,
323 'featurecollection': _parse_featurecollection,
324 'getfeatureinforesponse': _parse_getfeatureinforesponse,
325 'featureinforesponse': _parse_featureinforesponse,
326 'sachdatenabfrage': _parse_geobak,
327 'osiris_objektmenge': _parse_osiris,
328}