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/storage.py ADDED
@@ -0,0 +1,338 @@
1
+ """Tile stores: MBTiles, PMTiles and plain ``z/x/y`` folders.
2
+
3
+ Every store offers the same small interface used by the downloader:
4
+ ``has(tile)``, ``get(tile)``, ``put(tile, data)``, ``set_metadata(dict)``,
5
+ ``tiles()`` and ``close()``. Stores opened for writing keep what is already
6
+ there, which is how interrupted downloads resume.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sqlite3
13
+ from typing import Any, Iterator, Protocol
14
+
15
+ from .pmtiles import PMTilesReader, PMTilesWriter, tile_type_of, tileid_to_zxy, zxy_to_tileid
16
+ from .tiles import Tile, flip_y
17
+
18
+ __all__ = [
19
+ "TileStore",
20
+ "MBTilesStore",
21
+ "DirectoryStore",
22
+ "PMTilesStore",
23
+ "open_store",
24
+ "open_reader",
25
+ "store_kind",
26
+ "image_format",
27
+ ]
28
+
29
+ _EXT_BY_TYPE = {2: "png", 3: "jpg", 4: "webp", 5: "avif"}
30
+
31
+
32
+ def image_format(data: bytes) -> str | None:
33
+ """``"png"``, ``"jpg"``, ``"webp"`` or ``"avif"`` from magic bytes (else ``None``)."""
34
+ return _EXT_BY_TYPE.get(tile_type_of(data))
35
+
36
+
37
+ class TileStore(Protocol):
38
+ def has(self, tile: Tile) -> bool: ...
39
+
40
+ def get(self, tile: Tile) -> bytes | None: ...
41
+
42
+ def put(self, tile: Tile, data: bytes) -> None: ...
43
+
44
+ def set_metadata(self, metadata: dict[str, Any]) -> None: ...
45
+
46
+ def tiles(self) -> Iterator[Tile]: ...
47
+
48
+ def close(self) -> None: ...
49
+
50
+
51
+ def _meta_str(value: Any) -> str:
52
+ if isinstance(value, (list, tuple)):
53
+ return ",".join(_meta_str(v) for v in value)
54
+ if isinstance(value, float):
55
+ return repr(round(value, 7))
56
+ return str(value)
57
+
58
+
59
+ class MBTilesStore:
60
+ """MBTiles 1.3 (SQLite) store.
61
+
62
+ Rows are stored in TMS order (``tile_row = 2**z - 1 - y``) as the spec
63
+ requires; the public API always speaks XYZ.
64
+ """
65
+
66
+ def __init__(self, path: str | os.PathLike[str], mode: str = "a") -> None:
67
+ self.path = os.fspath(path)
68
+ if mode == "r" and not os.path.exists(self.path):
69
+ raise FileNotFoundError(self.path)
70
+ if mode == "w" and os.path.exists(self.path):
71
+ os.remove(self.path)
72
+ self.readonly = mode == "r"
73
+ self._db = sqlite3.connect(self.path)
74
+ if not self.readonly:
75
+ self._db.executescript(
76
+ """
77
+ CREATE TABLE IF NOT EXISTS metadata (name TEXT, value TEXT);
78
+ CREATE UNIQUE INDEX IF NOT EXISTS metadata_name ON metadata (name);
79
+ CREATE TABLE IF NOT EXISTS tiles (
80
+ zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB);
81
+ CREATE UNIQUE INDEX IF NOT EXISTS tile_index
82
+ ON tiles (zoom_level, tile_column, tile_row);
83
+ """
84
+ )
85
+ self._db.commit()
86
+ self._pending = 0
87
+
88
+ def __enter__(self) -> MBTilesStore:
89
+ return self
90
+
91
+ def __exit__(self, *exc: object) -> None:
92
+ self.close()
93
+
94
+ def has(self, tile: Tile) -> bool:
95
+ return self.get(tile) is not None
96
+
97
+ def get(self, tile: Tile) -> bytes | None:
98
+ z, x, y = tile
99
+ row = self._db.execute(
100
+ "SELECT tile_data FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=?",
101
+ (z, x, flip_y(z, y)),
102
+ ).fetchone()
103
+ return bytes(row[0]) if row else None
104
+
105
+ def put(self, tile: Tile, data: bytes) -> None:
106
+ z, x, y = tile
107
+ self._db.execute(
108
+ "INSERT OR REPLACE INTO tiles (zoom_level, tile_column, tile_row, tile_data) "
109
+ "VALUES (?, ?, ?, ?)",
110
+ (z, x, flip_y(z, y), sqlite3.Binary(data)),
111
+ )
112
+ self._pending += 1
113
+ if self._pending >= 256:
114
+ self._db.commit()
115
+ self._pending = 0
116
+
117
+ @property
118
+ def metadata(self) -> dict[str, str]:
119
+ return {str(k): str(v) for k, v in self._db.execute("SELECT name, value FROM metadata")}
120
+
121
+ def set_metadata(self, metadata: dict[str, Any]) -> None:
122
+ for k, v in metadata.items():
123
+ self._db.execute(
124
+ "INSERT OR REPLACE INTO metadata (name, value) VALUES (?, ?)", (k, _meta_str(v))
125
+ )
126
+ self._db.commit()
127
+
128
+ def tiles(self) -> Iterator[Tile]:
129
+ rows = self._db.execute(
130
+ "SELECT zoom_level, tile_column, tile_row FROM tiles "
131
+ "ORDER BY zoom_level, tile_column, tile_row"
132
+ ).fetchall()
133
+ for z, x, row in rows:
134
+ yield Tile(int(z), int(x), flip_y(int(z), int(row)))
135
+
136
+ def __len__(self) -> int:
137
+ return int(self._db.execute("SELECT COUNT(*) FROM tiles").fetchone()[0])
138
+
139
+ def close(self) -> None:
140
+ try:
141
+ self._db.commit()
142
+ finally:
143
+ self._db.close()
144
+
145
+
146
+ class DirectoryStore:
147
+ """A folder of ``{z}/{x}/{y}.{ext}`` files (XYZ rows, extension from content)."""
148
+
149
+ _EXTS = ("png", "jpg", "jpeg", "webp", "avif", "bin")
150
+
151
+ def __init__(self, root: str | os.PathLike[str], mode: str = "a") -> None:
152
+ self.root = os.fspath(root)
153
+ if mode == "r" and not os.path.isdir(self.root):
154
+ raise FileNotFoundError(self.root)
155
+ if mode != "r":
156
+ os.makedirs(self.root, exist_ok=True)
157
+
158
+ def __enter__(self) -> DirectoryStore:
159
+ return self
160
+
161
+ def __exit__(self, *exc: object) -> None:
162
+ self.close()
163
+
164
+ def _find(self, tile: Tile) -> str | None:
165
+ z, x, y = tile
166
+ base = os.path.join(self.root, str(z), str(x), str(y))
167
+ for ext in self._EXTS:
168
+ p = f"{base}.{ext}"
169
+ if os.path.exists(p):
170
+ return p
171
+ return None
172
+
173
+ def has(self, tile: Tile) -> bool:
174
+ return self._find(tile) is not None
175
+
176
+ def get(self, tile: Tile) -> bytes | None:
177
+ p = self._find(tile)
178
+ if p is None:
179
+ return None
180
+ with open(p, "rb") as fh:
181
+ return fh.read()
182
+
183
+ def put(self, tile: Tile, data: bytes) -> None:
184
+ z, x, y = tile
185
+ folder = os.path.join(self.root, str(z), str(x))
186
+ os.makedirs(folder, exist_ok=True)
187
+ path = os.path.join(folder, f"{y}.{image_format(data) or 'bin'}")
188
+ tmp = path + ".tmp"
189
+ with open(tmp, "wb") as fh:
190
+ fh.write(data)
191
+ os.replace(tmp, path)
192
+
193
+ def set_metadata(self, metadata: dict[str, Any]) -> None:
194
+ import json
195
+
196
+ with open(os.path.join(self.root, "metadata.json"), "w", encoding="utf-8") as fh:
197
+ json.dump(metadata, fh, indent=2, ensure_ascii=False)
198
+
199
+ def tiles(self) -> Iterator[Tile]:
200
+ if not os.path.isdir(self.root):
201
+ return
202
+ for zd in sorted(os.listdir(self.root), key=lambda s: (len(s), s)):
203
+ if not zd.isdigit():
204
+ continue
205
+ zpath = os.path.join(self.root, zd)
206
+ for xd in sorted(os.listdir(zpath), key=lambda s: (len(s), s)):
207
+ if not xd.isdigit():
208
+ continue
209
+ for fn in sorted(os.listdir(os.path.join(zpath, xd))):
210
+ stem, _, ext = fn.partition(".")
211
+ if stem.isdigit() and ext in self._EXTS:
212
+ yield Tile(int(zd), int(xd), int(stem))
213
+
214
+ def close(self) -> None:
215
+ pass
216
+
217
+
218
+ class PMTilesStore:
219
+ """Write-side PMTiles store (stages tiles, writes the archive on close)."""
220
+
221
+ def __init__(self, path: str | os.PathLike[str], mode: str = "a") -> None:
222
+ self.path = os.fspath(path)
223
+ if mode == "w":
224
+ for p in (self.path, self.path + ".part"):
225
+ if os.path.exists(p):
226
+ os.remove(p)
227
+ part = self.path + ".part"
228
+ if mode == "a" and os.path.exists(self.path) and not os.path.exists(part):
229
+ # Resume on top of a finished archive: re-stage its tiles.
230
+ with PMTilesReader(self.path) as r:
231
+ meta = r.metadata
232
+ staged = [(t, r.get(t)) for t in r.tiles()]
233
+ self._writer = PMTilesWriter(self.path, meta)
234
+ for t, d in staged:
235
+ if d is not None:
236
+ self._writer.put(t, d)
237
+ else:
238
+ self._writer = PMTilesWriter(self.path)
239
+
240
+ def __enter__(self) -> PMTilesStore:
241
+ return self
242
+
243
+ def __exit__(self, *exc: object) -> None:
244
+ if exc[0] is None:
245
+ self.close()
246
+ else:
247
+ self.abort()
248
+
249
+ def has(self, tile: Tile) -> bool:
250
+ return self._writer.has(tile)
251
+
252
+ def get(self, tile: Tile) -> bytes | None:
253
+ row = self._writer._db.execute(
254
+ "SELECT data FROM tiles WHERE tile_id = ?",
255
+ (zxy_to_tileid(*tile),),
256
+ ).fetchone()
257
+ return bytes(row[0]) if row else None
258
+
259
+ def put(self, tile: Tile, data: bytes) -> None:
260
+ self._writer.put(tile, data)
261
+
262
+ def set_metadata(self, metadata: dict[str, Any]) -> None:
263
+ self._writer.set_metadata(metadata)
264
+
265
+ def tiles(self) -> Iterator[Tile]:
266
+ for (tid,) in self._writer._db.execute(
267
+ "SELECT tile_id FROM tiles ORDER BY tile_id"
268
+ ).fetchall():
269
+ yield tileid_to_zxy(int(tid))
270
+
271
+ def abort(self) -> None:
272
+ self._writer.abort()
273
+
274
+ def close(self) -> None:
275
+ self._writer.close()
276
+
277
+
278
+ class _PMTilesReadStore:
279
+ """Read-only adapter so a PMTiles archive can feed :func:`tilefetch.mosaic`."""
280
+
281
+ def __init__(self, path: str | os.PathLike[str]) -> None:
282
+ self.reader = PMTilesReader(path)
283
+
284
+ def has(self, tile: Tile) -> bool:
285
+ return self.reader.has(tile)
286
+
287
+ def get(self, tile: Tile) -> bytes | None:
288
+ return self.reader.get(tile)
289
+
290
+ def put(self, tile: Tile, data: bytes) -> None:
291
+ raise OSError("PMTiles archives are opened read-only")
292
+
293
+ def set_metadata(self, metadata: dict[str, Any]) -> None:
294
+ raise OSError("PMTiles archives are opened read-only")
295
+
296
+ def tiles(self) -> Iterator[Tile]:
297
+ return self.reader.tiles()
298
+
299
+ @property
300
+ def metadata(self) -> dict[str, Any]:
301
+ return self.reader.metadata
302
+
303
+ def close(self) -> None:
304
+ self.reader.close()
305
+
306
+
307
+ def store_kind(path: str | os.PathLike[str]) -> str:
308
+ """Classify an output path: ``mbtiles``, ``pmtiles``, ``image`` or ``dir``."""
309
+ p = os.fspath(path).lower()
310
+ if p.endswith(".mbtiles"):
311
+ return "mbtiles"
312
+ if p.endswith(".pmtiles"):
313
+ return "pmtiles"
314
+ if p.endswith((".png", ".jpg", ".jpeg", ".tif", ".tiff", ".webp")):
315
+ return "image"
316
+ return "dir"
317
+
318
+
319
+ def open_store(path: str | os.PathLike[str], mode: str = "a") -> TileStore:
320
+ """Open a writable store chosen by the path's extension (folder otherwise)."""
321
+ kind = store_kind(path)
322
+ if kind == "mbtiles":
323
+ return MBTilesStore(path, mode)
324
+ if kind == "pmtiles":
325
+ return PMTilesStore(path, mode)
326
+ if kind == "image":
327
+ raise ValueError("image outputs are produced by tilefetch.mosaic, not a tile store")
328
+ return DirectoryStore(path, mode)
329
+
330
+
331
+ def open_reader(path: str | os.PathLike[str]) -> TileStore:
332
+ """Open an existing MBTiles file, PMTiles archive or tile folder for reading."""
333
+ kind = store_kind(path)
334
+ if kind == "mbtiles":
335
+ return MBTilesStore(path, "r")
336
+ if kind == "pmtiles":
337
+ return _PMTilesReadStore(path)
338
+ return DirectoryStore(path, "r")