tilefetch 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tilefetch/__init__.py +110 -0
- tilefetch/__main__.py +5 -0
- tilefetch/_version.py +1 -0
- tilefetch/api.py +592 -0
- tilefetch/cli.py +419 -0
- tilefetch/download.py +334 -0
- tilefetch/georef.py +291 -0
- tilefetch/mosaic.py +191 -0
- tilefetch/pmtiles.py +657 -0
- tilefetch/providers.py +137 -0
- tilefetch/py.typed +0 -0
- tilefetch/sources.py +546 -0
- tilefetch/storage.py +338 -0
- tilefetch/tiles.py +469 -0
- tilefetch-0.1.0.dist-info/METADATA +264 -0
- tilefetch-0.1.0.dist-info/RECORD +20 -0
- tilefetch-0.1.0.dist-info/WHEEL +5 -0
- tilefetch-0.1.0.dist-info/entry_points.txt +2 -0
- tilefetch-0.1.0.dist-info/licenses/LICENSE +21 -0
- tilefetch-0.1.0.dist-info/top_level.txt +1 -0
tilefetch/pmtiles.py
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
"""PMTiles version 3 writer and reader (pure Python, stdlib only).
|
|
2
|
+
|
|
3
|
+
Implements the layout of the PMTiles v3 specification
|
|
4
|
+
(https://github.com/protomaps/PMTiles/blob/main/spec/v3/spec.md):
|
|
5
|
+
|
|
6
|
+
* a fixed 127-byte little-endian header;
|
|
7
|
+
* directories of ``(tile_id, offset, length, run_length)`` entries, serialised
|
|
8
|
+
as unsigned LEB128 varints in four columns (delta-coded tile IDs, run
|
|
9
|
+
lengths, lengths, and offsets where ``0`` means "directly after the previous
|
|
10
|
+
entry") and gzip-compressed;
|
|
11
|
+
* tile IDs that walk zoom levels in order and the Hilbert curve within a level;
|
|
12
|
+
* a root directory that fits, together with the header, in the first 16 KiB,
|
|
13
|
+
with leaf directories when needed;
|
|
14
|
+
* de-duplication of identical tiles and run-length encoding of repeats.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import dataclasses
|
|
20
|
+
import gzip
|
|
21
|
+
import hashlib
|
|
22
|
+
import io
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sqlite3
|
|
26
|
+
import struct
|
|
27
|
+
from collections import OrderedDict
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import IO, Any, Iterator, NamedTuple
|
|
30
|
+
|
|
31
|
+
from .tiles import Tile
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"HEADER_SIZE",
|
|
35
|
+
"Compression",
|
|
36
|
+
"TileType",
|
|
37
|
+
"Entry",
|
|
38
|
+
"Header",
|
|
39
|
+
"encode_varint",
|
|
40
|
+
"decode_varint",
|
|
41
|
+
"zxy_to_tileid",
|
|
42
|
+
"tileid_to_zxy",
|
|
43
|
+
"serialize_directory",
|
|
44
|
+
"deserialize_directory",
|
|
45
|
+
"PMTilesWriter",
|
|
46
|
+
"PMTilesReader",
|
|
47
|
+
"write_pmtiles",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
HEADER_SIZE = 127
|
|
51
|
+
_ROOT_LIMIT = 16384
|
|
52
|
+
_MIN_LEAF_SIZE = 4096
|
|
53
|
+
_MAGIC = b"PMTiles"
|
|
54
|
+
_HEADER_FMT = "<7sB11Q6B4iB2i"
|
|
55
|
+
assert struct.calcsize(_HEADER_FMT) == HEADER_SIZE
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Compression:
|
|
59
|
+
UNKNOWN = 0
|
|
60
|
+
NONE = 1
|
|
61
|
+
GZIP = 2
|
|
62
|
+
BROTLI = 3
|
|
63
|
+
ZSTD = 4
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TileType:
|
|
67
|
+
UNKNOWN = 0
|
|
68
|
+
MVT = 1
|
|
69
|
+
PNG = 2
|
|
70
|
+
JPEG = 3
|
|
71
|
+
WEBP = 4
|
|
72
|
+
AVIF = 5
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def tile_type_of(data: bytes) -> int:
|
|
76
|
+
"""Guess the PMTiles tile type from a tile's magic bytes."""
|
|
77
|
+
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
78
|
+
return TileType.PNG
|
|
79
|
+
if data.startswith(b"\xff\xd8\xff"):
|
|
80
|
+
return TileType.JPEG
|
|
81
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
82
|
+
return TileType.WEBP
|
|
83
|
+
if data[4:12] in (b"ftypavif", b"ftypavis"):
|
|
84
|
+
return TileType.AVIF
|
|
85
|
+
return TileType.UNKNOWN
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Varints and tile IDs
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def encode_varint(value: int, out: bytearray | None = None) -> bytearray:
|
|
94
|
+
"""Append ``value`` as an unsigned LEB128 varint to ``out`` and return it."""
|
|
95
|
+
if value < 0:
|
|
96
|
+
raise ValueError("varints are unsigned")
|
|
97
|
+
buf = out if out is not None else bytearray()
|
|
98
|
+
while True:
|
|
99
|
+
byte = value & 0x7F
|
|
100
|
+
value >>= 7
|
|
101
|
+
if value:
|
|
102
|
+
buf.append(byte | 0x80)
|
|
103
|
+
else:
|
|
104
|
+
buf.append(byte)
|
|
105
|
+
return buf
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def decode_varint(data: bytes, pos: int) -> tuple[int, int]:
|
|
109
|
+
"""Decode one unsigned LEB128 varint; returns ``(value, new_pos)``."""
|
|
110
|
+
result = 0
|
|
111
|
+
shift = 0
|
|
112
|
+
while True:
|
|
113
|
+
if pos >= len(data):
|
|
114
|
+
raise ValueError("truncated varint")
|
|
115
|
+
b = data[pos]
|
|
116
|
+
pos += 1
|
|
117
|
+
result |= (b & 0x7F) << shift
|
|
118
|
+
if not b & 0x80:
|
|
119
|
+
return result, pos
|
|
120
|
+
shift += 7
|
|
121
|
+
if shift > 63:
|
|
122
|
+
raise ValueError("varint is too long")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _rotate(n: int, x: int, y: int, rx: int, ry: int) -> tuple[int, int]:
|
|
126
|
+
if ry == 0:
|
|
127
|
+
if rx == 1:
|
|
128
|
+
x = n - 1 - x
|
|
129
|
+
y = n - 1 - y
|
|
130
|
+
return y, x
|
|
131
|
+
return x, y
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def zxy_to_tileid(z: int, x: int, y: int) -> int:
|
|
135
|
+
"""PMTiles tile ID: tiles of all lower zooms, plus the Hilbert index at ``z``."""
|
|
136
|
+
if z < 0 or z > 31:
|
|
137
|
+
raise ValueError("zoom must be between 0 and 31")
|
|
138
|
+
n = 1 << z
|
|
139
|
+
if not (0 <= x < n and 0 <= y < n):
|
|
140
|
+
raise ValueError(f"tile {z}/{x}/{y} is outside the zoom-{z} grid")
|
|
141
|
+
acc = ((1 << (2 * z)) - 1) // 3
|
|
142
|
+
d = 0
|
|
143
|
+
s = n >> 1
|
|
144
|
+
while s > 0:
|
|
145
|
+
rx = 1 if x & s else 0
|
|
146
|
+
ry = 1 if y & s else 0
|
|
147
|
+
d += s * s * ((3 * rx) ^ ry)
|
|
148
|
+
x, y = _rotate(n, x, y, rx, ry)
|
|
149
|
+
s >>= 1
|
|
150
|
+
return acc + d
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def tileid_to_zxy(tile_id: int) -> Tile:
|
|
154
|
+
"""Inverse of :func:`zxy_to_tileid`."""
|
|
155
|
+
if tile_id < 0:
|
|
156
|
+
raise ValueError("tile id must be non-negative")
|
|
157
|
+
acc = 0
|
|
158
|
+
for z in range(32):
|
|
159
|
+
count = 1 << (2 * z)
|
|
160
|
+
if tile_id < acc + count:
|
|
161
|
+
d = tile_id - acc
|
|
162
|
+
n = 1 << z
|
|
163
|
+
x = y = 0
|
|
164
|
+
s = 1
|
|
165
|
+
while s < n:
|
|
166
|
+
rx = 1 & (d // 2)
|
|
167
|
+
ry = 1 & (d ^ rx)
|
|
168
|
+
x, y = _rotate(s, x, y, rx, ry)
|
|
169
|
+
x += s * rx
|
|
170
|
+
y += s * ry
|
|
171
|
+
d //= 4
|
|
172
|
+
s <<= 1
|
|
173
|
+
return Tile(z, x, y)
|
|
174
|
+
acc += count
|
|
175
|
+
raise ValueError("tile id is too large")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Directories
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class Entry(NamedTuple):
|
|
184
|
+
tile_id: int
|
|
185
|
+
offset: int
|
|
186
|
+
length: int
|
|
187
|
+
run_length: int
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def serialize_directory(entries: list[Entry]) -> bytes:
|
|
191
|
+
"""Serialise directory entries (uncompressed) per the v3 spec."""
|
|
192
|
+
buf = bytearray()
|
|
193
|
+
encode_varint(len(entries), buf)
|
|
194
|
+
last = 0
|
|
195
|
+
for e in entries:
|
|
196
|
+
encode_varint(e.tile_id - last, buf)
|
|
197
|
+
last = e.tile_id
|
|
198
|
+
for e in entries:
|
|
199
|
+
encode_varint(e.run_length, buf)
|
|
200
|
+
for e in entries:
|
|
201
|
+
encode_varint(e.length, buf)
|
|
202
|
+
for i, e in enumerate(entries):
|
|
203
|
+
if i > 0 and e.offset == entries[i - 1].offset + entries[i - 1].length:
|
|
204
|
+
encode_varint(0, buf)
|
|
205
|
+
else:
|
|
206
|
+
encode_varint(e.offset + 1, buf)
|
|
207
|
+
return bytes(buf)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def deserialize_directory(data: bytes) -> list[Entry]:
|
|
211
|
+
"""Inverse of :func:`serialize_directory`."""
|
|
212
|
+
n, pos = decode_varint(data, 0)
|
|
213
|
+
ids = []
|
|
214
|
+
last = 0
|
|
215
|
+
for _ in range(n):
|
|
216
|
+
d, pos = decode_varint(data, pos)
|
|
217
|
+
last += d
|
|
218
|
+
ids.append(last)
|
|
219
|
+
runs = []
|
|
220
|
+
for _ in range(n):
|
|
221
|
+
v, pos = decode_varint(data, pos)
|
|
222
|
+
runs.append(v)
|
|
223
|
+
lengths = []
|
|
224
|
+
for _ in range(n):
|
|
225
|
+
v, pos = decode_varint(data, pos)
|
|
226
|
+
lengths.append(v)
|
|
227
|
+
entries: list[Entry] = []
|
|
228
|
+
for i in range(n):
|
|
229
|
+
v, pos = decode_varint(data, pos)
|
|
230
|
+
if v == 0 and i > 0:
|
|
231
|
+
offset = entries[i - 1].offset + entries[i - 1].length
|
|
232
|
+
else:
|
|
233
|
+
offset = v - 1
|
|
234
|
+
entries.append(Entry(ids[i], offset, lengths[i], runs[i]))
|
|
235
|
+
return entries
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _compress(data: bytes, compression: int) -> bytes:
|
|
239
|
+
if compression == Compression.GZIP:
|
|
240
|
+
return gzip.compress(data, compresslevel=9, mtime=0)
|
|
241
|
+
if compression == Compression.NONE:
|
|
242
|
+
return data
|
|
243
|
+
raise ValueError(f"unsupported internal compression {compression}")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _decompress(data: bytes, compression: int) -> bytes:
|
|
247
|
+
if compression == Compression.GZIP:
|
|
248
|
+
return gzip.decompress(data)
|
|
249
|
+
if compression in (Compression.NONE, Compression.UNKNOWN):
|
|
250
|
+
return data
|
|
251
|
+
raise ValueError(f"unsupported internal compression {compression}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _build_directories(entries: list[Entry], compression: int) -> tuple[bytes, bytes]:
|
|
255
|
+
"""Return ``(root, leaves)``; the root fits in the first 16 KiB with the header."""
|
|
256
|
+
root = _compress(serialize_directory(entries), compression)
|
|
257
|
+
if len(root) <= _ROOT_LIMIT - HEADER_SIZE:
|
|
258
|
+
return root, b""
|
|
259
|
+
leaf_size = max(_MIN_LEAF_SIZE, len(entries) // 3500)
|
|
260
|
+
while True:
|
|
261
|
+
leaves = bytearray()
|
|
262
|
+
root_entries = []
|
|
263
|
+
for i in range(0, len(entries), leaf_size):
|
|
264
|
+
chunk = entries[i : i + leaf_size]
|
|
265
|
+
blob = _compress(serialize_directory(chunk), compression)
|
|
266
|
+
root_entries.append(Entry(chunk[0].tile_id, len(leaves), len(blob), 0))
|
|
267
|
+
leaves.extend(blob)
|
|
268
|
+
root = _compress(serialize_directory(root_entries), compression)
|
|
269
|
+
if len(root) <= _ROOT_LIMIT - HEADER_SIZE:
|
|
270
|
+
return root, bytes(leaves)
|
|
271
|
+
leaf_size *= 2
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
# Header
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass
|
|
280
|
+
class Header:
|
|
281
|
+
root_offset: int = HEADER_SIZE
|
|
282
|
+
root_length: int = 0
|
|
283
|
+
metadata_offset: int = 0
|
|
284
|
+
metadata_length: int = 0
|
|
285
|
+
leaf_offset: int = 0
|
|
286
|
+
leaf_length: int = 0
|
|
287
|
+
data_offset: int = 0
|
|
288
|
+
data_length: int = 0
|
|
289
|
+
addressed_tiles: int = 0
|
|
290
|
+
tile_entries: int = 0
|
|
291
|
+
tile_contents: int = 0
|
|
292
|
+
clustered: bool = True
|
|
293
|
+
internal_compression: int = Compression.GZIP
|
|
294
|
+
tile_compression: int = Compression.NONE
|
|
295
|
+
tile_type: int = TileType.UNKNOWN
|
|
296
|
+
min_zoom: int = 0
|
|
297
|
+
max_zoom: int = 0
|
|
298
|
+
min_lon: float = -180.0
|
|
299
|
+
min_lat: float = -85.0
|
|
300
|
+
max_lon: float = 180.0
|
|
301
|
+
max_lat: float = 85.0
|
|
302
|
+
center_zoom: int = 0
|
|
303
|
+
center_lon: float = 0.0
|
|
304
|
+
center_lat: float = 0.0
|
|
305
|
+
|
|
306
|
+
def pack(self) -> bytes:
|
|
307
|
+
def e7(v: float) -> int:
|
|
308
|
+
return int(round(v * 10_000_000))
|
|
309
|
+
|
|
310
|
+
return struct.pack(
|
|
311
|
+
_HEADER_FMT,
|
|
312
|
+
_MAGIC,
|
|
313
|
+
3,
|
|
314
|
+
self.root_offset,
|
|
315
|
+
self.root_length,
|
|
316
|
+
self.metadata_offset,
|
|
317
|
+
self.metadata_length,
|
|
318
|
+
self.leaf_offset,
|
|
319
|
+
self.leaf_length,
|
|
320
|
+
self.data_offset,
|
|
321
|
+
self.data_length,
|
|
322
|
+
self.addressed_tiles,
|
|
323
|
+
self.tile_entries,
|
|
324
|
+
self.tile_contents,
|
|
325
|
+
1 if self.clustered else 0,
|
|
326
|
+
self.internal_compression,
|
|
327
|
+
self.tile_compression,
|
|
328
|
+
self.tile_type,
|
|
329
|
+
self.min_zoom,
|
|
330
|
+
self.max_zoom,
|
|
331
|
+
e7(self.min_lon),
|
|
332
|
+
e7(self.min_lat),
|
|
333
|
+
e7(self.max_lon),
|
|
334
|
+
e7(self.max_lat),
|
|
335
|
+
self.center_zoom,
|
|
336
|
+
e7(self.center_lon),
|
|
337
|
+
e7(self.center_lat),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
@classmethod
|
|
341
|
+
def unpack(cls, data: bytes) -> Header:
|
|
342
|
+
if len(data) < HEADER_SIZE:
|
|
343
|
+
raise ValueError("file is too short to be a PMTiles archive")
|
|
344
|
+
v = struct.unpack(_HEADER_FMT, data[:HEADER_SIZE])
|
|
345
|
+
if v[0] != _MAGIC:
|
|
346
|
+
raise ValueError("not a PMTiles archive (bad magic)")
|
|
347
|
+
if v[1] != 3:
|
|
348
|
+
raise ValueError(f"unsupported PMTiles version {v[1]}")
|
|
349
|
+
values: list[Any] = [*v[2:13], bool(v[13]), *v[14:19]]
|
|
350
|
+
values += [
|
|
351
|
+
v[19] / 1e7,
|
|
352
|
+
v[20] / 1e7,
|
|
353
|
+
v[21] / 1e7,
|
|
354
|
+
v[22] / 1e7,
|
|
355
|
+
v[23],
|
|
356
|
+
v[24] / 1e7,
|
|
357
|
+
v[25] / 1e7,
|
|
358
|
+
]
|
|
359
|
+
names = [f.name for f in dataclasses.fields(cls)]
|
|
360
|
+
return cls(**dict(zip(names, values)))
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
# Writer
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class PMTilesWriter:
|
|
369
|
+
"""Collect tiles, then write a PMTiles v3 archive on :meth:`close`.
|
|
370
|
+
|
|
371
|
+
Tiles are staged in an SQLite file next to the output (``<path>.part``), so
|
|
372
|
+
an interrupted download can resume: reopening the writer keeps the staged
|
|
373
|
+
tiles. The archive is assembled in tile-ID order (``clustered``) when the
|
|
374
|
+
writer is closed, and the staging file is then removed.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
def __init__(
|
|
378
|
+
self, path: str | os.PathLike[str], metadata: dict[str, Any] | None = None
|
|
379
|
+
) -> None:
|
|
380
|
+
self.path = os.fspath(path)
|
|
381
|
+
self.staging = self.path + ".part"
|
|
382
|
+
self.metadata: dict[str, Any] = dict(metadata or {})
|
|
383
|
+
self._db = sqlite3.connect(self.staging)
|
|
384
|
+
self._db.execute(
|
|
385
|
+
"CREATE TABLE IF NOT EXISTS tiles (tile_id INTEGER PRIMARY KEY, z INTEGER, data BLOB)"
|
|
386
|
+
)
|
|
387
|
+
self._db.execute("CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT)")
|
|
388
|
+
for k, v in self._db.execute("SELECT k, v FROM meta"):
|
|
389
|
+
self.metadata.setdefault(k, json.loads(v))
|
|
390
|
+
self._db.commit()
|
|
391
|
+
self._pending = 0
|
|
392
|
+
self.closed = False
|
|
393
|
+
|
|
394
|
+
def __enter__(self) -> PMTilesWriter:
|
|
395
|
+
return self
|
|
396
|
+
|
|
397
|
+
def __exit__(self, *exc: object) -> None:
|
|
398
|
+
if exc[0] is None:
|
|
399
|
+
self.close()
|
|
400
|
+
else:
|
|
401
|
+
self.abort()
|
|
402
|
+
|
|
403
|
+
def has(self, tile: Tile) -> bool:
|
|
404
|
+
row = self._db.execute(
|
|
405
|
+
"SELECT 1 FROM tiles WHERE tile_id = ?", (zxy_to_tileid(*tile),)
|
|
406
|
+
).fetchone()
|
|
407
|
+
return row is not None
|
|
408
|
+
|
|
409
|
+
def put(self, tile: Tile, data: bytes) -> None:
|
|
410
|
+
self._db.execute(
|
|
411
|
+
"INSERT OR REPLACE INTO tiles (tile_id, z, data) VALUES (?, ?, ?)",
|
|
412
|
+
(zxy_to_tileid(*tile), tile[0], sqlite3.Binary(data)),
|
|
413
|
+
)
|
|
414
|
+
self._pending += 1
|
|
415
|
+
if self._pending >= 256:
|
|
416
|
+
self._db.commit()
|
|
417
|
+
self._pending = 0
|
|
418
|
+
|
|
419
|
+
def set_metadata(self, metadata: dict[str, Any]) -> None:
|
|
420
|
+
self.metadata.update(metadata)
|
|
421
|
+
for k, v in metadata.items():
|
|
422
|
+
self._db.execute("INSERT OR REPLACE INTO meta (k, v) VALUES (?, ?)", (k, json.dumps(v)))
|
|
423
|
+
|
|
424
|
+
def __len__(self) -> int:
|
|
425
|
+
return int(self._db.execute("SELECT COUNT(*) FROM tiles").fetchone()[0])
|
|
426
|
+
|
|
427
|
+
def abort(self) -> None:
|
|
428
|
+
"""Close without writing the archive; the staging file is kept for resume."""
|
|
429
|
+
if not self.closed:
|
|
430
|
+
self._db.commit()
|
|
431
|
+
self._db.close()
|
|
432
|
+
self.closed = True
|
|
433
|
+
|
|
434
|
+
def close(self) -> None:
|
|
435
|
+
"""Write the archive and delete the staging file."""
|
|
436
|
+
if self.closed:
|
|
437
|
+
return
|
|
438
|
+
self._db.commit()
|
|
439
|
+
rows = self._db.execute("SELECT tile_id, data FROM tiles ORDER BY tile_id")
|
|
440
|
+
write_pmtiles(self.path, ((int(t), bytes(d)) for t, d in rows), self.metadata)
|
|
441
|
+
self._db.close()
|
|
442
|
+
self.closed = True
|
|
443
|
+
os.remove(self.staging)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def write_pmtiles(
|
|
447
|
+
path: str | os.PathLike[str] | IO[bytes],
|
|
448
|
+
tiles: Any,
|
|
449
|
+
metadata: dict[str, Any] | None = None,
|
|
450
|
+
*,
|
|
451
|
+
internal_compression: int = Compression.GZIP,
|
|
452
|
+
) -> Header:
|
|
453
|
+
"""Write a PMTiles v3 archive.
|
|
454
|
+
|
|
455
|
+
``tiles`` is an iterable of ``(tile_id, bytes)`` sorted by tile ID, or of
|
|
456
|
+
``(Tile, bytes)`` in any order. ``metadata`` is stored as the JSON metadata
|
|
457
|
+
block; the keys ``bounds`` (``[w, s, e, n]``), ``center``
|
|
458
|
+
(``[lon, lat, zoom]``), ``minzoom`` and ``maxzoom`` also fill the header
|
|
459
|
+
when present.
|
|
460
|
+
"""
|
|
461
|
+
meta = dict(metadata or {})
|
|
462
|
+
items = list(tiles)
|
|
463
|
+
if items and not isinstance(items[0][0], int):
|
|
464
|
+
items = sorted(((zxy_to_tileid(*t), d) for t, d in items), key=lambda it: it[0])
|
|
465
|
+
data_buf = io.BytesIO()
|
|
466
|
+
offsets: dict[bytes, tuple[int, int]] = {}
|
|
467
|
+
entries: list[Entry] = []
|
|
468
|
+
tile_type = TileType.UNKNOWN
|
|
469
|
+
last_id = -1
|
|
470
|
+
min_z, max_z = 99, -1
|
|
471
|
+
for tile_id, data in items:
|
|
472
|
+
if tile_id <= last_id:
|
|
473
|
+
raise ValueError("tile IDs must be unique and sorted")
|
|
474
|
+
last_id = tile_id
|
|
475
|
+
if tile_type == TileType.UNKNOWN:
|
|
476
|
+
tile_type = tile_type_of(data)
|
|
477
|
+
digest = hashlib.sha256(data).digest()
|
|
478
|
+
if digest in offsets:
|
|
479
|
+
off, length = offsets[digest]
|
|
480
|
+
else:
|
|
481
|
+
off, length = data_buf.tell(), len(data)
|
|
482
|
+
data_buf.write(data)
|
|
483
|
+
offsets[digest] = (off, length)
|
|
484
|
+
prev = entries[-1] if entries else None
|
|
485
|
+
if prev is not None and prev.offset == off and prev.tile_id + prev.run_length == tile_id:
|
|
486
|
+
entries[-1] = prev._replace(run_length=prev.run_length + 1)
|
|
487
|
+
else:
|
|
488
|
+
entries.append(Entry(tile_id, off, length, 1))
|
|
489
|
+
z = tileid_to_zxy(tile_id).z
|
|
490
|
+
min_z, max_z = min(min_z, z), max(max_z, z)
|
|
491
|
+
if not entries:
|
|
492
|
+
min_z = max_z = 0
|
|
493
|
+
|
|
494
|
+
root, leaves = _build_directories(entries, internal_compression)
|
|
495
|
+
meta_bytes = _compress(
|
|
496
|
+
json.dumps(meta, separators=(",", ":")).encode("utf-8"), internal_compression
|
|
497
|
+
)
|
|
498
|
+
tile_data = data_buf.getvalue()
|
|
499
|
+
|
|
500
|
+
h = Header()
|
|
501
|
+
h.root_offset = HEADER_SIZE
|
|
502
|
+
h.root_length = len(root)
|
|
503
|
+
h.metadata_offset = h.root_offset + h.root_length
|
|
504
|
+
h.metadata_length = len(meta_bytes)
|
|
505
|
+
h.leaf_offset = h.metadata_offset + h.metadata_length
|
|
506
|
+
h.leaf_length = len(leaves)
|
|
507
|
+
h.data_offset = h.leaf_offset + h.leaf_length
|
|
508
|
+
h.data_length = len(tile_data)
|
|
509
|
+
h.addressed_tiles = len(items)
|
|
510
|
+
h.tile_entries = len(entries)
|
|
511
|
+
h.tile_contents = len(offsets)
|
|
512
|
+
h.clustered = True
|
|
513
|
+
h.internal_compression = internal_compression
|
|
514
|
+
h.tile_compression = Compression.NONE if tile_type != TileType.MVT else Compression.UNKNOWN
|
|
515
|
+
h.tile_type = tile_type
|
|
516
|
+
h.min_zoom = int(meta.get("minzoom", min_z))
|
|
517
|
+
h.max_zoom = int(meta.get("maxzoom", max_z))
|
|
518
|
+
if "bounds" in meta:
|
|
519
|
+
h.min_lon, h.min_lat, h.max_lon, h.max_lat = (float(v) for v in meta["bounds"])
|
|
520
|
+
if "center" in meta:
|
|
521
|
+
h.center_lon, h.center_lat = float(meta["center"][0]), float(meta["center"][1])
|
|
522
|
+
h.center_zoom = int(meta["center"][2])
|
|
523
|
+
else:
|
|
524
|
+
h.center_lon = (h.min_lon + h.max_lon) / 2.0
|
|
525
|
+
h.center_lat = (h.min_lat + h.max_lat) / 2.0
|
|
526
|
+
h.center_zoom = h.min_zoom
|
|
527
|
+
|
|
528
|
+
blob = h.pack() + root + meta_bytes + leaves + tile_data
|
|
529
|
+
if hasattr(path, "write"):
|
|
530
|
+
path.write(blob)
|
|
531
|
+
else:
|
|
532
|
+
tmp = os.fspath(path) + ".tmp"
|
|
533
|
+
with open(tmp, "wb") as fh:
|
|
534
|
+
fh.write(blob)
|
|
535
|
+
os.replace(tmp, os.fspath(path))
|
|
536
|
+
return h
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# ---------------------------------------------------------------------------
|
|
540
|
+
# Reader
|
|
541
|
+
# ---------------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _find(entries: list[Entry], tile_id: int) -> Entry | None:
|
|
545
|
+
lo, hi = 0, len(entries) - 1
|
|
546
|
+
while lo <= hi:
|
|
547
|
+
mid = (lo + hi) // 2
|
|
548
|
+
c = entries[mid].tile_id
|
|
549
|
+
if c < tile_id:
|
|
550
|
+
lo = mid + 1
|
|
551
|
+
elif c > tile_id:
|
|
552
|
+
hi = mid - 1
|
|
553
|
+
else:
|
|
554
|
+
return entries[mid]
|
|
555
|
+
if hi >= 0:
|
|
556
|
+
e = entries[hi]
|
|
557
|
+
if e.run_length == 0:
|
|
558
|
+
return e
|
|
559
|
+
if tile_id - e.tile_id < e.run_length:
|
|
560
|
+
return e
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
class PMTilesReader:
|
|
565
|
+
"""Random access to a PMTiles v3 archive on disk."""
|
|
566
|
+
|
|
567
|
+
def __init__(self, path: str | os.PathLike[str]) -> None:
|
|
568
|
+
self.path = os.fspath(path)
|
|
569
|
+
self._fh = open(self.path, "rb") # noqa: SIM115 - closed in close()
|
|
570
|
+
self.header = Header.unpack(self._read(0, HEADER_SIZE))
|
|
571
|
+
self._root = self._dir(self.header.root_offset, self.header.root_length)
|
|
572
|
+
self._leaves: OrderedDict[tuple[int, int], list[Entry]] = OrderedDict()
|
|
573
|
+
|
|
574
|
+
def __enter__(self) -> PMTilesReader:
|
|
575
|
+
return self
|
|
576
|
+
|
|
577
|
+
def __exit__(self, *exc: object) -> None:
|
|
578
|
+
self.close()
|
|
579
|
+
|
|
580
|
+
def close(self) -> None:
|
|
581
|
+
self._fh.close()
|
|
582
|
+
|
|
583
|
+
def _read(self, offset: int, length: int) -> bytes:
|
|
584
|
+
self._fh.seek(offset)
|
|
585
|
+
data = self._fh.read(length)
|
|
586
|
+
if len(data) != length:
|
|
587
|
+
raise ValueError("unexpected end of PMTiles archive")
|
|
588
|
+
return data
|
|
589
|
+
|
|
590
|
+
def _dir(self, offset: int, length: int) -> list[Entry]:
|
|
591
|
+
return deserialize_directory(
|
|
592
|
+
_decompress(self._read(offset, length), self.header.internal_compression)
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
def _leaf(self, offset: int, length: int) -> list[Entry]:
|
|
596
|
+
key = (offset, length)
|
|
597
|
+
entries = self._leaves.get(key)
|
|
598
|
+
if entries is None:
|
|
599
|
+
entries = self._dir(self.header.leaf_offset + offset, length)
|
|
600
|
+
self._leaves[key] = entries
|
|
601
|
+
if len(self._leaves) > 64:
|
|
602
|
+
self._leaves.popitem(last=False)
|
|
603
|
+
else:
|
|
604
|
+
self._leaves.move_to_end(key)
|
|
605
|
+
return entries
|
|
606
|
+
|
|
607
|
+
@property
|
|
608
|
+
def metadata(self) -> dict[str, Any]:
|
|
609
|
+
h = self.header
|
|
610
|
+
if not h.metadata_length:
|
|
611
|
+
return {}
|
|
612
|
+
raw = _decompress(self._read(h.metadata_offset, h.metadata_length), h.internal_compression)
|
|
613
|
+
result: dict[str, Any] = json.loads(raw.decode("utf-8"))
|
|
614
|
+
return result
|
|
615
|
+
|
|
616
|
+
def get(self, tile: Tile | tuple[int, int, int]) -> bytes | None:
|
|
617
|
+
"""Tile bytes, or ``None`` when the tile is not in the archive."""
|
|
618
|
+
z, x, y = tile
|
|
619
|
+
n = 1 << z
|
|
620
|
+
if not (0 <= x < n and 0 <= y < n):
|
|
621
|
+
return None
|
|
622
|
+
tid = zxy_to_tileid(z, x, y)
|
|
623
|
+
entries = self._root
|
|
624
|
+
for _ in range(4): # the spec allows at most 3 directory levels below the root
|
|
625
|
+
e = _find(entries, tid)
|
|
626
|
+
if e is None:
|
|
627
|
+
return None
|
|
628
|
+
if e.run_length == 0:
|
|
629
|
+
entries = self._leaf(e.offset, e.length)
|
|
630
|
+
continue
|
|
631
|
+
return self._read(self.header.data_offset + e.offset, e.length)
|
|
632
|
+
raise ValueError("directory nesting is too deep")
|
|
633
|
+
|
|
634
|
+
def has(self, tile: Tile) -> bool:
|
|
635
|
+
return self.get(tile) is not None
|
|
636
|
+
|
|
637
|
+
def entries(self) -> Iterator[Entry]:
|
|
638
|
+
"""All tile entries, leaf directories expanded, in tile-ID order."""
|
|
639
|
+
|
|
640
|
+
def walk(entries: list[Entry], depth: int) -> Iterator[Entry]:
|
|
641
|
+
for e in entries:
|
|
642
|
+
if e.run_length == 0:
|
|
643
|
+
if depth > 3:
|
|
644
|
+
raise ValueError("directory nesting is too deep")
|
|
645
|
+
yield from walk(
|
|
646
|
+
self._dir(self.header.leaf_offset + e.offset, e.length), depth + 1
|
|
647
|
+
)
|
|
648
|
+
else:
|
|
649
|
+
yield e
|
|
650
|
+
|
|
651
|
+
yield from walk(self._root, 1)
|
|
652
|
+
|
|
653
|
+
def tiles(self) -> Iterator[Tile]:
|
|
654
|
+
"""Every addressed tile (runs expanded)."""
|
|
655
|
+
for e in self.entries():
|
|
656
|
+
for i in range(e.run_length):
|
|
657
|
+
yield tileid_to_zxy(e.tile_id + i)
|