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.
@@ -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)