Skip to content
Merged
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
62 changes: 58 additions & 4 deletions src/polystore/imagej_distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@
import urllib.request
import zipfile
from abc import ABC, abstractmethod
from collections.abc import Iterator
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from enum import Enum
from pathlib import Path, PurePosixPath
from typing import Any
from typing import Any, ClassVar

from platformdirs import user_cache_path

Expand Down Expand Up @@ -141,10 +141,37 @@ def extract_into(self, archive_path: Path, target_directory: Path) -> None:

@dataclass(frozen=True, slots=True)
class ImageJArchiveDownloadPolicy:
"""Retry and timeout policy shared by declared ImageJ archives."""
"""Admission, retry and timeout policy shared by declared ImageJ archives."""

allow_download_environment_key: ClassVar[str] = "POLYSTORE_IMAGEJ_ALLOW_DOWNLOAD"

timeout_seconds: float = 60.0
retry_delays_seconds: tuple[float, ...] = (1.0, 2.0, 4.0)
allow_download: bool = True

@classmethod
def from_environment(
cls, environment: Mapping[str, str] | None = None
) -> ImageJArchiveDownloadPolicy:
"""Decode download permission once at process configuration."""
values = os.environ if environment is None else environment
declared = values.get(cls.allow_download_environment_key)
if declared is None:
return cls()
normalized = declared.strip().casefold()
if normalized not in {"true", "false"}:
raise ImageJDistributionUnavailableError(
f"{cls.allow_download_environment_key} must be true or false."
)
return cls(allow_download=normalized == "true")

def require_download_allowed(self, label: str) -> None:
"""Reject missing artifacts before staging, cache mutation or network IO."""
if not self.allow_download:
raise ImageJDistributionUnavailableError(
f"Downloading {label} is forbidden by the ImageJ download policy; "
"prepare the declared bundle cache before starting this process."
)

def download(
self,
Expand All @@ -154,6 +181,7 @@ def download(
) -> Path:
"""Download a verified archive under this retry policy."""

self.require_download_allowed(archive.label)
retry_delays = iter(self.retry_delays_seconds)
while True:
try:
Expand Down Expand Up @@ -354,6 +382,8 @@ def apply(
class FijiArchiveDistribution(ImageJDistributionABC):
"""Checksummed Fiji application bundle from one immutable official archive."""

cache_root_environment_key: ClassVar[str] = "POLYSTORE_IMAGEJ_CACHE_ROOT"

release_id: str
imagej_version: str
archive_base_url: str
Expand All @@ -362,6 +392,27 @@ class FijiArchiveDistribution(ImageJDistributionABC):
lock_timeout_seconds: float = 1800.0
download_policy: ImageJArchiveDownloadPolicy = ImageJArchiveDownloadPolicy()

@classmethod
def cache_root_from_environment(
cls, environment: Mapping[str, str] | None = None
) -> Path | None:
"""Decode the bundle-only root at process configuration, not each use.

This does not redirect XDG caches, logs, preferences or other runtime
artifacts. Unset retains platformdirs policy; invalid explicit settings
fail instead of silently creating another per-run bundle.
"""
values = os.environ if environment is None else environment
declared = values.get(cls.cache_root_environment_key)
if declared is None:
return None
root = Path(declared).expanduser()
if not declared.strip() or not root.is_absolute():
raise ImageJDistributionUnavailableError(
f"{cls.cache_root_environment_key} must be an absolute bundle-cache path."
)
return root.resolve(strict=False)

@property
def label(self) -> str:
"""Return the declared Fiji version and immutable archive revision."""
Expand All @@ -377,7 +428,6 @@ def materialize(self) -> ImageJRuntimeLaunch:
if self.cache_root is not None
else user_cache_path("polystore") / "imagej"
)
cache_parent.mkdir(parents=True, exist_ok=True)
runtime_digest = hashlib.sha256(
"".join((asset.sha256, *(overlay.sha256 for overlay in self.runtime_overlays))).encode(
"ascii"
Expand All @@ -390,6 +440,8 @@ def materialize(self) -> ImageJRuntimeLaunch:
if materialization is not None:
return materialization

self.download_policy.require_download_allowed(self.label)
cache_parent.mkdir(parents=True, exist_ok=True)
lock_path = cache_parent / f".{runtime_directory.name}.lock"
with file_lock(lock_path, timeout=self.lock_timeout_seconds):
materialization = self._discover_runtime(runtime_directory)
Expand Down Expand Up @@ -494,6 +546,8 @@ def require_compatible_gateway(self, gateway: Any) -> None:
release_id="20260718-0417",
imagej_version="2.18.0",
archive_base_url=("https://downloads.imagej.net/fiji/archive/latest/20260718-0417"),
cache_root=FijiArchiveDistribution.cache_root_from_environment(),
download_policy=ImageJArchiveDownloadPolicy.from_environment(),
runtime_overlays=(
ImageJRuntimeOverlay(
label="PyImageJ 1.7.0 Fiji bridge overlay",
Expand Down
72 changes: 72 additions & 0 deletions tests/imagej_cache_process_fixture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Tiny verified artifact response for fresh-process bundle-cache tests; no JVM."""

from __future__ import annotations

import argparse
import json
import os
from dataclasses import dataclass, field, replace
from pathlib import Path
from unittest.mock import patch

import polystore.imagej_distribution as distribution_module
from polystore.imagej_distribution import (
FIJI_IMAGEJ_DISTRIBUTION,
FijiBundleAsset,
ImageJArchiveDownloadPolicy,
ImageJRuntimeArchive,
)
from polystore.imagej_runtime import FIJI_IMAGEJ_RUNTIME


@dataclass(frozen=True)
class FixtureAsset:
source: Path
sha256: str
name: str = "LOCAL_FIXTURE"

def archive(self, *, archive_base_url, label):
return ImageJRuntimeArchive(label, self.source.as_uri(), self.sha256)


@dataclass(frozen=True)
class CountedDownloadPolicy(ImageJArchiveDownloadPolicy):
downloaded: list[str] = field(default_factory=list)

def download(self, archive, *, target_directory):
self.downloaded.append(archive.sha256)
return super().download(archive, target_directory=target_directory)


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("archive", type=Path)
parser.add_argument("sha256")
args = parser.parse_args()
assert FIJI_IMAGEJ_RUNTIME.distribution is FIJI_IMAGEJ_DISTRIBUTION
policy = CountedDownloadPolicy(retry_delays_seconds=())
# Only the external artifact/host response is controlled. Canonical root,
# checksum verification, cache locking/staging/discovery remain production.
distribution = replace(
FIJI_IMAGEJ_RUNTIME.distribution, runtime_overlays=(), download_policy=policy
)
with patch.object(
FijiBundleAsset, "for_current_host", return_value=FixtureAsset(args.archive, args.sha256)
):
launch = distribution.materialize()
print(
json.dumps(
{
"cache_root": str(FIJI_IMAGEJ_DISTRIBUTION.cache_root),
"imagej_directory": str(launch.imagej_directory),
"java_home": str(launch.java_home),
"downloads": len(policy.downloaded),
"xdg_cache_home": os.environ["XDG_CACHE_HOME"],
"source": str(Path(distribution_module.__file__).resolve()),
}
)
)


if __name__ == "__main__":
main()
128 changes: 128 additions & 0 deletions tests/test_imagej_cache_environment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Bundle-only configuration survives independent process cache policies."""

from __future__ import annotations

import hashlib
import json
import os
import subprocess
import sys
import zipfile
from pathlib import Path

import pytest

from polystore.imagej_distribution import (
FijiArchiveDistribution,
ImageJArchiveDownloadPolicy,
ImageJDistributionUnavailableError,
ImageJRuntimeArchive,
)


@pytest.mark.parametrize("value, allowed", ((None, True), ("true", True), (" FALSE ", False)))
def test_download_policy_decodes_permission_without_copying_retry_defaults(value, allowed):
environment = {} if value is None else {
ImageJArchiveDownloadPolicy.allow_download_environment_key: value
}
policy = ImageJArchiveDownloadPolicy.from_environment(environment)
assert policy.allow_download is allowed
default = ImageJArchiveDownloadPolicy()
assert policy.timeout_seconds == default.timeout_seconds
assert policy.retry_delays_seconds == default.retry_delays_seconds


@pytest.mark.parametrize("value", ("", "yes", "0", "relative/cache"))
def test_invalid_download_permission_fails(value):
with pytest.raises(ImageJDistributionUnavailableError, match="true or false"):
ImageJArchiveDownloadPolicy.from_environment({
ImageJArchiveDownloadPolicy.allow_download_environment_key: value
})


def test_forbidden_download_has_no_archive_or_cache_side_effects(monkeypatch, tmp_path):
def forbidden_io(*args, **kwargs):
pytest.fail("Forbidden policy reached archive IO")

monkeypatch.setattr(ImageJRuntimeArchive, "download_verified_once", forbidden_io)
root = tmp_path / "absent-cache"
policy = ImageJArchiveDownloadPolicy(allow_download=False)
with pytest.raises(ImageJDistributionUnavailableError, match="forbidden"):
policy.download(
ImageJRuntimeArchive("fixture", "https://invalid.example/fixture.zip", "0" * 64),
target_directory=root,
)
distribution = FijiArchiveDistribution(
release_id="fixture", imagej_version="fixture", archive_base_url="https://invalid.example",
cache_root=root, download_policy=policy,
)
with pytest.raises(ImageJDistributionUnavailableError, match="forbidden"):
distribution.materialize()
assert not root.exists()


def test_unset_bundle_root_retains_distribution_default():
assert FijiArchiveDistribution.cache_root_from_environment({}) is None


@pytest.mark.parametrize("value", ("", " ", "relative/cache"))
def test_invalid_explicit_bundle_root_fails_without_a_fallback(value):
with pytest.raises(ImageJDistributionUnavailableError, match="absolute bundle-cache"):
FijiArchiveDistribution.cache_root_from_environment(
{FijiArchiveDistribution.cache_root_environment_key: value}
)


def test_bundle_root_decode_is_independent_of_xdg_cache(tmp_path):
root = tmp_path / "bundles"
for run in ("first", "second"):
assert (
FijiArchiveDistribution.cache_root_from_environment(
{
FijiArchiveDistribution.cache_root_environment_key: str(root),
"XDG_CACHE_HOME": str(tmp_path / run),
}
)
== root
)


def test_fresh_processes_share_one_verified_bundle_not_their_xdg_caches(tmp_path):
archive = tmp_path / "tiny-fiji.zip"
with zipfile.ZipFile(archive, "w") as fixture:
fixture.writestr("Fiji/jars/core.jar", b"verified fixture")
fixture.writestr("Fiji/plugins/plugin.jar", b"fixture")
fixture.writestr("Fiji/java/linux64/jdk/bin/java", b"no JVM execution")
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
root = tmp_path / "shared-bundles"
helper = Path(__file__).with_name("imagej_cache_process_fixture.py")
results = []
for run in ("first", "second"):
isolated = tmp_path / run
isolated.mkdir()
(isolated / "run.log").write_text(run)
environment = dict(os.environ)
environment[FijiArchiveDistribution.cache_root_environment_key] = str(root)
environment["XDG_CACHE_HOME"] = str(isolated)
child = subprocess.run(
[sys.executable, str(helper), str(archive), digest],
env=environment,
capture_output=True,
text=True,
check=True,
timeout=10,
)
result = json.loads(child.stdout)
assert result["cache_root"] == str(root)
assert result["xdg_cache_home"] == str(isolated)
assert result["source"] == str(
Path(__file__).resolve().parents[1] / "src/polystore/imagej_distribution.py"
)
assert (isolated / "run.log").read_text() == run
assert not (isolated / "polystore/imagej").exists()
results.append(result)
assert [result["downloads"] for result in results] == [1, 0]
assert results[0]["imagej_directory"] == results[1]["imagej_directory"]
assert results[0]["java_home"] == results[1]["java_home"]
assert len(tuple(root.glob("fiji-*"))) == 1
assert not tuple(root.glob(".imagej-download.*"))
Loading