tilearray 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.
- tilearray/__init__.py +89 -0
- tilearray/_version.py +3 -0
- tilearray/array.py +899 -0
- tilearray/decode.py +353 -0
- tilearray/errors.py +41 -0
- tilearray/fetch.py +772 -0
- tilearray/fetch_presets.py +77 -0
- tilearray/pressure.py +129 -0
- tilearray/py.typed +0 -0
- tilearray/service/__init__.py +33 -0
- tilearray/service/base.py +282 -0
- tilearray/service/config.py +455 -0
- tilearray/service/requests.py +70 -0
- tilearray/service/wcs.py +657 -0
- tilearray/service/wms.py +127 -0
- tilearray/service/wmts.py +590 -0
- tilearray/service/xyz.py +145 -0
- tilearray/tiles.py +142 -0
- tilearray/types.py +235 -0
- tilearray-0.1.0.dist-info/METADATA +345 -0
- tilearray-0.1.0.dist-info/RECORD +23 -0
- tilearray-0.1.0.dist-info/WHEEL +4 -0
- tilearray-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Fetch-policy presets for common public tile endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ._version import __version__
|
|
8
|
+
|
|
9
|
+
OSM_TILE_TEMPLATE = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
10
|
+
|
|
11
|
+
EA_LIDAR_WCS_URL = (
|
|
12
|
+
"https://environment.data.gov.uk/spatialdata/"
|
|
13
|
+
"lidar-composite-digital-terrain-model-dtm-1m/wcs"
|
|
14
|
+
)
|
|
15
|
+
EA_LIDAR_COVERAGE_ID = (
|
|
16
|
+
"13787b9a-26a4-4775-8523-806d13af58fc__Lidar_Composite_Elevation_DTM_1m"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Tiny 64 m OSGB bbox used in contract tests / EA bench (4 tiles at 32x32 chunks).
|
|
20
|
+
EA_LIDAR_BENCH_BBOX = (431900.0, 382700.0, 431964.0, 382764.0)
|
|
21
|
+
|
|
22
|
+
DEFAULT_CONTACT_URL = "https://github.com/MatthewJWhittle/tilearray/issues"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def tilearray_user_agent(contact_url: str = DEFAULT_CONTACT_URL) -> str:
|
|
26
|
+
"""Return a descriptive User-Agent string for public tile services."""
|
|
27
|
+
|
|
28
|
+
return f"tilearray/{__version__} (+{contact_url})"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def osm_fetch_defaults(
|
|
32
|
+
*,
|
|
33
|
+
user_agent: str | None = None,
|
|
34
|
+
contact_url: str = DEFAULT_CONTACT_URL,
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
"""
|
|
37
|
+
Polite fetch defaults for OpenStreetMap raster tiles (OSMF policy).
|
|
38
|
+
|
|
39
|
+
- Identifiable User-Agent (required by OSM tile usage policy)
|
|
40
|
+
- Low concurrency (2 in-flight) and 2 req/s per host
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
"max_concurrent_requests": 2,
|
|
45
|
+
"adaptive_concurrency": False,
|
|
46
|
+
"rate_limit_per_second": 2.0,
|
|
47
|
+
"fetch_retries": 2,
|
|
48
|
+
"fetch_timeout": 30.0,
|
|
49
|
+
"headers": {
|
|
50
|
+
"User-Agent": user_agent or tilearray_user_agent(contact_url),
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def ea_dsp_fetch_defaults() -> dict[str, Any]:
|
|
56
|
+
"""
|
|
57
|
+
Fetch defaults for Environment Agency Data Service Platform WCS endpoints.
|
|
58
|
+
|
|
59
|
+
Uses in-process AIMD concurrency (starts modest, ramps on success, backs off
|
|
60
|
+
hard on 403/408/429 / timeouts) with a bounded ceiling. No fixed per-second throttle;
|
|
61
|
+
``Retry-After`` and transient 403/408/429/503 are handled by :class:`~tilearray.fetch.TileFetcher`.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
"max_concurrent_requests": 10,
|
|
66
|
+
"initial_concurrent_requests": 8,
|
|
67
|
+
"min_concurrent_requests": 4,
|
|
68
|
+
"multiplicative_decrease": 0.75,
|
|
69
|
+
"adaptive_concurrency": True,
|
|
70
|
+
"forbidden_circuit_breaker": True,
|
|
71
|
+
"forbidden_window_seconds": 5.0,
|
|
72
|
+
"forbidden_threshold": 6,
|
|
73
|
+
"forbidden_cooldown_seconds": 15.0,
|
|
74
|
+
"rate_limit_per_second": None,
|
|
75
|
+
"fetch_retries": 4,
|
|
76
|
+
"fetch_timeout": 60.0,
|
|
77
|
+
}
|
tilearray/pressure.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Shared HTTP response classification for retry and AIMD pressure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
_BODY_SAMPLE_BYTES = 4096
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class PressureSignature:
|
|
15
|
+
"""Pluggable match rule: HTTP status plus optional body substrings."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
status_codes: frozenset[int]
|
|
19
|
+
body_substrings: tuple[bytes, ...] = ()
|
|
20
|
+
retryable: bool = True
|
|
21
|
+
aimd_pressure: bool = True
|
|
22
|
+
circuit_breaker: bool = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ResponseClassification:
|
|
27
|
+
"""Outcome of classifying one HTTP response."""
|
|
28
|
+
|
|
29
|
+
signature: PressureSignature | None = None
|
|
30
|
+
retryable: bool = False
|
|
31
|
+
aimd_pressure: bool = False
|
|
32
|
+
circuit_breaker: bool = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _gateway_status_codes() -> frozenset[int]:
|
|
36
|
+
return frozenset({403, 408, 429, 500, 502, 503, 504})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
BUILTIN_PRESSURE_SIGNATURES: tuple[PressureSignature, ...] = (
|
|
40
|
+
PressureSignature(
|
|
41
|
+
name="gateway_forbidden",
|
|
42
|
+
status_codes=frozenset({403}),
|
|
43
|
+
circuit_breaker=True,
|
|
44
|
+
),
|
|
45
|
+
PressureSignature(
|
|
46
|
+
name="gateway_timeout",
|
|
47
|
+
status_codes=frozenset({408}),
|
|
48
|
+
),
|
|
49
|
+
PressureSignature(
|
|
50
|
+
name="rate_limit",
|
|
51
|
+
status_codes=frozenset({429}),
|
|
52
|
+
circuit_breaker=True,
|
|
53
|
+
),
|
|
54
|
+
PressureSignature(
|
|
55
|
+
name="upstream_error",
|
|
56
|
+
status_codes=frozenset({500, 502, 503, 504}),
|
|
57
|
+
),
|
|
58
|
+
PressureSignature(
|
|
59
|
+
name="ogc_transient_404",
|
|
60
|
+
status_codes=frozenset({404}),
|
|
61
|
+
body_substrings=(
|
|
62
|
+
b"invalidparametervalue",
|
|
63
|
+
b"subsettingcrs",
|
|
64
|
+
b"exceptionreport",
|
|
65
|
+
),
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class PressureClassifier:
|
|
72
|
+
"""Match responses against ordered signatures (first match wins)."""
|
|
73
|
+
|
|
74
|
+
signatures: tuple[PressureSignature, ...] = field(
|
|
75
|
+
default_factory=lambda: BUILTIN_PRESSURE_SIGNATURES
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def register(self, signature: PressureSignature) -> None:
|
|
79
|
+
"""Append a custom signature (checked after existing rules)."""
|
|
80
|
+
|
|
81
|
+
self.signatures = (*self.signatures, signature)
|
|
82
|
+
|
|
83
|
+
def classify(self, response: httpx.Response) -> ResponseClassification:
|
|
84
|
+
body = response.content[:_BODY_SAMPLE_BYTES]
|
|
85
|
+
lowered_body = body.lower() if body else b""
|
|
86
|
+
|
|
87
|
+
for signature in self.signatures:
|
|
88
|
+
if response.status_code not in signature.status_codes:
|
|
89
|
+
continue
|
|
90
|
+
if signature.body_substrings and not any(
|
|
91
|
+
needle in lowered_body for needle in signature.body_substrings
|
|
92
|
+
):
|
|
93
|
+
continue
|
|
94
|
+
return ResponseClassification(
|
|
95
|
+
signature=signature,
|
|
96
|
+
retryable=signature.retryable,
|
|
97
|
+
aimd_pressure=signature.aimd_pressure,
|
|
98
|
+
circuit_breaker=signature.circuit_breaker,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return ResponseClassification()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
DEFAULT_PRESSURE_CLASSIFIER = PressureClassifier()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def classify_response(
|
|
108
|
+
response: httpx.Response,
|
|
109
|
+
classifier: PressureClassifier | None = None,
|
|
110
|
+
) -> ResponseClassification:
|
|
111
|
+
"""Classify ``response`` for retry / AIMD / circuit-breaker handling."""
|
|
112
|
+
|
|
113
|
+
return (classifier or DEFAULT_PRESSURE_CLASSIFIER).classify(response)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def register_pressure_signature(signature: PressureSignature) -> None:
|
|
117
|
+
"""Register a signature on the process-wide default classifier."""
|
|
118
|
+
|
|
119
|
+
DEFAULT_PRESSURE_CLASSIFIER.register(signature)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def reset_pressure_classifier(
|
|
123
|
+
signatures: Sequence[PressureSignature] | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Reset the default classifier (primarily for tests)."""
|
|
126
|
+
|
|
127
|
+
DEFAULT_PRESSURE_CLASSIFIER.signatures = tuple(
|
|
128
|
+
signatures if signatures is not None else BUILTIN_PRESSURE_SIGNATURES
|
|
129
|
+
)
|
tilearray/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Service abstractions and implementations for OGC-style tile services."""
|
|
2
|
+
|
|
3
|
+
from .base import (
|
|
4
|
+
BaseService,
|
|
5
|
+
TileGeometry,
|
|
6
|
+
detect_service_type,
|
|
7
|
+
get_service,
|
|
8
|
+
register_service,
|
|
9
|
+
)
|
|
10
|
+
from .config import ServiceConfig, WCSConfig, WMSConfig, WMTSConfig, XYZConfig
|
|
11
|
+
from .wcs import WCSParser, WCSService
|
|
12
|
+
from .wms import WMSService
|
|
13
|
+
from .wmts import WMTSParser, WMTSService
|
|
14
|
+
from .xyz import XYZService
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"BaseService",
|
|
18
|
+
"TileGeometry",
|
|
19
|
+
"detect_service_type",
|
|
20
|
+
"get_service",
|
|
21
|
+
"register_service",
|
|
22
|
+
"ServiceConfig",
|
|
23
|
+
"WCSConfig",
|
|
24
|
+
"WMSConfig",
|
|
25
|
+
"WMTSConfig",
|
|
26
|
+
"XYZConfig",
|
|
27
|
+
"WCSParser",
|
|
28
|
+
"WCSService",
|
|
29
|
+
"WMSService",
|
|
30
|
+
"WMTSParser",
|
|
31
|
+
"WMTSService",
|
|
32
|
+
"XYZService",
|
|
33
|
+
]
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Service registry and base abstractions for OGC tile services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from typing import Any, Callable, cast
|
|
9
|
+
from urllib.parse import parse_qs, urlparse
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
|
|
12
|
+
|
|
13
|
+
from ..types import CRS, BoundingBox, Format, ServiceTypeEnum, TileRequest
|
|
14
|
+
from .requests import compose_tile_request as _compose_tile_request
|
|
15
|
+
from .requests import merge_str_mappings
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"TileGeometry",
|
|
19
|
+
"BaseService",
|
|
20
|
+
"register_service",
|
|
21
|
+
"detect_service_type",
|
|
22
|
+
"get_service",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TileGeometry(BaseModel):
|
|
27
|
+
"""Spatial description of a tile that will be fetched from a service."""
|
|
28
|
+
|
|
29
|
+
bbox: BoundingBox = Field(..., description="Geographic extent that the tile covers")
|
|
30
|
+
width: int = Field(..., gt=0, description="Tile width in output pixels")
|
|
31
|
+
height: int = Field(..., gt=0, description="Tile height in output pixels")
|
|
32
|
+
crs: CRS = Field(
|
|
33
|
+
default=CRS.EPSG_4326, description="CRS in which bounds are expressed"
|
|
34
|
+
)
|
|
35
|
+
tile_x: int | None = Field(default=None, description="XYZ tile column index")
|
|
36
|
+
tile_y: int | None = Field(default=None, description="XYZ tile row index")
|
|
37
|
+
zoom: int | None = Field(default=None, description="XYZ zoom level")
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
40
|
+
|
|
41
|
+
@field_validator("bbox")
|
|
42
|
+
@classmethod
|
|
43
|
+
def ensure_bbox_crs(
|
|
44
|
+
cls, bbox: BoundingBox, info: ValidationInfo
|
|
45
|
+
) -> BoundingBox: # pragma: no cover - simple validation
|
|
46
|
+
crs = info.data.get("crs")
|
|
47
|
+
if crs is not None and bbox.crs != crs:
|
|
48
|
+
raise ValueError("TileGeometry bbox CRS must match the tile CRS")
|
|
49
|
+
return bbox
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BaseService(ABC):
|
|
53
|
+
"""Abstract base class for service-specific implementations."""
|
|
54
|
+
|
|
55
|
+
service_type: ServiceTypeEnum
|
|
56
|
+
|
|
57
|
+
def __init__(self, base_url: str, **config: object) -> None:
|
|
58
|
+
self.base_url = base_url.rstrip("/?")
|
|
59
|
+
self.config = config
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_url(cls, url: str, **config: object) -> BaseService:
|
|
63
|
+
"""Factory hook for constructing a service from a URL."""
|
|
64
|
+
|
|
65
|
+
return cls(url, **config)
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
# Tile generation API
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
def generate_tile_requests(
|
|
71
|
+
self,
|
|
72
|
+
bbox: BoundingBox,
|
|
73
|
+
chunk_size: tuple[int, int],
|
|
74
|
+
**options: object,
|
|
75
|
+
) -> list[TileRequest]:
|
|
76
|
+
"""Generate concrete tile requests for the provided bounding box."""
|
|
77
|
+
|
|
78
|
+
tile_geoms = list(self.plan_tiles(bbox, chunk_size, **options))
|
|
79
|
+
return [
|
|
80
|
+
self.build_tile_request(tile_geom, **options) for tile_geom in tile_geoms
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
def plan_tiles(
|
|
84
|
+
self,
|
|
85
|
+
bbox: BoundingBox,
|
|
86
|
+
chunk_size: tuple[int, int],
|
|
87
|
+
**options: object,
|
|
88
|
+
) -> Iterable[TileGeometry]:
|
|
89
|
+
"""Return the spatial layout of tiles for the requested area."""
|
|
90
|
+
|
|
91
|
+
width, height = chunk_size
|
|
92
|
+
resolution = options.get("resolution")
|
|
93
|
+
|
|
94
|
+
if resolution:
|
|
95
|
+
res_x, res_y = cast(tuple[float, float], resolution)
|
|
96
|
+
if res_x <= 0 or res_y <= 0:
|
|
97
|
+
raise ValueError("resolution values must be positive")
|
|
98
|
+
|
|
99
|
+
tile_width_units = width * res_x
|
|
100
|
+
tile_height_units = height * res_y
|
|
101
|
+
epsilon = min(res_x, res_y) / 10.0
|
|
102
|
+
|
|
103
|
+
current_min_y = float(bbox.min_y)
|
|
104
|
+
while current_min_y < bbox.max_y - epsilon:
|
|
105
|
+
current_max_y = min(bbox.max_y, current_min_y + tile_height_units)
|
|
106
|
+
span_y = current_max_y - current_min_y
|
|
107
|
+
pixel_height = max(1, int(math.ceil(span_y / res_y)))
|
|
108
|
+
|
|
109
|
+
current_min_x = float(bbox.min_x)
|
|
110
|
+
while current_min_x < bbox.max_x - epsilon:
|
|
111
|
+
current_max_x = min(bbox.max_x, current_min_x + tile_width_units)
|
|
112
|
+
span_x = current_max_x - current_min_x
|
|
113
|
+
pixel_width = max(1, int(math.ceil(span_x / res_x)))
|
|
114
|
+
|
|
115
|
+
yield TileGeometry(
|
|
116
|
+
bbox=BoundingBox(
|
|
117
|
+
min_x=current_min_x,
|
|
118
|
+
min_y=current_min_y,
|
|
119
|
+
max_x=current_max_x,
|
|
120
|
+
max_y=current_max_y,
|
|
121
|
+
crs=bbox.crs,
|
|
122
|
+
),
|
|
123
|
+
width=pixel_width,
|
|
124
|
+
height=pixel_height,
|
|
125
|
+
crs=bbox.crs,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
current_min_x = current_max_x
|
|
129
|
+
current_min_y = current_max_y
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
grid_shape_option = options.get("grid_shape")
|
|
133
|
+
if grid_shape_option is None:
|
|
134
|
+
rows, cols = 1, 1
|
|
135
|
+
elif isinstance(grid_shape_option, tuple):
|
|
136
|
+
try:
|
|
137
|
+
grid_tuple_raw = cast(tuple[Any, Any], grid_shape_option)
|
|
138
|
+
row_raw, col_raw = grid_tuple_raw
|
|
139
|
+
except ValueError as exc: # pragma: no cover - guard
|
|
140
|
+
raise ValueError("grid_shape must be a tuple of two integers") from exc
|
|
141
|
+
|
|
142
|
+
rows, cols = int(row_raw), int(col_raw)
|
|
143
|
+
else:
|
|
144
|
+
raise ValueError("grid_shape must be a tuple of two integers")
|
|
145
|
+
if rows <= 0 or cols <= 0:
|
|
146
|
+
raise ValueError("grid_shape dimensions must be positive integers")
|
|
147
|
+
|
|
148
|
+
x_span = float(bbox.max_x - bbox.min_x)
|
|
149
|
+
y_span = float(bbox.max_y - bbox.min_y)
|
|
150
|
+
|
|
151
|
+
step_x = x_span / cols if cols > 0 else x_span
|
|
152
|
+
step_y = y_span / rows if rows > 0 else y_span
|
|
153
|
+
|
|
154
|
+
for row in range(rows):
|
|
155
|
+
min_y = float(bbox.min_y + row * step_y)
|
|
156
|
+
max_y = float(
|
|
157
|
+
bbox.min_y + (row + 1) * step_y if row < rows - 1 else bbox.max_y
|
|
158
|
+
)
|
|
159
|
+
for col in range(cols):
|
|
160
|
+
min_x = float(bbox.min_x + col * step_x)
|
|
161
|
+
max_x = float(
|
|
162
|
+
bbox.min_x + (col + 1) * step_x if col < cols - 1 else bbox.max_x
|
|
163
|
+
)
|
|
164
|
+
yield TileGeometry(
|
|
165
|
+
bbox=BoundingBox(
|
|
166
|
+
min_x=min_x,
|
|
167
|
+
min_y=min_y,
|
|
168
|
+
max_x=max_x,
|
|
169
|
+
max_y=max_y,
|
|
170
|
+
crs=bbox.crs,
|
|
171
|
+
),
|
|
172
|
+
width=width,
|
|
173
|
+
height=height,
|
|
174
|
+
crs=bbox.crs,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def request_headers(self, **options: object) -> dict[str, str]:
|
|
178
|
+
"""Merge service-level and per-call HTTP headers."""
|
|
179
|
+
|
|
180
|
+
return merge_str_mappings(self.config.get("headers"), options.get("headers"))
|
|
181
|
+
|
|
182
|
+
def compose_tile_request(
|
|
183
|
+
self,
|
|
184
|
+
tile: TileGeometry,
|
|
185
|
+
*,
|
|
186
|
+
url: str,
|
|
187
|
+
params: dict[str, Any] | None = None,
|
|
188
|
+
output_format: Format | None = None,
|
|
189
|
+
crs: CRS | None = None,
|
|
190
|
+
**options: object,
|
|
191
|
+
) -> TileRequest:
|
|
192
|
+
"""Build a tile request with config headers/params applied."""
|
|
193
|
+
|
|
194
|
+
return _compose_tile_request(
|
|
195
|
+
config=self.config,
|
|
196
|
+
options=dict(options),
|
|
197
|
+
url=url,
|
|
198
|
+
params=params,
|
|
199
|
+
bbox=tile.bbox,
|
|
200
|
+
width=tile.width,
|
|
201
|
+
height=tile.height,
|
|
202
|
+
crs=crs or tile.crs,
|
|
203
|
+
output_format=output_format,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
@abstractmethod
|
|
207
|
+
def build_tile_request(
|
|
208
|
+
self,
|
|
209
|
+
tile: TileGeometry,
|
|
210
|
+
**options: object,
|
|
211
|
+
) -> TileRequest:
|
|
212
|
+
"""Build the HTTP request description for a tile."""
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ----------------------------------------------------------------------
|
|
216
|
+
# Service registry utilities
|
|
217
|
+
# ----------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
_SERVICE_REGISTRY: dict[ServiceTypeEnum, type[BaseService]] = {}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def register_service(
|
|
223
|
+
service_type: ServiceTypeEnum,
|
|
224
|
+
) -> Callable[[type[BaseService]], type[BaseService]]:
|
|
225
|
+
"""Decorator for registering service implementations."""
|
|
226
|
+
|
|
227
|
+
def decorator(cls: type[BaseService]) -> type[BaseService]:
|
|
228
|
+
_SERVICE_REGISTRY[service_type] = cls
|
|
229
|
+
cls.service_type = service_type
|
|
230
|
+
return cls
|
|
231
|
+
|
|
232
|
+
return decorator
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def detect_service_type(
|
|
236
|
+
url: str, fallback: ServiceTypeEnum | None = None
|
|
237
|
+
) -> ServiceTypeEnum:
|
|
238
|
+
"""Infer the service type from the URL or query string."""
|
|
239
|
+
|
|
240
|
+
parsed = urlparse(url)
|
|
241
|
+
lower_path = parsed.path.lower()
|
|
242
|
+
query = parse_qs(parsed.query.lower())
|
|
243
|
+
|
|
244
|
+
if "service" in query:
|
|
245
|
+
try:
|
|
246
|
+
return ServiceTypeEnum(query["service"][0].upper())
|
|
247
|
+
except ValueError:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
if "wcs" in lower_path:
|
|
251
|
+
return ServiceTypeEnum.WCS
|
|
252
|
+
if "wms" in lower_path:
|
|
253
|
+
return ServiceTypeEnum.WMS
|
|
254
|
+
if "wmts" in lower_path:
|
|
255
|
+
return ServiceTypeEnum.WMTS
|
|
256
|
+
|
|
257
|
+
lower_url = url.lower()
|
|
258
|
+
if all(token in lower_url for token in ("{x}", "{y}", "{z}")):
|
|
259
|
+
return ServiceTypeEnum.XYZ
|
|
260
|
+
|
|
261
|
+
if fallback is not None:
|
|
262
|
+
return fallback
|
|
263
|
+
|
|
264
|
+
raise ValueError(f"Unable to detect service type from URL: {url}")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def get_service(
|
|
268
|
+
url: str,
|
|
269
|
+
*,
|
|
270
|
+
service_type: ServiceTypeEnum | None = None,
|
|
271
|
+
**config: object,
|
|
272
|
+
) -> BaseService:
|
|
273
|
+
"""Instantiate the appropriate service implementation for `url`."""
|
|
274
|
+
|
|
275
|
+
detected_type = service_type or detect_service_type(url)
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
service_cls = _SERVICE_REGISTRY[detected_type]
|
|
279
|
+
except KeyError as exc: # pragma: no cover - defensive path
|
|
280
|
+
raise ValueError(f"No service registered for type {detected_type}") from exc
|
|
281
|
+
|
|
282
|
+
return service_cls.from_url(url, **config)
|