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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""Source layers.
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.
9This package provides:
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.
22The ``SourceLayer`` and ``SourceStyle`` types are defined in ``types.pyinc``.
24Example::
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)
30Configuration example, select source layers by name::
32 sourceLayers.names ["streets" "buildings"]
33"""
35from typing import Optional, Iterable
37import re
39import gws
40import gws.lib.bounds
41import gws.lib.extent
42import gws.lib.crs
45##
47class LayerFilter(gws.Data):
48 """Selects source layers."""
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."""
68def layer_matches(sl: gws.SourceLayer, f: Optional[LayerFilter]) -> bool:
69 """Check if a source layer matches a filter.
71 All given conditions of the filter must match.
73 Args:
74 sl: Source layer.
75 f: Layer filter. If ``None``, every layer matches.
77 Returns:
78 ``True`` if the layer matches.
79 """
81 if not f:
82 return True
84 if f.level and sl.aLevel != f.level:
85 return False
87 if f.names and sl.name not in f.names:
88 return False
90 if f.titles and sl.title not in f.titles:
91 return False
93 if f.pattern and not re.search(f.pattern, sl.aPath):
94 return False
96 if f.isGroup is not None and sl.isGroup != f.isGroup:
97 return False
99 if f.isImage is not None and sl.isImage != f.isImage:
100 return False
102 if f.isQueryable is not None and sl.isQueryable != f.isQueryable:
103 return False
105 if f.isVisible is not None and sl.isVisible != f.isVisible:
106 return False
108 return True
111def check_layers(layers: Iterable[gws.SourceLayer], revert: bool = False) -> list[gws.SourceLayer]:
112 """Prepare a source layer tree.
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.
117 Args:
118 layers: Top-level source layers.
119 revert: Reverse the order of layers and sub-layers.
121 Returns:
122 The prepared top-level layers.
123 """
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))
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)
140 return sl
142 ls = gws.u.compact(walk(sl, '', 1) for sl in layers)
143 if revert:
144 ls = list(reversed(ls))
145 return ls
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.
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``.
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``.
170 Returns:
171 Matching layers. If there are no filter conditions, ``layers`` is returned as is.
172 """
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
184 if slf:
185 if extra:
186 slf = LayerFilter(slf, extra)
187 elif extra:
188 slf = LayerFilter(extra)
189 else:
190 return layers
192 found = []
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)
203 for sl in layers:
204 walk(sl)
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
209 if slf.names:
210 found.sort(key=lambda sl: slf.names.index(sl.name) if sl.name in slf.names else -1)
212 return found
215def combined_crs_list(layers: list[gws.SourceLayer]) -> list[gws.Crs]:
216 """Compute the CRS supported by all source layers.
218 Layers without supported CRS are ignored.
220 Args:
221 layers: Source layers.
223 Returns:
224 The intersection of the supported CRS lists, in no particular order.
225 """
227 cs: set = set()
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)
237 return list(cs)
240def combined_wgs_extent(layers: list[gws.SourceLayer]) -> Optional[gws.Extent]:
241 """Compute the union of the WGS extents of source layers.
243 Args:
244 layers: Source layers.
246 Returns:
247 The union extent, or ``None`` if no layer has an extent.
248 """
250 bs = gws.u.compact(sl.wgsExtent for sl in layers)
251 if bs:
252 return gws.lib.extent.union(*bs)
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.
258 Args:
259 layers: Source layers.
260 crs: Target CRS.
262 Returns:
263 Bounds in the target CRS, or ``None`` if no layer has an extent.
264 """
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)