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/__init__.py +110 -0
- tilefetch/__main__.py +5 -0
- tilefetch/_version.py +1 -0
- tilefetch/api.py +592 -0
- tilefetch/cli.py +419 -0
- tilefetch/download.py +334 -0
- tilefetch/georef.py +291 -0
- tilefetch/mosaic.py +191 -0
- tilefetch/pmtiles.py +657 -0
- tilefetch/providers.py +137 -0
- tilefetch/py.typed +0 -0
- tilefetch/sources.py +546 -0
- tilefetch/storage.py +338 -0
- tilefetch/tiles.py +469 -0
- tilefetch-0.1.0.dist-info/METADATA +264 -0
- tilefetch-0.1.0.dist-info/RECORD +20 -0
- tilefetch-0.1.0.dist-info/WHEEL +5 -0
- tilefetch-0.1.0.dist-info/entry_points.txt +2 -0
- tilefetch-0.1.0.dist-info/licenses/LICENSE +21 -0
- tilefetch-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|