lunar-toolkit 0.1.0.dev2__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.
Files changed (55) hide show
  1. lunar_toolkit/__init__.py +6 -0
  2. lunar_toolkit/asp.py +432 -0
  3. lunar_toolkit/cli/__init__.py +88 -0
  4. lunar_toolkit/cli/_jobs.py +437 -0
  5. lunar_toolkit/cli/_status.py +368 -0
  6. lunar_toolkit/cli/_utils.py +128 -0
  7. lunar_toolkit/cli/app.py +205 -0
  8. lunar_toolkit/cli/build_db.py +130 -0
  9. lunar_toolkit/cli/download.py +111 -0
  10. lunar_toolkit/cli/kaguya.py +253 -0
  11. lunar_toolkit/cli/ode_search.py +144 -0
  12. lunar_toolkit/cli/pairs.py +236 -0
  13. lunar_toolkit/cli/process.py +138 -0
  14. lunar_toolkit/cli/query.py +70 -0
  15. lunar_toolkit/cli/reference.py +233 -0
  16. lunar_toolkit/cli/search.py +90 -0
  17. lunar_toolkit/cli/serve.py +706 -0
  18. lunar_toolkit/cli/sfs.py +344 -0
  19. lunar_toolkit/cli/sfs_report.py +148 -0
  20. lunar_toolkit/cli/shadowcam.py +246 -0
  21. lunar_toolkit/cli/stereo.py +85 -0
  22. lunar_toolkit/cli/workspace.py +84 -0
  23. lunar_toolkit/config.py +345 -0
  24. lunar_toolkit/constants.py +217 -0
  25. lunar_toolkit/csm.py +340 -0
  26. lunar_toolkit/downloader.py +447 -0
  27. lunar_toolkit/geometry.py +556 -0
  28. lunar_toolkit/isis.py +222 -0
  29. lunar_toolkit/kaguya/__init__.py +45 -0
  30. lunar_toolkit/kaguya/mi.py +274 -0
  31. lunar_toolkit/kaguya/tc.py +370 -0
  32. lunar_toolkit/lroc/__init__.py +6 -0
  33. lunar_toolkit/lroc/cumindex.py +1197 -0
  34. lunar_toolkit/lroc/nac.py +702 -0
  35. lunar_toolkit/observation.py +127 -0
  36. lunar_toolkit/ode.py +485 -0
  37. lunar_toolkit/provenance.py +312 -0
  38. lunar_toolkit/reference.py +1111 -0
  39. lunar_toolkit/selection.py +714 -0
  40. lunar_toolkit/sfs.py +1534 -0
  41. lunar_toolkit/sfs_products.py +676 -0
  42. lunar_toolkit/sfs_report.py +1328 -0
  43. lunar_toolkit/shadowcam/__init__.py +29 -0
  44. lunar_toolkit/shadowcam/sc.py +392 -0
  45. lunar_toolkit/static/index.html +3259 -0
  46. lunar_toolkit/stereo.py +519 -0
  47. lunar_toolkit/stereo_dem.py +145 -0
  48. lunar_toolkit/tools.py +264 -0
  49. lunar_toolkit/workspace.py +1173 -0
  50. lunar_toolkit-0.1.0.dev2.dist-info/METADATA +177 -0
  51. lunar_toolkit-0.1.0.dev2.dist-info/RECORD +55 -0
  52. lunar_toolkit-0.1.0.dev2.dist-info/WHEEL +5 -0
  53. lunar_toolkit-0.1.0.dev2.dist-info/entry_points.txt +2 -0
  54. lunar_toolkit-0.1.0.dev2.dist-info/licenses/LICENSE +28 -0
  55. lunar_toolkit-0.1.0.dev2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,6 @@
1
+ """Find, download and process lunar orbital imagery and build DEMs with ISIS + ASP."""
2
+
3
+ from .downloader import PdsDownloader
4
+ from .lroc import CumIndex, NacImage, NacImageCollection, NacProcessor
5
+
6
+ __all__ = ["CumIndex", "NacImage", "NacImageCollection", "NacProcessor", "PdsDownloader"]
lunar_toolkit/asp.py ADDED
@@ -0,0 +1,432 @@
1
+ """Ames Stereo Pipeline wrappers: positional inputs as arguments, other options as keywords."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Sequence
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+
14
+ from .tools import asp_options, gdal_options, is_dry_run, run
15
+
16
+
17
+ def _paths(items: Sequence[Any]) -> list[str]:
18
+ return [str(p) for p in items]
19
+
20
+
21
+ def _parent(prefix: Path) -> None:
22
+ if not is_dry_run():
23
+ Path(prefix).parent.mkdir(parents=True, exist_ok=True)
24
+
25
+
26
+ def mapproject(
27
+ dem: Path, image: Path, out: Path, camera: Path | None = None, *, cwd: Path | None = None, **options: Any
28
+ ) -> Path:
29
+ """Orthorectify *image* onto *dem* (``tr=``, ``t_projwin=``, ``bundle_adjust_prefix=``)."""
30
+ _parent(out)
31
+ cams = [camera] if camera else []
32
+ run("mapproject", [*asp_options(options), dem, image, *cams, out], cwd=cwd)
33
+ return Path(out)
34
+
35
+
36
+ def bundle_adjust(
37
+ images: Sequence[Path],
38
+ out_prefix: Path,
39
+ cameras: Sequence[Path] = (),
40
+ *,
41
+ mapprojected: Sequence[Path] | None = None,
42
+ dem: Path | None = None,
43
+ parallel: bool = False,
44
+ cwd: Path | None = None,
45
+ **options: Any,
46
+ ) -> Path:
47
+ """Run ``bundle_adjust`` (or ``parallel_bundle_adjust`` with *parallel*).
48
+
49
+ *mapprojected* (same order as *images*) and the *dem* they were projected
50
+ onto become ``--mapprojected-data``, which finds matches between images of
51
+ very different illumination. Returns *out_prefix*.
52
+ """
53
+ _parent(out_prefix)
54
+ if mapprojected is not None:
55
+ options["mapprojected_data"] = " ".join(_paths([*mapprojected, *([dem] if dem else [])]))
56
+ tool = "parallel_bundle_adjust" if parallel else "bundle_adjust"
57
+ run(tool, [*_paths(images), *_paths(cameras), *asp_options(options), "-o", out_prefix], cwd=cwd)
58
+ return Path(out_prefix)
59
+
60
+
61
+ def apply_transform(
62
+ images: Sequence[Path],
63
+ transform: Path,
64
+ out_prefix: Path,
65
+ cameras: Sequence[Path] = (),
66
+ *,
67
+ input_adjustments_prefix: Path | None = None,
68
+ cwd: Path | None = None,
69
+ **options: Any,
70
+ ) -> Path:
71
+ """Apply a ``pc_align`` 4×4 transform to (adjusted) cameras, without optimizing."""
72
+ return bundle_adjust(
73
+ images,
74
+ out_prefix,
75
+ cameras,
76
+ initial_transform=transform,
77
+ input_adjustments_prefix=input_adjustments_prefix,
78
+ apply_initial_transform_only=True,
79
+ cwd=cwd,
80
+ **options,
81
+ )
82
+
83
+
84
+ def sfs(
85
+ dem: Path,
86
+ images: Sequence[Path],
87
+ out_prefix: Path,
88
+ cameras: Sequence[Path] = (),
89
+ *,
90
+ parallel: bool = False,
91
+ cwd: Path | None = None,
92
+ **options: Any,
93
+ ) -> Path:
94
+ """Run ``sfs`` (or ``parallel_sfs``); returns ``<out_prefix>-DEM-final.tif``."""
95
+ _parent(out_prefix)
96
+ tool = "parallel_sfs" if parallel else "sfs"
97
+ run(
98
+ tool, ["-i", dem, *_paths(images), *_paths(cameras), *asp_options(options), "-o", out_prefix], cwd=cwd
99
+ )
100
+ return Path(f"{out_prefix}-DEM-final.tif")
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class SunAngles:
105
+ image: str
106
+ azimuth: float
107
+ elevation: float
108
+
109
+
110
+ _SUN_RE = re.compile(r"Sun azimuth and elevation for: (?P<image>\S+) are (?P<az>\S+) and (?P<el>\S+) degrees")
111
+
112
+
113
+ def parse_sun_angles(text: str) -> list[SunAngles]:
114
+ """Parse ``sfs --query`` output into per-image Sun azimuth / elevation."""
115
+ return [SunAngles(m["image"], float(m["az"]), float(m["el"])) for m in _SUN_RE.finditer(text)]
116
+
117
+
118
+ def sun_angles(
119
+ dem: Path,
120
+ images: Sequence[Path],
121
+ cameras: Sequence[Path] = (),
122
+ *,
123
+ out_prefix: Path = Path("sfs_query/run"),
124
+ cwd: Path | None = None,
125
+ **options: Any,
126
+ ) -> list[SunAngles]:
127
+ """Sun azimuth (from north) and elevation per image at the DEM centre.
128
+
129
+ Runs ``sfs --query``; results come back in input order. Sort by azimuth
130
+ before bundle adjustment and SfS.
131
+ """
132
+ _parent(out_prefix)
133
+ res = run(
134
+ "sfs",
135
+ ["-i", dem, *_paths(images), *_paths(cameras), "--query", *asp_options(options), "-o", out_prefix],
136
+ cwd=cwd,
137
+ capture=True,
138
+ )
139
+ return parse_sun_angles(res.stdout or "")
140
+
141
+
142
+ def pc_align(
143
+ reference: Path,
144
+ source: Path,
145
+ out_prefix: Path,
146
+ *,
147
+ max_displacement: float,
148
+ cwd: Path | None = None,
149
+ **options: Any,
150
+ ) -> Path:
151
+ """Align *source* to *reference*; returns ``<out_prefix>-transform.txt``."""
152
+ _parent(out_prefix)
153
+ run(
154
+ "pc_align",
155
+ ["--max-displacement", max_displacement, *asp_options(options), reference, source, "-o", out_prefix],
156
+ cwd=cwd,
157
+ )
158
+ return Path(f"{out_prefix}-transform.txt")
159
+
160
+
161
+ def point2dem(cloud: Path, *, cwd: Path | None = None, **options: Any) -> Path:
162
+ """Grid a point cloud; returns ``<prefix>-DEM.tif`` (prefix from ``o=`` or the cloud)."""
163
+ run("point2dem", [*asp_options(options), cloud], cwd=cwd)
164
+ prefix = options.get("o") or options.get("output_prefix")
165
+ if prefix is None:
166
+ stem = str(cloud)
167
+ prefix = stem[: -len("-PC.tif")] if stem.endswith("-PC.tif") else str(Path(stem).with_suffix(""))
168
+ return Path(f"{prefix}-DEM.tif")
169
+
170
+
171
+ def stereo(
172
+ left: Path,
173
+ right: Path,
174
+ out_prefix: Path,
175
+ left_camera: Path | None = None,
176
+ right_camera: Path | None = None,
177
+ *,
178
+ dem: Path | None = None,
179
+ parallel: bool = True,
180
+ cwd: Path | None = None,
181
+ **options: Any,
182
+ ) -> Path:
183
+ """Run ``parallel_stereo`` (or ``stereo``); returns ``<out_prefix>-PC.tif``.
184
+
185
+ Pass *dem* when the inputs are mapprojected onto it.
186
+ """
187
+ _parent(out_prefix)
188
+ cams = [c for c in (left_camera, right_camera) if c]
189
+ tail = [dem] if dem else []
190
+ tool = "parallel_stereo" if parallel else "stereo"
191
+ run(tool, [*asp_options(options), left, right, *cams, out_prefix, *tail], cwd=cwd)
192
+ return Path(f"{out_prefix}-PC.tif")
193
+
194
+
195
+ def dem_mosaic(inputs: Sequence[Path], out: Path, *, cwd: Path | None = None, **options: Any) -> Path:
196
+ """Mosaic DEMs or images (``max=True`` for a maximally-lit mosaic)."""
197
+ _parent(out)
198
+ run("dem_mosaic", [*_paths(inputs), *asp_options(options), "-o", out], cwd=cwd)
199
+ return Path(out)
200
+
201
+
202
+ def hillshade(
203
+ dem: Path,
204
+ out: Path | None = None,
205
+ *,
206
+ azimuth: float = 300,
207
+ elevation: float = 20,
208
+ cwd: Path | None = None,
209
+ **options: Any,
210
+ ) -> Path:
211
+ out = Path(out) if out else Path(dem).with_name(Path(dem).stem + "_hillshade.tif")
212
+ run(
213
+ "hillshade",
214
+ [dem, "--azimuth", azimuth, "--elevation", elevation, *asp_options(options), "-o", out],
215
+ cwd=cwd,
216
+ )
217
+ return out
218
+
219
+
220
+ def geodiff(dem1: Path, dem2: Path, out_prefix: Path, *, cwd: Path | None = None, **options: Any) -> Path:
221
+ """``dem1 - dem2`` (``absolute=True`` for |diff|); returns ``<out_prefix>-diff.tif``."""
222
+ _parent(out_prefix)
223
+ run("geodiff", [*asp_options(options), dem1, dem2, "-o", out_prefix], cwd=cwd)
224
+ return Path(f"{out_prefix}-diff.tif")
225
+
226
+
227
+ def gdal_translate(
228
+ src: Path,
229
+ dst: Path,
230
+ *,
231
+ projwin: Sequence[float] | None = None,
232
+ tr: float | None = None,
233
+ r: str | None = None,
234
+ cwd: Path | None = None,
235
+ **options: Any,
236
+ ) -> Path:
237
+ """Crop (``projwin`` = ulx uly lrx lry) and / or resample (``tr`` metres, ``r`` method)."""
238
+ _parent(dst)
239
+ opts = {
240
+ "projwin": projwin,
241
+ "tr": (tr, tr) if tr is not None else None,
242
+ "r": r,
243
+ "of": "GTiff",
244
+ "co": "COMPRESS=LZW",
245
+ **options,
246
+ }
247
+ run("gdal_translate", [*gdal_options(opts), src, dst], cwd=cwd)
248
+ return Path(dst)
249
+
250
+
251
+ def bundle_adjust_file_name(prefix: Path, image: Path, camera: Path | None = None) -> Path:
252
+ """``<prefix>-<stem>.adjust`` as ASP names it (stem of *camera*, else *image*, up to the last dot)."""
253
+ name = Path(camera or image).name
254
+ stem = name[: name.rfind(".")] if "." in name else name
255
+ return Path(f"{prefix}-{stem}.adjust")
256
+
257
+
258
+ @dataclass(frozen=True)
259
+ class MatchOffsetStats:
260
+ """One row of ``<prefix>-mapproj_match_offset_stats.txt`` (metres; -1 when no matches)."""
261
+
262
+ image: str
263
+ p25: float
264
+ p50: float
265
+ p75: float
266
+ p85: float
267
+ p95: float
268
+ count: int
269
+
270
+
271
+ def read_mapproj_offset_stats(path: Path) -> dict[str, MatchOffsetStats]:
272
+ """Parse ``bundle_adjust --mapproj-dem`` per-image offsets, keyed by image file name.
273
+
274
+ Format (ASP ``saveMapprojOffsets``): ``# image_name 25% 50% 75% 85% 95% count``.
275
+ """
276
+ out = {}
277
+ for line in Path(path).read_text().splitlines():
278
+ parts = line.split()
279
+ if not parts or parts[0].startswith("#") or len(parts) < 7:
280
+ continue
281
+ p25, p50, p75, p85, p95 = map(float, parts[1:6])
282
+ out[Path(parts[0]).name] = MatchOffsetStats(parts[0], p25, p50, p75, p85, p95, int(float(parts[6])))
283
+ return out
284
+
285
+
286
+ @dataclass(frozen=True)
287
+ class ResidualStats:
288
+ """One row of ``<prefix>-final_residuals_stats.txt`` (pixels)."""
289
+
290
+ image: str
291
+ mean: float
292
+ median: float
293
+ count: int
294
+
295
+
296
+ def read_residual_stats(path: Path) -> dict[str, ResidualStats]:
297
+ """Parse per-camera reprojection errors (``# Image, mean, median, count``), keyed by file name."""
298
+ out = {}
299
+ for line in Path(path).read_text().splitlines():
300
+ parts = [p.strip() for p in line.split(",")]
301
+ if not parts[0] or parts[0].startswith("#") or len(parts) < 4:
302
+ continue
303
+ out[Path(parts[0]).name] = ResidualStats(parts[0], float(parts[1]), float(parts[2]), int(parts[3]))
304
+ return out
305
+
306
+
307
+ def read_residual_norms(path: Path) -> dict[str, np.ndarray]:
308
+ """Per-image reprojection error norms (pixels) from ``<prefix>-final_residuals_raw_pixels.txt``.
309
+
310
+ The file has, per image, a ``name, count`` line followed by *count* ``ex, ey`` lines.
311
+ """
312
+ out: dict[str, np.ndarray] = {}
313
+ lines = Path(path).read_text().splitlines()
314
+ i = 0
315
+ while i < len(lines):
316
+ head = [p.strip() for p in lines[i].split(",")]
317
+ i += 1
318
+ if len(head) != 2 or not head[0] or head[0].startswith("#"):
319
+ continue
320
+ n = int(head[1])
321
+ xy = np.array([[float(v) for v in ln.split(",")] for ln in lines[i : i + n]]).reshape(-1, 2)
322
+ out[Path(head[0]).name] = np.hypot(xy[:, 0], xy[:, 1])
323
+ i += n
324
+ return out
325
+
326
+
327
+ def image_subset(
328
+ images: Sequence[Path], out: Path, *, threshold: float, cwd: Path | None = None, **options: Any
329
+ ) -> list[tuple[str, int]]:
330
+ """Run ``image_subset``; returns ``(image, contributing pixels)`` best first.
331
+
332
+ Writes the image list to ``<out>.in.txt``; ``t_projwin=(xmin, ymin, xmax, ymax)`` limits
333
+ the evaluated extent. Empty in a dry run.
334
+ """
335
+ _parent(out)
336
+ image_list = Path(f"{out}.in.txt")
337
+ if not is_dry_run():
338
+ image_list.write_text("".join(f"{p}\n" for p in images))
339
+ run(
340
+ "image_subset",
341
+ ["--image-list", image_list, "--threshold", threshold, *asp_options(options), "-o", out],
342
+ cwd=cwd,
343
+ )
344
+ if is_dry_run() or not Path(out).exists():
345
+ return []
346
+ return [
347
+ (parts[0], int(float(parts[1])))
348
+ for parts in (ln.split() for ln in Path(out).read_text().splitlines())
349
+ if len(parts) >= 2 and not parts[0].startswith("#")
350
+ ]
351
+
352
+
353
+ def raster_info(path: Path) -> dict:
354
+ """``gdalinfo -json`` of *path* (empty in a dry run)."""
355
+ res = run("gdalinfo", ["-json", path], capture=True)
356
+ return json.loads(res.stdout) if res.stdout else {}
357
+
358
+
359
+ def raster_bounds(path: Path) -> tuple[float, float, float, float] | None:
360
+ """``(ulx, uly, lrx, lry)`` of a north-up raster, or None in a dry run."""
361
+ info = raster_info(path)
362
+ if not info:
363
+ return None
364
+ x0, dx, _, y0, _, dy = info["geoTransform"]
365
+ nx, ny = info["size"]
366
+ return x0, y0, x0 + nx * dx, y0 + ny * dy
367
+
368
+
369
+ def srs_proj4(path: Path) -> str:
370
+ """PROJ string of a raster's spatial reference (``gdalsrsinfo -o proj4``; empty in a dry run)."""
371
+ res = run("gdalsrsinfo", ["-o", "proj4", path], capture=True)
372
+ return (res.stdout or "").strip().strip("'")
373
+
374
+
375
+ @dataclass(frozen=True)
376
+ class Raster:
377
+ """Band 1 of a north-up raster: ``data[row, col]`` (NaN = nodata), top-left corner and pixel size."""
378
+
379
+ data: np.ndarray
380
+ x0: float
381
+ y0: float
382
+ dx: float
383
+ dy: float
384
+
385
+
386
+ def read_raster(
387
+ path: Path,
388
+ *,
389
+ projwin: Sequence[float] | None = None,
390
+ outsize: tuple[int, int] | None = None,
391
+ r: str = "average",
392
+ ) -> Raster | None:
393
+ """Read (a resampled window of) a raster through ``gdal_translate -of AAIGrid /vsistdout/``.
394
+
395
+ *projwin* (``ulx uly lrx lry``) may extend past the raster (filled with nodata) and
396
+ *outsize* ``(columns, rows)`` resamples with *r*, so several images read with the
397
+ same window share one grid. Meant for small grids; returns None in a dry run.
398
+ """
399
+ opts = {
400
+ "q": True,
401
+ "of": "AAIGrid",
402
+ "ot": "Float32",
403
+ "b": 1,
404
+ "projwin": projwin,
405
+ "outsize": outsize,
406
+ "r": r if outsize else None,
407
+ }
408
+ res = run("gdal_translate", [*gdal_options(opts), path, "/vsistdout/"], capture=True)
409
+ return parse_aaigrid(res.stdout) if res.stdout else None
410
+
411
+
412
+ def parse_aaigrid(text: str) -> Raster:
413
+ """Parse ESRI ASCII grid text (as GDAL's AAIGrid driver writes it)."""
414
+ lines = text.splitlines()
415
+ head: dict[str, float] = {}
416
+ k = 0
417
+ while k < len(lines) and lines[k][:1].isalpha():
418
+ key, value = lines[k].split()[:2]
419
+ head[key.lower()] = float(value)
420
+ k += 1
421
+ nx, ny = int(head["ncols"]), int(head["nrows"])
422
+ dx = head.get("dx", head.get("cellsize"))
423
+ dy = head.get("dy", head.get("cellsize"))
424
+ if dx is None or dy is None:
425
+ raise ValueError("AAIGrid header lacks cellsize / dx / dy")
426
+ x0 = head["xllcorner"] if "xllcorner" in head else head["xllcenter"] - dx / 2
427
+ yll = head["yllcorner"] if "yllcorner" in head else head["yllcenter"] - dy / 2
428
+ # GDAL also writes the .prj (WKT) to /vsistdout/ after the cells.
429
+ data = np.array(" ".join(lines[k:]).split()[: nx * ny], dtype=float).reshape(ny, nx)
430
+ if "nodata_value" in head:
431
+ data[data == head["nodata_value"]] = np.nan
432
+ return Raster(data, x0, yll + ny * dy, dx, -dy)
@@ -0,0 +1,88 @@
1
+ """``lunar`` CLI; each subcommand module exports COMMAND, add_parser(sub) and run(args)."""
2
+
3
+ import argparse
4
+ import logging
5
+ import sys
6
+
7
+ from . import (
8
+ app,
9
+ build_db,
10
+ download,
11
+ kaguya,
12
+ ode_search,
13
+ pairs,
14
+ process,
15
+ query,
16
+ reference,
17
+ search,
18
+ serve,
19
+ sfs,
20
+ sfs_report,
21
+ shadowcam,
22
+ stereo,
23
+ workspace,
24
+ )
25
+
26
+ _COMMANDS = [
27
+ build_db,
28
+ download,
29
+ query,
30
+ search,
31
+ ode_search,
32
+ pairs,
33
+ reference,
34
+ process,
35
+ kaguya,
36
+ shadowcam,
37
+ stereo,
38
+ sfs,
39
+ sfs_report,
40
+ workspace,
41
+ serve,
42
+ app,
43
+ ]
44
+
45
+
46
+ def _build_parser() -> argparse.ArgumentParser:
47
+ parser = argparse.ArgumentParser(
48
+ prog="lunar",
49
+ description=(
50
+ "Lunar imagery toolkit - build LROC index databases, search footprints, "
51
+ "query ODE (ShadowCam, LROC, Kaguya TC and MI), download and import images into ISIS, "
52
+ "build stereo and shape-from-shading DEMs with ISIS + ASP, and fetch Artemis III "
53
+ "reference data."
54
+ ),
55
+ formatter_class=argparse.RawDescriptionHelpFormatter,
56
+ )
57
+ parser.add_argument(
58
+ "-v",
59
+ "--verbose",
60
+ action="store_true",
61
+ help="Enable DEBUG-level logging.",
62
+ )
63
+ sub = parser.add_subparsers(dest="cmd", required=True)
64
+ for cmd in _COMMANDS:
65
+ cmd.add_parser(sub)
66
+ return parser
67
+
68
+
69
+ def main(argv: list[str] | None = None) -> int:
70
+ parser = _build_parser()
71
+ args = parser.parse_args(argv)
72
+
73
+ logging.basicConfig(
74
+ level=logging.DEBUG if args.verbose else logging.INFO,
75
+ format="%(asctime)s %(levelname)-8s %(message)s",
76
+ datefmt="%H:%M:%S",
77
+ )
78
+
79
+ for cmd in _COMMANDS:
80
+ if args.cmd == cmd.COMMAND:
81
+ return cmd.run(args)
82
+
83
+ parser.print_help()
84
+ return 1
85
+
86
+
87
+ if __name__ == "__main__":
88
+ sys.exit(main())