openads 2.0.0__py3-none-any.whl

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.

Potentially problematic release.


This version of openads might be problematic. Click here for more details.

Files changed (145) hide show
  1. openads/__init__.py +77 -0
  2. openads/__main__.py +8 -0
  3. openads/_lazy.py +70 -0
  4. openads/_version.py +5 -0
  5. openads/assets/__init__.py +162 -0
  6. openads/assets/fetch.py +157 -0
  7. openads/assets/manifest.json +545 -0
  8. openads/assets/manifest.py +105 -0
  9. openads/cli/__init__.py +5 -0
  10. openads/cli/main.py +408 -0
  11. openads/config/__init__.py +32 -0
  12. openads/config/convert.py +155 -0
  13. openads/config/defaults/dwi.yaml +123 -0
  14. openads/config/defaults/keep_files.yaml +132 -0
  15. openads/config/defaults/pwi.yaml +204 -0
  16. openads/config/loader.py +254 -0
  17. openads/config/schema.py +177 -0
  18. openads/core/__init__.py +23 -0
  19. openads/core/artifacts.py +192 -0
  20. openads/core/layout.py +1180 -0
  21. openads/core/result.py +55 -0
  22. openads/core/types.py +42 -0
  23. openads/core/workspace.py +327 -0
  24. openads/errors.py +106 -0
  25. openads/inputs/__init__.py +45 -0
  26. openads/inputs/builtin_derivations.py +208 -0
  27. openads/inputs/derivations.py +297 -0
  28. openads/inputs/discovery.py +284 -0
  29. openads/inputs/sidecars.py +102 -0
  30. openads/inputs/spec.py +165 -0
  31. openads/io/__init__.py +26 -0
  32. openads/io/ants.py +244 -0
  33. openads/io/cleanup.py +209 -0
  34. openads/io/figures.py +48 -0
  35. openads/io/json.py +39 -0
  36. openads/io/manifest.py +150 -0
  37. openads/io/nifti.py +224 -0
  38. openads/io/surfa.py +53 -0
  39. openads/io/tables.py +58 -0
  40. openads/io/text.py +25 -0
  41. openads/io/transforms.py +106 -0
  42. openads/logging.py +37 -0
  43. openads/models/__init__.py +19 -0
  44. openads/models/checkpoints.py +112 -0
  45. openads/models/dagmnet_dwi.py +679 -0
  46. openads/models/dagmnet_pwi.py +199 -0
  47. openads/models/device.py +95 -0
  48. openads/models/synthstrip.py +300 -0
  49. openads/ops/__init__.py +83 -0
  50. openads/ops/adc.py +43 -0
  51. openads/ops/atlas.py +858 -0
  52. openads/ops/bimodal.py +84 -0
  53. openads/ops/hp_symclassic.py +227 -0
  54. openads/ops/inference_dwi.py +152 -0
  55. openads/ops/inference_pwi.py +184 -0
  56. openads/ops/interpret.py +392 -0
  57. openads/ops/masks.py +97 -0
  58. openads/ops/metrics.py +122 -0
  59. openads/ops/morphology.py +190 -0
  60. openads/ops/normalize.py +289 -0
  61. openads/ops/padding.py +62 -0
  62. openads/ops/postprocess.py +75 -0
  63. openads/ops/postprocess_hp.py +104 -0
  64. openads/ops/prob_is.py +91 -0
  65. openads/ops/pseudo_dwi.py +95 -0
  66. openads/ops/radiology.py +964 -0
  67. openads/ops/registration.py +63 -0
  68. openads/ops/ttp/__init__.py +17 -0
  69. openads/ops/ttp/fallback.py +54 -0
  70. openads/ops/ttp/fill.py +177 -0
  71. openads/ops/ttp/gd.py +145 -0
  72. openads/ops/ttp/motion.py +16 -0
  73. openads/ops/ttp/peaks.py +96 -0
  74. openads/ops/ttp/slicetime.py +152 -0
  75. openads/ops/viz.py +273 -0
  76. openads/ops/volume_order.py +137 -0
  77. openads/ops/volumes.py +69 -0
  78. openads/pipelines/__init__.py +59 -0
  79. openads/pipelines/base.py +600 -0
  80. openads/pipelines/combined.py +135 -0
  81. openads/pipelines/dwi.py +50 -0
  82. openads/pipelines/pwi.py +53 -0
  83. openads/py.typed +0 -0
  84. openads/registry/__init__.py +331 -0
  85. openads/registry/base.py +97 -0
  86. openads/stages/__init__.py +261 -0
  87. openads/stages/base.py +378 -0
  88. openads/stages/cleanup/__init__.py +55 -0
  89. openads/stages/cleanup/methods/__init__.py +6 -0
  90. openads/stages/cleanup/methods/keep_manifest.py +43 -0
  91. openads/stages/cleanup/params.py +29 -0
  92. openads/stages/gen_mask/__init__.py +53 -0
  93. openads/stages/gen_mask/common.py +33 -0
  94. openads/stages/gen_mask/methods/__init__.py +12 -0
  95. openads/stages/gen_mask/methods/synthstrip.py +88 -0
  96. openads/stages/gen_mask/params.py +37 -0
  97. openads/stages/gen_ttp/__init__.py +55 -0
  98. openads/stages/gen_ttp/common.py +169 -0
  99. openads/stages/gen_ttp/methods/__init__.py +13 -0
  100. openads/stages/gen_ttp/methods/argmin.py +47 -0
  101. openads/stages/gen_ttp/methods/peak_legacy.py +393 -0
  102. openads/stages/gen_ttp/moco.py +262 -0
  103. openads/stages/gen_ttp/params.py +248 -0
  104. openads/stages/inference/__init__.py +91 -0
  105. openads/stages/inference/methods/__init__.py +13 -0
  106. openads/stages/inference/methods/dagmnet_dwi.py +124 -0
  107. openads/stages/inference/methods/dagmnet_pwi.py +280 -0
  108. openads/stages/inference/params.py +144 -0
  109. openads/stages/prepdata/__init__.py +60 -0
  110. openads/stages/prepdata/methods/__init__.py +13 -0
  111. openads/stages/prepdata/methods/standard.py +267 -0
  112. openads/stages/prepdata/params.py +34 -0
  113. openads/stages/registration/__init__.py +151 -0
  114. openads/stages/registration/methods/__init__.py +12 -0
  115. openads/stages/registration/methods/ants_affine_syn.py +337 -0
  116. openads/stages/registration/params.py +78 -0
  117. openads/stages/report/__init__.py +107 -0
  118. openads/stages/report/methods/__init__.py +13 -0
  119. openads/stages/report/methods/dwi_standard.py +96 -0
  120. openads/stages/report/methods/pwi_standard.py +64 -0
  121. openads/stages/report/params.py +103 -0
  122. openads/stages/report/steps/__init__.py +13 -0
  123. openads/stages/report/steps/figures.py +230 -0
  124. openads/stages/report/steps/interpretation.py +83 -0
  125. openads/stages/report/steps/qfv.py +113 -0
  126. openads/stages/report/steps/radiology.py +281 -0
  127. openads/stages/skull_strip/__init__.py +58 -0
  128. openads/stages/skull_strip/methods/__init__.py +12 -0
  129. openads/stages/skull_strip/methods/apply_mask.py +86 -0
  130. openads/stages/skull_strip/params.py +36 -0
  131. openads/stages/ttpadc_coreg/__init__.py +119 -0
  132. openads/stages/ttpadc_coreg/methods/__init__.py +12 -0
  133. openads/stages/ttpadc_coreg/methods/ants_affine_masks.py +157 -0
  134. openads/stages/ttpadc_coreg/params.py +36 -0
  135. openads/steps/__init__.py +38 -0
  136. openads/steps/normalize.py +46 -0
  137. openads/steps/segmentation_dwi.py +274 -0
  138. openads/steps/segmentation_pwi.py +285 -0
  139. openads/steps/warp.py +54 -0
  140. openads-2.0.0.dist-info/METADATA +342 -0
  141. openads-2.0.0.dist-info/RECORD +145 -0
  142. openads-2.0.0.dist-info/WHEEL +5 -0
  143. openads-2.0.0.dist-info/entry_points.txt +3 -0
  144. openads-2.0.0.dist-info/licenses/LICENSE +24 -0
  145. openads-2.0.0.dist-info/top_level.txt +1 -0
openads/__init__.py ADDED
@@ -0,0 +1,77 @@
1
+ """OpenADS: acute stroke DWI/PWI pipelines.
2
+
3
+ ``import openads`` is light: it imports no torch, ants, shap, matplotlib, surfa or
4
+ sklearn. Public names load on first access (PEP 562).
5
+
6
+ Example:
7
+ >>> import openads
8
+ >>> openads.__version__ # doctest: +SKIP
9
+ '2.0.0'
10
+ >>> res = openads.run_dwi("sub-01", all_stages=True, output_root="out") # doctest: +SKIP
11
+ >>> res = openads.run_stage( # doctest: +SKIP
12
+ ... "gen_mask", subject_id="sub-01", modality="DWI", output_root="out"
13
+ ... )
14
+
15
+ Subpackages, from the top of the layering down: ``cli`` -> ``pipelines`` ->
16
+ ``stages`` -> ``steps`` -> ``ops``/``io``/``models``/``core``; ``config``,
17
+ ``inputs``, ``registry`` and ``assets`` support them.
18
+ """
19
+
20
+ from openads._lazy import attach as _attach
21
+ from openads._version import __version__
22
+
23
+ __getattr__, __dir__, _names = _attach(
24
+ __name__,
25
+ submodules=[
26
+ "assets",
27
+ "cli",
28
+ "config",
29
+ "core",
30
+ "errors",
31
+ "inputs",
32
+ "io",
33
+ "logging",
34
+ "models",
35
+ "ops",
36
+ "pipelines",
37
+ "registry",
38
+ "stages",
39
+ "steps",
40
+ ],
41
+ attributes={
42
+ # running
43
+ "run_dwi": "openads.pipelines",
44
+ "run_pwi": "openads.pipelines",
45
+ "run_combined": "openads.pipelines",
46
+ "run_stage": "openads.pipelines",
47
+ "get_pipeline": "openads.pipelines",
48
+ "Pipeline": "openads.pipelines",
49
+ "RunPlan": "openads.pipelines",
50
+ "PipelineResult": "openads.core.result",
51
+ "StageResult": "openads.core.result",
52
+ # configuration and inputs
53
+ "load_config": "openads.config.loader",
54
+ "PipelineConfig": "openads.config.schema",
55
+ "SubjectSpec": "openads.inputs.spec",
56
+ "discover_inputs": "openads.inputs.discovery",
57
+ "example_subjects": "openads.assets",
58
+ "download_assets": "openads.assets",
59
+ # workspace and layout
60
+ "SubjectWorkspace": "openads.core.workspace",
61
+ "OutputLayout": "openads.core.layout",
62
+ "ARTIFACTS": "openads.core.layout",
63
+ "Modality": "openads.core.types",
64
+ # extension
65
+ "register": "openads.registry",
66
+ "Registry": "openads.registry",
67
+ "register_stage": "openads.stages",
68
+ "get_stage": "openads.stages",
69
+ "list_stages": "openads.stages",
70
+ "Stage": "openads.stages.base",
71
+ "StageContext": "openads.stages.base",
72
+ "StageSpec": "openads.stages.base",
73
+ "parse_ref": "openads.stages",
74
+ },
75
+ )
76
+
77
+ __all__ = ["__version__", *_names]
openads/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Run the OpenADS CLI with ``python -m openads``."""
2
+
3
+ import sys
4
+
5
+ from openads.cli.main import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
openads/_lazy.py ADDED
@@ -0,0 +1,70 @@
1
+ """Helpers for lazy imports.
2
+
3
+ Heavy third-party libraries (torch, ants, surfa, shap, matplotlib, sklearn)
4
+ must never be imported when ``import openads`` runs. Modules use :func:`require`
5
+ inside functions, and packages use :func:`attach` to expose submodules and
6
+ attributes on first access (PEP 562).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib
12
+ from collections.abc import Callable, Iterable, Mapping
13
+ from types import ModuleType
14
+ from typing import Any
15
+
16
+ __all__ = ["attach", "require"]
17
+
18
+
19
+ def require(module: str, *, purpose: str = "") -> ModuleType:
20
+ """Import ``module`` now and return it, with a clear error if it is missing.
21
+
22
+ Args:
23
+ module: Absolute module name, for example ``"torch"``.
24
+ purpose: Short text for the error message, for example ``"SynthStrip"``.
25
+
26
+ Returns:
27
+ The imported module.
28
+
29
+ Raises:
30
+ ImportError: If the module cannot be imported.
31
+ """
32
+ try:
33
+ return importlib.import_module(module)
34
+ except ImportError as exc: # pragma: no cover - depends on the environment
35
+ why = f" (needed for {purpose})" if purpose else ""
36
+ raise ImportError(f"OpenADS needs the package providing '{module}'{why}: {exc}") from exc
37
+
38
+
39
+ def attach(
40
+ package: str,
41
+ submodules: Iterable[str] = (),
42
+ attributes: Mapping[str, str] | None = None,
43
+ ) -> tuple[Callable[[str], Any], Callable[[], list[str]], list[str]]:
44
+ """Build ``__getattr__``, ``__dir__`` and ``__all__`` for a lazy package.
45
+
46
+ Args:
47
+ package: The package name (``__name__`` of the caller).
48
+ submodules: Submodule names that load on first attribute access.
49
+ attributes: Map of attribute name to the module that defines it
50
+ (relative names start with a dot, for example ``".workspace"``).
51
+
52
+ Returns:
53
+ A tuple ``(__getattr__, __dir__, __all__)``.
54
+ """
55
+ subs = set(submodules)
56
+ attrs = dict(attributes or {})
57
+ names = sorted(subs | set(attrs))
58
+
59
+ def __getattr__(name: str) -> Any: # noqa: N807
60
+ if name in subs:
61
+ return importlib.import_module(f"{package}.{name}")
62
+ if name in attrs:
63
+ mod = importlib.import_module(attrs[name], package)
64
+ return getattr(mod, name)
65
+ raise AttributeError(f"module {package!r} has no attribute {name!r}")
66
+
67
+ def __dir__() -> list[str]: # noqa: N807
68
+ return list(names)
69
+
70
+ return __getattr__, __dir__, list(names)
openads/_version.py ADDED
@@ -0,0 +1,5 @@
1
+ """Single source of the package version."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "2.0.0"
@@ -0,0 +1,162 @@
1
+ """Asset resolution (model weights, atlases, templates, example data).
2
+
3
+ Assets are not part of the wheel (about 320 MB). The assets root is found in
4
+ this order (AMENDMENTS A5); the first candidate that exists wins, and there is
5
+ no per-file fall-through to later candidates:
6
+
7
+ 1. the explicit ``assets_dir`` argument (``paths.assets_dir`` in the config),
8
+ 2. the ``ADS_ASSETS_DIR`` environment variable,
9
+ 3. ``<repo>/assets`` when the package runs from a source checkout,
10
+ 4. the user cache ``$XDG_CACHE_HOME/openads/assets`` (default
11
+ ``~/.cache/openads/assets``).
12
+
13
+ Nothing is downloaded implicitly. ``openads assets download`` (:func:`download`)
14
+ fetches the manifest's files from a public mirror into the user cache (or
15
+ ``ADS_ASSETS_DIR``), verifying each SHA-256; running it accepts the licence it
16
+ prints. Lookups only check that a file exists. SHA-256 verification of a
17
+ folder runs on request (``openads assets verify``).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ from pathlib import Path
24
+
25
+ from openads.assets.manifest import AssetEntry, AssetStatus, load_manifest, verify
26
+ from openads.errors import AssetNotFoundError
27
+
28
+ __all__ = [
29
+ "ENV_VAR",
30
+ "AssetEntry",
31
+ "AssetStatus",
32
+ "DownloadReport",
33
+ "assets_root",
34
+ "candidate_roots",
35
+ "download",
36
+ "download_assets",
37
+ "example_subjects",
38
+ "load_manifest",
39
+ "resolve",
40
+ "verify",
41
+ ]
42
+
43
+ #: Environment variable that points to the assets root.
44
+ ENV_VAR = "ADS_ASSETS_DIR"
45
+
46
+
47
+ def _repo_assets() -> Path | None:
48
+ """``<repo>/assets`` if this package runs from a source checkout."""
49
+ pkg = Path(__file__).resolve().parents[1] # .../src/ads
50
+ repo = pkg.parents[1] # .../<repo>
51
+ if (pkg.parent.name == "src") and (repo / "pyproject.toml").is_file():
52
+ return repo / "assets"
53
+ return None
54
+
55
+
56
+ def _cache_assets() -> Path:
57
+ base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
58
+ return Path(base) / "openads" / "assets"
59
+
60
+
61
+ def candidate_roots(assets_dir: Path | str | None = None) -> list[tuple[str, Path]]:
62
+ """Candidate assets roots in resolution order, labelled by their source.
63
+
64
+ Args:
65
+ assets_dir: Explicit assets folder.
66
+
67
+ Returns:
68
+ ``[(source, path), ...]``; sources are ``argument``, ``ADS_ASSETS_DIR``,
69
+ ``repository`` and ``user-cache``.
70
+ """
71
+ out: list[tuple[str, Path]] = []
72
+ if assets_dir is not None:
73
+ out.append(("argument", Path(assets_dir).expanduser()))
74
+ env = os.environ.get(ENV_VAR)
75
+ if env:
76
+ out.append((ENV_VAR, Path(env).expanduser()))
77
+ repo = _repo_assets()
78
+ if repo is not None:
79
+ out.append(("repository", repo))
80
+ out.append(("user-cache", _cache_assets()))
81
+ return out
82
+
83
+
84
+ def assets_root(assets_dir: Path | str | None = None) -> Path:
85
+ """Return the first existing assets root.
86
+
87
+ An explicit ``assets_dir`` or ``ADS_ASSETS_DIR`` that does not exist is an
88
+ error (it is never skipped silently).
89
+
90
+ Raises:
91
+ AssetNotFoundError: If no candidate exists.
92
+ """
93
+ tried = []
94
+ for source, path in candidate_roots(assets_dir):
95
+ if path.is_dir():
96
+ return path
97
+ if source in ("argument", ENV_VAR):
98
+ raise AssetNotFoundError(f"assets folder from {source} does not exist: {path}")
99
+ tried.append(f"{source}: {path}")
100
+ raise AssetNotFoundError(
101
+ "no assets folder found; run `openads assets download` (or set ADS_ASSETS_DIR or "
102
+ "paths.assets_dir). Tried: " + "; ".join(tried)
103
+ )
104
+
105
+
106
+ def resolve(rel: Path | str, *, assets_dir: Path | str | None = None) -> Path:
107
+ """Resolve an asset path.
108
+
109
+ Args:
110
+ rel: Path relative to the assets root (``"models/synthstrip.1.pt"``),
111
+ or an absolute path (returned unchanged if it exists).
112
+ assets_dir: Explicit assets folder (highest priority).
113
+
114
+ Returns:
115
+ The existing path.
116
+
117
+ Raises:
118
+ AssetNotFoundError: If the file or folder does not exist.
119
+ """
120
+ p = Path(rel).expanduser()
121
+ if p.is_absolute():
122
+ if not p.exists():
123
+ raise AssetNotFoundError(f"asset not found: {p}")
124
+ return p
125
+ root = assets_root(assets_dir)
126
+ full = root / p
127
+ if not full.exists():
128
+ raise AssetNotFoundError(
129
+ f"asset {p.as_posix()!r} not found under {root}; run `openads assets download`"
130
+ )
131
+ return full
132
+
133
+
134
+ def example_subjects(
135
+ modality: str | None = None, *, assets_dir: Path | str | None = None
136
+ ) -> list[Path]:
137
+ """List the bundled example subject folders (``examples/<mod>/sub-*``).
138
+
139
+ Args:
140
+ modality: ``"dwi"`` or ``"pwi"`` (case-insensitive); ``None`` lists both.
141
+ assets_dir: Explicit assets folder (highest priority).
142
+
143
+ Returns:
144
+ Sorted subject folders; empty if the assets have no examples.
145
+
146
+ Raises:
147
+ ValueError: On an unknown modality.
148
+ AssetNotFoundError: If no assets folder exists.
149
+ """
150
+ mods = ["dwi", "pwi"] if modality is None else [modality.lower()]
151
+ if any(m not in ("dwi", "pwi") for m in mods):
152
+ raise ValueError(f"modality must be 'dwi' or 'pwi', got {modality!r}")
153
+ root = assets_root(assets_dir) / "examples"
154
+ return sorted(
155
+ d for m in mods if (root / m).is_dir() for d in (root / m).iterdir() if d.is_dir()
156
+ )
157
+
158
+
159
+ from openads.assets.fetch import DownloadReport, download # noqa: E402 (needs the names above)
160
+
161
+ #: Same as :func:`download`, under the name exported at the package top level.
162
+ download_assets = download
@@ -0,0 +1,157 @@
1
+ """Download the assets (model weights, atlases, example subjects) of the packaged manifest.
2
+
3
+ The wheel ships code only. ``openads assets download`` (or :func:`download`) fetches the files
4
+ from a public mirror, by default the ``assets/`` folder of the official farialab/OpenADS GitHub
5
+ repository at a fixed commit (:data:`DEFAULT_URL`), verifies each one
6
+ against the manifest's SHA-256 and writes it under the assets root. Nothing is downloaded
7
+ implicitly: running the command is the user's acceptance of the licence in :data:`NOTICE`.
8
+ Files already present with the right digest are skipped, so the command is safe to re-run.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import sys
15
+ import tempfile
16
+ import urllib.error
17
+ import urllib.parse
18
+ import urllib.request
19
+ from collections.abc import Iterable
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+
23
+ from openads.assets.manifest import AssetEntry, load_manifest, sha256
24
+
25
+ __all__ = [
26
+ "DEFAULT_COMMIT",
27
+ "DEFAULT_URL",
28
+ "NOTICE",
29
+ "URL_ENV_VAR",
30
+ "DownloadReport",
31
+ "default_target",
32
+ "download",
33
+ ]
34
+
35
+ #: Mirror holding the assets at the same relative paths as the manifest: ``assets/`` of
36
+ #: farialab/OpenADS, pinned to the commit the manifest was made from (6c378d1, OpenADS 1.0), so a
37
+ #: later change on ``main`` cannot make a download fail verification.
38
+ DEFAULT_COMMIT = "6c378d1ae36278c9351c247e00286f19e0a893ac"
39
+ DEFAULT_URL = f"https://raw.githubusercontent.com/farialab/OpenADS/{DEFAULT_COMMIT}/assets"
40
+ #: Environment variable that replaces :data:`DEFAULT_URL` (another mirror, a local copy via ``file://``).
41
+ URL_ENV_VAR = "ADS_ASSETS_URL"
42
+
43
+ NOTICE = """\
44
+ OpenADS model weights, atlases and example data are distributed under the JHU Non-Profit Research
45
+ Software License Agreement (see the LICENSE file of the openads package): non-commercial, non-profit
46
+ research and education only; commercial use, including research by for-profit entities, needs a
47
+ separate written licence from Johns Hopkins University; cite OpenADS in publications.
48
+ By downloading these files you accept this licence."""
49
+
50
+
51
+ @dataclass
52
+ class DownloadReport:
53
+ """What :func:`download` did.
54
+
55
+ Attributes:
56
+ root: Assets root written to.
57
+ downloaded: Paths fetched and verified.
58
+ present: Paths already there with the right digest.
59
+ missing: Paths the mirror does not have (HTTP 404).
60
+ failed: ``(path, reason)`` for other errors, including a digest mismatch.
61
+ """
62
+
63
+ root: Path
64
+ downloaded: list[str] = field(default_factory=list)
65
+ present: list[str] = field(default_factory=list)
66
+ missing: list[str] = field(default_factory=list)
67
+ failed: list[tuple[str, str]] = field(default_factory=list)
68
+
69
+ @property
70
+ def ok(self) -> bool:
71
+ """Every requested file is present and verified."""
72
+ return not self.missing and not self.failed
73
+
74
+
75
+ def default_target() -> Path:
76
+ """Target folder when none is given.
77
+
78
+ ``ADS_ASSETS_DIR`` if set, else the user cache that :func:`openads.assets.assets_root`
79
+ searches last.
80
+ """
81
+ from openads.assets import ENV_VAR, _cache_assets
82
+
83
+ env = os.environ.get(ENV_VAR)
84
+ return Path(env).expanduser() if env else _cache_assets()
85
+
86
+
87
+ def _fetch(url: str, dest: Path, entry: AssetEntry) -> None:
88
+ dest.parent.mkdir(parents=True, exist_ok=True)
89
+ fd, tmp = tempfile.mkstemp(dir=dest.parent, prefix=".part-")
90
+ try:
91
+ with os.fdopen(fd, "wb") as out, urllib.request.urlopen(url, timeout=60) as r:
92
+ while block := r.read(1 << 20):
93
+ out.write(block)
94
+ got = sha256(Path(tmp))
95
+ if got != entry.sha256:
96
+ raise ValueError(f"SHA-256 mismatch (expected {entry.sha256[:12]}…, got {got[:12]}…)")
97
+ os.replace(tmp, dest)
98
+ finally:
99
+ if os.path.exists(tmp):
100
+ os.unlink(tmp)
101
+
102
+
103
+ def download(
104
+ kinds: Iterable[str] | None = None,
105
+ *,
106
+ assets_dir: Path | str | None = None,
107
+ base_url: str | None = None,
108
+ quiet: bool = False,
109
+ ) -> DownloadReport:
110
+ """Download and verify assets.
111
+
112
+ Args:
113
+ kinds: ``"model"``, ``"atlas"``, ``"example"``; ``None`` downloads all.
114
+ assets_dir: Target assets root (default :func:`default_target`).
115
+ base_url: Mirror URL (default ``$ADS_ASSETS_URL`` or :data:`DEFAULT_URL`).
116
+ quiet: Do not print the licence notice and progress.
117
+
118
+ Returns:
119
+ A :class:`DownloadReport`; check ``report.ok``.
120
+ """
121
+ root = Path(assets_dir).expanduser() if assets_dir is not None else default_target()
122
+ base = (base_url or os.environ.get(URL_ENV_VAR) or DEFAULT_URL).rstrip("/")
123
+ wanted = set(kinds) if kinds else None
124
+ entries = [e for e in load_manifest() if wanted is None or e.kind in wanted]
125
+ report = DownloadReport(root)
126
+ say = (lambda *a: None) if quiet else (lambda *a: print(*a, file=sys.stderr, flush=True))
127
+ say(NOTICE)
128
+ say(
129
+ f"\nDownloading {len(entries)} files ({sum(e.size for e in entries) / 1e6:.0f} MB)"
130
+ f" from {base}\ninto {root}"
131
+ )
132
+ for k, e in enumerate(entries, 1):
133
+ dest = root / e.path
134
+ if dest.is_file() and dest.stat().st_size == e.size and sha256(dest) == e.sha256:
135
+ report.present.append(e.path)
136
+ continue
137
+ url = f"{base}/{urllib.parse.quote(e.path)}"
138
+ say(f"[{k}/{len(entries)}] {e.path} ({e.size / 1e6:.1f} MB)")
139
+ try:
140
+ _fetch(url, dest, e)
141
+ report.downloaded.append(e.path)
142
+ except urllib.error.HTTPError as err:
143
+ if err.code == 404:
144
+ report.missing.append(e.path)
145
+ else:
146
+ report.failed.append((e.path, f"HTTP {err.code}"))
147
+ except (OSError, ValueError) as err:
148
+ report.failed.append((e.path, str(err)))
149
+ say(
150
+ f"\n{len(report.downloaded)} downloaded, {len(report.present)} already present, "
151
+ f"{len(report.missing)} not on the mirror, {len(report.failed)} failed"
152
+ )
153
+ for p in report.missing:
154
+ say(f" not on the mirror: {p}")
155
+ for p, why in report.failed:
156
+ say(f" failed: {p}: {why}")
157
+ return report