tilefetch 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tilefetch/providers.py ADDED
@@ -0,0 +1,137 @@
1
+ """Built-in provider registry.
2
+
3
+ Only services whose published terms allow scripted, offline use are listed.
4
+ Each entry carries its terms URL and the attribution its operator asks for,
5
+ plus conservative per-job limits that stay well inside those terms. Limits on
6
+ built-in providers cannot be raised from the API or the CLI; they can only be
7
+ lowered.
8
+
9
+ Popular community tile servers (tile.openstreetmap.org, OpenTopoMap, ...)
10
+ are deliberately absent: their policies forbid bulk or offline downloading.
11
+ For those data, run your own tile server or use a commercial plan that
12
+ permits it, and pass its URL template to :func:`tilefetch.fetch`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from .sources import TileSource
20
+
21
+ __all__ = ["PROVIDERS", "get_provider", "list_providers", "register_provider"]
22
+
23
+ _USGS_ATTRIBUTION = (
24
+ "Map services and data available from U.S. Geological Survey, National Geospatial Program."
25
+ )
26
+ _USGS_TERMS = (
27
+ "https://www.usgs.gov/faqs/what-are-terms-uselicensing-map-services-and-data-national-map"
28
+ )
29
+ _GIBS_ATTRIBUTION = (
30
+ "Imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of "
31
+ "NASA's Earth Science Data and Information System (ESDIS)."
32
+ )
33
+ _GIBS_TERMS = "https://nasa-gibs.github.io/gibs-api-docs/map-library-usage/#bulk-downloading"
34
+ _GIBS = "https://gibs.earthdata.nasa.gov/wmts/epsg3857/best"
35
+
36
+ # USGS states there are "no restrictions" on The National Map services; GIBS
37
+ # defines a bulk download as > 1,000,000 tiles in 24 h and asks for advance
38
+ # coordination above that. The caps below keep one job far from either limit.
39
+ _USGS_LIMITS: dict[str, Any] = {"max_tiles": 50_000, "max_rate": 8.0, "max_threads": 4}
40
+ _GIBS_LIMITS: dict[str, Any] = {"max_tiles": 100_000, "max_rate": 16.0, "max_threads": 8}
41
+
42
+ _US_BOUNDS = (-180.0, -15.0, 180.0, 72.0)
43
+
44
+ PROVIDERS: dict[str, TileSource] = {}
45
+
46
+
47
+ def register_provider(source: TileSource, *, overwrite: bool = False) -> TileSource:
48
+ """Add a source to the registry under ``source.name``."""
49
+ if source.name in PROVIDERS and not overwrite:
50
+ raise ValueError(f"provider {source.name!r} is already registered")
51
+ PROVIDERS[source.name] = source
52
+ return source
53
+
54
+
55
+ def get_provider(name: str) -> TileSource:
56
+ """Look up a registered provider by name."""
57
+ try:
58
+ return PROVIDERS[name]
59
+ except KeyError:
60
+ raise KeyError(
61
+ f"unknown provider {name!r}; known: {', '.join(sorted(PROVIDERS))}"
62
+ ) from None
63
+
64
+
65
+ def list_providers() -> list[TileSource]:
66
+ """All registered providers, sorted by name."""
67
+ return [PROVIDERS[k] for k in sorted(PROVIDERS)]
68
+
69
+
70
+ def _usgs(name: str, service: str, description: str) -> TileSource:
71
+ return TileSource(
72
+ name=name,
73
+ url=f"https://basemap.nationalmap.gov/arcgis/rest/services/{service}/MapServer/tile/{{z}}/{{y}}/{{x}}",
74
+ attribution=_USGS_ATTRIBUTION,
75
+ terms_url=_USGS_TERMS,
76
+ description=description + " United States and territories; public domain.",
77
+ min_zoom=0,
78
+ max_zoom=16,
79
+ format="jpg",
80
+ bounds=_US_BOUNDS,
81
+ builtin=True,
82
+ **_USGS_LIMITS,
83
+ )
84
+
85
+
86
+ def _gibs(
87
+ name: str, layer: str, matrix_set: str, max_zoom: int, description: str, time: str | None = None
88
+ ) -> TileSource:
89
+ if time is None:
90
+ url = f"{_GIBS}/{layer}/default/default/{matrix_set}/{{z}}/{{y}}/{{x}}.jpeg"
91
+ params: dict[str, str] = {}
92
+ else:
93
+ url = f"{_GIBS}/{layer}/default/{{time}}/{matrix_set}/{{z}}/{{y}}/{{x}}.jpg"
94
+ params = {"time": time}
95
+ return TileSource(
96
+ name=name,
97
+ url=url,
98
+ params=params,
99
+ attribution=_GIBS_ATTRIBUTION,
100
+ terms_url=_GIBS_TERMS,
101
+ description=description,
102
+ min_zoom=0,
103
+ max_zoom=max_zoom,
104
+ format="jpg",
105
+ builtin=True,
106
+ **_GIBS_LIMITS,
107
+ )
108
+
109
+
110
+ for _src in (
111
+ _usgs("usgs-topo", "USGSTopo", "USGS Topo base map (The National Map)."),
112
+ _usgs("usgs-imagery", "USGSImageryOnly", "USGS orthoimagery base map (The National Map)."),
113
+ _usgs("usgs-shaded-relief", "USGSShadedReliefOnly", "USGS shaded relief (The National Map)."),
114
+ _gibs(
115
+ "nasa-bluemarble",
116
+ "BlueMarble_NextGeneration",
117
+ "GoogleMapsCompatible_Level8",
118
+ 8,
119
+ "NASA Blue Marble: Next Generation global true-colour mosaic (500 m).",
120
+ ),
121
+ _gibs(
122
+ "nasa-bluemarble-relief",
123
+ "BlueMarble_ShadedRelief_Bathymetry",
124
+ "GoogleMapsCompatible_Level8",
125
+ 8,
126
+ "NASA Blue Marble with shaded relief and bathymetry.",
127
+ ),
128
+ _gibs(
129
+ "nasa-modis-terra",
130
+ "MODIS_Terra_CorrectedReflectance_TrueColor",
131
+ "GoogleMapsCompatible_Level9",
132
+ 9,
133
+ "NASA MODIS Terra daily corrected reflectance (true colour). Set params time=YYYY-MM-DD.",
134
+ time="2024-06-01",
135
+ ),
136
+ ):
137
+ register_provider(_src)
tilefetch/py.typed ADDED
File without changes
tilefetch/sources.py ADDED
@@ -0,0 +1,546 @@
1
+ """Tile sources: URL templates, WMTS capabilities parsing and usage policy.
2
+
3
+ A :class:`TileSource` turns an XYZ tile address into a URL and carries the
4
+ rules that govern its use: attribution, terms URL, a hard cap on tiles per
5
+ job, a request-rate limit and a thread limit.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import math
12
+ import re
13
+ import urllib.parse
14
+ import xml.etree.ElementTree as ET
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, Mapping
17
+
18
+ from .tiles import ORIGIN_SHIFT, Tile, flip_y, tile_bounds_mercator, tile_to_quadkey
19
+
20
+ __all__ = [
21
+ "TileSource",
22
+ "PolicyError",
23
+ "TileLimitError",
24
+ "RESTRICTED_HOSTS",
25
+ "check_url_policy",
26
+ "WMTSLayerInfo",
27
+ "parse_wmts_capabilities",
28
+ "wmts_source",
29
+ "DEFAULT_CUSTOM_MAX_TILES",
30
+ "DEFAULT_CUSTOM_MAX_RATE",
31
+ ]
32
+
33
+ #: Tile cap applied to user-supplied URL templates unless raised explicitly.
34
+ DEFAULT_CUSTOM_MAX_TILES = 10_000
35
+ #: Default request rate (requests per second) for user-supplied URL templates.
36
+ DEFAULT_CUSTOM_MAX_RATE = 10.0
37
+
38
+
39
+ class PolicyError(RuntimeError):
40
+ """The request conflicts with the tile provider's usage policy."""
41
+
42
+
43
+ class TileLimitError(PolicyError):
44
+ """The job needs more tiles than the provider allows per job."""
45
+
46
+
47
+ #: Hosts whose operators forbid bulk / offline downloading by third-party tools.
48
+ #: Matching is by host suffix. tilefetch refuses these outright; run your own
49
+ #: tile server (or use one whose terms allow bulk use) instead.
50
+ RESTRICTED_HOSTS: dict[str, tuple[str, str]] = {
51
+ "tile.openstreetmap.org": (
52
+ "The OSMF Tile Usage Policy forbids bulk downloading and offline use of "
53
+ "tile.openstreetmap.org tiles.",
54
+ "https://operations.osmfoundation.org/policies/tiles/",
55
+ ),
56
+ "tile.osm.org": (
57
+ "The OSMF Tile Usage Policy forbids bulk downloading and offline use of OSMF tiles.",
58
+ "https://operations.osmfoundation.org/policies/tiles/",
59
+ ),
60
+ "opentopomap.org": (
61
+ "OpenTopoMap asks users not to overload its server with mass downloads.",
62
+ "https://opentopomap.org/about",
63
+ ),
64
+ "google.com": (
65
+ "The Google Maps Platform terms forbid pre-fetching, caching or bulk download of tiles.",
66
+ "https://cloud.google.com/maps-platform/terms",
67
+ ),
68
+ "googleapis.com": (
69
+ "The Google Maps Platform terms forbid pre-fetching, caching or bulk download of tiles.",
70
+ "https://cloud.google.com/maps-platform/terms",
71
+ ),
72
+ "virtualearth.net": (
73
+ "The Microsoft Bing Maps terms forbid bulk download and offline caching of imagery.",
74
+ "https://www.microsoft.com/en-us/maps/bing-maps/product",
75
+ ),
76
+ "arcgisonline.com": (
77
+ "Esri basemap tiles may only be taken offline through Esri's own export tools "
78
+ "under an ArcGIS licence that allows it.",
79
+ "https://www.esri.com/en-us/legal/terms/full-master-agreement",
80
+ ),
81
+ }
82
+
83
+
84
+ def check_url_policy(url: str) -> None:
85
+ """Raise :class:`PolicyError` if ``url`` points at a host that forbids bulk use."""
86
+ host = (urllib.parse.urlsplit(url).hostname or "").lower()
87
+ # Templates may carry a subdomain placeholder such as "{s}.tile...".
88
+ host = re.sub(r"^\{[^}]*\}\.", "", host)
89
+ for suffix, (reason, terms) in RESTRICTED_HOSTS.items():
90
+ if host == suffix or host.endswith("." + suffix):
91
+ raise PolicyError(
92
+ f"{reason} tilefetch will not bulk-download from {host}. "
93
+ f"Terms: {terms}. Use a tile server you operate or one whose terms allow "
94
+ "bulk/offline use."
95
+ )
96
+
97
+
98
+ _PLACEHOLDER = re.compile(r"\{([^{}]+)\}")
99
+ _BUILTIN_KEYS = {
100
+ "z",
101
+ "x",
102
+ "y",
103
+ "-y",
104
+ "q",
105
+ "quadkey",
106
+ "s",
107
+ "bbox",
108
+ "bbox-epsg-3857",
109
+ "TileMatrix",
110
+ "TileRow",
111
+ "TileCol",
112
+ }
113
+
114
+
115
+ @dataclass
116
+ class TileSource:
117
+ """A tile endpoint and the rules for using it.
118
+
119
+ ``url`` is a template with any of these placeholders:
120
+
121
+ ``{z} {x} {y}``
122
+ XYZ address (``{y}`` is flipped when ``scheme="tms"``).
123
+ ``{-y}``
124
+ TMS row (always flipped, regardless of ``scheme``).
125
+ ``{q}`` / ``{quadkey}``
126
+ Bing-style quadkey.
127
+ ``{s}``
128
+ One of ``subdomains`` (chosen deterministically per tile).
129
+ ``{bbox}`` / ``{bbox-epsg-3857}``
130
+ Tile bounds ``minx,miny,maxx,maxy`` in EPSG:3857, for WMS GetMap URLs.
131
+ ``{TileMatrix} {TileRow} {TileCol}``
132
+ WMTS addressing (see :func:`wmts_source`).
133
+
134
+ Any other ``{name}`` must be supplied in ``params`` (API keys, dates, ...).
135
+ """
136
+
137
+ url: str
138
+ name: str = "custom"
139
+ attribution: str = ""
140
+ terms_url: str | None = None
141
+ description: str = ""
142
+ min_zoom: int = 0
143
+ max_zoom: int = 22
144
+ tile_size: int = 256
145
+ scheme: str = "xyz"
146
+ subdomains: tuple[str, ...] = ()
147
+ headers: dict[str, str] = field(default_factory=dict)
148
+ params: dict[str, str] = field(default_factory=dict)
149
+ max_tiles: int | None = DEFAULT_CUSTOM_MAX_TILES
150
+ max_rate: float | None = DEFAULT_CUSTOM_MAX_RATE
151
+ max_threads: int = 4
152
+ builtin: bool = False
153
+ format: str | None = None
154
+ bounds: tuple[float, float, float, float] | None = None
155
+ matrix_ids: dict[int, str] = field(default_factory=dict)
156
+ matrix_offsets: dict[int, tuple[int, int]] = field(default_factory=dict)
157
+
158
+ def __post_init__(self) -> None:
159
+ if self.scheme not in ("xyz", "tms"):
160
+ raise ValueError("scheme must be 'xyz' or 'tms'")
161
+ if self.min_zoom > self.max_zoom:
162
+ raise ValueError("min_zoom is greater than max_zoom")
163
+ self.subdomains = tuple(self.subdomains)
164
+ missing = [
165
+ k
166
+ for k in _PLACEHOLDER.findall(self.url)
167
+ if k not in _BUILTIN_KEYS and k not in self.params
168
+ ]
169
+ if missing:
170
+ raise ValueError(
171
+ f"URL template has placeholders without values: {sorted(set(missing))}; "
172
+ "pass them in params"
173
+ )
174
+ if "{s}" in self.url and not self.subdomains:
175
+ raise ValueError("URL template uses {s} but no subdomains were given")
176
+ if "://" in self.url:
177
+ check_url_policy(self.url)
178
+
179
+ @classmethod
180
+ def from_url(cls, url: str, **kwargs: Any) -> TileSource:
181
+ """Build a custom source from a URL template (``{z}/{x}/{y}`` etc.)."""
182
+ if "subdomains" not in kwargs and "{s}" in url:
183
+ kwargs["subdomains"] = ("a", "b", "c")
184
+ return cls(url=url, **kwargs)
185
+
186
+ def replace(self, **changes: Any) -> TileSource:
187
+ """Copy of this source with some fields changed."""
188
+ return dataclasses.replace(self, **changes)
189
+
190
+ @property
191
+ def cache_key(self) -> str:
192
+ """Filesystem-safe identifier used for the on-disk cache."""
193
+ import hashlib
194
+
195
+ h = hashlib.sha256(self.url.encode("utf-8"))
196
+ for k in sorted(self.params):
197
+ h.update(f"\0{k}={self.params[k]}".encode())
198
+ return re.sub(r"[^A-Za-z0-9_.-]", "_", self.name)[:40] + "-" + h.hexdigest()[:12]
199
+
200
+ def tile_url(self, tile: Tile | tuple[int, int, int]) -> str:
201
+ """URL of one tile."""
202
+ z, x, y = tile
203
+ if z < self.min_zoom or z > self.max_zoom:
204
+ raise ValueError(
205
+ f"zoom {z} is outside {self.name!r} range {self.min_zoom}-{self.max_zoom}"
206
+ )
207
+ if self.matrix_ids and z not in self.matrix_ids:
208
+ raise ValueError(f"the tile matrix set of {self.name!r} has no matrix for zoom {z}")
209
+ dx, dy = self.matrix_offsets.get(z, (0, 0))
210
+ values: dict[str, str] = {
211
+ "z": str(z),
212
+ "x": str(x),
213
+ "y": str(flip_y(z, y) if self.scheme == "tms" else y),
214
+ "-y": str(flip_y(z, y)),
215
+ "TileMatrix": self.matrix_ids.get(z, str(z)),
216
+ "TileRow": str(y - dy),
217
+ "TileCol": str(x - dx),
218
+ }
219
+ url = self.url
220
+ if "{q" in url:
221
+ values["q"] = values["quadkey"] = tile_to_quadkey((z, x, y))
222
+ if "{bbox" in url:
223
+ b = tile_bounds_mercator((z, x, y))
224
+ values["bbox"] = values["bbox-epsg-3857"] = ",".join(
225
+ repr(round(v, 6)) for v in (b.minx, b.miny, b.maxx, b.maxy)
226
+ )
227
+ if self.subdomains:
228
+ values["s"] = self.subdomains[(x + y) % len(self.subdomains)]
229
+ values.update(self.params)
230
+ return _PLACEHOLDER.sub(lambda m: values[m.group(1)], url)
231
+
232
+ def summary(self) -> dict[str, Any]:
233
+ """Plain-dict description (used by ``tilefetch providers``)."""
234
+ return {
235
+ "name": self.name,
236
+ "description": self.description,
237
+ "url": self.url,
238
+ "zoom": f"{self.min_zoom}-{self.max_zoom}",
239
+ "attribution": self.attribution,
240
+ "terms_url": self.terms_url,
241
+ "max_tiles": self.max_tiles,
242
+ "max_rate": self.max_rate,
243
+ "max_threads": self.max_threads,
244
+ }
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # WMTS
249
+ # ---------------------------------------------------------------------------
250
+
251
+ #: OGC "standardized rendering pixel size" in metres (WMTS 1.0.0, 6.1).
252
+ _PIXEL_SIZE = 0.00028
253
+ _MERCATOR_CRS = re.compile(
254
+ r"(?:EPSG(?::[\d.]*)?:+(?:3857|900913|3785|102100)$)|GoogleMapsCompatible", re.I
255
+ )
256
+
257
+
258
+ def _local(tag: str) -> str:
259
+ return tag.rsplit("}", 1)[-1]
260
+
261
+
262
+ def _child(el: ET.Element, name: str) -> ET.Element | None:
263
+ for c in el:
264
+ if _local(c.tag) == name:
265
+ return c
266
+ return None
267
+
268
+
269
+ def _children(el: ET.Element, name: str) -> list[ET.Element]:
270
+ return [c for c in el if _local(c.tag) == name]
271
+
272
+
273
+ def _text(el: ET.Element | None, name: str, default: str = "") -> str:
274
+ if el is None:
275
+ return default
276
+ c = _child(el, name)
277
+ return (c.text or "").strip() if c is not None and c.text else default
278
+
279
+
280
+ @dataclass
281
+ class WMTSLayerInfo:
282
+ """What :func:`parse_wmts_capabilities` found for one layer."""
283
+
284
+ identifier: str
285
+ title: str
286
+ formats: list[str]
287
+ styles: list[str]
288
+ default_style: str | None
289
+ tile_matrix_sets: list[str]
290
+ resource_urls: list[tuple[str, str]] # (format, template)
291
+ dimensions: dict[str, str] # identifier -> default value
292
+
293
+
294
+ @dataclass
295
+ class _MatrixSet:
296
+ identifier: str
297
+ crs: str
298
+ # zoom -> (matrix identifier, tile size, (col offset, row offset))
299
+ zooms: dict[int, tuple[str, int, tuple[int, int]]]
300
+ skipped: list[str]
301
+
302
+
303
+ def _parse_matrix_set(el: ET.Element) -> _MatrixSet:
304
+ ident = _text(el, "Identifier")
305
+ crs = _text(el, "SupportedCRS")
306
+ zooms: dict[int, tuple[str, int, tuple[int, int]]] = {}
307
+ skipped: list[str] = []
308
+ for tm in _children(el, "TileMatrix"):
309
+ mid = _text(tm, "Identifier")
310
+ try:
311
+ scale = float(_text(tm, "ScaleDenominator"))
312
+ tw = int(_text(tm, "TileWidth", "256"))
313
+ th = int(_text(tm, "TileHeight", "256"))
314
+ tlx, tly = (float(v) for v in _text(tm, "TopLeftCorner").split()[:2])
315
+ except ValueError:
316
+ skipped.append(mid)
317
+ continue
318
+ span = scale * _PIXEL_SIZE * tw # metres covered by one tile
319
+ zf = math.log2(2.0 * ORIGIN_SHIFT / span)
320
+ z = round(zf)
321
+ if tw != th or abs(zf - z) > 0.01 or z < 0:
322
+ skipped.append(mid)
323
+ continue
324
+ # The top-left corner must sit on the global tile grid.
325
+ cf = (tlx + ORIGIN_SHIFT) / span
326
+ rf = (ORIGIN_SHIFT - tly) / span
327
+ if abs(cf - round(cf)) > 0.01 or abs(rf - round(rf)) > 0.01:
328
+ skipped.append(mid)
329
+ continue
330
+ zooms[z] = (mid, tw, (round(cf), round(rf)))
331
+ return _MatrixSet(ident, crs, zooms, skipped)
332
+
333
+
334
+ def parse_wmts_capabilities(xml: str | bytes) -> tuple[dict[str, WMTSLayerInfo], dict[str, Any]]:
335
+ """Parse a WMTS 1.0.0 GetCapabilities document.
336
+
337
+ Returns ``(layers, extra)`` where ``layers`` maps layer identifiers to
338
+ :class:`WMTSLayerInfo` and ``extra`` holds ``kvp_url`` (the GetTile KVP
339
+ endpoint, if advertised), ``rest`` (whether RESTful encoding is
340
+ advertised) and ``matrix_sets`` (parsed tile matrix sets).
341
+ """
342
+ root = ET.fromstring(xml)
343
+ if _local(root.tag) != "Capabilities":
344
+ raise ValueError("not a WMTS Capabilities document")
345
+ contents = _child(root, "Contents")
346
+ if contents is None:
347
+ raise ValueError("WMTS capabilities has no Contents element")
348
+ layers: dict[str, WMTSLayerInfo] = {}
349
+ for lay in _children(contents, "Layer"):
350
+ styles = []
351
+ default_style = None
352
+ for st in _children(lay, "Style"):
353
+ sid = _text(st, "Identifier")
354
+ styles.append(sid)
355
+ if st.get("isDefault", "").lower() == "true":
356
+ default_style = sid
357
+ dims = {}
358
+ for d in _children(lay, "Dimension"):
359
+ dims[_text(d, "Identifier")] = _text(d, "Default") or _text(d, "Value")
360
+ info = WMTSLayerInfo(
361
+ identifier=_text(lay, "Identifier"),
362
+ title=_text(lay, "Title"),
363
+ formats=[(f.text or "").strip() for f in _children(lay, "Format")],
364
+ styles=styles,
365
+ default_style=default_style or (styles[0] if styles else None),
366
+ tile_matrix_sets=[
367
+ _text(link, "TileMatrixSet") for link in _children(lay, "TileMatrixSetLink")
368
+ ],
369
+ resource_urls=[
370
+ (r.get("format", ""), r.get("template", ""))
371
+ for r in _children(lay, "ResourceURL")
372
+ if r.get("resourceType", "tile") == "tile"
373
+ ],
374
+ dimensions=dims,
375
+ )
376
+ layers[info.identifier] = info
377
+ matrix_sets = {}
378
+ for tms in _children(contents, "TileMatrixSet"):
379
+ ms = _parse_matrix_set(tms)
380
+ matrix_sets[ms.identifier] = ms
381
+ kvp_url = None
382
+ rest = False
383
+ for op in root.iter():
384
+ if _local(op.tag) != "Operation" or op.get("name") != "GetTile":
385
+ continue
386
+ for get in op.iter():
387
+ if _local(get.tag) != "Get":
388
+ continue
389
+ href = get.get("{http://www.w3.org/1999/xlink}href", "")
390
+ values = [
391
+ (v.text or "").strip().upper() for v in get.iter() if _local(v.tag) == "Value"
392
+ ]
393
+ if "KVP" in values or not values:
394
+ kvp_url = kvp_url or href
395
+ if "RESTFUL" in values:
396
+ rest = True
397
+ return layers, {"kvp_url": kvp_url, "rest": rest, "matrix_sets": matrix_sets}
398
+
399
+
400
+ def _fetch_text(url: str, headers: Mapping[str, str], timeout: float) -> bytes:
401
+ import urllib.request
402
+
403
+ from ._version import __version__
404
+
405
+ h = {"User-Agent": f"tilefetch/{__version__}"}
406
+ h.update(headers)
407
+ req = urllib.request.Request(url, headers=h)
408
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 - user URL
409
+ data: bytes = resp.read()
410
+ return data
411
+
412
+
413
+ _EXT = {"image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg", "image/webp": "webp"}
414
+
415
+
416
+ def wmts_source(
417
+ capabilities: str | bytes,
418
+ layer: str,
419
+ *,
420
+ tile_matrix_set: str | None = None,
421
+ style: str | None = None,
422
+ format: str | None = None,
423
+ dimensions: Mapping[str, str] | None = None,
424
+ encoding: str = "auto",
425
+ attribution: str = "",
426
+ terms_url: str | None = None,
427
+ headers: Mapping[str, str] | None = None,
428
+ timeout: float = 30.0,
429
+ **kwargs: Any,
430
+ ) -> TileSource:
431
+ """Build a :class:`TileSource` for a WMTS layer.
432
+
433
+ ``capabilities`` is a GetCapabilities URL, a local file path or the XML
434
+ itself. A Web Mercator tile matrix set is chosen automatically (or pass
435
+ ``tile_matrix_set``); its matrices are mapped to XYZ zoom levels by scale
436
+ denominator, so matrix identifiers such as ``"EPSG:3857:12"`` and matrix
437
+ sets with a smaller extent (offset top-left corner) both work.
438
+ ``encoding`` is ``"rest"``, ``"kvp"`` or ``"auto"`` (REST when the layer
439
+ has a ResourceURL template). ``dimensions`` overrides defaults such as
440
+ ``{"Time": "2024-06-01"}``.
441
+ """
442
+ hdrs = dict(headers or {})
443
+ if isinstance(capabilities, bytes):
444
+ xml: str | bytes = capabilities
445
+ elif capabilities.lstrip().startswith("<"):
446
+ xml = capabilities
447
+ elif re.match(r"^https?://", capabilities):
448
+ check_url_policy(capabilities)
449
+ xml = _fetch_text(capabilities, hdrs, timeout)
450
+ else:
451
+ with open(capabilities, "rb") as fh:
452
+ xml = fh.read()
453
+ layers, extra = parse_wmts_capabilities(xml)
454
+ if layer not in layers:
455
+ raise ValueError(f"layer {layer!r} not found; available: {sorted(layers)[:20]}")
456
+ info = layers[layer]
457
+ matrix_sets: dict[str, _MatrixSet] = extra["matrix_sets"]
458
+
459
+ candidates = [tile_matrix_set] if tile_matrix_set else info.tile_matrix_sets
460
+ chosen: _MatrixSet | None = None
461
+ for name in candidates:
462
+ ms = matrix_sets.get(name or "")
463
+ if ms is None:
464
+ if tile_matrix_set:
465
+ raise ValueError(f"tile matrix set {tile_matrix_set!r} not found")
466
+ continue
467
+ if _MERCATOR_CRS.search(ms.crs) or _MERCATOR_CRS.search(ms.identifier):
468
+ if ms.zooms:
469
+ chosen = ms
470
+ break
471
+ elif tile_matrix_set:
472
+ raise ValueError(
473
+ f"tile matrix set {tile_matrix_set!r} uses {ms.crs}; only Web Mercator "
474
+ "(EPSG:3857) matrix sets are supported"
475
+ )
476
+ if chosen is None:
477
+ raise ValueError(
478
+ f"layer {layer!r} has no Web Mercator (EPSG:3857) tile matrix set "
479
+ f"(links: {info.tile_matrix_sets})"
480
+ )
481
+ sizes = {size for _, size, _ in chosen.zooms.values()}
482
+ if len(sizes) != 1:
483
+ raise ValueError("tile matrices with mixed tile sizes are not supported")
484
+
485
+ fmt = format or (info.formats[0] if info.formats else "image/png")
486
+ sty = style or info.default_style or "default"
487
+ dims = dict(info.dimensions)
488
+ dims.update(dimensions or {})
489
+
490
+ use_rest = encoding == "rest" or (encoding == "auto" and bool(info.resource_urls))
491
+ if encoding not in ("auto", "rest", "kvp"):
492
+ raise ValueError("encoding must be 'auto', 'rest' or 'kvp'")
493
+ if use_rest:
494
+ templates = [t for f, t in info.resource_urls if f == fmt] or [
495
+ t for _, t in info.resource_urls
496
+ ]
497
+ if not templates:
498
+ raise ValueError(f"layer {layer!r} has no ResourceURL for REST access")
499
+ url = templates[0]
500
+ fixed = {"TileMatrixSet": chosen.identifier, "Style": sty, "style": sty}
501
+ fixed.update(dims)
502
+
503
+ def sub(m: re.Match[str]) -> str:
504
+ key = m.group(1)
505
+ if key in ("TileMatrix", "TileRow", "TileCol"):
506
+ return m.group(0)
507
+ for k, v in fixed.items():
508
+ if k.lower() == key.lower():
509
+ return urllib.parse.quote(v, safe=":-_.")
510
+ raise ValueError(f"no value for WMTS template variable {{{key}}}; pass dimensions")
511
+
512
+ url = _PLACEHOLDER.sub(sub, url)
513
+ else:
514
+ base = extra["kvp_url"]
515
+ if not base:
516
+ raise ValueError("capabilities advertise no KVP GetTile endpoint")
517
+ query = {
518
+ "SERVICE": "WMTS",
519
+ "REQUEST": "GetTile",
520
+ "VERSION": "1.0.0",
521
+ "LAYER": layer,
522
+ "STYLE": sty,
523
+ "TILEMATRIXSET": chosen.identifier,
524
+ "FORMAT": fmt,
525
+ }
526
+ query.update(dims)
527
+ qs = urllib.parse.urlencode(query, safe=":/")
528
+ qs += "&TILEMATRIX={TileMatrix}&TILEROW={TileRow}&TILECOL={TileCol}"
529
+ sep = "" if base.endswith(("?", "&")) else ("&" if "?" in base else "?")
530
+ url = base + sep + qs
531
+
532
+ zooms = sorted(chosen.zooms)
533
+ kwargs.setdefault("name", f"wmts:{layer}")
534
+ return TileSource(
535
+ url=url,
536
+ attribution=attribution,
537
+ terms_url=terms_url,
538
+ headers=hdrs,
539
+ min_zoom=zooms[0],
540
+ max_zoom=zooms[-1],
541
+ tile_size=sizes.pop(),
542
+ format=_EXT.get(fmt.split(";")[0].strip().lower()),
543
+ matrix_ids={z: v[0] for z, v in chosen.zooms.items()},
544
+ matrix_offsets={z: v[2] for z, v in chosen.zooms.items() if v[2] != (0, 0)},
545
+ **kwargs,
546
+ )