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/cli.py ADDED
@@ -0,0 +1,419 @@
1
+ """Command-line interface: ``tilefetch providers|estimate|get|mosaic|info``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from typing import Any, Sequence
9
+
10
+ from ._version import __version__
11
+ from .api import estimate, fetch, mosaic
12
+ from .pmtiles import PMTilesReader
13
+ from .providers import list_providers
14
+ from .sources import PolicyError, TileSource, wmts_source
15
+ from .storage import MBTilesStore, store_kind
16
+
17
+ __all__ = ["main", "build_parser"]
18
+
19
+
20
+ def _kv(items: Sequence[str] | None, sep: str) -> dict[str, str]:
21
+ out: dict[str, str] = {}
22
+ for item in items or []:
23
+ if sep not in item:
24
+ raise ValueError(f"expected KEY{sep}VALUE, got {item!r}")
25
+ k, v = item.split(sep, 1)
26
+ out[k.strip()] = v.strip()
27
+ return out
28
+
29
+
30
+ def parse_subdomains(raw: str) -> tuple[str, ...]:
31
+ """``"abc"`` -> ``("a", "b", "c")``; ``"t0,t1"`` -> ``("t0", "t1")``."""
32
+ if "," in raw:
33
+ return tuple(s.strip() for s in raw.split(",") if s.strip())
34
+ return tuple(raw.strip())
35
+
36
+
37
+ def _add_source(p: argparse.ArgumentParser, allow_input: bool = False) -> None:
38
+ g = p.add_argument_group("source (choose one)")
39
+ g.add_argument("--provider", "-p", help="built-in provider name (see `tilefetch providers`)")
40
+ g.add_argument(
41
+ "--url", "-u", help="URL template, e.g. https://tiles.example.org/{z}/{x}/{y}.png"
42
+ )
43
+ g.add_argument("--wmts", help="WMTS GetCapabilities URL or file (with --layer)")
44
+ if allow_input:
45
+ g.add_argument(
46
+ "--input", "-i", help="existing .mbtiles, .pmtiles or z/x/y folder (offline)"
47
+ )
48
+ g.add_argument("--layer", help="WMTS layer identifier")
49
+ g.add_argument(
50
+ "--tile-matrix-set", help="WMTS tile matrix set (default: first Web Mercator one)"
51
+ )
52
+ g.add_argument("--style", help="WMTS style")
53
+ g.add_argument("--format", dest="wmts_format", help="WMTS format, e.g. image/png")
54
+ g.add_argument(
55
+ "--encoding", choices=["auto", "rest", "kvp"], default="auto", help="WMTS request encoding"
56
+ )
57
+ g.add_argument(
58
+ "--param",
59
+ action="append",
60
+ metavar="KEY=VALUE",
61
+ help="template parameter / WMTS dimension (repeatable)",
62
+ )
63
+ g.add_argument(
64
+ "--tms", action="store_true", help="custom URL uses TMS rows ({y} counted from the south)"
65
+ )
66
+ g.add_argument("--subdomains", help="values for {s}, e.g. abc or a,b,c")
67
+ g.add_argument("--min-zoom", type=int, default=0, help="custom URL: lowest zoom served")
68
+ g.add_argument("--max-zoom", type=int, default=22, help="custom URL: highest zoom served")
69
+ g.add_argument("--attribution", default="", help="attribution text for a custom source")
70
+ g.add_argument("--terms-url", help="terms-of-use URL for a custom source")
71
+
72
+
73
+ def _add_area(p: argparse.ArgumentParser) -> None:
74
+ g = p.add_argument_group("area")
75
+ g.add_argument(
76
+ "--bbox", "-b", help="west,south,east,north (degrees unless --bbox-crs EPSG:3857)"
77
+ )
78
+ g.add_argument(
79
+ "--bbox-crs", default="EPSG:4326", help="CRS of --bbox: EPSG:4326 (default) or EPSG:3857"
80
+ )
81
+ g.add_argument("--geojson", "-g", help="GeoJSON file with (Multi)Polygon geometry")
82
+ g.add_argument("--zoom", "-z", required=True, help="zoom level(s): 14, 12-15 or 10,12,14")
83
+
84
+
85
+ def _add_download(p: argparse.ArgumentParser) -> None:
86
+ g = p.add_argument_group("download")
87
+ g.add_argument("--threads", "-t", type=int, default=2, help="concurrent requests (default 2)")
88
+ g.add_argument("--rate", type=float, help="max requests per second (default: provider limit)")
89
+ g.add_argument(
90
+ "--max-tiles", type=int, help="tile cap for this job (custom sources only can raise it)"
91
+ )
92
+ g.add_argument("--retries", type=int, default=4, help="retries per tile (default 4)")
93
+ g.add_argument("--timeout", type=float, default=30.0, help="request timeout in seconds")
94
+ g.add_argument("--cache", help="on-disk HTTP cache folder (enables ETag revalidation)")
95
+ g.add_argument(
96
+ "--revalidate", action="store_true", help="revalidate cached tiles (If-None-Match)"
97
+ )
98
+ g.add_argument("--user-agent", help="identify your application (browser strings are refused)")
99
+ g.add_argument(
100
+ "--header", "-H", action="append", metavar="'Name: value'", help="extra HTTP header"
101
+ )
102
+ g.add_argument("--quiet", "-q", action="store_true", help="no attribution/progress on stderr")
103
+
104
+
105
+ def build_parser() -> argparse.ArgumentParser:
106
+ parser = argparse.ArgumentParser(
107
+ prog="tilefetch",
108
+ description=(
109
+ "Download XYZ/TMS/WMTS tiles for an area into MBTiles, PMTiles, a z/x/y folder "
110
+ "or one georeferenced image. Respect each provider's terms of use."
111
+ ),
112
+ )
113
+ parser.add_argument("--version", action="version", version=f"tilefetch {__version__}")
114
+ sub = parser.add_subparsers(dest="command", required=True)
115
+
116
+ pp = sub.add_parser("providers", help="list built-in providers with their terms")
117
+ pp.add_argument("--json", action="store_true", help="print JSON")
118
+
119
+ pe = sub.add_parser("estimate", help="count tiles and estimate size before downloading")
120
+ _add_source(pe)
121
+ _add_area(pe)
122
+ pe.add_argument("--sample", type=int, default=0, help="download N tiles to measure tile size")
123
+ pe.add_argument("--max-tiles", type=int)
124
+ pe.add_argument("--rate", type=float)
125
+ pe.add_argument("--user-agent")
126
+ pe.add_argument("--header", "-H", action="append")
127
+ pe.add_argument("--json", action="store_true", help="print JSON")
128
+
129
+ pg = sub.add_parser("get", help="download tiles into .mbtiles, .pmtiles, a folder or an image")
130
+ _add_source(pg)
131
+ _add_area(pg)
132
+ _add_download(pg)
133
+ pg.add_argument(
134
+ "--output", "-o", required=True, help="output path (.mbtiles, .pmtiles, folder, .tif, .png)"
135
+ )
136
+ pg.add_argument(
137
+ "--overwrite", action="store_true", help="start from scratch instead of resuming"
138
+ )
139
+ pg.add_argument(
140
+ "--no-resume", action="store_true", help="re-download tiles already in the output"
141
+ )
142
+ pg.add_argument("--name", help="tileset name stored in the metadata")
143
+
144
+ pm = sub.add_parser("mosaic", help="stitch one zoom level into a georeferenced image")
145
+ _add_source(pm, allow_input=True)
146
+ _add_area(pm)
147
+ _add_download(pm)
148
+ pm.add_argument("--output", "-o", required=True, help="output image (.tif, .png, .jpg, .webp)")
149
+ pm.add_argument(
150
+ "--crs", default="EPSG:3857", help="output CRS: EPSG:3857 (default) or EPSG:4326"
151
+ )
152
+ pm.add_argument(
153
+ "--crop",
154
+ choices=["exact", "snap", "none"],
155
+ default="exact",
156
+ help="crop mode (default exact)",
157
+ )
158
+ pm.add_argument("--resampling", choices=["nearest", "bilinear", "bicubic"], default="bilinear")
159
+ pm.add_argument("--width", type=int, help="output width in pixels (EPSG:4326 only)")
160
+ pm.add_argument(
161
+ "--engine", choices=["pillow", "rasterio"], default="pillow", help="GeoTIFF writer"
162
+ )
163
+
164
+ pi = sub.add_parser("info", help="show header/metadata of a .mbtiles or .pmtiles file")
165
+ pi.add_argument("path")
166
+ pi.add_argument("--json", action="store_true")
167
+ return parser
168
+
169
+
170
+ def _source(args: argparse.Namespace) -> Any:
171
+ params = _kv(args.param, "=")
172
+ headers = _kv(getattr(args, "header", None), ":")
173
+ chosen = [n for n in ("provider", "url", "wmts", "input") if getattr(args, n, None)]
174
+ if len(chosen) != 1:
175
+ raise SystemExit(
176
+ "error: give exactly one of --provider, --url, --wmts"
177
+ + (", --input" if hasattr(args, "input") else "")
178
+ )
179
+ if args.provider:
180
+ return args.provider, params, headers
181
+ if getattr(args, "input", None):
182
+ return args.input, params, headers
183
+ if args.url:
184
+ subs: tuple[str, ...] = ()
185
+ if args.subdomains:
186
+ subs = parse_subdomains(args.subdomains)
187
+ src = TileSource.from_url(
188
+ args.url,
189
+ params=params,
190
+ headers=headers,
191
+ scheme="tms" if args.tms else "xyz",
192
+ attribution=args.attribution,
193
+ terms_url=args.terms_url,
194
+ min_zoom=args.min_zoom,
195
+ max_zoom=args.max_zoom,
196
+ **({"subdomains": subs} if subs else {}),
197
+ )
198
+ return src, {}, {}
199
+ if not args.layer:
200
+ raise SystemExit("error: --wmts needs --layer")
201
+ src = wmts_source(
202
+ args.wmts,
203
+ args.layer,
204
+ tile_matrix_set=args.tile_matrix_set,
205
+ style=args.style,
206
+ format=args.wmts_format,
207
+ dimensions=params,
208
+ encoding=args.encoding,
209
+ attribution=args.attribution,
210
+ terms_url=args.terms_url,
211
+ headers=headers,
212
+ )
213
+ return src, {}, {}
214
+
215
+
216
+ def _area(args: argparse.Namespace) -> dict[str, Any]:
217
+ if bool(args.bbox) == bool(args.geojson):
218
+ raise SystemExit("error: give exactly one of --bbox or --geojson")
219
+ return {"bbox": args.bbox, "geometry": args.geojson, "bbox_crs": args.bbox_crs}
220
+
221
+
222
+ class _Progress:
223
+ def __init__(self, enabled: bool) -> None:
224
+ self.enabled = enabled
225
+ self.last = -1
226
+
227
+ def __call__(self, done: int, total: int) -> None:
228
+ if not self.enabled or total == 0:
229
+ return
230
+ pct = int(100 * done / total)
231
+ if pct != self.last or done == total:
232
+ self.last = pct
233
+ end = "\n" if done == total else ""
234
+ print(
235
+ f"\r[tilefetch] {done}/{total} tiles ({pct}%)", end=end, file=sys.stderr, flush=True
236
+ )
237
+
238
+
239
+ def _fmt_bytes(n: float) -> str:
240
+ for unit in ("B", "KB", "MB", "GB"):
241
+ if n < 1024 or unit == "GB":
242
+ return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} B"
243
+ n /= 1024.0
244
+ return f"{n:.1f} GB" # pragma: no cover
245
+
246
+
247
+ def _cmd_providers(args: argparse.Namespace) -> int:
248
+ provs = list_providers()
249
+ if args.json:
250
+ print(json.dumps([p.summary() for p in provs], indent=2))
251
+ return 0
252
+ for p in provs:
253
+ print(
254
+ f"{p.name} (zoom {p.min_zoom}-{p.max_zoom}, max {p.max_tiles} tiles/job, {p.max_rate} req/s)"
255
+ )
256
+ print(f" {p.description}")
257
+ print(f" attribution: {p.attribution}")
258
+ print(f" terms: {p.terms_url}")
259
+ print(
260
+ "\nOther sources: pass --url with a template for a server you operate or whose terms "
261
+ "allow bulk/offline use. tile.openstreetmap.org and similar community servers forbid it."
262
+ )
263
+ return 0
264
+
265
+
266
+ def _cmd_estimate(args: argparse.Namespace) -> int:
267
+ src, params, headers = _source(args)
268
+ est = estimate(
269
+ src,
270
+ zoom=args.zoom,
271
+ params=params or None,
272
+ sample=args.sample,
273
+ max_tiles=args.max_tiles,
274
+ rate=args.rate,
275
+ user_agent=args.user_agent,
276
+ headers=headers or None,
277
+ **_area(args),
278
+ )
279
+ if args.json:
280
+ print(json.dumps(est.as_dict(), indent=2))
281
+ return 0 if est.within_limit else 1
282
+ print(f"source: {est.source}")
283
+ for z, n in est.tiles_per_zoom.items():
284
+ size = est.mosaic_size.get(z)
285
+ px = f", mosaic {size[0]}x{size[1]} px" if size else ""
286
+ print(f" zoom {z:2d}: {n} tiles, {est.resolution_m[z]:.2f} m/px{px}")
287
+ how = f"measured from {est.sampled} tiles" if est.sampled else "typical size"
288
+ print(f"total: {est.total_tiles} tiles, ~{_fmt_bytes(est.estimated_bytes)} ({how})")
289
+ if est.estimated_seconds is not None:
290
+ print(f"time at {est.rate} req/s: >= {est.estimated_seconds:.0f} s")
291
+ if est.max_tiles is not None:
292
+ state = "within" if est.within_limit else "EXCEEDS"
293
+ print(f"limit: {est.max_tiles} tiles per job ({state})")
294
+ return 0 if est.within_limit else 1
295
+
296
+
297
+ def _download_kwargs(args: argparse.Namespace) -> dict[str, Any]:
298
+ return {
299
+ "threads": args.threads,
300
+ "rate": args.rate,
301
+ "max_tiles": args.max_tiles,
302
+ "retries": args.retries,
303
+ "timeout": args.timeout,
304
+ "cache_dir": args.cache,
305
+ "revalidate": args.revalidate,
306
+ "user_agent": args.user_agent,
307
+ "quiet": args.quiet,
308
+ "progress": _Progress(not args.quiet and sys.stderr.isatty()),
309
+ }
310
+
311
+
312
+ def _cmd_get(args: argparse.Namespace) -> int:
313
+ src, params, headers = _source(args)
314
+ res = fetch(
315
+ src,
316
+ zoom=args.zoom,
317
+ out=args.output,
318
+ params=params or None,
319
+ headers=headers or None,
320
+ overwrite=args.overwrite,
321
+ resume=not args.no_resume,
322
+ name=args.name,
323
+ **_area(args),
324
+ **_download_kwargs(args),
325
+ )
326
+ s = res.stats
327
+ print(
328
+ f"{res.path}: {s.total} tiles - {s.downloaded} downloaded, {s.cached} from cache, "
329
+ f"{s.skipped} already present, {s.missing} missing, {s.failed} failed "
330
+ f"({_fmt_bytes(s.bytes)})"
331
+ )
332
+ for tile, err in s.errors[:5]:
333
+ print(f" failed {tile.z}/{tile.x}/{tile.y}: {err}", file=sys.stderr)
334
+ return 0 if res.ok else 1
335
+
336
+
337
+ _CROP: dict[str, bool | str] = {"exact": True, "snap": "snap", "none": False}
338
+
339
+
340
+ def _cmd_mosaic(args: argparse.Namespace) -> int:
341
+ src, params, headers = _source(args)
342
+ kwargs = _download_kwargs(args)
343
+ zoom = int(args.zoom)
344
+ res = mosaic(
345
+ src,
346
+ zoom=zoom,
347
+ out=args.output,
348
+ crs=args.crs,
349
+ crop=_CROP[args.crop],
350
+ resampling=args.resampling,
351
+ width=args.width,
352
+ engine=args.engine,
353
+ params=params or None,
354
+ headers=headers or None,
355
+ **_area(args),
356
+ **kwargs,
357
+ )
358
+ b = res.bounds
359
+ print(
360
+ f"{res.path}: {res.image.width}x{res.image.height} px, {res.crs}, "
361
+ f"bounds {b.minx:.6f},{b.miny:.6f},{b.maxx:.6f},{b.maxy:.6f}, missing tiles {res.missing}"
362
+ )
363
+ if res.attribution and not args.quiet and getattr(args, "input", None):
364
+ # Network sources already announced it on stderr; offline packages carry it in metadata.
365
+ print(f"Attribution: {res.attribution}")
366
+ failed = res.stats.failed if res.stats else 0
367
+ return 0 if failed == 0 else 1
368
+
369
+
370
+ def _cmd_info(args: argparse.Namespace) -> int:
371
+ kind = store_kind(args.path)
372
+ info: dict[str, Any] = {"path": args.path, "kind": kind}
373
+ if kind == "pmtiles":
374
+ with PMTilesReader(args.path) as r:
375
+ info["header"] = dict(vars(r.header))
376
+ info["metadata"] = r.metadata
377
+ elif kind == "mbtiles":
378
+ with MBTilesStore(args.path, "r") as m:
379
+ info["metadata"] = m.metadata
380
+ info["tiles"] = len(m)
381
+ else:
382
+ print("error: info supports .mbtiles and .pmtiles files", file=sys.stderr)
383
+ return 2
384
+ if args.json:
385
+ print(json.dumps(info, indent=2, default=str))
386
+ else:
387
+ for k, v in info.items():
388
+ if isinstance(v, dict):
389
+ print(f"{k}:")
390
+ for kk, vv in v.items():
391
+ print(f" {kk}: {vv}")
392
+ else:
393
+ print(f"{k}: {v}")
394
+ return 0
395
+
396
+
397
+ def main(argv: Sequence[str] | None = None) -> int:
398
+ parser = build_parser()
399
+ args = parser.parse_args(argv)
400
+ handlers = {
401
+ "providers": _cmd_providers,
402
+ "estimate": _cmd_estimate,
403
+ "get": _cmd_get,
404
+ "mosaic": _cmd_mosaic,
405
+ "info": _cmd_info,
406
+ }
407
+ try:
408
+ return handlers[args.command](args)
409
+ except (PolicyError, ValueError, KeyError, FileNotFoundError) as exc:
410
+ msg = exc.args[0] if isinstance(exc, KeyError) and exc.args else exc
411
+ print(f"error: {msg}", file=sys.stderr)
412
+ return 2
413
+ except KeyboardInterrupt: # pragma: no cover - interactive
414
+ print("\ninterrupted; run the same command again to resume", file=sys.stderr)
415
+ return 130
416
+
417
+
418
+ if __name__ == "__main__": # pragma: no cover
419
+ sys.exit(main())