Coverage for gws-app/gws/plugin/ows_client/wms/provider.py: 76%
94 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
1"""WMS service provider."""
3from typing import Optional, cast
5import gws
6import gws.base.ows.client
7import gws.config.util
8import gws.lib.crs
9import gws.lib.extent
10import gws.gis.source
13from . import caps
16class Config(gws.base.ows.client.provider.Config):
17 """Connection to a WMS service."""
19 bottomFirst: bool = False
20 """The service lists layers in its capabilities from bottom to top."""
21 maxRequestPixels: int = 4096
22 """Max. size of a single map request in pixels. (added in 8.5)"""
25class Object(gws.base.ows.client.provider.Object):
26 """WMS service provider.
28 Reads the capabilities of a WMS service and runs GetMap and GetFeatureInfo
29 requests for the layers, finders and models that use the service.
30 """
32 protocol = gws.OwsProtocol.WMS
34 maxRequestPixels: int
35 """Max. width and height of a single GetMap request in pixels; the grabber fetches larger images in chunks."""
37 def configure(self):
38 self.maxRequestPixels = self.cfg('maxRequestPixels')
40 cc = caps.parse(self.get_capabilities(), self.cfg('bottomFirst', default=False))
42 self.metadata = cc.metadata
43 self.sourceLayers = cc.sourceLayers
44 self.version = cc.version
46 self.configure_operations(cc.operations)
48 def cache_hash(self):
49 return gws.u.sha256([
50 super().cache_hash(),
51 self.cfg('bottomFirst', default=False),
52 ])
54 def get_map(self, bounds: gws.Bounds, width: int, height: int, source_layers: list[gws.SourceLayer], mime: gws.MimeType = '') -> bytes:
55 """Fetch a map image with GetMap.
57 The source layers are given top-first and sent bottom-first, as WMS expects.
58 The image is requested with a transparent background.
60 Args:
61 bounds: Bounds of the image; their CRS is the request CRS.
62 width: Image width in pixels.
63 height: Image height in pixels.
64 source_layers: Source layers to render, topmost first.
65 mime: MIME type to request, as spelled by the service, the default of the operation if empty.
67 Returns:
68 The image content.
70 Raises:
71 ``gws.ExternalServiceError``: If the service has no GetMap operation
72 or the response is not an image.
73 """
74 op = self.get_operation(gws.OwsVerb.GetMap)
75 if not op:
76 raise gws.ExternalServiceError(f'no GetMap operation in {self.url!r}')
78 v3 = self.version >= '1.3'
80 bbox = bounds.extent
81 always_xy = self.alwaysXY or not v3
82 if bounds.crs.isYX and not always_xy:
83 bbox = gws.lib.extent.swap_xy(bbox)
85 layer_names = list(reversed([sl.name for sl in source_layers]))
87 params = {
88 'BBOX': bbox,
89 'CRS' if v3 else 'SRS': bounds.crs.to_string(gws.CrsFormat.epsg),
90 'WIDTH': width,
91 'HEIGHT': height,
92 'LAYERS': layer_names,
93 'STYLES': [''] * len(layer_names),
94 'TRANSPARENT': 'TRUE',
95 'VERSION': self.version,
96 }
97 mime = mime or op.defaultMimeType
98 if mime:
99 params.setdefault('FORMAT', mime)
101 args = self.prepare_operation(op, params=params)
102 res = gws.base.ows.client.request.get(args)
103 if not res.content_type.startswith('image/'):
104 raise gws.ExternalServiceError(f'GetMap failed: content type {res.content_type!r} url={self.url!r}')
105 return res.content
107 DEFAULT_GET_FEATURE_LIMIT = 100
108 """Value of ``FEATURE_COUNT`` in GetFeatureInfo requests, if the search has no limit."""
110 def create_leaf_layer_config(self, source_layers):
111 """Create the configuration of a ``wmsflat`` layer for the given source layers.
113 Used by the ``wms`` tree layer to create its leaf layers.
115 Args:
116 source_layers: Source layers to render in the layer.
118 Returns:
119 A layer configuration that uses this provider.
120 """
121 return dict(
122 type='wmsflat',
123 _defaultProvider=self,
124 _defaultSourceLayers=source_layers,
125 )
127 def get_features(self, search, source_layers, mime=''):
128 v3 = self.version >= '1.3'
130 shape = search.shape
131 if not shape or shape.type != gws.GeometryType.point:
132 return []
134 request_crs = self.forceCrs
135 if not request_crs:
136 request_crs = gws.lib.crs.best_match(
137 shape.crs,
138 gws.gis.source.combined_crs_list(source_layers))
140 box_size_m = 500
141 box_size_deg = 1
142 box_size_px = 500
144 size = None
146 if shape.crs.uom == gws.Uom.m:
147 size = box_size_px * search.resolution
148 if shape.crs.uom == gws.Uom.deg:
149 # @TODO use search.resolution here as well
150 size = box_size_deg
151 if not size:
152 gws.log.debug(f'cannot request crs {shape.crs!r}, unsupported unit')
153 return []
155 bbox = (
156 shape.x - (size / 2),
157 shape.y - (size / 2),
158 shape.x + (size / 2),
159 shape.y + (size / 2),
160 )
162 bbox = gws.lib.extent.transform(bbox, shape.crs, request_crs)
164 always_xy = self.alwaysXY or not v3
165 if request_crs.isYX and not always_xy:
166 bbox = gws.lib.extent.swap_xy(bbox)
168 layer_names = [sl.name for sl in source_layers]
170 params = {
171 'BBOX': bbox,
172 'CRS' if v3 else 'SRS': request_crs.to_string(gws.CrsFormat.epsg),
173 'WIDTH': box_size_px,
174 'HEIGHT': box_size_px,
175 'I' if v3 else 'X': box_size_px >> 1,
176 'J' if v3 else 'Y': box_size_px >> 1,
177 'LAYERS': layer_names,
178 'QUERY_LAYERS': layer_names,
179 'STYLES': [''] * len(layer_names),
180 'VERSION': self.version,
181 'FEATURE_COUNT': search.limit or self.DEFAULT_GET_FEATURE_LIMIT,
182 }
184 if search.extraParams:
185 params = gws.u.merge(params, gws.u.to_upper_dict(search.extraParams))
187 op = self.get_operation(gws.OwsVerb.GetFeatureInfo)
188 if not op:
189 return []
191 mime = mime or op.defaultMimeType
192 if mime:
193 params.setdefault('INFO_FORMAT', mime)
195 args = self.prepare_operation(op, params=params)
196 text = gws.base.ows.client.request.get_text(args)
198 try:
199 records = gws.base.ows.client.featureinfo.parse(text, default_crs=request_crs, always_xy=self.alwaysXY)
200 except gws.Error as exc:
201 gws.log.error(f'get_features: parse error: {exc!r}')
202 return []
204 gws.log.debug(f'get_features: FOUND={len(records)} params={params!r}')
206 for rec in records:
207 if rec.shape:
208 rec.shape = rec.shape.transformed_to(shape.crs)
210 return records