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,455 @@
1
+ """Configuration helpers for constructing service instances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+ from requests import RequestException
10
+
11
+ from ..fetch import (
12
+ _DEFAULT_MULTIPLICATIVE_DECREASE,
13
+ FetchPolicy,
14
+ HostRateLimiter,
15
+ )
16
+ from ..types import CRS, Format, ServiceTypeEnum
17
+ from .base import BaseService, get_service
18
+
19
+
20
+ class ServiceConfig(BaseModel):
21
+ """Serializable configuration describing how to build a service instance."""
22
+
23
+ model_config = ConfigDict(arbitrary_types_allowed=True)
24
+
25
+ base_url: str = Field(..., description="Base endpoint URL for the service")
26
+ service_type: ServiceTypeEnum = Field(
27
+ ..., description="Type of service to instantiate"
28
+ )
29
+ crs: CRS | None = Field(
30
+ None, description="Preferred coordinate reference system for requests"
31
+ )
32
+ output_format: Format | None = Field(
33
+ None, description="Preferred data format for tile requests"
34
+ )
35
+ headers: dict[str, str] = Field(
36
+ default_factory=dict, description="Additional HTTP headers to include"
37
+ )
38
+ params: dict[str, Any] = Field(
39
+ default_factory=dict, description="Additional query parameters to include"
40
+ )
41
+ chunk_size: tuple[int, int] | None = Field(
42
+ None,
43
+ description="Default chunk size (width, height) to use when building arrays",
44
+ )
45
+ grid_shape: tuple[int, int] | None = Field(
46
+ None,
47
+ description="Default grid shape (rows, cols) to use when planning tiles",
48
+ )
49
+ cache_dir: str | Path | None = Field(
50
+ None, description="Optional cache directory for fetched tiles"
51
+ )
52
+ resolution: tuple[float, float] | None = Field(
53
+ None,
54
+ description="Native resolution of the service responses (units per pixel in X and Y)",
55
+ )
56
+ max_concurrent_requests: int = Field(
57
+ default=3,
58
+ ge=1,
59
+ description="Maximum in-flight tile HTTP requests for this service",
60
+ )
61
+ initial_concurrent_requests: int = Field(
62
+ default=2,
63
+ ge=1,
64
+ description="Starting in-flight limit when adaptive_concurrency is enabled",
65
+ )
66
+ min_concurrent_requests: int = Field(
67
+ default=1,
68
+ ge=1,
69
+ description="Floor in-flight limit after AIMD multiplicative decrease",
70
+ )
71
+ multiplicative_decrease: float = Field(
72
+ default=_DEFAULT_MULTIPLICATIVE_DECREASE,
73
+ gt=0.0,
74
+ lt=1.0,
75
+ description="AIMD multiplicative decrease factor on pressure (0–1 exclusive)",
76
+ )
77
+ adaptive_concurrency: bool = Field(
78
+ default=False,
79
+ description=(
80
+ "Enable per-host AIMD concurrency (additive increase on success, "
81
+ "multiplicative decrease on 429/timeouts)"
82
+ ),
83
+ )
84
+ max_connections: int | None = Field(
85
+ default=None,
86
+ ge=1,
87
+ description="httpx connection pool size (defaults to max_concurrent_requests)",
88
+ )
89
+ fetch_retries: int = Field(
90
+ default=3,
91
+ ge=0,
92
+ description="Retry attempts for retryable HTTP/network tile fetch failures",
93
+ )
94
+ fetch_timeout: float = Field(
95
+ default=30.0,
96
+ gt=0,
97
+ description="Per-tile HTTP read/connect timeout in seconds",
98
+ )
99
+ rate_limit_per_second: float | None = Field(
100
+ default=2.0,
101
+ description=(
102
+ "Conservative per-host request rate for tile fetches (requests/s); "
103
+ "set to None to disable the built-in limiter"
104
+ ),
105
+ )
106
+ rate_limiter: HostRateLimiter | None = Field(
107
+ default=None,
108
+ description="Optional custom per-host rate limiter hook",
109
+ )
110
+ forbidden_circuit_breaker: bool = Field(
111
+ default=False,
112
+ description=(
113
+ "Trip a pressure circuit breaker (freeze at floor) when classified "
114
+ "breaker events (e.g. gateway 403 or 429) cluster within "
115
+ "forbidden_window_seconds"
116
+ ),
117
+ )
118
+ forbidden_window_seconds: float = Field(
119
+ default=5.0,
120
+ gt=0,
121
+ description="Sliding window for counting clustered breaker-classified events",
122
+ )
123
+ forbidden_threshold: int = Field(
124
+ default=6,
125
+ ge=1,
126
+ description="Breaker event count within the window that trips the circuit breaker",
127
+ )
128
+ forbidden_cooldown_seconds: float = Field(
129
+ default=15.0,
130
+ gt=0,
131
+ description="Cooldown at floor after the circuit breaker trips",
132
+ )
133
+
134
+ def build_service(self) -> BaseService:
135
+ """Create the appropriate service implementation for this configuration."""
136
+
137
+ return get_service(
138
+ self.base_url,
139
+ service_type=self.service_type,
140
+ **self.service_kwargs(),
141
+ )
142
+
143
+ # ------------------------------------------------------------------
144
+ # Helper accessors
145
+ # ------------------------------------------------------------------
146
+ def fetch_policy(self) -> FetchPolicy:
147
+ """Build a :class:`~tilearray.fetch.FetchPolicy` from this configuration."""
148
+
149
+ return FetchPolicy(
150
+ max_concurrent=self.max_concurrent_requests,
151
+ max_connections=self.max_connections,
152
+ retries=self.fetch_retries,
153
+ timeout=self.fetch_timeout,
154
+ rate_limit_per_second=self.rate_limit_per_second,
155
+ rate_limiter=self.rate_limiter,
156
+ adaptive_concurrency=self.adaptive_concurrency,
157
+ initial_concurrent=self.initial_concurrent_requests,
158
+ min_concurrent=self.min_concurrent_requests,
159
+ multiplicative_decrease=self.multiplicative_decrease,
160
+ forbidden_circuit_breaker=self.forbidden_circuit_breaker,
161
+ forbidden_window_seconds=self.forbidden_window_seconds,
162
+ forbidden_threshold=self.forbidden_threshold,
163
+ forbidden_cooldown_seconds=self.forbidden_cooldown_seconds,
164
+ )
165
+
166
+ def service_kwargs(self) -> dict[str, Any]:
167
+ """Keyword arguments used when instantiating the service."""
168
+
169
+ kwargs: dict[str, Any] = {}
170
+ if self.crs is not None:
171
+ kwargs["crs"] = self.crs
172
+ if self.output_format is not None:
173
+ kwargs["output_format"] = self.output_format
174
+ if self.headers:
175
+ kwargs["headers"] = dict(self.headers)
176
+ if self.params:
177
+ kwargs["params"] = dict(self.params)
178
+ return kwargs
179
+
180
+ def tile_kwargs(self) -> dict[str, Any]:
181
+ """Keyword arguments provided to the tile request planner."""
182
+
183
+ kwargs: dict[str, Any] = {}
184
+ if self.crs is not None:
185
+ kwargs["crs"] = self.crs
186
+ if self.output_format is not None:
187
+ kwargs["output_format"] = self.output_format
188
+ if self.params:
189
+ kwargs["params"] = dict(self.params)
190
+ if self.headers:
191
+ kwargs["headers"] = dict(self.headers)
192
+ if self.resolution is not None:
193
+ kwargs["resolution"] = self.resolution
194
+ return kwargs
195
+
196
+ def array_defaults(self) -> dict[str, Any]:
197
+ """Default array-level configuration supplied by the service."""
198
+
199
+ defaults: dict[str, Any] = {}
200
+ if self.chunk_size is not None:
201
+ defaults["chunk_size"] = self.chunk_size
202
+ if self.grid_shape is not None:
203
+ defaults["grid_shape"] = self.grid_shape
204
+ if self.cache_dir is not None:
205
+ defaults["cache_dir"] = self.cache_dir
206
+ if self.resolution is not None:
207
+ defaults["resolution"] = self.resolution
208
+ return defaults
209
+
210
+
211
+ class WCSConfig(ServiceConfig):
212
+ """Configuration helper for Web Coverage Services."""
213
+
214
+ coverage_id: str = Field(..., description="Coverage identifier to request")
215
+ version: str = Field(default="2.0.1", description="WCS protocol version")
216
+ service_type: ServiceTypeEnum = Field(
217
+ default=ServiceTypeEnum.WCS, init=False, description="Service type constant"
218
+ )
219
+
220
+ @classmethod
221
+ def from_url(cls, url: str, coverage_id: str, **kwargs: Any) -> WCSConfig:
222
+ """Convenience constructor mirroring high-level usage patterns."""
223
+
224
+ return cls(base_url=url, coverage_id=coverage_id, **kwargs)
225
+
226
+ @classmethod
227
+ def for_ea_dsp(
228
+ cls,
229
+ url: str,
230
+ coverage_id: str,
231
+ **kwargs: Any,
232
+ ) -> WCSConfig:
233
+ """
234
+ WCS preset for Environment Agency Data Service Platform endpoints.
235
+
236
+ Enables per-host AIMD concurrency (starts at 8, ceiling 10) plus a
237
+ sustained-403 circuit breaker for large cold mosaics on Azure App Gateway.
238
+ """
239
+
240
+ from ..fetch_presets import ea_dsp_fetch_defaults
241
+
242
+ defaults = ea_dsp_fetch_defaults()
243
+ defaults.update(kwargs)
244
+ return cls.from_url(url, coverage_id=coverage_id, **defaults)
245
+
246
+ def build_service(self) -> BaseService:
247
+ """Construct a ``WCSService`` instance from this configuration."""
248
+
249
+ from .wcs import WCSService
250
+
251
+ kwargs = self.service_kwargs()
252
+ kwargs.setdefault("coverage_id", self.coverage_id)
253
+ kwargs.setdefault("version", self.version)
254
+ service = WCSService(self.base_url, **kwargs)
255
+
256
+ try:
257
+ service.describe_coverage(self.coverage_id)
258
+ except ValueError as exc:
259
+ raise ValueError(
260
+ f"Coverage '{self.coverage_id}' is not available from {self.base_url}" # noqa: G004
261
+ ) from exc
262
+ except RequestException as exc:
263
+ raise ValueError(
264
+ f"Failed to validate coverage '{self.coverage_id}' against {self.base_url}" # noqa: G004
265
+ ) from exc
266
+
267
+ return service
268
+
269
+ def service_kwargs(self) -> dict[str, Any]:
270
+ kwargs = super().service_kwargs()
271
+ kwargs.setdefault("coverage_id", self.coverage_id)
272
+ kwargs.setdefault("version", self.version)
273
+ return kwargs
274
+
275
+ def tile_kwargs(self) -> dict[str, Any]:
276
+ kwargs = super().tile_kwargs()
277
+ kwargs["coverage_id"] = self.coverage_id
278
+ return kwargs
279
+
280
+
281
+ class XYZConfig(ServiceConfig):
282
+ """Configuration helper for XYZ / slippy-map tile templates."""
283
+
284
+ zoom: int = Field(..., ge=0, description="Fixed zoom level for tile requests")
285
+ tile_size: int = Field(default=256, gt=0, description="Tile edge length in pixels")
286
+ service_type: ServiceTypeEnum = Field(
287
+ default=ServiceTypeEnum.XYZ, init=False, description="Service type constant"
288
+ )
289
+
290
+ @classmethod
291
+ def from_url(cls, url: str, *, zoom: int, **kwargs: Any) -> XYZConfig:
292
+ """Convenience constructor for XYZ tile templates."""
293
+
294
+ return cls(base_url=url, zoom=zoom, **kwargs)
295
+
296
+ @classmethod
297
+ def for_openstreetmap(
298
+ cls,
299
+ *,
300
+ zoom: int,
301
+ user_agent: str | None = None,
302
+ contact_url: str | None = None,
303
+ **kwargs: Any,
304
+ ) -> XYZConfig:
305
+ """
306
+ XYZ preset for OpenStreetMap raster tiles (OSMF tile usage policy).
307
+
308
+ Sets an identifiable User-Agent and polite concurrency / rate limits.
309
+ """
310
+
311
+ from ..fetch_presets import OSM_TILE_TEMPLATE, osm_fetch_defaults
312
+
313
+ defaults = osm_fetch_defaults(
314
+ user_agent=user_agent,
315
+ contact_url=contact_url
316
+ or "https://github.com/MatthewJWhittle/tilearray/issues",
317
+ )
318
+ defaults.update(kwargs)
319
+ return cls.from_url(OSM_TILE_TEMPLATE, zoom=zoom, **defaults)
320
+
321
+ def build_service(self) -> BaseService:
322
+ """Construct an ``XYZService`` instance from this configuration."""
323
+
324
+ from .xyz import XYZService
325
+
326
+ kwargs = self.service_kwargs()
327
+ kwargs.setdefault("zoom", self.zoom)
328
+ kwargs.setdefault("tile_size", self.tile_size)
329
+ return XYZService(self.base_url, **kwargs)
330
+
331
+ def service_kwargs(self) -> dict[str, Any]:
332
+ kwargs = super().service_kwargs()
333
+ kwargs.setdefault("zoom", self.zoom)
334
+ kwargs.setdefault("tile_size", self.tile_size)
335
+ return kwargs
336
+
337
+ def tile_kwargs(self) -> dict[str, Any]:
338
+ kwargs = super().tile_kwargs()
339
+ kwargs["zoom"] = self.zoom
340
+ kwargs["tile_size"] = self.tile_size
341
+ return kwargs
342
+
343
+
344
+ class WMSConfig(ServiceConfig):
345
+ """Configuration helper for Web Map Services."""
346
+
347
+ layers: str = Field(..., description="Comma-separated WMS layer names")
348
+ version: str = Field(default="1.3.0", description="WMS protocol version")
349
+ styles: str = Field(default="", description="WMS style names")
350
+ service_type: ServiceTypeEnum = Field(
351
+ default=ServiceTypeEnum.WMS, init=False, description="Service type constant"
352
+ )
353
+
354
+ @classmethod
355
+ def from_url(cls, url: str, layers: str, **kwargs: Any) -> WMSConfig:
356
+ """Convenience constructor for WMS endpoints."""
357
+
358
+ return cls(base_url=url, layers=layers, **kwargs)
359
+
360
+ def build_service(self) -> BaseService:
361
+ """Construct a ``WMSService`` instance from this configuration."""
362
+
363
+ from .wms import WMSService
364
+
365
+ kwargs = self.service_kwargs()
366
+ kwargs.setdefault("layers", self.layers)
367
+ kwargs.setdefault("version", self.version)
368
+ kwargs.setdefault("styles", self.styles)
369
+ return WMSService(self.base_url, **kwargs)
370
+
371
+ def service_kwargs(self) -> dict[str, Any]:
372
+ kwargs = super().service_kwargs()
373
+ kwargs.setdefault("layers", self.layers)
374
+ kwargs.setdefault("version", self.version)
375
+ kwargs.setdefault("styles", self.styles)
376
+ return kwargs
377
+
378
+ def tile_kwargs(self) -> dict[str, Any]:
379
+ kwargs = super().tile_kwargs()
380
+ kwargs["layers"] = self.layers
381
+ kwargs["styles"] = self.styles
382
+ return kwargs
383
+
384
+
385
+ class WMTSConfig(ServiceConfig):
386
+ """Configuration helper for Web Map Tile Services."""
387
+
388
+ layer: str = Field(..., description="WMTS layer identifier")
389
+ tile_matrix_set: str = Field(..., description="Tile matrix set identifier")
390
+ tile_matrix: int = Field(..., ge=0, description="Tile matrix / zoom level")
391
+ style: str = Field(default="", description="WMTS style identifier")
392
+ url_template: str | None = Field(
393
+ default=None,
394
+ description="REST GetTile URL template from GetCapabilities",
395
+ )
396
+ version: str = Field(default="1.0.0", description="WMTS protocol version")
397
+ service_type: ServiceTypeEnum = Field(
398
+ default=ServiceTypeEnum.WMTS, init=False, description="Service type constant"
399
+ )
400
+
401
+ @classmethod
402
+ def from_url(
403
+ cls,
404
+ url: str,
405
+ *,
406
+ layer: str,
407
+ tile_matrix_set: str,
408
+ tile_matrix: int,
409
+ **kwargs: Any,
410
+ ) -> WMTSConfig:
411
+ """Convenience constructor for WMTS endpoints."""
412
+
413
+ return cls(
414
+ base_url=url,
415
+ layer=layer,
416
+ tile_matrix_set=tile_matrix_set,
417
+ tile_matrix=tile_matrix,
418
+ **kwargs,
419
+ )
420
+
421
+ def build_service(self) -> BaseService:
422
+ """Construct a ``WMTSService`` instance from this configuration."""
423
+
424
+ from .wmts import WMTSService
425
+
426
+ kwargs = self.service_kwargs()
427
+ kwargs.setdefault("layer", self.layer)
428
+ kwargs.setdefault("tile_matrix_set", self.tile_matrix_set)
429
+ kwargs.setdefault("tile_matrix", self.tile_matrix)
430
+ kwargs.setdefault("style", self.style)
431
+ kwargs.setdefault("version", self.version)
432
+ if self.url_template is not None:
433
+ kwargs.setdefault("url_template", self.url_template)
434
+ return WMTSService(self.base_url, **kwargs)
435
+
436
+ def service_kwargs(self) -> dict[str, Any]:
437
+ kwargs = super().service_kwargs()
438
+ kwargs.setdefault("layer", self.layer)
439
+ kwargs.setdefault("tile_matrix_set", self.tile_matrix_set)
440
+ kwargs.setdefault("tile_matrix", self.tile_matrix)
441
+ kwargs.setdefault("style", self.style)
442
+ kwargs.setdefault("version", self.version)
443
+ if self.url_template is not None:
444
+ kwargs.setdefault("url_template", self.url_template)
445
+ return kwargs
446
+
447
+ def tile_kwargs(self) -> dict[str, Any]:
448
+ kwargs = super().tile_kwargs()
449
+ kwargs["layer"] = self.layer
450
+ kwargs["tile_matrix_set"] = self.tile_matrix_set
451
+ kwargs["tile_matrix"] = self.tile_matrix
452
+ kwargs["style"] = self.style
453
+ if self.url_template is not None:
454
+ kwargs["url_template"] = self.url_template
455
+ return kwargs
@@ -0,0 +1,70 @@
1
+ """Shared helpers for composing HTTP tile requests from service configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..types import CRS, BoundingBox, Format, TileRequest
8
+
9
+
10
+ def merge_str_mappings(*sources: object) -> dict[str, str]:
11
+ """Merge string-keyed mappings from config/options overlays."""
12
+
13
+ merged: dict[str, str] = {}
14
+ for source in sources:
15
+ if isinstance(source, dict):
16
+ merged.update({str(key): str(value) for key, value in source.items()})
17
+ return merged
18
+
19
+
20
+ def merge_params(*sources: object) -> dict[str, Any]:
21
+ """Merge query-parameter mappings from config/options overlays."""
22
+
23
+ merged: dict[str, Any] = {}
24
+ for source in sources:
25
+ if isinstance(source, dict):
26
+ merged.update(source)
27
+ return merged
28
+
29
+
30
+ def compose_tile_request(
31
+ *,
32
+ config: dict[str, object],
33
+ options: dict[str, object],
34
+ url: str,
35
+ bbox: BoundingBox,
36
+ width: int,
37
+ height: int,
38
+ crs: CRS,
39
+ params: dict[str, Any] | None = None,
40
+ output_format: Format | None = None,
41
+ ) -> TileRequest:
42
+ """
43
+ Build a :class:`TileRequest` with headers and params wired from config.
44
+
45
+ Service implementations supply URL/bbox/format specifics; this helper
46
+ ensures headers (User-Agent, auth hooks, etc.) and params always land
47
+ on the outgoing request.
48
+ """
49
+
50
+ headers = merge_str_mappings(config.get("headers"), options.get("headers"))
51
+ merged_params = merge_params(config.get("params"), params, options.get("params"))
52
+
53
+ fmt = output_format
54
+ if fmt is None:
55
+ fmt_option = options.get("output_format")
56
+ if isinstance(fmt_option, Format):
57
+ fmt = fmt_option
58
+ elif isinstance(fmt_option, str):
59
+ fmt = Format(fmt_option)
60
+
61
+ return TileRequest(
62
+ url=url,
63
+ params=merged_params,
64
+ headers=headers or None,
65
+ output_format=fmt,
66
+ crs=crs,
67
+ bbox=bbox,
68
+ width=width,
69
+ height=height,
70
+ )