quick-spice-manager 0.2.2__tar.gz → 0.2.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quick-spice-manager
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Project-URL: documentation, https://luca-penasa.github.io/quick-spice-manager
5
5
  Project-URL: homepage, https://luca-penasa.github.io/quick-spice-manager
6
6
  Project-URL: repository, https://github.com/luca-penasa/quick-spice-manager
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quick-spice-manager"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = ""
5
5
  authors = [{ name = "Luca Penasa", email = "luca.penasa@gmail.com" }]
6
6
  requires-python = ">=3.10, <4.0"
@@ -31,9 +31,10 @@ def log_disable(mod: str = "quick_spice_manager") -> None:
31
31
  log.disable(mod)
32
32
 
33
33
 
34
+ from .ftp import KernelDownloadError
34
35
  from .spice_manager import QuickSpiceManager, snapshot_pool
35
36
 
36
- __all__ = ["QuickSpiceManager", "SpiceManager", "snapshot_pool"]
37
+ __all__ = ["KernelDownloadError", "QuickSpiceManager", "SpiceManager", "snapshot_pool"]
37
38
 
38
39
 
39
40
  def __getattr__(name: str) -> object:
@@ -61,6 +61,31 @@ _SLOT_POLL_INTERVAL_SECONDS = 0.1
61
61
  # How long to wait for a free connection slot before giving up.
62
62
  _CONNECTION_SLOT_TIMEOUT_SECONDS = 300
63
63
 
64
+ # Kernels whose download failed are tried again this many times in all, with a
65
+ # pause of _RETRY_DELAY_SECONDS * attempt in between (a busy server drops
66
+ # connections; it usually answers again after a short wait).
67
+ _DOWNLOAD_ATTEMPTS = 3
68
+ _RETRY_DELAY_SECONDS = 5.0
69
+
70
+
71
+ class KernelDownloadError(ConnectionError):
72
+ """Some kernels of a metakernel could not be downloaded, even after retries.
73
+
74
+ ``missing`` holds their paths relative to the kernels directory, and
75
+ ``errors`` the last error for each of them.
76
+ """
77
+
78
+ def __init__(self, metakernel: Path, missing: dict[str, BaseException]) -> None:
79
+ self.metakernel = metakernel
80
+ self.missing = sorted(missing)
81
+ self.errors = missing
82
+ listing = "\n".join(f" {rel}: {missing[rel]}" for rel in self.missing)
83
+ super().__init__(
84
+ f"{len(missing)} kernel(s) of {metakernel.name} could not be downloaded "
85
+ f"from {_FTP_HOST} after {_DOWNLOAD_ATTEMPTS} attempts:\n{listing}\n"
86
+ "Try again later: kernels already on disk are kept.",
87
+ )
88
+
64
89
  # ---------------------------------------------------------------------------
65
90
  # FTP server and mission path constants
66
91
  # ---------------------------------------------------------------------------
@@ -453,8 +478,13 @@ def _ftp_download_file(ftp: ftplib.FTP, remote: str, local: Path) -> None:
453
478
  a partially-written file: it sees either the previous complete file (if
454
479
  any) or the new complete one, never a truncated in-between state. On any
455
480
  failure the temp file is removed and the exception re-raised.
481
+
482
+ When the server reports the file size (FTP ``SIZE``), a transfer that
483
+ ends short of it raises :class:`ftplib.error_temp`, so a dropped data
484
+ connection never leaves a truncated kernel in the cache.
456
485
  """
457
486
  local.parent.mkdir(parents=True, exist_ok=True)
487
+ expected = _remote_size(ftp, remote)
458
488
  tmp_fd, tmp_name = tempfile.mkstemp(
459
489
  dir=local.parent, prefix=f"{local.name}.", suffix=".part",
460
490
  )
@@ -462,12 +492,26 @@ def _ftp_download_file(ftp: ftplib.FTP, remote: str, local: Path) -> None:
462
492
  try:
463
493
  with os.fdopen(tmp_fd, "wb") as fh:
464
494
  ftp.retrbinary(f"RETR {remote}", fh.write)
495
+ got = tmp_path.stat().st_size
496
+ if expected is not None and got != expected:
497
+ msg = f"transfer of {remote} incomplete: {got} of {expected} bytes"
498
+ raise ftplib.error_temp(msg)
465
499
  tmp_path.replace(local) # atomic rename, same filesystem
466
500
  except BaseException:
467
501
  tmp_path.unlink(missing_ok=True)
468
502
  raise
469
503
 
470
504
 
505
+ def _remote_size(ftp: ftplib.FTP, remote: str) -> int | None:
506
+ """Size of *remote* in bytes, or ``None`` when the server does not tell."""
507
+ try:
508
+ ftp.voidcmd("TYPE I") # SIZE is defined for binary transfers
509
+ size = ftp.size(remote)
510
+ except ftplib.error_perm:
511
+ return None
512
+ return size if isinstance(size, int) else None
513
+
514
+
471
515
  def _locked_download(
472
516
  ftp: ftplib.FTP, remote: str, local: Path, *, force: bool = False,
473
517
  ) -> bool:
@@ -608,6 +652,10 @@ def download_kernels_via_ftp(
608
652
  ConnectionError
609
653
  If the ESA FTP server cannot be reached and no usable local cache
610
654
  exists for this ``(spacecraft, mk, version)``.
655
+ KernelDownloadError
656
+ If some referenced kernels could not be downloaded even after
657
+ retries (its ``missing`` lists them). Nothing is recorded in the
658
+ resolution cache, so the next call tries them again.
611
659
  FileNotFoundError
612
660
  If ``download_kernels=False`` and no complete local cache exists.
613
661
  """
@@ -651,6 +699,8 @@ def download_kernels_via_ftp(
651
699
  )
652
700
  except FileNotFoundError:
653
701
  raise # a genuine "no such kernel/version" answer, not connectivity
702
+ except KernelDownloadError:
703
+ raise # the server answered; some kernels did not come through
654
704
  except ftplib.all_errors as exc:
655
705
  log.warning(f"FTP: could not reach {_FTP_HOST} ({exc})")
656
706
  cached_tm = _usable_cached_tm()
@@ -745,40 +795,67 @@ def _download_kernels_via_ftp_online(
745
795
  f"{n_skip} already cached — using {n_workers} parallel connections",
746
796
  )
747
797
 
748
- # --- Parallel download of missing kernels --------------------------------
749
- n_dl = n_fail = 0
750
- if to_download:
751
- futures = {}
752
- with ThreadPoolExecutor(max_workers=n_workers) as pool:
753
- for remote_kernel, local_kernel in to_download:
754
- log.debug(f"FTP: queuing {local_kernel.name}")
755
- fut = pool.submit(
756
- _download_one_kernel, _FTP_HOST, remote_kernel, local_kernel,
757
- )
758
- futures[fut] = local_kernel
759
-
760
- with tqdm(
761
- total=len(to_download),
762
- desc="Downloading kernels",
763
- unit="file",
764
- dynamic_ncols=True,
765
- ) as pbar:
766
- for fut in as_completed(futures):
767
- local_kernel = futures[fut]
768
- exc = fut.exception()
769
- if exc is None:
770
- log.info(f"FTP: downloaded {local_kernel.name}")
771
- n_dl += 1
772
- pbar.set_postfix_str(local_kernel.name, refresh=False)
773
- else:
774
- log.warning(
775
- f"FTP: could not download '{local_kernel.name}': {exc}",
776
- )
777
- n_fail += 1
778
- pbar.update(1)
798
+ # --- Parallel download of missing kernels, retrying the failed ones -----
799
+ n_dl = 0
800
+ failed: dict[str, BaseException] = {}
801
+ pending = to_download
802
+ for attempt in range(1, _DOWNLOAD_ATTEMPTS + 1):
803
+ if not pending:
804
+ break
805
+ if attempt > 1:
806
+ delay = _RETRY_DELAY_SECONDS * (attempt - 1)
807
+ log.warning(
808
+ f"FTP: {len(pending)} kernel(s) failed, retrying in {delay:.0f} s "
809
+ f"(attempt {attempt}/{_DOWNLOAD_ATTEMPTS})",
810
+ )
811
+ time.sleep(delay)
812
+ failed = _download_batch(pending, kernels_dir, n_workers)
813
+ n_dl += len(pending) - len(failed)
814
+ pending = [(r, loc) for r, loc in pending if _rel(loc, kernels_dir) in failed]
779
815
 
780
816
  log.info(
781
- f"FTP: done - {n_dl} downloaded, {n_skip} cached, {n_fail} failed",
817
+ f"FTP: done - {n_dl} downloaded, {n_skip} cached, {len(failed)} failed",
782
818
  )
819
+ if failed:
820
+ raise KernelDownloadError(local_tm, failed)
783
821
 
784
822
  return local_tm
823
+
824
+
825
+ def _rel(local: Path, kernels_dir: Path) -> str:
826
+ return local.relative_to(kernels_dir).as_posix()
827
+
828
+
829
+ def _download_batch(
830
+ to_download: list[tuple[str, Path]], kernels_dir: Path, n_workers: int,
831
+ ) -> dict[str, BaseException]:
832
+ """Download *to_download* in parallel; return the failures by relative path."""
833
+ failed: dict[str, BaseException] = {}
834
+ futures = {}
835
+ with ThreadPoolExecutor(max_workers=n_workers) as pool:
836
+ for remote_kernel, local_kernel in to_download:
837
+ log.debug(f"FTP: queuing {local_kernel.name}")
838
+ fut = pool.submit(
839
+ _download_one_kernel, _FTP_HOST, remote_kernel, local_kernel,
840
+ )
841
+ futures[fut] = local_kernel
842
+
843
+ with tqdm(
844
+ total=len(to_download),
845
+ desc="Downloading kernels",
846
+ unit="file",
847
+ dynamic_ncols=True,
848
+ ) as pbar:
849
+ for fut in as_completed(futures):
850
+ local_kernel = futures[fut]
851
+ exc = fut.exception()
852
+ if exc is None:
853
+ log.info(f"FTP: downloaded {local_kernel.name}")
854
+ pbar.set_postfix_str(local_kernel.name, refresh=False)
855
+ else:
856
+ log.warning(
857
+ f"FTP: could not download '{local_kernel.name}': {exc}",
858
+ )
859
+ failed[_rel(local_kernel, kernels_dir)] = exc
860
+ pbar.update(1)
861
+ return failed