Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ def resolve_dependencies(
date: datetime.datetime,
*,
sink_id: str,
force_download: bool = False,
) -> tuple[DependencyResolution, AnyPath | None]:
"""Resolve all dependencies in a spec for the given date.

Expand Down Expand Up @@ -312,4 +313,9 @@ def resolve_dependencies(
workspace=self._workspace,
transport=self._transport,
)
return pipeline.run(dep_spec, date, sink_id=sink_id)
return pipeline.run(
dep_spec,
date,
sink_id=sink_id,
force_download=force_download,
)
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
logger = logging.getLogger(__name__)


def _remote_uri(protocol: str, hostname: str, directory: str, filename: str) -> str:
"""Build a remote URI without duplicating schemes or path separators."""
base = hostname if "://" in hostname else f"{protocol}://{hostname}"
return f"{base.rstrip('/')}/{directory.strip('/')}/{filename}"


class ProductQuery:
"""Fluent builder for constructing and executing a GNSS product search.

Expand Down Expand Up @@ -352,8 +358,11 @@ def search(self) -> list[FoundResource]:
)
else:
proto = (rq.server.protocol or "ftp").lower()
uri = (
f"{proto}://{hostname}/{rq.directory.value or rq.directory.pattern}/{filename}" # type: ignore[union-attr]
uri = _remote_uri(
proto,
hostname,
rq.directory.value or rq.directory.pattern, # type: ignore[union-attr]
filename,
)
r = FoundResource(
product=rq.product.name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@

import logging
import os
import re
import threading
from contextlib import contextmanager
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
from urllib.parse import quote, urlparse

import fsspec
import fsspec.utils

logger = logging.getLogger(__name__)

_AIUB_DOWNLOAD_ROOT = "https://www.aiub.unibe.ch/download"
_AIUB_LISTING_ENDPOINT = (
"https://code.aiub.unibe.ch/s3_script/aiub_s3_bucket_listing.php?path="
)


class ConnectionPool:
"""Thread-safe pool of fsspec filesystem instances for a single host.
Expand Down Expand Up @@ -249,6 +255,17 @@ def list_directory(self, hostname: str, directory: str) -> list[str]:
full_path = pool.full_path(directory)

def _ls(conn: "fsspec.AbstractFileSystem") -> list[str]:
if hostname.rstrip("/") == _AIUB_DOWNLOAD_ROOT:
listing_url = _AIUB_LISTING_ENDPOINT + quote(directory.strip("/"), safe="/")
html = conn.cat_file(listing_url)
if isinstance(html, bytes):
html = html.decode("utf-8", errors="replace")
download_prefix = re.escape(
f'{_AIUB_DOWNLOAD_ROOT}/{directory.strip("/")}/'
)
return sorted(
set(re.findall(rf'href="{download_prefix}([^"/]+)"', html))
)
raw = conn.ls(full_path, detail=False)
return [Path(p).name for p in raw]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def run(
date: datetime.datetime,
*,
sink_id: str = "local_config",
force: bool = False,
) -> Path | None | list[Path | None]:
"""Download found resources to the workspace.

Expand All @@ -79,7 +80,7 @@ def run(

paths: list[Path | None] = []
for r in resources:
path = self._download_one(r, date, sink_id)
path = self._download_one(r, date, sink_id, force=force)
paths.append(path)

if single:
Expand All @@ -91,6 +92,8 @@ def _download_one(
resource: FoundResource,
date: datetime.datetime,
sink_id: str,
*,
force: bool = False,
) -> Path | None:
"""Download a single resource and write its sidecar lockfile.

Expand All @@ -102,7 +105,7 @@ def _download_one(
Returns:
Path to the resolved file, or ``None`` on failure.
"""
if resource.is_local:
if resource.is_local and not force:
local_path = resource.path
if local_path and local_path.exists():
logger.debug("Already local: %s", local_path)
Expand All @@ -121,9 +124,10 @@ def _download_one(
local_resource_id=sink_id,
local_factory=self._planner._workspace,
date=date,
force=force,
)
if path is not None:
if get_lock_product(path) is None:
if force or get_lock_product(path) is None:
lock = build_lock_product(sink=path, url=resource.uri, name=resource.product)
write_lock_product(lock)
logger.info("Downloaded %s → %s", resource.product, path)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ def run(
sink_id: str = "local_config",
centers: list[str] | None = None,
download: bool = True,
force_download: bool = False,
) -> tuple[DependencyResolution, AnyPath | None]:
"""Resolve all dependencies in *spec* for *date*.

Expand Down Expand Up @@ -126,7 +127,7 @@ def run(
date=date,
version=version,
)
if existing is not None:
if existing is not None and not force_download:
resolution = self._resolution_from_lockfile(existing, spec)
if resolution.all_required_fulfilled:
logger.info(
Expand All @@ -151,6 +152,7 @@ def run(
preferences=spec.preferences,
centers=centers,
download=download,
force_download=force_download,
)
with ThreadPoolExecutor(max_workers=15) as executor:
resolved = list(executor.map(resolve_one, spec.dependencies))
Expand All @@ -176,6 +178,7 @@ def _resolve_one(
preferences: list[SearchPreference],
centers: list[str] | None,
download: bool,
force_download: bool,
) -> ResolvedDependency:
"""Resolve a single dependency.

Expand All @@ -201,7 +204,10 @@ def _resolve_one(
if centers:
q = q.sources(*centers)
candidates = q.search()
found: FoundResource | None = candidates[0] if candidates else None
if force_download:
found = next((candidate for candidate in candidates if not candidate.is_local), None)
else:
found = candidates[0] if candidates else None
except Exception as exc:
logger.debug("No candidates for %s: %s", dep.spec, exc)
return ResolvedDependency(spec=dep.spec, required=dep.required, status="missing")
Expand All @@ -227,7 +233,7 @@ def _resolve_one(
remote_url=found.uri,
)

path = self._downloader.run(found, date, sink_id=sink_id)
path = self._downloader.run(found, date, sink_id=sink_id, force=force_download)
if path is None:
logger.warning("Download failed for dependency %s", dep.spec)
return ResolvedDependency(spec=dep.spec, required=dep.required, status="missing")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,8 @@ def download_one(
local_resource_id: str,
local_factory: WorkSpace,
date: datetime.datetime,
*,
force: bool = False,
) -> AnyPath | None:
"""Synchronously download matched files for one search target.

Expand Down Expand Up @@ -329,10 +331,13 @@ def download_one(
destination_dir.mkdir(parents=True, exist_ok=True)
destination_path = destination_dir / query.product.filename.value # type: ignore[union-attr]

# Prefer an already-decompressed version on disk
# Prefer an already-decompressed version on disk unless the caller
# explicitly requested a fresh copy from the remote source.
if destination_path.suffix == ".gz":
decompressed_path = destination_path.with_suffix("")
if (
not force
and
decompressed_path.exists()
and decompressed_path.stat().st_size > 0
and self._validate_cached_or_evict(decompressed_path)
Expand All @@ -348,6 +353,8 @@ def download_one(
# describes the file as served, so it only applies to the
# non-decompressed destination path.
if (
not force
and
destination_path.exists()
and destination_path.stat().st_size > 0
and self._validate_cached_or_evict(destination_path, query.checksum)
Expand Down
37 changes: 37 additions & 0 deletions packages/gnss-product-management/test/test_code_https.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Regression tests for CODE's S3-backed HTTPS product archive."""

from unittest.mock import MagicMock

from gnss_product_management.client.product_query import _remote_uri
from gnss_product_management.factories.connection_pool import ConnectionPoolFactory


def test_code_https_listing_extracts_download_filenames(monkeypatch) -> None:
html = b"""
<a href="https://www.aiub.unibe.ch/download/CODE/COD0OPSRAP_20262390000_01D_05M_ORB.SP3">
COD0OPSRAP_20262390000_01D_05M_ORB.SP3
</a>
"""
factory = ConnectionPoolFactory(max_connections=1)
hostname = "https://www.aiub.unibe.ch/download"
factory.add_connection(hostname)
pool = factory._pools[hostname]
connection = MagicMock()
connection.cat_file.return_value = html
monkeypatch.setattr(pool, "_connect", lambda: connection)

assert factory.list_directory(hostname, "CODE/") == [
"COD0OPSRAP_20262390000_01D_05M_ORB.SP3"
]


def test_remote_uri_keeps_existing_https_scheme() -> None:
assert _remote_uri(
"https",
"https://www.aiub.unibe.ch/download",
"CODE/",
"COD0OPSRAP_20262390000_01D_05M_ORB.SP3",
) == (
"https://www.aiub.unibe.ch/download/CODE/"
"COD0OPSRAP_20262390000_01D_05M_ORB.SP3"
)
19 changes: 18 additions & 1 deletion packages/gnss-product-management/test/test_download_integrity.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,20 @@ def env(tmp_path: Path):
}


def _download(env, filename: str = "TEST.SP3", checksum: str | None = None) -> Path | None:
def _download(
env,
filename: str = "TEST.SP3",
checksum: str | None = None,
*,
force: bool = False,
) -> Path | None:
query = _make_query(env["remote_root"], filename, checksum)
return env["wormhole"].download_one(
query=query,
local_resource_id="local_config",
local_factory=env["workspace"],
date=TEST_DATE,
force=force,
)


Expand Down Expand Up @@ -191,6 +198,16 @@ def test_cache_without_sidecar_is_trusted(self, env, monkeypatch) -> None:
assert _download(env) == cached
assert calls == []

def test_force_redownload_replaces_valid_cache(self, env) -> None:
cached = env["sink_dir"] / "TEST.SP3"
cached.parent.mkdir(parents=True)
cached.write_bytes(b"valid but stale product")

result = _download(env, force=True)

assert result == cached
assert result.read_bytes() == REMOTE_CONTENT

def test_corrupt_cache_is_evicted_and_redownloaded(self, env) -> None:
"""A cached file whose hash no longer matches its sidecar must be
evicted (with its stale sidecar) and fetched fresh."""
Expand Down
Loading
Loading