Coverage for gws-app/gws/gis/source/__init__.py: 95%

102 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""Source layers. 

2 

3Source layers (``gws.SourceLayer``) describe the layers of an external 

4source, such as a WMS, WFS or WMTS service or a QGIS project, as read from 

5its capabilities. They form a tree. GWS layers that wrap a source select 

6the source layers they render or query with a ``LayerFilter``, e.g. in the 

7``sourceLayers`` option. 

8 

9This package provides: 

10 

11- ``check_layers``: prepares a source layer tree after parsing the 

12 capabilities. It assigns each layer a uid (``aUid``), a path of uids from 

13 the root (``aPath``) and a depth (``aLevel``, 1 for the top level), removes 

14 missing entries, optionally reverses the layer order, and derives a group's 

15 WGS extent from its children when it has none. 

16- ``LayerFilter``, ``layer_matches`` and ``filter_layers``: select source 

17 layers by depth, name, title, path pattern and flags. Filtering walks the 

18 tree and stops at the first matching layer of each branch. 

19- ``combined_crs_list``, ``combined_wgs_extent`` and ``combined_bounds``: 

20 combine the CRS lists and extents of several source layers. 

21 

22The ``SourceLayer`` and ``SourceStyle`` types are defined in ``types.pyinc``. 

23 

24Example:: 

25 

26 sls = gws.gis.source.check_layers(parsed_layers) 

27 roads = gws.gis.source.filter_layers(sls, gws.gis.source.LayerFilter(pattern='roads'), is_image=True) 

28 bounds = gws.gis.source.combined_bounds(roads, gws.lib.crs.WEBMERCATOR) 

29 

30Configuration example, select source layers by name:: 

31 

32 sourceLayers.names ["streets" "buildings"] 

33""" 

34 

35from typing import Optional, Iterable 

36 

37import re 

38 

39import gws 

40import gws.lib.bounds 

41import gws.lib.extent 

42import gws.lib.crs 

43 

44 

45## 

46 

47class LayerFilter(gws.Data): 

48 """Selects source layers.""" 

49 

50 level: int = 0 

51 """Match only layers at this depth in the source tree, 1 being the top.""" 

52 names: Optional[list[str]] 

53 """Match these layer names (top-to-bottom order).""" 

54 titles: Optional[list[str]] 

55 """Match these layer titles.""" 

56 pattern: gws.Regex = '' 

57 """Match layers whose path matches a regular expression.""" 

58 isGroup: Optional[bool] 

59 """Match only group layers.""" 

60 isImage: Optional[bool] 

61 """Match only image layers.""" 

62 isQueryable: Optional[bool] 

63 """Match only queryable layers.""" 

64 isVisible: Optional[bool] 

65 """Match only visible layers.""" 

66 

67 

68def layer_matches(sl: gws.SourceLayer, f: Optional[LayerFilter]) -> bool: 

69 """Check if a source layer matches a filter. 

70 

71 All given conditions of the filter must match. 

72 

73 Args: 

74 sl: Source layer. 

75 f: Layer filter. If ``None``, every layer matches. 

76 

77 Returns: 

78 ``True`` if the layer matches. 

79 """ 

80 

81 if not f: 

82 return True 

83 

84 if f.level and sl.aLevel != f.level: 

85 return False 

86 

87 if f.names and sl.name not in f.names: 

88 return False 

89 

90 if f.titles and sl.title not in f.titles: 

91 return False 

92 

93 if f.pattern and not re.search(f.pattern, sl.aPath): 

94 return False 

95 

96 if f.isGroup is not None and sl.isGroup != f.isGroup: 

97 return False 

98 

99 if f.isImage is not None and sl.isImage != f.isImage: 

100 return False 

101 

102 if f.isQueryable is not None and sl.isQueryable != f.isQueryable: 

103 return False 

104 

105 if f.isVisible is not None and sl.isVisible != f.isVisible: 

106 return False 

107 

108 return True 

109 

110 

111def check_layers(layers: Iterable[gws.SourceLayer], revert: bool = False) -> list[gws.SourceLayer]: 

112 """Prepare a source layer tree. 

113 

114 Sets ``aUid``, ``aPath`` and ``aLevel`` on each layer, removes empty entries and sets 

115 the WGS extent of layers without one to the union of their children's extents. 

116 

117 Args: 

118 layers: Top-level source layers. 

119 revert: Reverse the order of layers and sub-layers. 

120 

121 Returns: 

122 The prepared top-level layers. 

123 """ 

124 

125 def walk(sl, parent_path, level): 

126 if not sl: 

127 return 

128 sl.aUid = gws.u.to_uid(sl.name or sl.metadata.get('title')) 

129 sl.aPath = parent_path + '/' + sl.aUid 

130 sl.aLevel = level 

131 sl.layers = gws.u.compact(walk(c, sl.aPath, level + 1) for c in (sl.layers or [])) 

132 if revert: 

133 sl.layers = list(reversed(sl.layers)) 

134 

135 if not sl.wgsExtent and sl.layers: 

136 exts = gws.u.compact(c.wgsExtent for c in sl.layers) 

137 if exts: 

138 sl.wgsExtent = gws.lib.extent.union(*exts) 

139 

140 return sl 

141 

142 ls = gws.u.compact(walk(sl, '', 1) for sl in layers) 

143 if revert: 

144 ls = list(reversed(ls)) 

145 return ls 

146 

147 

148def filter_layers( 

149 layers: list[gws.SourceLayer], 

150 slf: LayerFilter = None, 

151 is_group: bool = None, 

152 is_image: bool = None, 

153 is_queryable: bool = None, 

154 is_visible: bool = None, 

155) -> list[gws.SourceLayer]: 

156 """Select source layers from a tree. 

157 

158 The flag arguments are added to the filter. Each branch of the tree is searched until 

159 a matching layer is found; the sub-layers of a matching layer are not searched. 

160 If the filter has ``names``, the result is sorted in the order of ``names``. 

161 

162 Args: 

163 layers: Top-level source layers. 

164 slf: Layer filter. 

165 is_group: Match only group layers, or only non-group layers if ``False``. 

166 is_image: Match only image layers, or only non-image layers if ``False``. 

167 is_queryable: Match only queryable layers, or only non-queryable layers if ``False``. 

168 is_visible: Match only visible layers, or only invisible layers if ``False``. 

169 

170 Returns: 

171 Matching layers. If there are no filter conditions, ``layers`` is returned as is. 

172 """ 

173 

174 extra = {} 

175 if is_group is not None: 

176 extra['isGroup'] = is_group 

177 if is_image is not None: 

178 extra['isImage'] = is_image 

179 if is_queryable is not None: 

180 extra['isQueryable'] = is_queryable 

181 if is_visible is not None: 

182 extra['isVisible'] = is_visible 

183 

184 if slf: 

185 if extra: 

186 slf = LayerFilter(slf, extra) 

187 elif extra: 

188 slf = LayerFilter(extra) 

189 else: 

190 return layers 

191 

192 found = [] 

193 

194 def walk(sl): 

195 # if a layer matches, add it and don't go any further 

196 # otherwise, inspect the sublayers (@TODO optimize if slf.level is given) 

197 if layer_matches(sl, slf): 

198 found.append(sl) 

199 return 

200 for sl2 in sl.layers: 

201 walk(sl2) 

202 

203 for sl in layers: 

204 walk(sl) 

205 

206 # NB: if 'names' is given, maintain the given order, which is expected to be top-to-bottom 

207 # see note in ext/layers/wms 

208 

209 if slf.names: 

210 found.sort(key=lambda sl: slf.names.index(sl.name) if sl.name in slf.names else -1) 

211 

212 return found 

213 

214 

215def combined_crs_list(layers: list[gws.SourceLayer]) -> list[gws.Crs]: 

216 """Compute the CRS supported by all source layers. 

217 

218 Layers without supported CRS are ignored. 

219 

220 Args: 

221 layers: Source layers. 

222 

223 Returns: 

224 The intersection of the supported CRS lists, in no particular order. 

225 """ 

226 

227 cs: set = set() 

228 

229 for sl in layers: 

230 if not sl.supportedCrs: 

231 continue 

232 if not cs: 

233 cs.update(sl.supportedCrs) 

234 else: 

235 cs = cs.intersection(sl.supportedCrs) 

236 

237 return list(cs) 

238 

239 

240def combined_wgs_extent(layers: list[gws.SourceLayer]) -> Optional[gws.Extent]: 

241 """Compute the union of the WGS extents of source layers. 

242 

243 Args: 

244 layers: Source layers. 

245 

246 Returns: 

247 The union extent, or ``None`` if no layer has an extent. 

248 """ 

249 

250 bs = gws.u.compact(sl.wgsExtent for sl in layers) 

251 if bs: 

252 return gws.lib.extent.union(*bs) 

253 

254 

255def combined_bounds(layers: list[gws.SourceLayer], crs: gws.Crs) -> Optional[gws.Bounds]: 

256 """Compute the union of the WGS extents of source layers, transformed to a CRS. 

257 

258 Args: 

259 layers: Source layers. 

260 crs: Target CRS. 

261 

262 Returns: 

263 Bounds in the target CRS, or ``None`` if no layer has an extent. 

264 """ 

265 

266 ext = combined_wgs_extent(layers) 

267 if ext: 

268 b = gws.Bounds(extent=ext, crs=gws.lib.crs.WGS84) 

269 return gws.lib.bounds.transform(b, crs)