airmate-navdata-sync 0.3.0__tar.gz → 0.3.2__tar.gz
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.
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/PKG-INFO +1 -1
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/navdata_sync/__init__.py +1 -1
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/navdata_sync/usb.py +119 -25
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tests/test_usb.py +46 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/.gitignore +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/LICENSE +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/README.md +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/config.example.toml +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/navdata-update.py +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/navdata_sync/__main__.py +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/navdata_sync/catalog.py +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/navdata_sync/cli.py +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/navdata_sync/config.py +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/navdata_sync/download.py +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/navdata_sync/prepare.py +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/pyproject.toml +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tests/__init__.py +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tests/conftest.py +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tests/helpers.py +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tests/test_catalog.py +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tests/test_cli.py +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tests/test_config.py +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tests/test_download.py +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tests/test_prepare.py +0 -0
- {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tools/mbtiles_to_dcf.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: airmate-navdata-sync
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Download an Airmate AIRAC cycle and lay it out for a Dynon SkyView USB stick
|
|
5
5
|
Project-URL: Homepage, https://github.com/Natim/skydemon-airmate-navdata-sync
|
|
6
6
|
Project-URL: Source, https://github.com/Natim/skydemon-airmate-navdata-sync
|
|
@@ -27,6 +27,7 @@ from __future__ import annotations
|
|
|
27
27
|
import asyncio
|
|
28
28
|
import hashlib
|
|
29
29
|
import os
|
|
30
|
+
import time
|
|
30
31
|
from collections.abc import Callable
|
|
31
32
|
from contextlib import AsyncExitStack
|
|
32
33
|
from dataclasses import dataclass
|
|
@@ -63,13 +64,40 @@ class SyncReport:
|
|
|
63
64
|
|
|
64
65
|
def relative_files(root: Path) -> list[Path]:
|
|
65
66
|
"""Files under root, as paths relative to it, in a stable order."""
|
|
67
|
+
files, _dirs = _scan_tree(root)
|
|
68
|
+
return files
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _scan_tree(
|
|
72
|
+
root: Path,
|
|
73
|
+
on_file: Callable[[int, Path], None] | None = None,
|
|
74
|
+
) -> tuple[list[Path], list[Path]]:
|
|
75
|
+
"""Files and directories under root, relative to it.
|
|
76
|
+
|
|
77
|
+
Files are sorted. Directories are deepest-first so empty ones can be
|
|
78
|
+
removed after extras are deleted. ``on_file(n, rel)`` runs as each file
|
|
79
|
+
is discovered (``n`` starts at 1), before the sort.
|
|
80
|
+
"""
|
|
66
81
|
if not root.is_dir():
|
|
67
|
-
return []
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
82
|
+
return [], []
|
|
83
|
+
files: list[Path] = []
|
|
84
|
+
dirs: list[Path] = []
|
|
85
|
+
seen = 0
|
|
86
|
+
for path in root.rglob("*"):
|
|
87
|
+
if path.is_symlink():
|
|
88
|
+
continue
|
|
89
|
+
rel = path.relative_to(root)
|
|
90
|
+
if path.is_dir():
|
|
91
|
+
dirs.append(rel)
|
|
92
|
+
continue
|
|
93
|
+
if path.is_file() and not path.name.startswith("."):
|
|
94
|
+
files.append(rel)
|
|
95
|
+
seen += 1
|
|
96
|
+
if on_file is not None:
|
|
97
|
+
on_file(seen, rel)
|
|
98
|
+
files.sort()
|
|
99
|
+
dirs.sort(key=lambda rel: len(rel.parts), reverse=True)
|
|
100
|
+
return files, dirs
|
|
73
101
|
|
|
74
102
|
|
|
75
103
|
def _norm_rel(rel: Path) -> str:
|
|
@@ -77,26 +105,36 @@ def _norm_rel(rel: Path) -> str:
|
|
|
77
105
|
return str(rel).casefold()
|
|
78
106
|
|
|
79
107
|
|
|
80
|
-
def prune_extras(
|
|
81
|
-
|
|
108
|
+
def prune_extras(
|
|
109
|
+
target: Path,
|
|
110
|
+
wanted: set[Path],
|
|
111
|
+
on_scan: Callable[[int, Path], None] | None = None,
|
|
112
|
+
on_purge: Callable[[int, int, Path], None] | None = None,
|
|
113
|
+
) -> list[Path]:
|
|
114
|
+
"""Delete dest files (and then empty dirs) that are not in the staging tree.
|
|
115
|
+
|
|
116
|
+
``on_scan`` follows the directory walk. ``on_purge(done, total, rel)`` follows
|
|
117
|
+
each deletion and each directory-removal attempt.
|
|
118
|
+
"""
|
|
82
119
|
wanted_norm = {_norm_rel(rel) for rel in wanted}
|
|
83
|
-
|
|
120
|
+
files, dirs = _scan_tree(target, on_scan)
|
|
121
|
+
extras = [rel for rel in files if _norm_rel(rel) not in wanted_norm]
|
|
122
|
+
steps = len(extras) + len(dirs)
|
|
123
|
+
done = 0
|
|
84
124
|
for rel in extras:
|
|
85
125
|
(target / rel).unlink(missing_ok=True)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def _remove_empty_dirs(target: Path) -> None:
|
|
91
|
-
for directory in sorted(
|
|
92
|
-
(path for path in target.rglob("*") if path.is_dir()),
|
|
93
|
-
key=lambda path: len(path.parts),
|
|
94
|
-
reverse=True,
|
|
95
|
-
):
|
|
126
|
+
done += 1
|
|
127
|
+
if on_purge is not None:
|
|
128
|
+
on_purge(done, steps, rel)
|
|
129
|
+
for rel in dirs:
|
|
96
130
|
try:
|
|
97
|
-
|
|
131
|
+
(target / rel).rmdir()
|
|
98
132
|
except OSError:
|
|
99
133
|
pass
|
|
134
|
+
done += 1
|
|
135
|
+
if on_purge is not None:
|
|
136
|
+
on_purge(done, steps, rel)
|
|
137
|
+
return extras
|
|
100
138
|
|
|
101
139
|
|
|
102
140
|
def describe_sync(prepared_dir: Path, usb_targets: tuple[Path, ...]) -> str:
|
|
@@ -135,6 +173,7 @@ async def _sync(prepared_dir: Path, usb_targets: tuple[Path, ...]) -> int:
|
|
|
135
173
|
print(f"❌ {prepared_dir} est vide, rien à copier")
|
|
136
174
|
return 1
|
|
137
175
|
|
|
176
|
+
started = time.perf_counter()
|
|
138
177
|
async with AsyncioContext(max_requests=32) as ctx:
|
|
139
178
|
manifest = await _index_source(prepared_dir, source_files, ctx)
|
|
140
179
|
with _usb_progress() as progress:
|
|
@@ -157,6 +196,7 @@ async def _sync(prepared_dir: Path, usb_targets: tuple[Path, ...]) -> int:
|
|
|
157
196
|
)
|
|
158
197
|
print("💾 Vidage des caches disque...")
|
|
159
198
|
os.sync()
|
|
199
|
+
elapsed = time.perf_counter() - started
|
|
160
200
|
|
|
161
201
|
failed = False
|
|
162
202
|
for report in reports:
|
|
@@ -173,11 +213,26 @@ async def _sync(prepared_dir: Path, usb_targets: tuple[Path, ...]) -> int:
|
|
|
173
213
|
f"{report.copied} copiés, {report.skipped} inchangés, "
|
|
174
214
|
f"{report.deleted} supprimés"
|
|
175
215
|
)
|
|
216
|
+
print(f"⏱️ Durée totale : {_format_duration(elapsed)}")
|
|
176
217
|
if missing:
|
|
177
218
|
failed = True
|
|
178
219
|
return 1 if failed else 0
|
|
179
220
|
|
|
180
221
|
|
|
222
|
+
def _format_duration(seconds: float) -> str:
|
|
223
|
+
"""Wall-clock duration for the summary, e.g. ``1 h 2 min 3 s``."""
|
|
224
|
+
whole = max(0, round(seconds))
|
|
225
|
+
hours, rem = divmod(whole, 3600)
|
|
226
|
+
minutes, secs = divmod(rem, 60)
|
|
227
|
+
parts: list[str] = []
|
|
228
|
+
if hours:
|
|
229
|
+
parts.append(f"{hours} h")
|
|
230
|
+
if hours or minutes:
|
|
231
|
+
parts.append(f"{minutes} min")
|
|
232
|
+
parts.append(f"{secs} s")
|
|
233
|
+
return " ".join(parts)
|
|
234
|
+
|
|
235
|
+
|
|
181
236
|
class _WorkColumn(ProgressColumn):
|
|
182
237
|
"""Bytes for copy/hash, file counts for verify/purge."""
|
|
183
238
|
|
|
@@ -185,6 +240,8 @@ class _WorkColumn(ProgressColumn):
|
|
|
185
240
|
completed = float(task.completed or 0)
|
|
186
241
|
total = float(task.total or 0)
|
|
187
242
|
if task.fields.get("unit") == "fichier":
|
|
243
|
+
if task.total is None:
|
|
244
|
+
return Text(f"{int(completed)} fichiers")
|
|
188
245
|
return Text(f"{int(completed)}/{int(total)} fichiers")
|
|
189
246
|
return Text(f"{human_size(completed)}/{human_size(total)}")
|
|
190
247
|
|
|
@@ -218,6 +275,31 @@ def _advance(progress: Progress, task_id: TaskID, n: int) -> None:
|
|
|
218
275
|
progress.update(task_id, advance=n)
|
|
219
276
|
|
|
220
277
|
|
|
278
|
+
def _restart_phase(
|
|
279
|
+
progress: Progress,
|
|
280
|
+
task_id: TaskID,
|
|
281
|
+
*,
|
|
282
|
+
phase: str,
|
|
283
|
+
total: float | None,
|
|
284
|
+
unit: str,
|
|
285
|
+
) -> None:
|
|
286
|
+
"""Start a phase from zero. ``total=None`` keeps the bar open-ended.
|
|
287
|
+
|
|
288
|
+
Progress.reset treats ``total=None`` as "keep the current total", so the
|
|
289
|
+
directory walk (unknown length) has to assign it under the progress lock.
|
|
290
|
+
"""
|
|
291
|
+
now = progress.get_time()
|
|
292
|
+
with progress._lock:
|
|
293
|
+
task = progress._tasks[task_id]
|
|
294
|
+
task._reset()
|
|
295
|
+
task.total = total
|
|
296
|
+
task.completed = 0
|
|
297
|
+
task.start_time = now
|
|
298
|
+
task.finished_time = None
|
|
299
|
+
task.fields.update(phase=phase, current="", unit=unit)
|
|
300
|
+
progress.refresh()
|
|
301
|
+
|
|
302
|
+
|
|
221
303
|
def _finish(progress: Progress, task_id: TaskID, phase: str, current: str = "") -> None:
|
|
222
304
|
total = 1.0
|
|
223
305
|
for task in progress.tasks:
|
|
@@ -266,13 +348,12 @@ async def _sync_target(
|
|
|
266
348
|
# of plates up front blocked the event loop, froze both bars at 0 %, and
|
|
267
349
|
# emptied the stick before anything was verified.
|
|
268
350
|
to_copy: list[_FileInfo] = []
|
|
269
|
-
|
|
270
|
-
for i, info in enumerate(manifest, 1):
|
|
351
|
+
for info in manifest:
|
|
271
352
|
dest = target / info.rel
|
|
272
353
|
progress.update(
|
|
273
354
|
task_id,
|
|
274
355
|
phase="Vérif.",
|
|
275
|
-
current=
|
|
356
|
+
current=str(info.rel),
|
|
276
357
|
)
|
|
277
358
|
if dest.is_file() and dest.stat().st_size == info.size:
|
|
278
359
|
digest = await _digest(dest, ctx)
|
|
@@ -297,8 +378,21 @@ async def _sync_target(
|
|
|
297
378
|
)
|
|
298
379
|
report.copied += 1
|
|
299
380
|
|
|
300
|
-
progress
|
|
301
|
-
|
|
381
|
+
_restart_phase(progress, task_id, phase="Liste", total=None, unit="fichier")
|
|
382
|
+
|
|
383
|
+
def on_scan(n: int, rel: Path) -> None:
|
|
384
|
+
progress.update(task_id, completed=n, current=str(rel))
|
|
385
|
+
|
|
386
|
+
purge_started = False
|
|
387
|
+
|
|
388
|
+
def on_purge(done: int, total: int, rel: Path) -> None:
|
|
389
|
+
nonlocal purge_started
|
|
390
|
+
if not purge_started:
|
|
391
|
+
_restart_phase(progress, task_id, phase="Purge", total=total, unit="fichier")
|
|
392
|
+
purge_started = True
|
|
393
|
+
progress.update(task_id, completed=done, current=str(rel))
|
|
394
|
+
|
|
395
|
+
extras = await asyncio.to_thread(prune_extras, target, wanted, on_scan, on_purge)
|
|
302
396
|
report.deleted = len(extras)
|
|
303
397
|
if extras:
|
|
304
398
|
progress.log(f"🗑️ [{label}] {len(extras)} fichiers superflus")
|
|
@@ -71,6 +71,52 @@ def test_sync_two_targets_copy_skip_rewrite_and_delete(
|
|
|
71
71
|
assert not (lh / "Raster" / "VFR.dcf").exists()
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
def test_prune_reports_scan_and_purge(tmp_path: Path) -> None:
|
|
75
|
+
stick = tmp_path / "RH D1000"
|
|
76
|
+
(stick / "keep.txt").parent.mkdir()
|
|
77
|
+
(stick / "keep.txt").write_text("ok", encoding="utf-8")
|
|
78
|
+
(stick / "old").mkdir()
|
|
79
|
+
(stick / "old" / "nested.bin").write_bytes(b"stale")
|
|
80
|
+
|
|
81
|
+
scanned: list[tuple[int, Path]] = []
|
|
82
|
+
purged: list[tuple[int, int, Path]] = []
|
|
83
|
+
extras = usb.prune_extras(
|
|
84
|
+
stick,
|
|
85
|
+
{Path("keep.txt")},
|
|
86
|
+
on_scan=lambda n, rel: scanned.append((n, rel)),
|
|
87
|
+
on_purge=lambda done, total, rel: purged.append((done, total, rel)),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
assert [n for n, _rel in scanned] == list(range(1, len(scanned) + 1))
|
|
91
|
+
assert {rel for _n, rel in scanned} == {Path("keep.txt"), Path("old/nested.bin")}
|
|
92
|
+
assert extras == [Path("old/nested.bin")]
|
|
93
|
+
assert purged[0][2] == Path("old/nested.bin")
|
|
94
|
+
assert purged[-1][0] == purged[-1][1]
|
|
95
|
+
assert Path("old") in [rel for _done, _total, rel in purged]
|
|
96
|
+
assert not (stick / "old").exists()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_format_duration() -> None:
|
|
100
|
+
assert usb._format_duration(0) == "0 s"
|
|
101
|
+
assert usb._format_duration(59.4) == "59 s"
|
|
102
|
+
assert usb._format_duration(61) == "1 min 1 s"
|
|
103
|
+
assert usb._format_duration(3661) == "1 h 1 min 1 s"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_sync_summary_includes_total_duration(
|
|
107
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
108
|
+
) -> None:
|
|
109
|
+
monkeypatch.setattr(usb.os, "sync", lambda: None)
|
|
110
|
+
src = tmp_path / "prepared"
|
|
111
|
+
stick = tmp_path / "LH D1000"
|
|
112
|
+
src.mkdir()
|
|
113
|
+
(src / "a.txt").write_text("a", encoding="utf-8")
|
|
114
|
+
stick.mkdir()
|
|
115
|
+
|
|
116
|
+
assert usb.sync(src, (stick,)) == 0
|
|
117
|
+
assert "Durée totale" in capsys.readouterr().out
|
|
118
|
+
|
|
119
|
+
|
|
74
120
|
def test_describe_sync_reports_mount_status(tmp_path: Path) -> None:
|
|
75
121
|
mounted = tmp_path / "RH D1000"
|
|
76
122
|
mounted.mkdir()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|