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
tilearray/array.py
ADDED
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
# pyright: reportMissingImports=false, reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false
|
|
2
|
+
|
|
3
|
+
"""High-level array construction utilities built on service abstractions."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import (
|
|
14
|
+
TYPE_CHECKING,
|
|
15
|
+
Any,
|
|
16
|
+
Callable,
|
|
17
|
+
cast,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import xarray as xr
|
|
22
|
+
from dask.array import block as da_block
|
|
23
|
+
from dask.array import concatenate as da_concatenate
|
|
24
|
+
from dask.array import from_delayed as da_from_delayed
|
|
25
|
+
from dask.delayed import Delayed, delayed
|
|
26
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
27
|
+
|
|
28
|
+
from .decode import (
|
|
29
|
+
NDArrayFloat,
|
|
30
|
+
TileDecoder,
|
|
31
|
+
band_count_from_array,
|
|
32
|
+
default_band_count_for_format,
|
|
33
|
+
sniff_effective_content_type,
|
|
34
|
+
)
|
|
35
|
+
from .decode import (
|
|
36
|
+
decoder_for_format as _decoder_for_format,
|
|
37
|
+
)
|
|
38
|
+
from .errors import NetworkError
|
|
39
|
+
from .fetch import FetchPolicy, FetchProgress, ProgressCallback
|
|
40
|
+
from .service import get_service
|
|
41
|
+
from .service.base import BaseService
|
|
42
|
+
from .service.config import ServiceConfig as ServiceConfigModel
|
|
43
|
+
from .tiles import fetch_tile
|
|
44
|
+
from .types import (
|
|
45
|
+
CRS,
|
|
46
|
+
BBoxTuple,
|
|
47
|
+
BoundingBox,
|
|
48
|
+
Format,
|
|
49
|
+
ServiceTypeEnum,
|
|
50
|
+
TileRequest,
|
|
51
|
+
TileResponse,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from dask.array.core import Array as DaskArray
|
|
56
|
+
else: # pragma: no cover - typing aid
|
|
57
|
+
DaskArray = Any
|
|
58
|
+
|
|
59
|
+
_FETCH_POLICY_ATTR = "tilearray_fetch_policy"
|
|
60
|
+
_RECOMMENDED_NUM_WORKERS_ATTR = "tilearray_recommended_num_workers"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ArrayRequest(BaseModel):
|
|
64
|
+
service_config: ServiceConfigModel | None = None
|
|
65
|
+
service_url: str
|
|
66
|
+
bbox: BoundingBox
|
|
67
|
+
target_crs: CRS
|
|
68
|
+
chunk_size: tuple[int, int]
|
|
69
|
+
grid_shape: tuple[int, int]
|
|
70
|
+
resolution: tuple[float, float] | None = None
|
|
71
|
+
output_format: Format | None = None
|
|
72
|
+
cache_dir: Path | None = None
|
|
73
|
+
service_options: dict[str, Any] = Field(default_factory=dict)
|
|
74
|
+
|
|
75
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def chunk_height(self) -> int:
|
|
79
|
+
return self.chunk_size[0]
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def chunk_width(self) -> int:
|
|
83
|
+
return self.chunk_size[1]
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def chunk_pixels(self) -> tuple[int, int]:
|
|
87
|
+
return self.chunk_width, self.chunk_height
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def cache_path(self) -> Path | None:
|
|
91
|
+
return self.cache_dir
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def service_kwargs(self) -> dict[str, Any]:
|
|
95
|
+
kwargs: dict[str, Any] = {}
|
|
96
|
+
if self.service_config is not None:
|
|
97
|
+
return kwargs
|
|
98
|
+
kwargs.update(self.service_options)
|
|
99
|
+
kwargs.setdefault("crs", self.target_crs)
|
|
100
|
+
if self.output_format is not None:
|
|
101
|
+
kwargs.setdefault("output_format", self.output_format)
|
|
102
|
+
return kwargs
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_inputs(
|
|
106
|
+
cls,
|
|
107
|
+
*,
|
|
108
|
+
service_url: str,
|
|
109
|
+
service_config: ServiceConfigModel | None,
|
|
110
|
+
bbox_input: BoundingBox | BBoxTuple,
|
|
111
|
+
crs_input: CRS | str | int,
|
|
112
|
+
chunk_size_input: tuple[int, int] | None,
|
|
113
|
+
grid_shape_input: tuple[int, int] | None,
|
|
114
|
+
output_format_input: Format | None,
|
|
115
|
+
cache_dir_input: str | Path | None,
|
|
116
|
+
service_options_input: dict[str, Any],
|
|
117
|
+
) -> ArrayRequest:
|
|
118
|
+
target_crs = _coerce_crs(crs_input)
|
|
119
|
+
normalized_bbox = _normalize_bbox(bbox_input, target_crs)
|
|
120
|
+
|
|
121
|
+
defaults = service_config.array_defaults() if service_config else {}
|
|
122
|
+
|
|
123
|
+
chunk_candidate = chunk_size_input or defaults.get("chunk_size") or (256, 256)
|
|
124
|
+
chunk_height, chunk_width = _validate_chunk_size(chunk_candidate)
|
|
125
|
+
|
|
126
|
+
user_options = dict(service_options_input)
|
|
127
|
+
user_resolution = user_options.pop("resolution", None)
|
|
128
|
+
resolution = user_resolution or defaults.get("resolution")
|
|
129
|
+
|
|
130
|
+
fallback_grid = defaults.get("grid_shape")
|
|
131
|
+
grid = _resolve_grid_shape(grid_shape_input, fallback_grid)
|
|
132
|
+
if (
|
|
133
|
+
grid_shape_input is None
|
|
134
|
+
and fallback_grid is None
|
|
135
|
+
and resolution is not None
|
|
136
|
+
):
|
|
137
|
+
res_x, res_y = resolution
|
|
138
|
+
if res_x <= 0 or res_y <= 0:
|
|
139
|
+
raise ValueError("resolution values must be positive")
|
|
140
|
+
span_x = normalized_bbox.max_x - normalized_bbox.min_x
|
|
141
|
+
span_y = normalized_bbox.max_y - normalized_bbox.min_y
|
|
142
|
+
cols = (
|
|
143
|
+
max(1, int(math.ceil(span_x / (chunk_width * res_x))))
|
|
144
|
+
if chunk_width > 0
|
|
145
|
+
else 1
|
|
146
|
+
)
|
|
147
|
+
rows = (
|
|
148
|
+
max(1, int(math.ceil(span_y / (chunk_height * res_y))))
|
|
149
|
+
if chunk_height > 0
|
|
150
|
+
else 1
|
|
151
|
+
)
|
|
152
|
+
grid = (rows, cols)
|
|
153
|
+
|
|
154
|
+
cache_candidate = cache_dir_input or defaults.get("cache_dir")
|
|
155
|
+
cache_path = (
|
|
156
|
+
Path(cache_candidate).expanduser().resolve() if cache_candidate else None
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
effective_format = output_format_input
|
|
160
|
+
if (
|
|
161
|
+
effective_format is None
|
|
162
|
+
and service_config
|
|
163
|
+
and service_config.output_format is not None
|
|
164
|
+
):
|
|
165
|
+
effective_format = service_config.output_format
|
|
166
|
+
|
|
167
|
+
return cls(
|
|
168
|
+
service_config=service_config,
|
|
169
|
+
service_url=service_url,
|
|
170
|
+
bbox=normalized_bbox,
|
|
171
|
+
target_crs=target_crs,
|
|
172
|
+
chunk_size=(chunk_height, chunk_width),
|
|
173
|
+
grid_shape=grid,
|
|
174
|
+
resolution=resolution,
|
|
175
|
+
output_format=effective_format,
|
|
176
|
+
cache_dir=cache_path,
|
|
177
|
+
service_options=user_options,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def build_service(self, service_type: ServiceTypeEnum | None) -> BaseService:
|
|
181
|
+
if self.service_config is not None:
|
|
182
|
+
return self.service_config.build_service()
|
|
183
|
+
return get_service(
|
|
184
|
+
self.service_url, service_type=service_type, **self.service_kwargs
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def effective_format(self, service: BaseService) -> Format | None:
|
|
188
|
+
if self.output_format is not None:
|
|
189
|
+
return self.output_format
|
|
190
|
+
service_format = getattr(service, "output_format", None)
|
|
191
|
+
if isinstance(service_format, Format):
|
|
192
|
+
return service_format
|
|
193
|
+
if isinstance(service_format, str):
|
|
194
|
+
try:
|
|
195
|
+
return Format(service_format)
|
|
196
|
+
except ValueError:
|
|
197
|
+
return None
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
def tile_options(self) -> dict[str, Any]:
|
|
201
|
+
options: dict[str, Any] = {}
|
|
202
|
+
if self.service_config is not None:
|
|
203
|
+
options.update(self.service_config.tile_kwargs())
|
|
204
|
+
options.update(self.service_options)
|
|
205
|
+
options.setdefault("crs", self.target_crs)
|
|
206
|
+
if self.output_format is not None:
|
|
207
|
+
options.setdefault("output_format", self.output_format)
|
|
208
|
+
options["grid_shape"] = self.grid_shape
|
|
209
|
+
if self.resolution is not None:
|
|
210
|
+
options.setdefault("resolution", self.resolution)
|
|
211
|
+
return options
|
|
212
|
+
|
|
213
|
+
def plan_tile_requests(
|
|
214
|
+
self, service: BaseService
|
|
215
|
+
) -> tuple[list[TileRequest], dict[str, Any]]:
|
|
216
|
+
tile_options = self.tile_options()
|
|
217
|
+
tile_requests_iter = service.generate_tile_requests(
|
|
218
|
+
self.bbox,
|
|
219
|
+
self.chunk_pixels,
|
|
220
|
+
**dict(tile_options),
|
|
221
|
+
)
|
|
222
|
+
tile_requests = list(tile_requests_iter)
|
|
223
|
+
|
|
224
|
+
inferred_grid = getattr(service, "inferred_grid_shape", None)
|
|
225
|
+
if (
|
|
226
|
+
inferred_grid is not None
|
|
227
|
+
and self.grid_shape == (1, 1)
|
|
228
|
+
and len(tile_requests) > 1
|
|
229
|
+
):
|
|
230
|
+
self.grid_shape = inferred_grid
|
|
231
|
+
|
|
232
|
+
return tile_requests, tile_options
|
|
233
|
+
|
|
234
|
+
def array_attrs(
|
|
235
|
+
self,
|
|
236
|
+
service: BaseService,
|
|
237
|
+
tile_options: dict[str, Any],
|
|
238
|
+
effective_format: Format | None,
|
|
239
|
+
) -> dict[str, Any]:
|
|
240
|
+
attrs: dict[str, Any] = {
|
|
241
|
+
"crs": self.target_crs.value,
|
|
242
|
+
"service_url": self.service_url,
|
|
243
|
+
"service_type": service.service_type.value,
|
|
244
|
+
}
|
|
245
|
+
if effective_format is not None:
|
|
246
|
+
attrs["output_format"] = effective_format.value
|
|
247
|
+
coverage_id = tile_options.get("coverage_id") or getattr(
|
|
248
|
+
service, "coverage_id", None
|
|
249
|
+
)
|
|
250
|
+
if coverage_id:
|
|
251
|
+
attrs["coverage_id"] = coverage_id
|
|
252
|
+
return attrs
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _resolve_decoder(
|
|
256
|
+
request: ArrayRequest, service: BaseService, explicit: TileDecoder | None
|
|
257
|
+
) -> TileDecoder:
|
|
258
|
+
if explicit is not None:
|
|
259
|
+
return explicit
|
|
260
|
+
|
|
261
|
+
fmt = request.effective_format(service)
|
|
262
|
+
if fmt is not None:
|
|
263
|
+
decoder = _decoder_for_format(fmt)
|
|
264
|
+
if decoder is not None:
|
|
265
|
+
return decoder
|
|
266
|
+
|
|
267
|
+
raise RuntimeError(
|
|
268
|
+
f"No tile decoder available for format {fmt!r}; please provide tile_decoder."
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _delayed_call(func: Callable[..., Any], *args: Any) -> Delayed:
|
|
273
|
+
"""Typed helper around ``dask.delayed`` to satisfy static analysis."""
|
|
274
|
+
|
|
275
|
+
return cast(Delayed, delayed(func)(*args))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _organize_tiles(
|
|
279
|
+
tile_requests: Sequence[TileRequest],
|
|
280
|
+
rows: int,
|
|
281
|
+
cols: int,
|
|
282
|
+
) -> list[list[TileRequest]]:
|
|
283
|
+
if len(tile_requests) != rows * cols:
|
|
284
|
+
raise ValueError(
|
|
285
|
+
f"Service produced {len(tile_requests)} tile requests; expected {rows * cols}"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
annotated: list[tuple[TileRequest, BoundingBox]] = []
|
|
289
|
+
for request in tile_requests:
|
|
290
|
+
if request.bbox is None:
|
|
291
|
+
raise ValueError("TileRequest is missing spatial metadata (bbox)")
|
|
292
|
+
annotated.append((request, request.bbox))
|
|
293
|
+
|
|
294
|
+
sorted_tiles = [
|
|
295
|
+
request
|
|
296
|
+
for request, _ in sorted(
|
|
297
|
+
annotated,
|
|
298
|
+
key=lambda item: (-item[1].max_y, item[1].min_x),
|
|
299
|
+
)
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
grid: list[list[TileRequest]] = []
|
|
303
|
+
idx = 0
|
|
304
|
+
for _ in range(rows):
|
|
305
|
+
row_tiles: list[TileRequest] = []
|
|
306
|
+
for _ in range(cols):
|
|
307
|
+
row_tiles.append(sorted_tiles[idx])
|
|
308
|
+
idx += 1
|
|
309
|
+
grid.append(row_tiles)
|
|
310
|
+
return grid
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _fetch_policy_summary(policy: FetchPolicy) -> dict[str, Any]:
|
|
314
|
+
"""JSON-serializable fetch policy snapshot for xarray attrs."""
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
"max_concurrent": policy.max_concurrent,
|
|
318
|
+
"max_connections": policy.max_connections,
|
|
319
|
+
"retries": policy.retries,
|
|
320
|
+
"timeout": policy.timeout,
|
|
321
|
+
"rate_limit_per_second": policy.rate_limit_per_second,
|
|
322
|
+
"adaptive_concurrency": policy.adaptive_concurrency,
|
|
323
|
+
"initial_concurrent": policy.initial_concurrent,
|
|
324
|
+
"min_concurrent": policy.min_concurrent,
|
|
325
|
+
"multiplicative_decrease": policy.multiplicative_decrease,
|
|
326
|
+
"additive_increase": policy.additive_increase,
|
|
327
|
+
"forbidden_circuit_breaker": policy.forbidden_circuit_breaker,
|
|
328
|
+
"forbidden_window_seconds": policy.forbidden_window_seconds,
|
|
329
|
+
"forbidden_threshold": policy.forbidden_threshold,
|
|
330
|
+
"forbidden_cooldown_seconds": policy.forbidden_cooldown_seconds,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _fetch_policy_from_summary(summary: dict[str, Any]) -> FetchPolicy:
|
|
335
|
+
defaults = FetchPolicy()
|
|
336
|
+
return FetchPolicy(
|
|
337
|
+
max_concurrent=int(summary.get("max_concurrent", defaults.max_concurrent)),
|
|
338
|
+
max_connections=summary.get("max_connections", defaults.max_connections),
|
|
339
|
+
retries=int(summary.get("retries", defaults.retries)),
|
|
340
|
+
timeout=float(summary.get("timeout", defaults.timeout)),
|
|
341
|
+
rate_limit_per_second=summary.get(
|
|
342
|
+
"rate_limit_per_second", defaults.rate_limit_per_second
|
|
343
|
+
),
|
|
344
|
+
adaptive_concurrency=bool(
|
|
345
|
+
summary.get("adaptive_concurrency", defaults.adaptive_concurrency)
|
|
346
|
+
),
|
|
347
|
+
initial_concurrent=int(
|
|
348
|
+
summary.get("initial_concurrent", defaults.initial_concurrent)
|
|
349
|
+
),
|
|
350
|
+
min_concurrent=int(summary.get("min_concurrent", defaults.min_concurrent)),
|
|
351
|
+
multiplicative_decrease=float(
|
|
352
|
+
summary.get("multiplicative_decrease", defaults.multiplicative_decrease)
|
|
353
|
+
),
|
|
354
|
+
additive_increase=int(
|
|
355
|
+
summary.get("additive_increase", defaults.additive_increase)
|
|
356
|
+
),
|
|
357
|
+
forbidden_circuit_breaker=bool(
|
|
358
|
+
summary.get("forbidden_circuit_breaker", defaults.forbidden_circuit_breaker)
|
|
359
|
+
),
|
|
360
|
+
forbidden_window_seconds=float(
|
|
361
|
+
summary.get("forbidden_window_seconds", defaults.forbidden_window_seconds)
|
|
362
|
+
),
|
|
363
|
+
forbidden_threshold=int(
|
|
364
|
+
summary.get("forbidden_threshold", defaults.forbidden_threshold)
|
|
365
|
+
),
|
|
366
|
+
forbidden_cooldown_seconds=float(
|
|
367
|
+
summary.get(
|
|
368
|
+
"forbidden_cooldown_seconds", defaults.forbidden_cooldown_seconds
|
|
369
|
+
)
|
|
370
|
+
),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _attrs_from_compute_target(obj: Any) -> dict[str, Any] | None:
|
|
375
|
+
attrs = getattr(obj, "attrs", None)
|
|
376
|
+
if isinstance(attrs, dict):
|
|
377
|
+
return attrs
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _resolve_fetch_policy_for_compute(
|
|
382
|
+
obj: Any,
|
|
383
|
+
*,
|
|
384
|
+
fetch_policy: FetchPolicy | None = None,
|
|
385
|
+
max_concurrent: int | None = None,
|
|
386
|
+
) -> FetchPolicy:
|
|
387
|
+
if fetch_policy is not None:
|
|
388
|
+
return fetch_policy
|
|
389
|
+
if max_concurrent is not None:
|
|
390
|
+
return FetchPolicy(max_concurrent=max_concurrent)
|
|
391
|
+
attrs = _attrs_from_compute_target(obj)
|
|
392
|
+
if attrs is not None:
|
|
393
|
+
summary = attrs.get(_FETCH_POLICY_ATTR)
|
|
394
|
+
if isinstance(summary, dict):
|
|
395
|
+
return _fetch_policy_from_summary(summary)
|
|
396
|
+
return FetchPolicy()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def compute_thread_pool_size(
|
|
400
|
+
fetch_policy: FetchPolicy,
|
|
401
|
+
*,
|
|
402
|
+
override: int | None = None,
|
|
403
|
+
) -> int:
|
|
404
|
+
"""
|
|
405
|
+
Thread-pool size for Dask's ``threads`` scheduler when computing tile arrays.
|
|
406
|
+
|
|
407
|
+
Dask defaults to ``cpu_count()`` workers, which can cap in-flight tile fetches
|
|
408
|
+
below :attr:`~tilearray.fetch.FetchPolicy.max_concurrent`. Pass the result as
|
|
409
|
+
``num_workers`` when calling :meth:`xarray.DataArray.compute` manually.
|
|
410
|
+
"""
|
|
411
|
+
|
|
412
|
+
if override is not None:
|
|
413
|
+
return max(1, override)
|
|
414
|
+
cpu_count = os.cpu_count() or 1
|
|
415
|
+
return max(cpu_count, fetch_policy.max_concurrent)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def compute_with_policy(
|
|
419
|
+
obj: Any,
|
|
420
|
+
fetch_policy: FetchPolicy | None = None,
|
|
421
|
+
*,
|
|
422
|
+
max_concurrent: int | None = None,
|
|
423
|
+
num_workers: int | None = None,
|
|
424
|
+
**compute_kwargs: Any,
|
|
425
|
+
) -> Any:
|
|
426
|
+
"""
|
|
427
|
+
Compute a Dask-backed xarray or dask object with fetch-aligned thread workers.
|
|
428
|
+
|
|
429
|
+
Dask's default ``.compute()`` uses ``num_workers=cpu_count()``, which can cap
|
|
430
|
+
in-flight tile fetches below :attr:`~tilearray.fetch.FetchPolicy.max_concurrent`
|
|
431
|
+
when AIMD allows more parallel HTTP. This helper runs the threaded scheduler with
|
|
432
|
+
``num_workers=compute_thread_pool_size(policy)``.
|
|
433
|
+
|
|
434
|
+
Policy resolution order: explicit ``fetch_policy``, then ``max_concurrent`` (builds
|
|
435
|
+
a minimal policy), then ``tilearray_fetch_policy`` on ``obj.attrs`` (set by
|
|
436
|
+
:func:`create_array`), else :class:`~tilearray.fetch.FetchPolicy` defaults.
|
|
437
|
+
"""
|
|
438
|
+
|
|
439
|
+
policy = _resolve_fetch_policy_for_compute(
|
|
440
|
+
obj,
|
|
441
|
+
fetch_policy=fetch_policy,
|
|
442
|
+
max_concurrent=max_concurrent,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
if num_workers is None and fetch_policy is None and max_concurrent is None:
|
|
446
|
+
attrs = _attrs_from_compute_target(obj)
|
|
447
|
+
if attrs is not None:
|
|
448
|
+
recommended = attrs.get(_RECOMMENDED_NUM_WORKERS_ATTR)
|
|
449
|
+
if recommended is not None:
|
|
450
|
+
num_workers = int(recommended)
|
|
451
|
+
|
|
452
|
+
workers = compute_thread_pool_size(policy, override=num_workers)
|
|
453
|
+
merged = dict(compute_kwargs)
|
|
454
|
+
merged["scheduler"] = "threads"
|
|
455
|
+
merged["num_workers"] = workers
|
|
456
|
+
return obj.compute(**merged)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _resolve_fetch_policy(
|
|
460
|
+
service_config: ServiceConfigModel | None,
|
|
461
|
+
service_options: dict[str, Any],
|
|
462
|
+
) -> FetchPolicy:
|
|
463
|
+
base = (
|
|
464
|
+
service_config.fetch_policy() if service_config is not None else FetchPolicy()
|
|
465
|
+
)
|
|
466
|
+
return FetchPolicy(
|
|
467
|
+
max_concurrent=service_options.pop(
|
|
468
|
+
"max_concurrent_requests", base.max_concurrent
|
|
469
|
+
),
|
|
470
|
+
max_connections=service_options.pop("max_connections", base.max_connections),
|
|
471
|
+
retries=service_options.pop("fetch_retries", base.retries),
|
|
472
|
+
timeout=service_options.pop("fetch_timeout", base.timeout),
|
|
473
|
+
rate_limit_per_second=service_options.pop(
|
|
474
|
+
"rate_limit_per_second", base.rate_limit_per_second
|
|
475
|
+
),
|
|
476
|
+
rate_limiter=service_options.pop("rate_limiter", base.rate_limiter),
|
|
477
|
+
adaptive_concurrency=service_options.pop(
|
|
478
|
+
"adaptive_concurrency", base.adaptive_concurrency
|
|
479
|
+
),
|
|
480
|
+
initial_concurrent=service_options.pop(
|
|
481
|
+
"initial_concurrent_requests", base.initial_concurrent
|
|
482
|
+
),
|
|
483
|
+
min_concurrent=service_options.pop(
|
|
484
|
+
"min_concurrent_requests", base.min_concurrent
|
|
485
|
+
),
|
|
486
|
+
multiplicative_decrease=service_options.pop(
|
|
487
|
+
"multiplicative_decrease", base.multiplicative_decrease
|
|
488
|
+
),
|
|
489
|
+
forbidden_circuit_breaker=service_options.pop(
|
|
490
|
+
"forbidden_circuit_breaker", base.forbidden_circuit_breaker
|
|
491
|
+
),
|
|
492
|
+
forbidden_window_seconds=service_options.pop(
|
|
493
|
+
"forbidden_window_seconds", base.forbidden_window_seconds
|
|
494
|
+
),
|
|
495
|
+
forbidden_threshold=service_options.pop(
|
|
496
|
+
"forbidden_threshold", base.forbidden_threshold
|
|
497
|
+
),
|
|
498
|
+
forbidden_cooldown_seconds=service_options.pop(
|
|
499
|
+
"forbidden_cooldown_seconds", base.forbidden_cooldown_seconds
|
|
500
|
+
),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def create_array(
|
|
505
|
+
service_url: str | ServiceConfigModel,
|
|
506
|
+
bbox: BoundingBox | BBoxTuple,
|
|
507
|
+
crs: CRS | str | int,
|
|
508
|
+
*,
|
|
509
|
+
service_type: ServiceTypeEnum | None = None,
|
|
510
|
+
chunk_size: tuple[int, int] | None = None,
|
|
511
|
+
grid_shape: tuple[int, int] | None = None,
|
|
512
|
+
output_format: Format | None = None,
|
|
513
|
+
cache_dir: str | Path | None = None,
|
|
514
|
+
compute: bool = False,
|
|
515
|
+
compute_num_workers: int | None = None,
|
|
516
|
+
dtype: str | np.dtype[Any] = np.dtype("float32"),
|
|
517
|
+
tile_decoder: TileDecoder | None = None,
|
|
518
|
+
on_progress: ProgressCallback | None = None,
|
|
519
|
+
**service_options: Any,
|
|
520
|
+
) -> xr.DataArray:
|
|
521
|
+
"""
|
|
522
|
+
Create an xarray ``DataArray`` backed by Dask from a remote service.
|
|
523
|
+
|
|
524
|
+
When ``compute=True``, tile fetches run on Dask's threaded scheduler with
|
|
525
|
+
``num_workers=compute_thread_pool_size(fetch_policy)`` (override via
|
|
526
|
+
``compute_num_workers``) so AIMD can use the full fetch concurrency ceiling.
|
|
527
|
+
|
|
528
|
+
For lazy arrays (``compute=False``), prefer :func:`compute_with_policy` over
|
|
529
|
+
plain ``.compute()`` so Dask worker count matches the fetch ceiling. Fetch
|
|
530
|
+
policy metadata is stored on ``DataArray.attrs`` for that path.
|
|
531
|
+
"""
|
|
532
|
+
|
|
533
|
+
target_crs = _coerce_crs(crs)
|
|
534
|
+
normalized_bbox = _normalize_bbox(bbox, target_crs)
|
|
535
|
+
|
|
536
|
+
service_config = (
|
|
537
|
+
service_url if isinstance(service_url, ServiceConfigModel) else None
|
|
538
|
+
)
|
|
539
|
+
base_service_url = (
|
|
540
|
+
service_config.base_url if service_config else cast(str, service_url)
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
if (
|
|
544
|
+
service_config
|
|
545
|
+
and service_type is not None
|
|
546
|
+
and service_type != service_config.service_type
|
|
547
|
+
):
|
|
548
|
+
raise ValueError(
|
|
549
|
+
"Provided service_type does not match the ServiceConfig service_type"
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
options = dict(service_options)
|
|
553
|
+
fetch_policy = _resolve_fetch_policy(service_config, options)
|
|
554
|
+
|
|
555
|
+
request = ArrayRequest.from_inputs(
|
|
556
|
+
service_url=base_service_url,
|
|
557
|
+
service_config=service_config,
|
|
558
|
+
bbox_input=bbox,
|
|
559
|
+
crs_input=crs,
|
|
560
|
+
chunk_size_input=chunk_size,
|
|
561
|
+
grid_shape_input=grid_shape,
|
|
562
|
+
output_format_input=output_format,
|
|
563
|
+
cache_dir_input=cache_dir,
|
|
564
|
+
service_options_input=options,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
service = request.build_service(service_type)
|
|
568
|
+
decoder = _resolve_decoder(request, service, tile_decoder)
|
|
569
|
+
|
|
570
|
+
tile_requests, tile_options = request.plan_tile_requests(service)
|
|
571
|
+
|
|
572
|
+
rows, cols = request.grid_shape
|
|
573
|
+
tile_grid = _organize_tiles(tile_requests, rows, cols)
|
|
574
|
+
|
|
575
|
+
cache_path = request.cache_path
|
|
576
|
+
chunk_height, chunk_width = request.chunk_size
|
|
577
|
+
dtype_np = np.dtype(dtype)
|
|
578
|
+
progress = FetchProgress(total=len(tile_requests), on_progress=on_progress)
|
|
579
|
+
|
|
580
|
+
effective_format = request.effective_format(service)
|
|
581
|
+
n_bands = _probe_tile_band_count(
|
|
582
|
+
tile_requests[0],
|
|
583
|
+
cache_path,
|
|
584
|
+
decoder,
|
|
585
|
+
dtype_np,
|
|
586
|
+
fetch_policy,
|
|
587
|
+
effective_format,
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
blocks: list[list[DaskArray]] = []
|
|
591
|
+
for row_tiles in tile_grid:
|
|
592
|
+
row_blocks: list[DaskArray] = []
|
|
593
|
+
for tile_request in row_tiles:
|
|
594
|
+
height = tile_request.height or chunk_height
|
|
595
|
+
width = tile_request.width or chunk_width
|
|
596
|
+
delayed_tile = _delayed_call(
|
|
597
|
+
_load_tile_array,
|
|
598
|
+
tile_request,
|
|
599
|
+
cache_path,
|
|
600
|
+
decoder,
|
|
601
|
+
dtype_np,
|
|
602
|
+
fetch_policy,
|
|
603
|
+
progress,
|
|
604
|
+
)
|
|
605
|
+
block_shape = (height, width, n_bands) if n_bands > 1 else (height, width)
|
|
606
|
+
row_blocks.append(
|
|
607
|
+
da_from_delayed(
|
|
608
|
+
delayed_tile,
|
|
609
|
+
shape=block_shape,
|
|
610
|
+
dtype=dtype_np,
|
|
611
|
+
)
|
|
612
|
+
)
|
|
613
|
+
blocks.append(row_blocks)
|
|
614
|
+
|
|
615
|
+
data = _assemble_tile_mosaic(blocks, n_bands)
|
|
616
|
+
|
|
617
|
+
attrs = request.array_attrs(service, tile_options, effective_format)
|
|
618
|
+
attrs[_FETCH_POLICY_ATTR] = _fetch_policy_summary(fetch_policy)
|
|
619
|
+
attrs[_RECOMMENDED_NUM_WORKERS_ATTR] = compute_thread_pool_size(fetch_policy)
|
|
620
|
+
|
|
621
|
+
normalized_bbox = request.bbox
|
|
622
|
+
target_crs = request.target_crs
|
|
623
|
+
# Row 0 comes from the northernmost tiles (_organize_tiles sorts by -max_y).
|
|
624
|
+
y_coords = np.linspace(normalized_bbox.max_y, normalized_bbox.min_y, data.shape[0])
|
|
625
|
+
x_coords = np.linspace(normalized_bbox.min_x, normalized_bbox.max_x, data.shape[1])
|
|
626
|
+
|
|
627
|
+
coords: dict[str, Any] = {"y": y_coords, "x": x_coords}
|
|
628
|
+
dims: tuple[str, ...] = ("y", "x")
|
|
629
|
+
if n_bands > 1:
|
|
630
|
+
coords["band"] = np.arange(n_bands)
|
|
631
|
+
dims = ("y", "x", "band")
|
|
632
|
+
|
|
633
|
+
data_array = xr.DataArray(
|
|
634
|
+
data,
|
|
635
|
+
coords=coords,
|
|
636
|
+
dims=dims,
|
|
637
|
+
attrs=attrs,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
if compute:
|
|
641
|
+
num_workers = compute_thread_pool_size(
|
|
642
|
+
fetch_policy, override=compute_num_workers
|
|
643
|
+
)
|
|
644
|
+
return data_array.compute(
|
|
645
|
+
scheduler="threads",
|
|
646
|
+
num_workers=num_workers,
|
|
647
|
+
)
|
|
648
|
+
return data_array
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def load_array(*args: Any, compute: bool = True, **kwargs: Any) -> xr.DataArray:
|
|
652
|
+
"""Convenience wrapper around :func:`create_array`."""
|
|
653
|
+
|
|
654
|
+
return create_array(*args, compute=compute, **kwargs)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _coerce_crs(crs: CRS | str | int) -> CRS:
|
|
658
|
+
if isinstance(crs, CRS):
|
|
659
|
+
return crs
|
|
660
|
+
if isinstance(crs, str):
|
|
661
|
+
crs_upper = crs.upper()
|
|
662
|
+
return (
|
|
663
|
+
CRS.from_epsg(crs_upper)
|
|
664
|
+
if crs_upper.startswith("EPSG:")
|
|
665
|
+
else CRS.from_integer(int(crs))
|
|
666
|
+
)
|
|
667
|
+
return CRS.from_integer(crs)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _normalize_bbox(bbox: BoundingBox | BBoxTuple, crs: CRS) -> BoundingBox:
|
|
671
|
+
if isinstance(bbox, BoundingBox):
|
|
672
|
+
return bbox if bbox.crs == crs else bbox.to_crs(crs)
|
|
673
|
+
return BoundingBox.from_tuple(bbox, crs)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _resolve_grid_shape(
|
|
677
|
+
explicit: tuple[int, int] | None,
|
|
678
|
+
fallback: tuple[int, int] | None,
|
|
679
|
+
) -> tuple[int, int]:
|
|
680
|
+
if explicit is not None:
|
|
681
|
+
return _validate_grid_shape(explicit)
|
|
682
|
+
if fallback is not None:
|
|
683
|
+
return _validate_grid_shape(fallback)
|
|
684
|
+
return (1, 1)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _validate_grid_shape(grid: tuple[int, int]) -> tuple[int, int]:
|
|
688
|
+
rows, cols = grid
|
|
689
|
+
if rows <= 0 or cols <= 0:
|
|
690
|
+
raise ValueError("grid_shape must contain positive integers")
|
|
691
|
+
return rows, cols
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _validate_chunk_size(chunk_size: tuple[int, int]) -> tuple[int, int]:
|
|
695
|
+
width, height = chunk_size
|
|
696
|
+
if width <= 0 or height <= 0:
|
|
697
|
+
raise ValueError("chunk_size dimensions must be positive integers")
|
|
698
|
+
return height, width
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _assemble_tile_mosaic(
|
|
702
|
+
blocks: list[list[DaskArray]],
|
|
703
|
+
n_bands: int,
|
|
704
|
+
) -> DaskArray:
|
|
705
|
+
"""
|
|
706
|
+
Stitch decoded tile blocks into a single mosaic array.
|
|
707
|
+
|
|
708
|
+
``da.block`` maps the tile grid onto the leading axes of each block. For
|
|
709
|
+
multi-band tiles ``(y, x, band)`` that incorrectly concatenates along
|
|
710
|
+
``band`` instead of ``x``. Concatenate explicitly on spatial axes instead.
|
|
711
|
+
"""
|
|
712
|
+
|
|
713
|
+
if n_bands > 1:
|
|
714
|
+
row_arrays = [
|
|
715
|
+
cast(DaskArray, da_concatenate(row_blocks, axis=1)) for row_blocks in blocks
|
|
716
|
+
]
|
|
717
|
+
return cast(DaskArray, da_concatenate(row_arrays, axis=0))
|
|
718
|
+
return cast(DaskArray, da_block(blocks))
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _probe_tile_band_count(
|
|
722
|
+
sample_request: TileRequest,
|
|
723
|
+
cache_dir: Path | None,
|
|
724
|
+
decoder: TileDecoder,
|
|
725
|
+
dtype: np.dtype[Any],
|
|
726
|
+
fetch_policy: FetchPolicy | None,
|
|
727
|
+
effective_format: Format | None,
|
|
728
|
+
) -> int:
|
|
729
|
+
"""Determine band count before building the Dask graph."""
|
|
730
|
+
|
|
731
|
+
fixed = default_band_count_for_format(effective_format)
|
|
732
|
+
if fixed is not None:
|
|
733
|
+
return fixed
|
|
734
|
+
|
|
735
|
+
sample = _load_tile_array(
|
|
736
|
+
sample_request,
|
|
737
|
+
cache_dir,
|
|
738
|
+
decoder,
|
|
739
|
+
dtype,
|
|
740
|
+
fetch_policy,
|
|
741
|
+
)
|
|
742
|
+
return band_count_from_array(sample)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _resize_tile_array(
|
|
746
|
+
array: NDArrayFloat, target_height: int, target_width: int
|
|
747
|
+
) -> NDArrayFloat:
|
|
748
|
+
if array.ndim == 3:
|
|
749
|
+
resized_bands = [
|
|
750
|
+
_resize_tile_array(array[..., band], target_height, target_width)
|
|
751
|
+
for band in range(array.shape[2])
|
|
752
|
+
]
|
|
753
|
+
return cast(NDArrayFloat, np.stack(resized_bands, axis=-1))
|
|
754
|
+
|
|
755
|
+
actual_height, actual_width = array.shape
|
|
756
|
+
|
|
757
|
+
if actual_height == target_height and actual_width == target_width:
|
|
758
|
+
return array
|
|
759
|
+
|
|
760
|
+
if target_height <= 0 or target_width <= 0:
|
|
761
|
+
raise ValueError("target dimensions must be positive")
|
|
762
|
+
|
|
763
|
+
if actual_height < target_height or actual_width < target_width:
|
|
764
|
+
raise ValueError(
|
|
765
|
+
f"Decoded tile has shape {(actual_height, actual_width)}, expected at least {(target_height, target_width)}"
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
if actual_height % target_height == 0 and actual_width % target_width == 0:
|
|
769
|
+
factor_y = actual_height // target_height
|
|
770
|
+
factor_x = actual_width // target_width
|
|
771
|
+
reshaped = array.reshape(target_height, factor_y, target_width, factor_x)
|
|
772
|
+
return cast(NDArrayFloat, np.nanmean(reshaped, axis=(1, 3)))
|
|
773
|
+
|
|
774
|
+
y_edges = np.linspace(0, actual_height, target_height + 1, dtype=int)
|
|
775
|
+
x_edges = np.linspace(0, actual_width, target_width + 1, dtype=int)
|
|
776
|
+
y_edges[0] = 0
|
|
777
|
+
y_edges[-1] = actual_height
|
|
778
|
+
x_edges[0] = 0
|
|
779
|
+
x_edges[-1] = actual_width
|
|
780
|
+
|
|
781
|
+
resized = np.empty((target_height, target_width), dtype=array.dtype)
|
|
782
|
+
for row in range(target_height):
|
|
783
|
+
y_start, y_end = y_edges[row], y_edges[row + 1]
|
|
784
|
+
for col in range(target_width):
|
|
785
|
+
x_start, x_end = x_edges[col], x_edges[col + 1]
|
|
786
|
+
block = array[y_start:y_end, x_start:x_end]
|
|
787
|
+
resized[row, col] = np.nanmean(block) if block.size else np.nan
|
|
788
|
+
return resized
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _load_tile_array(
|
|
792
|
+
request: TileRequest,
|
|
793
|
+
cache_dir: Path | None,
|
|
794
|
+
decoder: TileDecoder,
|
|
795
|
+
dtype: np.dtype[Any],
|
|
796
|
+
fetch_policy: FetchPolicy | None = None,
|
|
797
|
+
progress: FetchProgress | None = None,
|
|
798
|
+
) -> NDArrayFloat:
|
|
799
|
+
if progress is not None:
|
|
800
|
+
progress.check_not_aborted()
|
|
801
|
+
response = _fetch_with_cache(request, cache_dir, fetch_policy, progress)
|
|
802
|
+
if progress is not None:
|
|
803
|
+
progress.tick(request, response)
|
|
804
|
+
if not response.success:
|
|
805
|
+
message = response.error_message or f"HTTP {response.status_code}"
|
|
806
|
+
if progress is not None:
|
|
807
|
+
progress.abort(message)
|
|
808
|
+
raise NetworkError(f"Tile fetch failed for {request.url}: {message}")
|
|
809
|
+
|
|
810
|
+
array = decoder(response, request)
|
|
811
|
+
if array.ndim not in (2, 3):
|
|
812
|
+
raise ValueError("tile_decoder must return a 2D or 3D (y, x[, band]) array")
|
|
813
|
+
|
|
814
|
+
target_height = request.height or array.shape[0]
|
|
815
|
+
target_width = request.width or array.shape[1]
|
|
816
|
+
|
|
817
|
+
array = _resize_tile_array(array, target_height, target_width)
|
|
818
|
+
|
|
819
|
+
return np.asarray(array, dtype=dtype)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _fetch_with_cache(
|
|
823
|
+
request: TileRequest,
|
|
824
|
+
cache_dir: Path | None,
|
|
825
|
+
fetch_policy: FetchPolicy | None = None,
|
|
826
|
+
progress: FetchProgress | None = None,
|
|
827
|
+
) -> TileResponse:
|
|
828
|
+
if progress is not None:
|
|
829
|
+
progress.check_not_aborted()
|
|
830
|
+
if cache_dir is not None:
|
|
831
|
+
cached = _read_cache(cache_dir, request)
|
|
832
|
+
if cached is not None:
|
|
833
|
+
cached_data, cached_content_type = cached
|
|
834
|
+
return TileResponse(
|
|
835
|
+
data=cached_data,
|
|
836
|
+
content_type=cached_content_type,
|
|
837
|
+
status_code=200,
|
|
838
|
+
headers={},
|
|
839
|
+
url=request.url,
|
|
840
|
+
success=True,
|
|
841
|
+
error_message=None,
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
response = fetch_tile(request, policy=fetch_policy, progress=progress)
|
|
845
|
+
if cache_dir is not None and response.success:
|
|
846
|
+
cached_data = bytes(response.data)
|
|
847
|
+
if cached_data:
|
|
848
|
+
_write_cache(cache_dir, request, cached_data, response.content_type)
|
|
849
|
+
return response
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _cache_key(request: TileRequest) -> str:
|
|
853
|
+
payload = {
|
|
854
|
+
"url": request.url,
|
|
855
|
+
"params": sorted((str(k), str(v)) for k, v in request.params.items()),
|
|
856
|
+
"format": request.output_format.value if request.output_format else None,
|
|
857
|
+
"bbox": request.bbox.model_dump() if request.bbox else None,
|
|
858
|
+
"width": request.width,
|
|
859
|
+
"height": request.height,
|
|
860
|
+
}
|
|
861
|
+
serialized = json.dumps(payload, sort_keys=True)
|
|
862
|
+
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _read_cache(cache_dir: Path, request: TileRequest) -> tuple[bytes, str] | None:
|
|
866
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
867
|
+
key = _cache_key(request)
|
|
868
|
+
path = cache_dir / f"{key}.tile"
|
|
869
|
+
if not path.exists():
|
|
870
|
+
return None
|
|
871
|
+
|
|
872
|
+
data = path.read_bytes()
|
|
873
|
+
meta_path = cache_dir / f"{key}.meta"
|
|
874
|
+
if meta_path.exists():
|
|
875
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
876
|
+
stored_type = str(meta.get("content_type", ""))
|
|
877
|
+
elif request.output_format is not None:
|
|
878
|
+
stored_type = request.output_format.value
|
|
879
|
+
else:
|
|
880
|
+
stored_type = ""
|
|
881
|
+
content_type = sniff_effective_content_type(stored_type, data)
|
|
882
|
+
return data, content_type
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _write_cache(
|
|
886
|
+
cache_dir: Path,
|
|
887
|
+
request: TileRequest,
|
|
888
|
+
data: bytes,
|
|
889
|
+
content_type: str,
|
|
890
|
+
) -> None:
|
|
891
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
892
|
+
key = _cache_key(request)
|
|
893
|
+
path = cache_dir / f"{key}.tile"
|
|
894
|
+
path.write_bytes(data)
|
|
895
|
+
meta_path = cache_dir / f"{key}.meta"
|
|
896
|
+
meta_path.write_text(
|
|
897
|
+
json.dumps({"content_type": content_type}, sort_keys=True),
|
|
898
|
+
encoding="utf-8",
|
|
899
|
+
)
|