Coverage for gws-app/gws/plugin/ows_client/wfs/provider.py: 0%
72 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"""WFS provider.
3References:
5- wfs 1.0.0: http://portal.opengeospatial.org/files/?artifact_id=7176 Sec 13.7.3
6- wfs 1.1.0: http://portal.opengeospatial.org/files/?artifact_id=8339 Sec 14.7.3
7- wfs 2.0.0: http://docs.opengeospatial.org/is/09-025r2/09-025r2.html Sec 11.1.3
9See also:
11- https://docs.geoserver.org/latest/en/user/services/wfs/reference.html
13"""
15from typing import Optional, cast
17import gws
18import gws.base.ows.client
19import gws.base.shape
20import gws.config.util
21import gws.lib.bounds
22import gws.lib.crs
23import gws.lib.extent
24import gws.gis.source
26from . import caps
29class Config(gws.base.ows.client.provider.Config):
30 """WFS provider configuration."""
32 withBboxCrs: Optional[bool]
33 """Add CRS to bbox request parameters."""
36class Object(gws.base.ows.client.provider.Object):
37 protocol = gws.OwsProtocol.WFS
38 withBboxCrs: bool
39 isWfs2: bool
41 def configure(self):
42 cc = caps.parse(self.get_capabilities())
44 self.metadata = cc.metadata
45 self.sourceLayers = cc.sourceLayers
46 self.version = cc.version
47 self.isWfs2 = self.version >= '2'
49 self.configure_operations(cc.operations)
51 # use bbox with crs for wfs 2 by default
52 # see also comments in qgis/qgswfsfeatureiterator.cpp buildURL
53 p = self.cfg('withBboxCrs')
54 self.withBboxCrs = self.isWfs2 if p is None else p
56 DEFAULT_GET_FEATURE_LIMIT = 100
58 def get_features(self, search, source_layers):
59 """Perform the WFS GetFeature operation.
61 We only do spatial searches here.
62 If no bounds and no shapes are given, return all features.
63 If a shape is given, find features within its bounds first,
64 and filter features on our side.
65 This is more performant than WFS spatial ops (at least for qgis),
66 and also works without spatial ops support on the provider side.
67 """
69 bounds = search.bounds
70 search_shape = None
72 if search.shape:
73 geometry_tolerance = 0.0
75 if search.tolerance:
76 n, u = search.tolerance
77 geometry_tolerance = n * (search.resolution or 1) if u == 'px' else n
79 search_shape = search.shape.tolerance_polygon(geometry_tolerance)
80 bounds = search_shape.bounds()
82 if not bounds:
83 gws.log.warning('get_features: no bounds or shape given')
84 return []
86 request_crs = self.forceCrs or gws.lib.crs.WGS84
88 bbox = gws.lib.bounds.transform(bounds, request_crs).extent
89 if request_crs.isYX and not self.alwaysXY:
90 bbox = gws.lib.extent.swap_xy(bbox)
91 bbox = ','.join(str(k) for k in bbox)
93 srs = request_crs.urn
94 if self.withBboxCrs:
95 bbox += ',' + srs
97 params = {
98 'BBOX': bbox,
99 'COUNT' if self.isWfs2 else 'MAXFEATURES': search.limit or self.DEFAULT_GET_FEATURE_LIMIT,
100 'SRSNAME': srs,
101 'TYPENAMES' if self.isWfs2 else 'TYPENAME': [sl.name for sl in source_layers],
102 'VERSION': self.version,
103 }
105 if search.extraParams:
106 params = gws.u.merge(params, gws.u.to_upper_dict(search.extraParams))
108 op = self.get_operation(gws.OwsVerb.GetFeature)
109 if not op:
110 return []
112 if op.preferredFormat:
113 params.setdefault('OUTPUTFORMAT', op.preferredFormat)
115 args = self.prepare_operation(op, params=params)
116 text = gws.base.ows.client.request.get_text(args)
118 try:
119 records = gws.base.ows.client.featureinfo.parse(text, default_crs=request_crs, always_xy=self.alwaysXY)
120 except gws.Error as exc:
121 gws.log.error(f'get_features: parse error: {exc!r}')
122 return []
124 gws.log.debug(f'get_features: FOUND={len(records)} params={params!r}')
126 for rec in records:
127 if rec.shape:
128 rec.shape = rec.shape.transformed_to(bounds.crs)
130 if not search_shape:
131 return records
133 filtered = [
134 rec for rec in records
135 if not rec.shape or rec.shape.intersects(search_shape)
136 ]
138 gws.log.debug(f'get_features: FILTERED={len(filtered)}')
139 return filtered