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

1"""Parse WMS/WFS FeatureInfo responses.""" 

2 

3import gws 

4import gws.base.shape 

5import gws.lib.gml 

6import gws.lib.xmlx as xmlx 

7 

8 

9class Error(gws.Error): 

10 pass 

11 

12 

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 

18 

19 

20def _parse(text, default_crs, always_xy): 

21 if not text.strip(): 

22 return [] 

23 

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 

29 

30 parser = _XML_FORMATS.get(xml_el.lcName) 

31 if not parser: 

32 raise Error(f'XML format error for {xml_el.name!r}') 

33 

34 recs = parser(xml_el, default_crs, always_xy) 

35 gws.log.debug(f'parsed with {parser.__name__} count={len(recs)}') 

36 return recs 

37 

38 raise Error(f'unknown format in {text[:100]!r}') 

39 

40 

41## 

42 

43 

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 # 

60 

61 recs = [] 

62 

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) 

72 

73 return recs 

74 

75 

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 # 

87 

88 recs = [] 

89 

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

102 

103 return recs 

104 

105 

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

120 

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 

128 

129 recs = [] 

130 

131 for layer_el in xml_el: 

132 layer_name = layer_el.get('name') 

133 

134 raster_rec = gws.FeatureRecord( 

135 attributes={}, 

136 uid='', 

137 meta={'layerName': layer_name}, 

138 ) 

139 

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) 

151 

152 if sub_el.lcName == 'attribute': 

153 attr(raster_rec, sub_el) 

154 

155 if raster_rec.attributes: 

156 recs.append(raster_rec) 

157 

158 return recs 

159 

160 

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

169 

170 recs = [] 

171 

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) 

183 

184 return recs 

185 

186 

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

200 

201 recs = [] 

202 layer_name = '' 

203 

204 for el in xml_el: 

205 if el.lcName == 'kartenebene': 

206 layer_name = el.text 

207 continue 

208 

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) 

217 

218 return recs 

219 

220 

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

229 

230 recs = [] 

231 

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) 

236 

237 return recs 

238 

239 

240## 

241 

242_DEEP_ATTRIBUTE_DELIMITER = '.' 

243 

244 

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) 

249 

250 rec = gws.FeatureRecord( 

251 attributes={}, 

252 uid=_get_uid(feature_el), 

253 meta={'layerName': feature_el.lcName}, 

254 ) 

255 

256 bbox = None 

257 

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 

278 

279 if not rec.shape and bbox: 

280 rec.shape = gws.base.shape.from_bounds(bbox) 

281 

282 return rec 

283 

284 

285_UIDS = ['id', 'fid', 'objectid', 'ID', 'FID', 'OBJECTID'] 

286 

287 

288def _get_uid(el): 

289 for u in _UIDS: 

290 if u in el.attrib: 

291 return el.get(u) 

292 return '' 

293 

294 

295## 

296 

297 

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}