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/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)