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.
Files changed (25) hide show
  1. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/PKG-INFO +1 -1
  2. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/navdata_sync/__init__.py +1 -1
  3. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/navdata_sync/usb.py +119 -25
  4. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tests/test_usb.py +46 -0
  5. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/.gitignore +0 -0
  6. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/LICENSE +0 -0
  7. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/README.md +0 -0
  8. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/config.example.toml +0 -0
  9. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/navdata-update.py +0 -0
  10. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/navdata_sync/__main__.py +0 -0
  11. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/navdata_sync/catalog.py +0 -0
  12. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/navdata_sync/cli.py +0 -0
  13. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/navdata_sync/config.py +0 -0
  14. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/navdata_sync/download.py +0 -0
  15. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/navdata_sync/prepare.py +0 -0
  16. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/pyproject.toml +0 -0
  17. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tests/__init__.py +0 -0
  18. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tests/conftest.py +0 -0
  19. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tests/helpers.py +0 -0
  20. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tests/test_catalog.py +0 -0
  21. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tests/test_cli.py +0 -0
  22. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tests/test_config.py +0 -0
  23. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tests/test_download.py +0 -0
  24. {airmate_navdata_sync-0.3.0 → airmate_navdata_sync-0.3.2}/tests/test_prepare.py +0 -0
  25. {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.0
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
@@ -1,3 +1,3 @@
1
1
  """Airmate navdata updater for a Dynon SkyView."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.2"
@@ -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
- return sorted(
69
- path.relative_to(root)
70
- for path in root.rglob("*")
71
- if path.is_file() and not path.is_symlink() and not path.name.startswith(".")
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(target: Path, wanted: set[Path]) -> list[Path]:
81
- """Delete dest files (and then empty dirs) that are not in the staging tree."""
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
- extras = [rel for rel in relative_files(target) if _norm_rel(rel) not in wanted_norm]
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
- _remove_empty_dirs(target)
87
- return extras
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
- directory.rmdir()
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
- n_files = len(manifest)
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=f"{i}/{n_files} {info.rel}",
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.update(task_id, phase="Purge", current="")
301
- extras = await asyncio.to_thread(prune_extras, target, wanted)
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()