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.
- {quick_spice_manager-0.2.2 → quick_spice_manager-0.2.3}/PKG-INFO +1 -1
- {quick_spice_manager-0.2.2 → quick_spice_manager-0.2.3}/pyproject.toml +1 -1
- {quick_spice_manager-0.2.2 → quick_spice_manager-0.2.3}/src/quick_spice_manager/__init__.py +2 -1
- {quick_spice_manager-0.2.2 → quick_spice_manager-0.2.3}/src/quick_spice_manager/ftp.py +109 -32
- {quick_spice_manager-0.2.2 → quick_spice_manager-0.2.3}/.gitignore +0 -0
- {quick_spice_manager-0.2.2 → quick_spice_manager-0.2.3}/LICENSE +0 -0
- {quick_spice_manager-0.2.2 → quick_spice_manager-0.2.3}/README.md +0 -0
- {quick_spice_manager-0.2.2 → quick_spice_manager-0.2.3}/src/quick_spice_manager/cli.py +0 -0
- {quick_spice_manager-0.2.2 → quick_spice_manager-0.2.3}/src/quick_spice_manager/dirs.py +0 -0
- {quick_spice_manager-0.2.2 → quick_spice_manager-0.2.3}/src/quick_spice_manager/locking.py +0 -0
- {quick_spice_manager-0.2.2 → quick_spice_manager-0.2.3}/src/quick_spice_manager/py.typed +0 -0
- {quick_spice_manager-0.2.2 → quick_spice_manager-0.2.3}/src/quick_spice_manager/spice_manager.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: quick-spice-manager
|
|
3
|
-
Version: 0.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
|
|
@@ -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 =
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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, {
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{quick_spice_manager-0.2.2 → quick_spice_manager-0.2.3}/src/quick_spice_manager/spice_manager.py
RENAMED
|
File without changes
|