diff --git a/python/CHANGES.md b/python/CHANGES.md index a1ba62b..f8f4dc6 100644 --- a/python/CHANGES.md +++ b/python/CHANGES.md @@ -1,5 +1,10 @@ # insta-science +## 0.2.0 + +Expose the `insta_science.ensure_installed` API for programmatic access +to the `science` binary's path. + ## 0.1.0 Initial release. diff --git a/python/RELEASE.md b/python/RELEASE.md index 9e7d34d..77646e6 100644 --- a/python/RELEASE.md +++ b/python/RELEASE.md @@ -4,7 +4,7 @@ ### Version Bump and Changelog -1. Bump the version in [`insta_science/__init__.py`](insta_science/__init__.py). +1. Bump the version in [`insta_science/version.py`](insta_science/version.py). 2. Run `uv run dev-cmd` as a sanity check on the state of the project. 3. Update [`CHANGES.md`](CHANGES.md) with any changes that are likely to be useful to consumers. 4. Open a PR with these changes and land it on https://github.com/a-scie/science-installers main. diff --git a/python/insta_science/__init__.py b/python/insta_science/__init__.py index d3e0961..0da3e88 100644 --- a/python/insta_science/__init__.py +++ b/python/insta_science/__init__.py @@ -1,4 +1,24 @@ # Copyright 2024 Science project contributors. # Licensed under the Apache License, Version 2.0 (see LICENSE). -__version__ = "0.1.0" +from ._internal import ( + Digest, + Fingerprint, + InputError, + Platform, + Science, + ScienceNotFound, + ensure_installed, +) +from .version import __version__ + +__all__ = ( + "Digest", + "Fingerprint", + "InputError", + "Platform", + "Science", + "ScienceNotFound", + "__version__", + "ensure_installed", +) diff --git a/python/insta_science/__main__.py b/python/insta_science/__main__.py index 84511bc..2eddf9f 100644 --- a/python/insta_science/__main__.py +++ b/python/insta_science/__main__.py @@ -1,7 +1,7 @@ # Copyright 2024 Science project contributors. # Licensed under the Apache License, Version 2.0 (see LICENSE). -from insta_science import shim +from . import shim if __name__ == "__main__": - shim.science() + shim.main() diff --git a/python/insta_science/_internal/__init__.py b/python/insta_science/_internal/__init__.py new file mode 100644 index 0000000..3cd60fa --- /dev/null +++ b/python/insta_science/_internal/__init__.py @@ -0,0 +1,18 @@ +# Copyright 2024 Science project contributors. +# Licensed under the Apache License, Version 2.0 (see LICENSE). + +from .errors import InputError, ScienceNotFound +from .hashing import Fingerprint +from .model import Digest, Science +from .platform import Platform +from .science import ensure_installed + +__all__ = ( + "Digest", + "Fingerprint", + "InputError", + "Platform", + "Science", + "ScienceNotFound", + "ensure_installed", +) diff --git a/python/insta_science/a_scie.py b/python/insta_science/_internal/a_scie.py similarity index 89% rename from python/insta_science/a_scie.py rename to python/insta_science/_internal/a_scie.py index c716208..37ac325 100644 --- a/python/insta_science/a_scie.py +++ b/python/insta_science/_internal/a_scie.py @@ -9,10 +9,10 @@ from packaging.version import Version -from insta_science.fetcher import fetch_and_verify -from insta_science.hashing import Digest, Fingerprint -from insta_science.model import Science, Url -from insta_science.platform import Platform +from .fetcher import fetch_and_verify +from .hashing import Digest, Fingerprint +from .model import Science, Url +from .platform import Platform @dataclass(frozen=True) diff --git a/python/insta_science/cache.py b/python/insta_science/_internal/cache.py similarity index 100% rename from python/insta_science/cache.py rename to python/insta_science/_internal/cache.py diff --git a/python/insta_science/errors.py b/python/insta_science/_internal/errors.py similarity index 80% rename from python/insta_science/errors.py rename to python/insta_science/_internal/errors.py index 55d161a..94ad651 100644 --- a/python/insta_science/errors.py +++ b/python/insta_science/_internal/errors.py @@ -16,3 +16,10 @@ class InputError(ValueError): class InvalidProjectError(InputError): """Indicates bad pyproject.toml configuration for `[tool.insta-science]`.""" + + +class ScienceNotFound(Exception): + """Indicates the science binary could not be found. + + This generally means there was an error downloading a science binary. + """ diff --git a/python/insta_science/fetcher.py b/python/insta_science/_internal/fetcher.py similarity index 93% rename from python/insta_science/fetcher.py rename to python/insta_science/_internal/fetcher.py index 525cfcf..81fc3b4 100644 --- a/python/insta_science/fetcher.py +++ b/python/insta_science/_internal/fetcher.py @@ -14,11 +14,11 @@ import httpx from tqdm import tqdm -from insta_science import hashing -from insta_science.cache import Missing, download_cache -from insta_science.errors import InputError -from insta_science.hashing import Digest, ExpectedDigest, Fingerprint -from insta_science.model import Url +from . import hashing +from .cache import Missing, download_cache +from .errors import InputError +from .hashing import Digest, ExpectedDigest, Fingerprint +from .model import Url logger = logging.getLogger(__name__) @@ -83,7 +83,7 @@ def require_password(auth_type: str) -> str: return None -def configured_client(url: Url, headers: Mapping[str, str] | None = None) -> httpx.Client: +def _configured_client(url: Url, headers: Mapping[str, str] | None = None) -> httpx.Client: headers = dict(headers) if headers else {} auth = _configure_auth(url) if "Authorization" not in headers else None return httpx.Client(follow_redirects=True, headers=headers, auth=auth) @@ -95,7 +95,7 @@ def _fetch_to_cache( with download_cache().get_or_create(url, ttl=ttl) as cache_result: if isinstance(cache_result, Missing): with ( - configured_client(url, headers).stream("GET", url) as response, + _configured_client(url, headers).stream("GET", url) as response, cache_result.work.open("wb") as cache_fp, ): for data in response.iter_bytes(): @@ -115,7 +115,7 @@ def _maybe_expected_digest( return ExpectedDigest(fingerprint=fingerprint, algorithm=algorithm) elif isinstance(fingerprint, Url): url = fingerprint - with configured_client(url, headers) as client: + with _configured_client(url, headers) as client: return ExpectedDigest( fingerprint=Fingerprint(client.get(url).text.split(" ", 1)[0].strip()), algorithm=algorithm, @@ -134,7 +134,7 @@ def _expected_digest( if expected_digest: return expected_digest - with configured_client(url, headers) as client: + with _configured_client(url, headers) as client: return ExpectedDigest( fingerprint=Fingerprint(client.get(f"{url}.{algorithm}").text.split(" ", 1)[0].strip()), algorithm=algorithm, @@ -155,7 +155,7 @@ def fetch_and_verify( # TODO(John Sirois): XXX: Log or invoke callback for logging. # click.secho(f"Downloading {url} ...", fg="green") work = cache_result.work - with configured_client(url, headers) as client: + with _configured_client(url, headers) as client: expected_digest = _expected_digest( url, headers, fingerprint, algorithm=digest_algorithm ) diff --git a/python/insta_science/hashing.py b/python/insta_science/_internal/hashing.py similarity index 98% rename from python/insta_science/hashing.py rename to python/insta_science/_internal/hashing.py index cdca240..05c4916 100644 --- a/python/insta_science/hashing.py +++ b/python/insta_science/_internal/hashing.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Callable -from insta_science.errors import InputError +from .errors import InputError DEFAULT_ALGORITHM = "sha256" diff --git a/python/insta_science/model.py b/python/insta_science/_internal/model.py similarity index 92% rename from python/insta_science/model.py rename to python/insta_science/_internal/model.py index efcab92..6a552ff 100644 --- a/python/insta_science/model.py +++ b/python/insta_science/_internal/model.py @@ -9,7 +9,7 @@ from packaging.version import Version -from insta_science.hashing import Digest +from .hashing import Digest @dataclass(frozen=True) diff --git a/python/insta_science/parser.py b/python/insta_science/_internal/parser.py similarity index 94% rename from python/insta_science/parser.py rename to python/insta_science/_internal/parser.py index 90d556d..0a904ed 100644 --- a/python/insta_science/parser.py +++ b/python/insta_science/_internal/parser.py @@ -7,10 +7,10 @@ from packaging.version import InvalidVersion, Version -from insta_science.errors import InputError -from insta_science.hashing import Digest, Fingerprint -from insta_science.model import Science -from insta_science.project import PyProjectToml +from .errors import InputError +from .hashing import Digest, Fingerprint +from .model import Science +from .project import PyProjectToml def _assert_dict_str_keys(obj: Any, *, path: str) -> dict[str, Any]: diff --git a/python/insta_science/platform.py b/python/insta_science/_internal/platform.py similarity index 97% rename from python/insta_science/platform.py rename to python/insta_science/_internal/platform.py index 79acd75..9e76578 100644 --- a/python/insta_science/platform.py +++ b/python/insta_science/_internal/platform.py @@ -7,7 +7,7 @@ from enum import Enum from functools import cache -from insta_science.errors import InputError +from .errors import InputError class Platform(Enum): diff --git a/python/insta_science/project.py b/python/insta_science/_internal/project.py similarity index 97% rename from python/insta_science/project.py rename to python/insta_science/_internal/project.py index ae0896a..0117606 100644 --- a/python/insta_science/project.py +++ b/python/insta_science/_internal/project.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Any -from insta_science.errors import InputError, InvalidProjectError +from .errors import InputError, InvalidProjectError try: import tomllib as toml # type: ignore[import-not-found] diff --git a/python/insta_science/_internal/science.py b/python/insta_science/_internal/science.py new file mode 100644 index 0000000..d29aa6f --- /dev/null +++ b/python/insta_science/_internal/science.py @@ -0,0 +1,101 @@ +# Copyright 2024 Science project contributors. +# Licensed under the Apache License, Version 2.0 (see LICENSE). + +from __future__ import annotations + +import shutil +import subprocess +import sys +from datetime import timedelta +from pathlib import Path, PurePath +from subprocess import CalledProcessError + +import colors +import httpx +from packaging.version import Version + +from . import a_scie, parser, project +from .cache import Missing, download_cache +from .errors import InputError, ScienceNotFound +from .hashing import ExpectedDigest +from .model import Science +from .platform import Platform + + +def _find_science_on_path(science: Science) -> PurePath | None: + url = "file:///science" + ttl: timedelta | None = None + if science.version: + url = f"{url}/v{science.version}" + if science.digest: + url = f"{url}#{science.digest.fingerprint}:{science.digest.size}" + else: + ttl = timedelta(days=5) + + with download_cache().get_or_create(url=url, ttl=ttl) as cache_result: + if isinstance(cache_result, Missing): + current_platform = Platform.current() + for binary_name in ( + current_platform.binary_name("science"), + current_platform.binary_name("science-fat"), + current_platform.qualified_binary_name("science"), + current_platform.qualified_binary_name("science-fat"), + ): + science_exe = shutil.which(binary_name) + if not science_exe: + continue + if science.version: + if science.version != Version( + subprocess.run( + args=[science_exe, "-V"], text=True, stdout=subprocess.PIPE + ).stdout.strip() + ): + continue + if science.digest and science.digest.fingerprint: + expected_digest = ExpectedDigest( + fingerprint=science.digest.fingerprint, size=science.digest.size + ) + try: + expected_digest.check_path(Path(science_exe)) + except InputError: + continue + shutil.copy(science_exe, cache_result.work) + return cache_result.path + return None + return cache_result.path + + +def ensure_installed(spec: Science | None = None) -> PurePath: + """Ensures an appropriate science binary is installed and returns its path. + + Args: + spec: An optional specification of which science binary is required. + + Returns: + The path of a science binary meeting the supplied ``spec``, if any. + + Raises: + InputError: No ``spec`` was supplied ; so the information about which ``science`` binary to + install was parsed from ``pyproject.toml`` and found to have errors. + ScienceNotFound: The science binary could not be found locally or downloaded. + """ + if spec is not None: + science = spec + else: + try: + pyproject_toml = project.find_pyproject_toml() + science = parser.configured_science(pyproject_toml) if pyproject_toml else Science() + except InputError as e: + sys.exit(f"{colors.red('Configuration error')}: {colors.yellow(str(e))}") + + try: + return _find_science_on_path(science) or a_scie.science(science) + except ( + OSError, + CalledProcessError, + httpx.HTTPError, + httpx.InvalidURL, + httpx.CookieConflict, + httpx.StreamError, + ) as e: + raise ScienceNotFound(str(e)) diff --git a/python/insta_science/shim.py b/python/insta_science/shim.py index 556e7c5..1e70f4b 100644 --- a/python/insta_science/shim.py +++ b/python/insta_science/shim.py @@ -4,95 +4,21 @@ from __future__ import annotations import os -import shutil import subprocess import sys -from datetime import timedelta -from pathlib import Path, PurePath -from subprocess import CalledProcessError from typing import NoReturn import colors -import httpx -from packaging.version import Version -from insta_science import a_scie, parser, project -from insta_science.cache import Missing, download_cache -from insta_science.errors import InputError -from insta_science.hashing import ExpectedDigest -from insta_science.model import Science -from insta_science.platform import Platform +from . import InputError, Platform, ScienceNotFound, ensure_installed -def _find_science_on_path(science: Science) -> PurePath | None: - cache = download_cache() - url = f"file://{cache.base_dir}/science" - ttl: timedelta | None = None - if science.version: - url = f"{url}/v{science.version}" - if science.digest: - url = f"{url}#{science.digest.fingerprint}:{science.digest.size}" - else: - ttl = timedelta(days=5) - - with cache.get_or_create(url=url, ttl=ttl) as cache_result: - if isinstance(cache_result, Missing): - current_platform = Platform.current() - for binary_name in ( - current_platform.binary_name("science"), - current_platform.binary_name("science-fat"), - current_platform.qualified_binary_name("science"), - current_platform.qualified_binary_name("science-fat"), - ): - science_exe = shutil.which(binary_name) - if not science_exe: - continue - if science.version: - if science.version != Version( - subprocess.run( - args=[science_exe, "-V"], text=True, stdout=subprocess.PIPE - ).stdout.strip() - ): - continue - if science.digest and science.digest.fingerprint: - expected_digest = ExpectedDigest( - fingerprint=science.digest.fingerprint, size=science.digest.size - ) - try: - expected_digest.check_path(Path(science_exe)) - except InputError: - continue - shutil.copy(science_exe, cache_result.work) - return cache_result.path - return None - return cache_result.path - - -def science(spec: Science | None = None) -> NoReturn: - """Ensures an appropriate science binary is installed and then forwards to it. - - spec: An optional specification of which science binary is required. - """ - if spec is not None: - science = spec - else: - try: - pyproject_toml = project.find_pyproject_toml() - science = parser.configured_science(pyproject_toml) if pyproject_toml else Science() - except InputError as e: - sys.exit(f"{colors.red('Configuration error')}: {colors.yellow(str(e))}") - +def main() -> NoReturn: try: - science_exe = _find_science_on_path(science) or a_scie.science(science) - except ( - OSError, - CalledProcessError, - InputError, - httpx.HTTPError, - httpx.InvalidURL, - httpx.CookieConflict, - httpx.StreamError, - ) as e: + science_exe = ensure_installed() + except InputError as e: + sys.exit(f"{colors.red('Configuration error')}: {colors.yellow(str(e))}") + except ScienceNotFound as e: sys.exit(colors.red(str(e))) argv = [str(science_exe), *sys.argv[1:]] diff --git a/python/insta_science/version.py b/python/insta_science/version.py new file mode 100644 index 0000000..a823ed7 --- /dev/null +++ b/python/insta_science/version.py @@ -0,0 +1,4 @@ +# Copyright 2024 Science project contributors. +# Licensed under the Apache License, Version 2.0 (see LICENSE). + +__version__ = "0.2.0" diff --git a/python/pyproject.toml b/python/pyproject.toml index 9279b1e..866e92d 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -50,11 +50,11 @@ Repository = "https://github.com/a-scie/science-installers/tree/main/python" Changelog = "https://github.com/a-scie/science-installers/blob/main/python/CHANGES.md" [project.scripts] -insta-science = "insta_science.shim:science" +insta-science = "insta_science.shim:main" insta-science-util = "insta_science.util:main" [tool.setuptools.dynamic] -version = {attr = "insta_science.__version__"} +version = {attr = "insta_science.version.__version__"} [tool.setuptools.packages.find] where = ["."] diff --git a/python/tests/test_shim.py b/python/tests/test_shim.py index 15aad86..f042d44 100644 --- a/python/tests/test_shim.py +++ b/python/tests/test_shim.py @@ -9,7 +9,7 @@ import pytest from packaging.version import Version -from insta_science.platform import Platform +from insta_science import Platform def test_self() -> None: diff --git a/python/uv.lock b/python/uv.lock index a3a539c..2e1e140 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -149,7 +149,7 @@ wheels = [ [[package]] name = "insta-science" -version = "0.1.0.dev0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "ansicolors" },