diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d38d4d..a4be220 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,8 @@ jobs: with: python-version: "3.x" - uses: pre-commit/action@v3.0.0 + - uses: astral-sh/setup-uv@v5 + - run: uv run ty check # Until they add a pre-commit hook test: strategy: diff --git a/docs/development.md b/docs/development.md index 435c3a5..117f1be 100644 --- a/docs/development.md +++ b/docs/development.md @@ -21,10 +21,18 @@ uv tool install pre-commit pre-commit install ``` +We use [ty](https://docs.astral.sh/ty/) for type checking. This will be added +to the pre-commit hook in the future (when an official ty hook is available), +but for now, you can run it manually: + +```sh +uv run ty check +``` + To run the tests: ```sh -uv run test pytest +uv run pytest ``` To build the documentation with [Jupyter Book](https://jupyterbook.org/): diff --git a/pyproject.toml b/pyproject.toml index 35b34c8..31dbe92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "click >= 8.0", "progressbar2 >= 4.0", "requests >= 2.24", + "typing-extensions>=4.15.0", ] [project.urls] @@ -50,6 +51,7 @@ test = [ ] dev = [ {include-group = "test"}, + "ty >= 0.0.9", ] docs = [ "jgo >= 1.0", diff --git a/src/cjdk/__main__.py b/src/cjdk/__main__.py index 803be4c..51211cf 100644 --- a/src/cjdk/__main__.py +++ b/src/cjdk/__main__.py @@ -1,6 +1,7 @@ # This file is part of cjdk. # Copyright 2022-25 Board of Regents of the University of Wisconsin System # SPDX-License-Identifier: MIT +from __future__ import annotations import os import subprocess @@ -44,7 +45,16 @@ help="Show or do not show progress bars.", ) @click.version_option(version=__version__) -def _cli(ctx, jdk, cache_dir, index_url, index_ttl, os, arch, progress): +def _cli( + ctx: click.Context, + jdk: str | None, + cache_dir: str | None, + index_url: str | None, + index_ttl: int | None, + os: str | None, + arch: str | None, + progress: bool, +) -> None: """ Download, cache, and run JDK or JRE distributions. @@ -67,7 +77,7 @@ def _cli(ctx, jdk, cache_dir, index_url, index_ttl, os, arch, progress): @click.command(short_help="List available JDK vendors.") @click.pass_context -def ls_vendors(ctx): +def ls_vendors(ctx: click.Context) -> None: """ Print the list of available JDK vendors. """ @@ -83,7 +93,7 @@ def ls_vendors(ctx): default=True, help="Show only already-cached JDKs, or show all available JDKs from the index (default cached only).", ) -def ls(ctx, cached: bool = False): +def ls(ctx: click.Context, cached: bool) -> None: """ Print the list of JDKs matching the given criteria. @@ -96,7 +106,7 @@ def ls(ctx, cached: bool = False): @click.command(short_help="Ensure the requested JDK is cached.") @click.pass_context -def cache(ctx): +def cache(ctx: click.Context) -> None: """ Download and extract the requested JDK if it is not already cached. @@ -112,7 +122,7 @@ def cache(ctx): @click.command(hidden=True) @click.pass_context -def cache_jdk(ctx): +def cache_jdk(ctx: click.Context) -> None: """ Deprecated. Use cache function instead. """ @@ -123,7 +133,7 @@ def cache_jdk(ctx): short_help="Print the Java home directory for the requested JDK." ) @click.pass_context -def java_home(ctx): +def java_home(ctx: click.Context) -> None: """ Print the path that is suitable as the value of JAVA_HOME for the requested JDK. @@ -143,7 +153,7 @@ def java_home(ctx): @click.pass_context @click.argument("prog", nargs=1) @click.argument("args", nargs=-1, type=click.UNPROCESSED) -def exec(ctx, prog, args): +def exec(ctx: click.Context, prog: str, args: tuple[str, ...]) -> None: """ Run PROG with the environment variables set for the requested JDK. @@ -191,7 +201,16 @@ def exec(ctx, prog, args): metavar="HASH", help="Check the downloaded file against the given SHA-512 hash.", ) -def cache_file(ctx, url, filename, name, ttl, sha1, sha256, sha512): +def cache_file( + ctx: click.Context, + url: str, + filename: str, + name: str | None, + ttl: int | None, + sha1: str | None, + sha256: str | None, + sha512: str | None, +) -> None: """ Download and store an arbitrary file if it is not already cached. @@ -236,7 +255,14 @@ def cache_file(ctx, url, filename, name, ttl, sha1, sha256, sha512): metavar="HASH", help="Check the downloaded file against the given SHA-512 hash.", ) -def cache_package(ctx, url, name, sha1, sha256, sha512): +def cache_package( + ctx: click.Context, + url: str, + name: str | None, + sha1: str | None, + sha256: str | None, + sha512: str | None, +) -> None: """ Download, extract, and store an arbitrary .zip or .tar.gz package if it is not already cached. @@ -262,7 +288,7 @@ def cache_package(ctx, url, name, sha1, sha256, sha512): @click.command(short_help="Remove all cached files.") @click.pass_context -def clear_cache(ctx): +def clear_cache(ctx: click.Context) -> None: """ Remove all cached JDKs, files, and packages from the cache directory. @@ -292,7 +318,7 @@ def clear_cache(ctx): _cli.add_command(cache_jdk) -def main(): +def main() -> None: try: _cli() except CjdkError as e: diff --git a/src/cjdk/_api.py b/src/cjdk/_api.py index 6412baa..9ed538c 100644 --- a/src/cjdk/_api.py +++ b/src/cjdk/_api.py @@ -20,7 +20,8 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterator from pathlib import Path - from typing import Any, Unpack + + from typing_extensions import Unpack from ._conf import ConfigKwargs @@ -46,6 +47,8 @@ def list_vendors(**kwargs: Unpack[ConfigKwargs]) -> list[str]: Other Parameters ---------------- + cache_dir : pathlib.Path or str, optional + Override the root cache directory. index_url : str, optional Alternative URL for the JDK index. @@ -122,8 +125,8 @@ def clear_cache(**kwargs: Unpack[ConfigKwargs]) -> Path: This should not be called when other processes may be using cjdk or the JDKs and files installed by cjdk. - Parameters - ---------- + Other Parameters + ---------------- cache_dir : pathlib.Path or str, optional Override the root cache directory. @@ -280,7 +283,7 @@ def cache_file( name: str, url: str, filename: str, - ttl: int | None = None, + ttl: float | None = None, *, sha1: str | None = None, sha256: str | None = None, @@ -301,13 +304,13 @@ def cache_file( The URL of the file resource. The scheme must be https. filename : str The filename under which the file will be stored. - ttl : int + ttl : int or float, optional Time to live (in seconds) for the cached file resource. - sha1 : str + sha1 : str, optional SHA-1 hash that the downloaded file must match. - sha256 : str + sha256 : str, optional SHA-256 hash that the downloaded file must match. - sha512 : str + sha512 : str, optional SHA-512 hash that the downloaded file must match. Returns @@ -328,6 +331,9 @@ def cache_file( The check for SHA-1/SHA-256/SHA-512 hashes is only performed after a download; it is not performed if the file already exists in the cache. """ + _conf.check_str("name", name) + _conf.check_str("url", url, allow_empty=False) + _conf.check_str("filename", filename, allow_empty=False) if ttl is None: ttl = 2**63 check_hashes = _make_hash_checker( @@ -369,11 +375,11 @@ def cache_package( url : str The URL of the file resource. The scheme must be tgz+https or zip+https. - sha1 : str + sha1 : str, optional SHA-1 hash that the downloaded file must match. - sha256 : str + sha256 : str, optional SHA-256 hash that the downloaded file must match. - sha512 : str + sha512 : str, optional SHA-512 hash that the downloaded file must match. Returns @@ -394,6 +400,8 @@ def cache_package( unextracted archive) after a download; it is not performed if the directory already exists in the cache. """ + _conf.check_str("name", name) + _conf.check_str("url", url, allow_empty=False) check_hashes = _make_hash_checker( dict(sha1=sha1, sha256=sha256, sha512=sha512) ) @@ -428,26 +436,33 @@ def _get_vendors(**kwargs: Unpack[ConfigKwargs]) -> set[str]: } -def _get_jdks(*, vendor=None, version=None, cached_only=True, **kwargs): - conf = _conf.configure( - vendor=vendor, - version=version, - fallback_to_default_vendor=False, - **kwargs, - ) - if conf.vendor is None: - # Search across all vendors. - kwargs.pop("jdk", None) # It was already parsed. +def _get_jdks( + *, + vendor: str | None = None, + version: str | None = None, + cached_only: bool = True, + **kwargs: Unpack[ConfigKwargs], +) -> list[str]: + jdk = kwargs.pop("jdk", None) + if jdk: + parsed_vendor, parsed_version = _conf.parse_vendor_version(jdk) + vendor = vendor or parsed_vendor or None + version = version or parsed_version or None + + # Handle "all vendors" before creating Configuration. + if vendor is None: return [ jdk for v in sorted(_get_vendors()) for jdk in _get_jdks( vendor=v, - version=conf.version, + version=version, cached_only=cached_only, **kwargs, ) ] + + conf = _conf.configure(vendor=vendor, version=version, **kwargs) index = _index.jdk_index(conf) jdks = _index.available_jdks(index, conf) versions = _index._get_versions(jdks, conf) @@ -455,7 +470,7 @@ def _get_jdks(*, vendor=None, version=None, cached_only=True, **kwargs): if cached_only: # Filter matches by existing key directories. - def is_cached(v): + def is_cached(v: str) -> bool: url = _index.jdk_url(index, conf, v) key = (_jdk._JDK_KEY_PREFIX, _cache._key_for_url(url)) keydir = _cache._key_directory(conf.cache_dir, key) @@ -464,21 +479,22 @@ def is_cached(v): matched = {k: v for k, v in matched.items() if is_cached(v)} class VersionElement: - def __init__(self, value): + def __init__(self, value: int | str) -> None: self.value = value - self.is_int = isinstance(value, int) - def __eq__(self, other): - if self.is_int and other.is_int: + def __eq__(self, other: VersionElement) -> bool: # type: ignore[override] + if isinstance(self.value, int) and isinstance(other.value, int): return self.value == other.value return str(self.value) == str(other.value) - def __lt__(self, other): - if self.is_int and other.is_int: + def __lt__(self, other: VersionElement) -> bool: + if isinstance(self.value, int) and isinstance(other.value, int): return self.value < other.value return str(self.value) < str(other.value) - def version_key(version_tuple): + def version_key( + version_tuple: tuple[tuple[int | str, ...], str], + ) -> tuple[VersionElement, ...]: return tuple(VersionElement(elem) for elem in version_tuple[0]) return [ @@ -487,24 +503,26 @@ def version_key(version_tuple): ] -def _make_hash_checker(hashes: dict) -> Callable[[Any], None]: +def _make_hash_checker( + hashes: dict[str, str | None], +) -> Callable[[Path], None]: checks = [ (hashes.pop("sha1", None), hashlib.sha1), (hashes.pop("sha256", None), hashlib.sha256), (hashes.pop("sha512", None), hashlib.sha512), ] - def check(filepath: Any) -> None: + def check(filepath: Path) -> None: for hash, hasher in checks: if hash: _hasher = hasher() try: with open(filepath, "rb") as infile: while True: - bytes = infile.read(16384) - if not len(bytes): + chunk = infile.read(16384) + if not len(chunk): break - _hasher.update(bytes) + _hasher.update(chunk) except OSError as e: raise InstallError( f"Failed to read file for hash verification: {e}" @@ -516,7 +534,7 @@ def check(filepath: Any) -> None: @contextmanager -def _env_var_set(name, value): +def _env_var_set(name: str, value: str) -> Iterator[None]: old_value = os.environ.get(name, None) os.environ[name] = value try: diff --git a/src/cjdk/_cache.py b/src/cjdk/_cache.py index bc23f7a..c1fc3c6 100644 --- a/src/cjdk/_cache.py +++ b/src/cjdk/_cache.py @@ -1,15 +1,20 @@ # This file is part of cjdk. # Copyright 2022-25 Board of Regents of the University of Wisconsin System # SPDX-License-Identifier: MIT +from __future__ import annotations import hashlib import sys import time -import urllib +import urllib.parse from contextlib import contextmanager from pathlib import Path +from typing import TYPE_CHECKING from . import _progress, _utils + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator from ._exceptions import ConfigError, InstallError __all__ = [ @@ -18,7 +23,7 @@ ] -def _key_for_url(url): +def _key_for_url(url: str | urllib.parse.ParseResult) -> str: """ Return a cache key suitable to cache content retrieved from the given URL. """ @@ -39,7 +44,7 @@ def _key_for_url(url): # And urllib never encodes - . _ ~ # In practice, this usually serves only to normalize '+' and the case of # percent encoding hex digits. - def percent_reencode(item): + def percent_reencode(item: str) -> str: try: decoded = urllib.parse.unquote(item, errors="strict") return urllib.parse.quote(decoded, safe="+-._", errors="strict") @@ -56,15 +61,15 @@ def percent_reencode(item): def atomic_file( - prefix, - key_url, - filename, - fetchfunc, + prefix: str, + key_url: str, + filename: str, + fetchfunc: Callable[[Path], None], *, - cache_dir, - ttl, - timeout_for_fetch_elsewhere=10, - timeout_for_read_elsewhere=2.5, + cache_dir: Path, + ttl: float, + timeout_for_fetch_elsewhere: float = 10, + timeout_for_read_elsewhere: float = 2.5, ) -> Path: """ Retrieve cached file for key, fetching with fetchfunc if necessary. @@ -122,13 +127,13 @@ def atomic_file( def permanent_directory( - prefix, - key_url, - fetchfunc, + prefix: str, + key_url: str, + fetchfunc: Callable[[Path], None], *, - cache_dir, - timeout_for_fetch_elsewhere=60, -): + cache_dir: Path, + timeout_for_fetch_elsewhere: float = 60, +) -> Path: """ Retrieve cached directory for key, fetching with fetchfunc if necessary. @@ -175,7 +180,7 @@ def permanent_directory( return keydir -def _file_exists_and_is_fresh(file, ttl) -> bool: +def _file_exists_and_is_fresh(file: Path, ttl: float) -> bool: if not file.is_file(): return False now = time.time() @@ -190,7 +195,9 @@ def _file_exists_and_is_fresh(file, ttl) -> bool: @contextmanager -def _create_key_tmpdir(cache_dir, key): +def _create_key_tmpdir( + cache_dir: Path, key: tuple[str, str] +) -> Iterator[Path | None]: tmpdir = _key_tmpdir(cache_dir, key) try: tmpdir.parent.mkdir(parents=True, exist_ok=True) @@ -217,15 +224,15 @@ def _create_key_tmpdir(cache_dir, key): _utils.rmtree_tempdir(tmpdir) -def _key_directory(cache_dir: Path, key) -> Path: +def _key_directory(cache_dir: Path, key: tuple[str, str]) -> Path: return cache_dir / "v0" / Path(*key) -def _key_tmpdir(cache_dir: Path, key) -> Path: +def _key_tmpdir(cache_dir: Path, key: tuple[str, str]) -> Path: return cache_dir / "v0" / Path("fetching", *key) -def _move_in_fetched_directory(target, tmpdir): +def _move_in_fetched_directory(target: Path, tmpdir: Path) -> None: try: target.parent.mkdir(parents=True, exist_ok=True) except OSError as e: @@ -238,7 +245,7 @@ def _move_in_fetched_directory(target, tmpdir): raise InstallError(f"Failed to move {tmpdir} to {target}: {e}") from e -def _add_url_file(keydir, key_url): +def _add_url_file(keydir: Path, key_url: str) -> None: url_file = keydir.parent / (keydir.name + ".url") try: with open(url_file, "w") as f: @@ -247,7 +254,9 @@ def _add_url_file(keydir, key_url): raise InstallError(f"Failed to write URL file {url_file}: {e}") from e -def _wait_for_dir_to_vanish(directory, timeout, progress=True): +def _wait_for_dir_to_vanish( + directory: Path, timeout: float, progress: bool = True +) -> None: print( "cjdk: Another process is currently downloading the same file", file=sys.stderr, diff --git a/src/cjdk/_conf.py b/src/cjdk/_conf.py index 301facb..15eccc0 100644 --- a/src/cjdk/_conf.py +++ b/src/cjdk/_conf.py @@ -14,18 +14,19 @@ from ._exceptions import ConfigError if TYPE_CHECKING: - from typing import TypedDict, Unpack + from typing import TypedDict + + from typing_extensions import Unpack class ConfigKwargs(TypedDict, total=False): - jdk: str - fallback_to_default_vendor: bool - os: str - arch: str + jdk: str | None + os: str | None + arch: str | None vendor: str | None version: str | None - cache_dir: Path - index_url: str - index_ttl: int + cache_dir: str | Path | None + index_url: str | None + index_ttl: float | None progress: bool _allow_insecure_for_testing: bool @@ -33,6 +34,7 @@ class ConfigKwargs(TypedDict, total=False): __all__ = [ "Configuration", "configure", + "parse_vendor_version", ] @@ -44,60 +46,78 @@ class Configuration: version: str cache_dir: Path index_url: str - index_ttl: int + index_ttl: float progress: bool _allow_insecure_for_testing: bool +def check_str( + name: str, + value: object, + *, + allow_none: bool = False, + allow_empty: bool = True, +) -> None: + if value is None: + if allow_none: + return + raise TypeError(f"{name} must be a string, got None") + if not isinstance(value, str): + raise TypeError(f"{name} must be a string, got {type(value).__name__}") + if not allow_empty and value == "": + raise ConfigError(f"{name} must not be empty") + + def configure(**kwargs: Unpack[ConfigKwargs]) -> Configuration: # kwargs must have API-specific items removed before passing here. + for name in ("jdk", "os", "arch", "vendor", "version"): + check_str(name, kwargs.get(name), allow_none=True) + jdk = kwargs.pop("jdk", None) if jdk: if kwargs.get("vendor"): raise ConfigError("Cannot specify jdk= together with vendor=") if kwargs.get("version"): raise ConfigError("Cannot specify jdk= together with version=") - kwargs["vendor"], kwargs["version"] = _parse_vendor_version(jdk) + kwargs["vendor"], kwargs["version"] = parse_vendor_version(jdk) + + index_ttl = kwargs.pop("index_ttl", None) + if not index_ttl and index_ttl != 0: + index_ttl = _default_index_ttl() + + cache_dir = kwargs.pop("cache_dir", None) or _default_cachedir() + if not isinstance(cache_dir, Path): + cache_dir = Path(cache_dir) - default_vendor = ( - _default_vendor() - if kwargs.pop("fallback_to_default_vendor", True) - else None - ) conf = Configuration( os=_canonicalize_os(kwargs.pop("os", None)), arch=_canonicalize_arch(kwargs.pop("arch", None)), - vendor=kwargs.pop("vendor", None) or default_vendor, + vendor=kwargs.pop("vendor", None) or _default_vendor(), version=kwargs.pop("version", "") or "", - cache_dir=kwargs.pop("cache_dir", None) or _default_cachedir(), + cache_dir=cache_dir, index_url=kwargs.pop("index_url", None) or _default_index_url(), - index_ttl=kwargs.pop("index_ttl", None), + index_ttl=index_ttl, progress=kwargs.pop("progress", True), _allow_insecure_for_testing=kwargs.pop( "_allow_insecure_for_testing", False ), ) - if not isinstance(conf.cache_dir, Path): - conf.cache_dir = Path(conf.cache_dir) - - if conf.index_ttl is None: - conf.index_ttl = _default_index_ttl() - if kwargs: raise ConfigError(f"Unrecognized kwargs: {tuple(kwargs.keys())}") return conf -def _parse_vendor_version(spec): +def parse_vendor_version(spec: str) -> tuple[str, str]: # Actually we don't fully parse here; we only disambiguate between vendor # and version when only one is given. if ":" in spec: parts = spec.split(":") if len(parts) != 2: raise ConfigError(f"Cannot parse JDK spec '{spec}'") - return tuple(parts) + vendor, version = parts + return (vendor, version) if len(spec) == 0: return "", "" if re.fullmatch(r"[a-z][a-z0-9-]*", spec): @@ -107,7 +127,7 @@ def _parse_vendor_version(spec): raise ConfigError(f"Cannot parse JDK spec '{spec}'") -def _default_cachedir(): +def _default_cachedir() -> Path: """ Return the cache directory path to be used by default. @@ -130,7 +150,7 @@ def _default_cachedir(): return _xdg_cachedir() -def _windows_cachedir(*, create=True): +def _windows_cachedir(*, create: bool = True) -> Path: cjdk_cache = _local_app_data(create=create) / "cjdk" if create: try: @@ -142,7 +162,7 @@ def _windows_cachedir(*, create=True): return cjdk_cache / "cache" -def _local_app_data(*, create=True): +def _local_app_data(*, create: bool = True) -> Path: # https://docs.microsoft.com/en-us/windows/win32/shell/knownfolderid#FOLDERID_LocalAppData # https://docs.microsoft.com/en-us/windows/win32/msi/localappdatafolder # It is not clear, but I'm pretty sure it's safe to assume that the @@ -155,7 +175,7 @@ def _local_app_data(*, create=True): raise ConfigError(f"Cannot determine home directory: {e}") from e -def _macos_cachedir(*, create=True): +def _macos_cachedir(*, create: bool = True) -> Path: # ~/Library/Caches almost always already exists, and both dirs are 0o700. # Create them here if they don't exist to ensure correct permissions. try: @@ -173,7 +193,7 @@ def _macos_cachedir(*, create=True): return caches / "cjdk" -def _xdg_cachedir(*, create=True): +def _xdg_cachedir(*, create: bool = True) -> Path: # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html if v := os.environ.get("XDG_CACHE_HOME"): caches = Path(v) @@ -194,7 +214,7 @@ def _xdg_cachedir(*, create=True): return caches / "cjdk" -def _default_index_url(): +def _default_index_url() -> str: # The Coursier JDK index is auto-generated, well curated, and clean. coursier_index_url = "https://raw.githubusercontent.com/coursier/jvm-index/master/index.json" return os.environ.get("CJDK_INDEX_URL") or coursier_index_url @@ -206,17 +226,17 @@ def _default_index_url(): # "https://raw.githubusercontent.com/shyiko/jabba/master/index.json" -def _default_index_ttl(): +def _default_index_ttl() -> float: ttl_str = os.environ.get("CJDK_INDEX_TTL") or "86400" try: - return int(ttl_str) + return float(ttl_str) except ValueError as e: raise ConfigError( - f"Invalid value for CJDK_INDEX_TTL: '{ttl_str}' (must be an integer)" + f"Invalid value for CJDK_INDEX_TTL: '{ttl_str}' (must be a number)" ) from e -def _canonicalize_os(osname): +def _canonicalize_os(osname: str | None) -> str: if not osname: osname = os.environ.get("CJDK_OS") or sys.platform osname = osname.lower() @@ -233,7 +253,7 @@ def _canonicalize_os(osname): return osname -def _canonicalize_arch(arch): +def _canonicalize_arch(arch: str | None) -> str: if not arch: arch = os.environ.get("CJDK_ARCH") or platform.machine() arch = arch.lower() @@ -248,7 +268,7 @@ def _canonicalize_arch(arch): return arch -def _default_vendor(): +def _default_vendor() -> str: """ Return the default vendor. diff --git a/src/cjdk/_download.py b/src/cjdk/_download.py index 656c8e9..db2f2be 100644 --- a/src/cjdk/_download.py +++ b/src/cjdk/_download.py @@ -1,16 +1,21 @@ # This file is part of cjdk. # Copyright 2022-25 Board of Regents of the University of Wisconsin System # SPDX-License-Identifier: MIT +from __future__ import annotations import sys import tarfile import tempfile import zipfile from pathlib import Path +from typing import TYPE_CHECKING from urllib.parse import urlparse import requests +if TYPE_CHECKING: + from collections.abc import Callable + from . import _progress, _utils from ._exceptions import InstallError, UnsupportedFormatError @@ -21,13 +26,13 @@ def download_and_extract( - destdir, - url, + destdir: Path, + url: str, *, - checkfunc=None, - progress=True, - _allow_insecure_for_testing=False, -): + checkfunc: Callable[[Path], None] | None = None, + progress: bool = True, + _allow_insecure_for_testing: bool = False, +) -> None: """ Download zip or tgz archive and extract to destdir. @@ -64,13 +69,13 @@ def download_and_extract( def download_file( - dest, - url, + dest: Path, + url: str, *, - checkfunc=None, - progress=False, - _allow_insecure_for_testing=False, -): + checkfunc: Callable[[Path], None] | None = None, + progress: bool = False, + _allow_insecure_for_testing: bool = False, +) -> None: """ Download any file at URL and place at dest. @@ -111,7 +116,7 @@ def download_file( checkfunc(dest) -def _extract_zip(destdir, srcfile, progress=True): +def _extract_zip(destdir: Path, srcfile: Path, progress: bool = True) -> None: try: with zipfile.ZipFile(srcfile) as zf: infolist = zf.infolist() @@ -130,7 +135,7 @@ def _extract_zip(destdir, srcfile, progress=True): raise InstallError(f"Failed to extract zip archive: {e}") from e -def _extract_tgz(destdir, srcfile, progress=True): +def _extract_tgz(destdir: Path, srcfile: Path, progress: bool = True) -> None: filter_kwargs = {} if sys.version_info < (3, 12) else {"filter": "tar"} try: with tarfile.open(srcfile, "r:gz", bufsize=65536) as tf: diff --git a/src/cjdk/_index.py b/src/cjdk/_index.py index 5953c27..1ab3b9d 100644 --- a/src/cjdk/_index.py +++ b/src/cjdk/_index.py @@ -7,7 +7,7 @@ import json import re import warnings -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeAlias from . import _install from ._exceptions import InstallError, JdkNotFoundError @@ -30,10 +30,10 @@ # Type alias declarations. -Versions = dict[str, str] # key = version, value = archive URL -Vendors = dict[str, Versions] # key = vendor name -Arches = dict[str, Vendors] # key = arch name -Index = dict[str, Arches] # key = os name +Versions: TypeAlias = dict[str, str] # key = version, value = archive URL +Vendors: TypeAlias = dict[str, Versions] # key = vendor name +Arches: TypeAlias = dict[str, Vendors] # key = arch name +Index: TypeAlias = dict[str, Arches] # key = os name def jdk_index(conf: Configuration) -> Index: @@ -43,7 +43,7 @@ def jdk_index(conf: Configuration) -> Index: return _read_index(_cached_index_path(conf)) -def available_jdks(index: Index, conf: Configuration) -> tuple[str, str]: +def available_jdks(index: Index, conf: Configuration) -> list[tuple[str, str]]: """ Find in index the available JDK vendor-version combinations. @@ -103,8 +103,7 @@ def jdk_url( def _cached_index_path(conf: Configuration) -> Path: - def check_index(path): - # Ensure valid JSON. + def check_index(path: Path) -> None: _read_index(path) conf_no_progress = copy.deepcopy(conf) @@ -171,13 +170,15 @@ def _postprocess_index(index: Index) -> Index: return index -def _get_versions(jdks: tuple[str, str], conf) -> list[str]: +def _get_versions( + jdks: list[tuple[str, str]], conf: Configuration +) -> list[str]: return [i[1] for i in jdks if i[0] == conf.vendor] def _match_versions( - vendor, candidates: list[str], requested -) -> dict[tuple[int], str]: + vendor: str, candidates: list[str], requested: str +) -> dict[tuple[int | str, ...], str]: # Find all candidates compatible with the request is_graal = "graalvm" in vendor.lower() normreq = _normalize_version(requested, remove_prefix_1=not is_graal) @@ -202,7 +203,7 @@ def _match_versions( } -def _match_version(vendor, candidates: list[str], requested) -> str: +def _match_version(vendor: str, candidates: list[str], requested: str) -> str: matched = _match_versions(vendor, candidates, requested) if len(matched) == 0: @@ -216,7 +217,9 @@ def _match_version(vendor, candidates: list[str], requested) -> str: _VER_SEPS = re.compile(r"[.+_-]") -def _normalize_version(ver, *, remove_prefix_1=False): +def _normalize_version( + ver: str, *, remove_prefix_1: bool = False +) -> tuple[int | str, ...]: # Normalize requested version and candidates: # - Split at dots and dashes (so we don't distinguish between '.' and '-') # - Try to convert elements to integers (so that we can compare elements @@ -241,14 +244,16 @@ def _normalize_version(ver, *, remove_prefix_1=False): return norm + plus -def _intify(s: str): +def _intify(s: str) -> int | str: try: return int(s) except ValueError: return s -def _is_version_compatible_with_spec(version, spec): +def _is_version_compatible_with_spec( + version: tuple[int | str, ...], spec: tuple[int | str, ...] +) -> bool: assert "+" not in version is_plus = spec and spec[-1] == "+" if is_plus: diff --git a/src/cjdk/_install.py b/src/cjdk/_install.py index 51f26de..974addb 100644 --- a/src/cjdk/_install.py +++ b/src/cjdk/_install.py @@ -1,12 +1,19 @@ # This file is part of cjdk. # Copyright 2022-25 Board of Regents of the University of Wisconsin System # SPDX-License-Identifier: MIT +from __future__ import annotations import sys from pathlib import Path +from typing import TYPE_CHECKING from . import _cache, _download +if TYPE_CHECKING: + from collections.abc import Callable + + from ._conf import Configuration + __all__ = [ "install_file", "install_dir", @@ -14,9 +21,16 @@ def install_file( - prefix, name, url, filename, conf, *, ttl, checkfunc=None + prefix: str, + name: str, + url: str, + filename: str, + conf: Configuration, + *, + ttl: float, + checkfunc: Callable[[Path], None] | None = None, ) -> Path: - def fetch(dest): + def fetch(dest: Path) -> None: _print_progress_header(conf, name) _download.download_file( dest, @@ -36,8 +50,15 @@ def fetch(dest): ) -def install_dir(prefix, name, url, conf, *, checkfunc=None) -> Path: - def fetch(destdir): +def install_dir( + prefix: str, + name: str, + url: str, + conf: Configuration, + *, + checkfunc: Callable[[Path], None] | None = None, +) -> Path: + def fetch(destdir: Path) -> None: _print_progress_header(conf, name) _download.download_and_extract( destdir, @@ -56,7 +77,7 @@ def fetch(destdir): ) -def _print_progress_header(conf, name): +def _print_progress_header(conf: Configuration, name: str) -> None: if conf.progress: print( f"cjdk: Installing {name} to {conf.cache_dir}", diff --git a/src/cjdk/_jdk.py b/src/cjdk/_jdk.py index 2fcbd57..4a2dd8c 100644 --- a/src/cjdk/_jdk.py +++ b/src/cjdk/_jdk.py @@ -1,6 +1,7 @@ # This file is part of cjdk. # Copyright 2022-25 Board of Regents of the University of Wisconsin System # SPDX-License-Identifier: MIT +from __future__ import annotations from pathlib import Path @@ -17,7 +18,7 @@ _JDK_KEY_PREFIX = "jdks" -def install_jdk(conf: Configuration): +def install_jdk(conf: Configuration) -> Path: """ Install a JDK if it is not already installed. """ @@ -34,7 +35,7 @@ def install_jdk(conf: Configuration): ) from e -def find_home(path, _recursion_depth=2): +def find_home(path: Path, _recursion_depth: int = 2) -> Path: """ Find the Java home directory within path. @@ -54,14 +55,14 @@ def find_home(path, _recursion_depth=2): raise InstallError(f"{path} does not look like it contains a JDK or JRE") -def _looks_like_java_home(path): +def _looks_like_java_home(path: Path) -> bool: return (path / "bin").is_dir() and ( (path / "bin" / "java").is_file() or (path / "bin" / "java.exe").is_file() ) -def _contains_single_subdir(path): +def _contains_single_subdir(path: Path) -> Path | None: try: items = list(i for i in path.iterdir() if i.is_dir()) except OSError as e: diff --git a/src/cjdk/_progress.py b/src/cjdk/_progress.py index 4676eea..48c6e3b 100644 --- a/src/cjdk/_progress.py +++ b/src/cjdk/_progress.py @@ -1,14 +1,21 @@ # This file is part of cjdk. # Copyright 2022-25 Board of Regents of the University of Wisconsin System # SPDX-License-Identifier: MIT +from __future__ import annotations import os import sys import time from contextlib import contextmanager +from typing import TYPE_CHECKING, TypeVar, cast import progressbar +if TYPE_CHECKING: + from collections.abc import Callable, Iterable, Iterator, Sized + +_T = TypeVar("_T") + __all__ = [ "indefinite", "data_transfer", @@ -17,7 +24,7 @@ @contextmanager -def indefinite(*, enabled, text): +def indefinite(*, enabled: bool, text: str) -> Iterator[Callable[[], None]]: """ Context manager optionally displaying indefinite progress bar. @@ -36,7 +43,9 @@ def indefinite(*, enabled, text): yield lambda: pbar.update() -def data_transfer(total_bytes, iter, *, enabled, text): +def data_transfer( + total_bytes: int | None, iter: Iterable[bytes], *, enabled: bool, text: str +) -> Iterator[bytes]: """ Wrap bytes iterator with optional progress bar. @@ -49,9 +58,10 @@ def data_transfer(total_bytes, iter, *, enabled, text): enabled = _bar_enabled(enabled) barclass = progressbar.DataTransferBar if enabled else progressbar.NullBar size = 0 - if total_bytes is None: - total_bytes = progressbar.UnknownLength - with barclass(max_value=total_bytes, prefix=f"{text} ") as pbar: + max_val: int | type[progressbar.UnknownLength] = ( + total_bytes if total_bytes is not None else progressbar.UnknownLength + ) + with barclass(max_value=max_val, prefix=f"{text} ") as pbar: pbar.start() for chunk in iter: yield chunk @@ -59,7 +69,9 @@ def data_transfer(total_bytes, iter, *, enabled, text): pbar.update(size) -def iterate(iter, *, enabled, text, total=None): +def iterate( + iter: Iterable[_T], *, enabled: bool, text: str, total: int | None = None +) -> Iterator[_T]: """ Wrap iterator with optional progress bar. @@ -71,16 +83,18 @@ def iterate(iter, *, enabled, text, total=None): """ enabled = _bar_enabled(enabled) barclass = progressbar.ProgressBar if enabled else progressbar.NullBar - if total is None: - if hasattr(iter, "__len__"): - total = len(iter) - else: - total = progressbar.UnknownLength - bar = barclass(prefix=f"{text} ", max_value=total) + max_val: int | type[progressbar.UnknownLength] + if total is not None: + max_val = total + elif hasattr(iter, "__len__"): + max_val = len(cast("Sized", iter)) + else: + max_val = progressbar.UnknownLength + bar = barclass(prefix=f"{text} ", max_value=max_val) yield from bar(iter) -def _bar_enabled(enabled): +def _bar_enabled(enabled: bool) -> bool: if os.environ.get("CJDK_HIDE_PROGRESS_BARS", "").lower() in ( "1", "true", diff --git a/src/cjdk/_utils.py b/src/cjdk/_utils.py index dca0732..71bee70 100644 --- a/src/cjdk/_utils.py +++ b/src/cjdk/_utils.py @@ -1,14 +1,20 @@ # This file is part of cjdk. # Copyright 2022-25 Board of Regents of the University of Wisconsin System # SPDX-License-Identifier: MIT +from __future__ import annotations import os import shutil import time +from typing import TYPE_CHECKING from . import _progress from ._exceptions import InstallError +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + __all__ = [ "backoff_seconds", "rmtree_tempdir", @@ -20,7 +26,12 @@ _WIN_OPEN_FILE_ERRS = (5, 32) -def backoff_seconds(initial_interval, max_interval, max_total, factor=1.5): +def backoff_seconds( + initial_interval: float, + max_interval: float, + max_total: float, + factor: float = 1.5, +) -> Iterator[float]: """ Yield intervals to sleep after repeated attempts with exponential backoff. @@ -47,7 +58,7 @@ def backoff_seconds(initial_interval, max_interval, max_total, factor=1.5): yield -1 -def rmtree_tempdir(path, timeout=2.5): +def rmtree_tempdir(path: Path, timeout: float = 2.5) -> None: # Try extra hard to clean up a temporary directory. See comment in # unlink_tempfile() for why. @@ -70,7 +81,7 @@ def rmtree_tempdir(path, timeout=2.5): return -def unlink_tempfile(path, timeout=2.5): +def unlink_tempfile(path: Path, timeout: float = 2.5) -> None: # On Windows, we may encounter errors when trying to delete a file that we # just closed after writing, due to Antivirus opening the file to scan it. # Microsoft Defender Antivirus is said to use FILE_SHARE_DELETE, but @@ -110,7 +121,9 @@ def unlink_tempfile(path, timeout=2.5): return -def swap_in_file(target, tmpfile, timeout, progress=False): +def swap_in_file( + target: Path, tmpfile: Path, timeout: float, progress: bool = False +) -> None: # On POSIX, we only need to try once to move tmpfile to target; this will # work even if target is opened by others, and any failure (e.g. # insufficient permissions) is permanent. diff --git a/tests/mock_server.py b/tests/mock_server.py index 4b10a4b..a9db955 100644 --- a/tests/mock_server.py +++ b/tests/mock_server.py @@ -1,17 +1,23 @@ # This file is part of cjdk. # Copyright 2022-25 Board of Regents of the University of Wisconsin System # SPDX-License-Identifier: MIT +from __future__ import annotations import os import threading from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager +from typing import TYPE_CHECKING import flask import requests from werkzeug.debug import DebuggedApplication from werkzeug.serving import make_server +if TYPE_CHECKING: + from collections.abc import Iterator + from typing import Any + __all__ = [ "port", "start", @@ -21,20 +27,20 @@ _PORT = int(os.environ.get("CJDK_TEST_PORT", "5000")) -def port(): +def port() -> int: return _PORT @contextmanager def start( *, - endpoint="/test", - data={}, - download_endpoint="/download", - download_size=0, - file_endpoint="/file.txt", - file_data=b"hello", -): + endpoint: str = "/test", + data: dict[str, Any] = {}, + download_endpoint: str = "/download", + download_size: int = 0, + file_endpoint: str = "/file.txt", + file_data: bytes = b"hello", +) -> Iterator[_MockServer]: server = _start( endpoint, data, @@ -50,40 +56,46 @@ def start( def _start( - endpoint, data, download_endpoint, download_size, file_endpoint, file_data -): - def run_server(endpoint, data): + endpoint: str, + data: dict[str, Any], + download_endpoint: str, + download_size: int, + file_endpoint: str, + file_data: bytes, +) -> _MockServer: + def run_server(endpoint: str, data: dict[str, Any]) -> None: exec = ThreadPoolExecutor() server = None app = flask.Flask("mock_server") request_count = 0 @app.route("/health") - def health(): + def health() -> flask.Response: return flask.jsonify({}) @app.route("/shutdown") - def shutdown(): + def shutdown() -> str: nonlocal server + assert server is not None # The shutdown() method will block until the server exits, so we # need to run it from another thread. exec.submit(server.shutdown) return "Exiting" @app.route(endpoint) - def test(): + def test() -> flask.Response: nonlocal request_count request_count += 1 return flask.jsonify(data) @app.route("/request_count") - def count(): + def count() -> flask.Response: nonlocal request_count return flask.jsonify({"count": request_count}) @app.route(download_endpoint) - def download(): - def generate(): + def download() -> flask.Response: + def generate() -> Iterator[bytes]: remaining = download_size chunk_size = 4096 while remaining > chunk_size: @@ -96,12 +108,12 @@ def generate(): content_type="application/octet-stream", headers={ "content-disposition": "attachment; filename=test.zip", - "content-length": download_size, + "content-length": str(download_size), }, ) @app.route(file_endpoint) - def file(): + def file() -> flask.Response: return flask.Response( file_data, content_type="application/octet-stream", @@ -130,20 +142,22 @@ def file(): class _MockServer: - def __init__(self, port, endpoint, thread): + def __init__( + self, port: int, endpoint: str, thread: threading.Thread + ) -> None: self.port = port self.endpoint = endpoint self._thread = thread - def url(self, path): + def url(self, path: str) -> str: assert path.startswith("/") return f"http://127.0.0.1:{self.port}{path}" - def request_count(self): + def request_count(self) -> int: response = requests.get(self.url("/request_count")) return response.json()["count"] - def _shutdown(self): + def _shutdown(self) -> None: response = requests.get(self.url("/shutdown")) assert "Exiting" in response.text self._thread.join() diff --git a/tests/test_cache.py b/tests/test_cache.py index 362c749..43df82f 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -36,12 +36,12 @@ def test_file_exists_and_is_fresh(tmp_path): def test_key_directory(): f = _cache._key_directory - assert f(Path("a"), ("b",)) == Path("a/v0/b") + assert f(Path("a"), ("b", "c")) == Path("a/v0/b/c") def test_key_tmpdir(): f = _cache._key_tmpdir - assert f(Path("a"), ("b",)) == Path("a/v0/fetching/b") + assert f(Path("a"), ("b", "c")) == Path("a/v0/fetching/b/c") def test_move_in_fetched_directory(tmp_path): diff --git a/tests/test_conf.py b/tests/test_conf.py index 7930cc6..7535d34 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -32,10 +32,6 @@ def test_configure(): assert conf.vendor == _conf._default_vendor() assert not conf.version - conf = f(jdk=":", fallback_to_default_vendor=False) - assert conf.vendor is None - assert not conf.version - conf = f(cache_dir="abc") assert conf.cache_dir == Path("abc") @@ -46,7 +42,7 @@ def test_configure(): def test_read_vendor_version(): - f = _conf._parse_vendor_version + f = _conf.parse_vendor_version assert f("temurin:17") == ("temurin", "17") assert f(":") == ("", "") assert f("17") == ("", "17") diff --git a/uv.lock b/uv.lock index 3437856..27182a2 100644 --- a/uv.lock +++ b/uv.lock @@ -377,12 +377,14 @@ dependencies = [ { name = "click" }, { name = "progressbar2" }, { name = "requests" }, + { name = "typing-extensions" }, ] [package.dev-dependencies] dev = [ { name = "flask" }, { name = "pytest" }, + { name = "ty" }, { name = "werkzeug" }, ] docs = [ @@ -403,12 +405,14 @@ requires-dist = [ { name = "click", specifier = ">=8.0" }, { name = "progressbar2", specifier = ">=4.0" }, { name = "requests", specifier = ">=2.24" }, + { name = "typing-extensions", specifier = ">=4.15.0" }, ] [package.metadata.requires-dev] dev = [ { name = "flask", specifier = ">=2.0" }, { name = "pytest", specifier = ">=7.0" }, + { name = "ty", specifier = ">=0.0.9" }, { name = "werkzeug", specifier = ">=2.0" }, ] docs = [ @@ -515,7 +519,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -2446,6 +2450,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, ] +[[package]] +name = "ty" +version = "0.0.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/7b/4f677c622d58563c593c32081f8a8572afd90e43dc15b0dedd27b4305038/ty-0.0.9.tar.gz", hash = "sha256:83f980c46df17586953ab3060542915827b43c4748a59eea04190c59162957fe", size = 4858642, upload-time = "2026-01-05T12:24:56.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/3f/c1ee119738b401a8081ff84341781122296b66982e5982e6f162d946a1ff/ty-0.0.9-py3-none-linux_armv6l.whl", hash = "sha256:dd270d4dd6ebeb0abb37aee96cbf9618610723677f500fec1ba58f35bfa8337d", size = 9763596, upload-time = "2026-01-05T12:24:37.43Z" }, + { url = "https://files.pythonhosted.org/packages/63/41/6b0669ef4cd806d4bd5c30263e6b732a362278abac1bc3a363a316cde896/ty-0.0.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:debfb2ba418b00e86ffd5403cb666b3f04e16853f070439517dd1eaaeeff9255", size = 9591514, upload-time = "2026-01-05T12:24:26.891Z" }, + { url = "https://files.pythonhosted.org/packages/02/a1/874aa756aee5118e690340a771fb9ded0d0c2168c0b7cc7d9561c2a750b0/ty-0.0.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:107c76ebb05a13cdb669172956421f7ffd289ad98f36d42a44a465588d434d58", size = 9097773, upload-time = "2026-01-05T12:24:14.442Z" }, + { url = "https://files.pythonhosted.org/packages/32/62/cb9a460cf03baab77b3361d13106b93b40c98e274d07c55f333ce3c716f6/ty-0.0.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6868ca5c87ca0caa1b3cb84603c767356242b0659b88307eda69b2fb0bfa416b", size = 9581824, upload-time = "2026-01-05T12:24:35.074Z" }, + { url = "https://files.pythonhosted.org/packages/5a/97/633ecb348c75c954f09f8913669de8c440b13b43ea7d214503f3f1c4bb60/ty-0.0.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14a4aa0eb5c1d3591c2adbdda4e44429a6bb5d2e298a704398bb2a7ccdafdfe", size = 9591050, upload-time = "2026-01-05T12:24:08.804Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/4b0c6a7a8a234e2113f88c80cc7aaa9af5868de7a693859f3c49da981934/ty-0.0.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01bd4466504cefa36b465c6608e9af4504415fa67f6affc01c7d6ce36663c7f4", size = 10018262, upload-time = "2026-01-05T12:24:53.791Z" }, + { url = "https://files.pythonhosted.org/packages/cb/97/076d72a028f6b31e0b87287aa27c5b71a2f9927ee525260ea9f2f56828b8/ty-0.0.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:76c8253d1b30bc2c3eaa1b1411a1c34423decde0f4de0277aa6a5ceacfea93d9", size = 10911642, upload-time = "2026-01-05T12:24:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/705d6a5ed07ea36b1f23592c3f0dbc8fc7649267bfbb3bf06464cdc9a98a/ty-0.0.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8992fa4a9c6a5434eae4159fdd4842ec8726259bfd860e143ab95d078de6f8e3", size = 10632468, upload-time = "2026-01-05T12:24:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/44/78/4339a254537488d62bf392a936b3ec047702c0cc33d6ce3a5d613f275cd0/ty-0.0.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c79d503d151acb4a145a3d98702d07cb641c47292f63e5ffa0151e4020a5d33", size = 10273422, upload-time = "2026-01-05T12:24:45.8Z" }, + { url = "https://files.pythonhosted.org/packages/90/40/e7f386e87c9abd3670dcee8311674d7e551baa23b2e4754e2405976e6c92/ty-0.0.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a7ebf89ed276b564baa1f0dd9cd708e7b5aa89f19ce1b2f7d7132075abf93e", size = 10120289, upload-time = "2026-01-05T12:24:17.424Z" }, + { url = "https://files.pythonhosted.org/packages/f7/46/1027442596e725c50d0d1ab5179e9fa78a398ab412994b3006d0ee0899c7/ty-0.0.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ae3866e50109d2400a886bb11d9ef607f23afc020b226af773615cf82ae61141", size = 9566657, upload-time = "2026-01-05T12:24:51.048Z" }, + { url = "https://files.pythonhosted.org/packages/56/be/df921cf1967226aa01690152002b370a7135c6cced81e86c12b86552cdc4/ty-0.0.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:185244a5eacfcd8f5e2d85b95e4276316772f1e586520a6cb24aa072ec1bac26", size = 9610334, upload-time = "2026-01-05T12:24:20.334Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e8/f085268860232cc92ebe95415e5c8640f7f1797ac3a49ddd137c6222924d/ty-0.0.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f834ff27d940edb24b2e86bbb3fb45ab9e07cf59ca8c5ac615095b2542786408", size = 9726701, upload-time = "2026-01-05T12:24:29.785Z" }, + { url = "https://files.pythonhosted.org/packages/42/b4/9394210c66041cd221442e38f68a596945103d9446ece505889ffa9b3da9/ty-0.0.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:773f4b3ba046de952d7c1ad3a2c09b24f3ed4bc8342ae3cbff62ebc14aa6d48c", size = 10227082, upload-time = "2026-01-05T12:24:40.132Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9f/75951eb573b473d35dd9570546fc1319f7ca2d5b5c50a5825ba6ea6cb33a/ty-0.0.9-py3-none-win32.whl", hash = "sha256:1f20f67e373038ff20f36d5449e787c0430a072b92d5933c5b6e6fc79d3de4c8", size = 9176458, upload-time = "2026-01-05T12:24:32.559Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/b1cdf71ac874e72678161e25e2326a7d30bc3489cd3699561355a168e54f/ty-0.0.9-py3-none-win_amd64.whl", hash = "sha256:2c415f3bbb730f8de2e6e0b3c42eb3a91f1b5fbbcaaead2e113056c3b361c53c", size = 10040479, upload-time = "2026-01-05T12:24:42.697Z" }, + { url = "https://files.pythonhosted.org/packages/b5/8f/abc75c4bb774b12698629f02d0d12501b0a7dff9c31dc3bd6b6c6467e90a/ty-0.0.9-py3-none-win_arm64.whl", hash = "sha256:48e339d794542afeed710ea4f846ead865cc38cecc335a9c781804d02eaa2722", size = 9543127, upload-time = "2026-01-05T12:24:11.731Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"