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

1"""WMS service provider.""" 

2 

3from typing import Optional, cast 

4 

5import gws 

6import gws.base.ows.client 

7import gws.config.util 

8import gws.lib.crs 

9import gws.lib.extent 

10import gws.gis.source 

11 

12 

13from . import caps 

14 

15 

16class Config(gws.base.ows.client.provider.Config): 

17 """Connection to a WMS service.""" 

18 

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

23 

24 

25class Object(gws.base.ows.client.provider.Object): 

26 """WMS service provider. 

27 

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

31 

32 protocol = gws.OwsProtocol.WMS 

33 

34 maxRequestPixels: int 

35 """Max. width and height of a single GetMap request in pixels; the grabber fetches larger images in chunks.""" 

36 

37 def configure(self): 

38 self.maxRequestPixels = self.cfg('maxRequestPixels') 

39 

40 cc = caps.parse(self.get_capabilities(), self.cfg('bottomFirst', default=False)) 

41 

42 self.metadata = cc.metadata 

43 self.sourceLayers = cc.sourceLayers 

44 self.version = cc.version 

45 

46 self.configure_operations(cc.operations) 

47 

48 def cache_hash(self): 

49 return gws.u.sha256([ 

50 super().cache_hash(), 

51 self.cfg('bottomFirst', default=False), 

52 ]) 

53 

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. 

56 

57 The source layers are given top-first and sent bottom-first, as WMS expects. 

58 The image is requested with a transparent background. 

59 

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. 

66 

67 Returns: 

68 The image content. 

69 

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

77 

78 v3 = self.version >= '1.3' 

79 

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) 

84 

85 layer_names = list(reversed([sl.name for sl in source_layers])) 

86 

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) 

100 

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 

106 

107 DEFAULT_GET_FEATURE_LIMIT = 100 

108 """Value of ``FEATURE_COUNT`` in GetFeatureInfo requests, if the search has no limit.""" 

109 

110 def create_leaf_layer_config(self, source_layers): 

111 """Create the configuration of a ``wmsflat`` layer for the given source layers. 

112 

113 Used by the ``wms`` tree layer to create its leaf layers. 

114 

115 Args: 

116 source_layers: Source layers to render in the layer. 

117 

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 ) 

126 

127 def get_features(self, search, source_layers, mime=''): 

128 v3 = self.version >= '1.3' 

129 

130 shape = search.shape 

131 if not shape or shape.type != gws.GeometryType.point: 

132 return [] 

133 

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

139 

140 box_size_m = 500 

141 box_size_deg = 1 

142 box_size_px = 500 

143 

144 size = None 

145 

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 [] 

154 

155 bbox = ( 

156 shape.x - (size / 2), 

157 shape.y - (size / 2), 

158 shape.x + (size / 2), 

159 shape.y + (size / 2), 

160 ) 

161 

162 bbox = gws.lib.extent.transform(bbox, shape.crs, request_crs) 

163 

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) 

167 

168 layer_names = [sl.name for sl in source_layers] 

169 

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 } 

183 

184 if search.extraParams: 

185 params = gws.u.merge(params, gws.u.to_upper_dict(search.extraParams)) 

186 

187 op = self.get_operation(gws.OwsVerb.GetFeatureInfo) 

188 if not op: 

189 return [] 

190 

191 mime = mime or op.defaultMimeType 

192 if mime: 

193 params.setdefault('INFO_FORMAT', mime) 

194 

195 args = self.prepare_operation(op, params=params) 

196 text = gws.base.ows.client.request.get_text(args) 

197 

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 [] 

203 

204 gws.log.debug(f'get_features: FOUND={len(records)} params={params!r}') 

205 

206 for rec in records: 

207 if rec.shape: 

208 rec.shape = rec.shape.transformed_to(shape.crs) 

209 

210 return records