Skip to content

Commit

Permalink
Expose the insta_science.ensure_installed API. (#3)
Browse files Browse the repository at this point in the history
Re-structure code to clearly delineate the public APIs through
`insta_science` package re-exports of `insta_science._internal*`
symbols.
  • Loading branch information
jsirois authored Dec 23, 2024
1 parent 73cfa3f commit b2203a1
Show file tree
Hide file tree
Showing 20 changed files with 191 additions and 110 deletions.
5 changes: 5 additions & 0 deletions python/CHANGES.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion python/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 21 additions & 1 deletion python/insta_science/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
)
4 changes: 2 additions & 2 deletions python/insta_science/__main__.py
Original file line number Diff line number Diff line change
@@ -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()
18 changes: 18 additions & 0 deletions python/insta_science/_internal/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
)
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)
Expand All @@ -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():
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from packaging.version import Version

from insta_science.hashing import Digest
from .hashing import Digest


@dataclass(frozen=True)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
101 changes: 101 additions & 0 deletions python/insta_science/_internal/science.py
Original file line number Diff line number Diff line change
@@ -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://<just-a-cache-key>/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))
Loading

0 comments on commit b2203a1

Please sign in to comment.