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