diff --git a/docs/config/hatch.md b/docs/config/hatch.md index fec2dda01..21da37343 100644 --- a/docs/config/hatch.md +++ b/docs/config/hatch.md @@ -205,14 +205,14 @@ Any type of environment that is not explicitly defined will default to `/pythons`. +This determines where to install specific versions of Python. -The following values have special meanings. +The following values have special meanings: | Value | Path | | --- | --- | -| `isolated` (default) | `/pythons` | -| `shared` | `~/.pythons` | +| `shared` (default) | `~/.pythons` | +| `isolated` | `/pythons` | ## Terminal diff --git a/docs/history/hatch.md b/docs/history/hatch.md index 176f14131..a7308850e 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ***Added:*** - Add standalone binaries +- Add the ability to manage Python installations - Bump the minimum supported version of Hatchling to 1.17.1 - Bump the minimum supported version of `click` to 8.0.6 diff --git a/hatch.toml b/hatch.toml index 42dd5b43d..07f6d7e90 100644 --- a/hatch.toml +++ b/hatch.toml @@ -123,9 +123,20 @@ HATCH_BUILD_CLEAN = "true" build = "python -m build backend" publish = "hatch publish backend/dist" version = "cd backend && hatch version {args}" -update-data = [ + +[envs.upkeep] +detached = true +dependencies = [ + "httpx", +] +[envs.upkeep.scripts] +update-hatch = [ + "update-distributions", +] +update-hatchling = [ "update-licenses", ] +update-distributions = "python scripts/update_distributions.py" update-licenses = "python backend/scripts/update_licenses.py" [envs.release] diff --git a/pyproject.toml b/pyproject.toml index ae005efde..d745092e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ dependencies = [ "tomlkit>=0.11.1", "userpath~=1.7", "virtualenv>=20.16.2", + "zstandard<1", ] dynamic = ["version"] diff --git a/scripts/update_distributions.py b/scripts/update_distributions.py new file mode 100644 index 000000000..731e95f94 --- /dev/null +++ b/scripts/update_distributions.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import re +from ast import literal_eval +from collections import defaultdict + +import httpx +from utils import ROOT + +URL = 'https://raw.githubusercontent.com/ofek/pyapp/master/build.rs' +OUTPUT_FILE = ROOT / 'src' / 'hatch' / 'python' / 'distributions.py' +ARCHES = {('linux', 'x86'): 'i686', ('windows', 'x86_64'): 'amd64', ('windows', 'x86'): 'i386'} + +# system, architecture, ABI, variant +MAX_IDENTIFIER_COMPONENTS = 4 + + +def parse_distributions(contents: str, constant: str): + match = re.search(f'^const {constant}.+?^];$', contents, flags=re.DOTALL | re.MULTILINE) + if not match: + message = f'Could not find {constant} in {URL}' + raise ValueError(message) + + block = match.group(0).replace('",\n', '",') + for line in block.splitlines()[1:-1]: + line = line.strip() + if not line or line.startswith('//'): + continue + + identifier, *data, source = literal_eval(line[:-1]) + os, arch = data[:2] + if arch == 'powerpc64': + arch = 'ppc64le' + elif os == 'macos' and arch == 'aarch64': + arch = 'arm64' + + # Force everything to have a variant to maintain structure + if len(data) != MAX_IDENTIFIER_COMPONENTS: + data.append('') + + data[1] = ARCHES.get((os, arch), arch) + yield identifier, tuple(data), source + + +def main(): + response = httpx.get(URL) + response.raise_for_status() + + contents = response.text + distributions = defaultdict(list) + ordering_data = defaultdict(dict) + + for i, distribution_type in enumerate(('DEFAULT_CPYTHON_DISTRIBUTIONS', 'DEFAULT_PYPY_DISTRIBUTIONS')): + for identifier, data, source in parse_distributions(contents, distribution_type): + ordering_data[i][identifier] = None + distributions[identifier].append((data, source)) + + ordered = [identifier for identifiers in ordering_data.values() for identifier in reversed(identifiers)] + output = [ + 'from __future__ import annotations', + '', + '# fmt: off', + 'ORDERED_DISTRIBUTIONS: tuple[str, ...] = (', + ] + for identifier in ordered: + output.append(f' {identifier!r},') + output.append(')') + + output.append('DISTRIBUTIONS: dict[str, dict[tuple[str, ...], str]] = {') + for identifier, data in distributions.items(): + output.append(f' {identifier!r}: {{') + + for d, source in data: + output.append(f' {d!r}:') + output.append(f' {source!r},') + + output.append(' },') + + output.append('}') + output.append('') + output = '\n'.join(output) + + with open(OUTPUT_FILE, 'w') as f: + f.write(output) + + +if __name__ == '__main__': + main() diff --git a/src/hatch/cli/__init__.py b/src/hatch/cli/__init__.py index 95229d08a..bc07b49e4 100644 --- a/src/hatch/cli/__init__.py +++ b/src/hatch/cli/__init__.py @@ -12,6 +12,7 @@ from hatch.cli.new import new from hatch.cli.project import project from hatch.cli.publish import publish +from hatch.cli.python import python from hatch.cli.run import run from hatch.cli.shell import shell from hatch.cli.status import status @@ -22,7 +23,9 @@ from hatch.utils.fs import Path -@click.group(context_settings={'help_option_names': ['-h', '--help']}, invoke_without_command=True) +@click.group( + context_settings={'help_option_names': ['-h', '--help'], 'max_content_width': 120}, invoke_without_command=True +) @click.option( '--env', '-e', @@ -37,20 +40,6 @@ envvar=ConfigEnvVars.PROJECT, help='The name of the project to work on [env var: `HATCH_PROJECT`]', ) -@click.option( - '--color/--no-color', - default=None, - help='Whether or not to display colored output (default is auto-detection) [env vars: `FORCE_COLOR`/`NO_COLOR`]', -) -@click.option( - '--interactive/--no-interactive', - envvar=AppEnvVars.INTERACTIVE, - default=None, - help=( - 'Whether or not to allow features like prompts and progress bars (default is auto-detection) ' - '[env var: `HATCH_INTERACTIVE`]' - ), -) @click.option( '--verbose', '-v', @@ -65,6 +54,20 @@ count=True, help='Decrease verbosity (can be used additively) [env var: `HATCH_QUIET`]', ) +@click.option( + '--color/--no-color', + default=None, + help='Whether or not to display colored output (default is auto-detection) [env vars: `FORCE_COLOR`/`NO_COLOR`]', +) +@click.option( + '--interactive/--no-interactive', + envvar=AppEnvVars.INTERACTIVE, + default=None, + help=( + 'Whether or not to allow features like prompts and progress bars (default is auto-detection) ' + '[env var: `HATCH_INTERACTIVE`]' + ), +) @click.option( '--data-dir', envvar=ConfigEnvVars.DATA, @@ -83,7 +86,7 @@ ) @click.version_option(version=__version__, prog_name='Hatch') @click.pass_context -def hatch(ctx: click.Context, env_name, project, color, interactive, verbose, quiet, data_dir, cache_dir, config_file): +def hatch(ctx: click.Context, env_name, project, verbose, quiet, color, interactive, data_dir, cache_dir, config_file): """ \b _ _ _ _ @@ -194,6 +197,7 @@ def hatch(ctx: click.Context, env_name, project, color, interactive, verbose, qu hatch.add_command(new) hatch.add_command(project) hatch.add_command(publish) +hatch.add_command(python) hatch.add_command(run) hatch.add_command(shell) hatch.add_command(status) diff --git a/src/hatch/cli/application.py b/src/hatch/cli/application.py index ab21d57ac..32296c357 100644 --- a/src/hatch/cli/application.py +++ b/src/hatch/cli/application.py @@ -2,6 +2,7 @@ import os import sys +from functools import cached_property from typing import TYPE_CHECKING, cast from hatch.cli.terminal import Terminal @@ -214,6 +215,27 @@ def get_env_directory(self, environment_type): else: return self.data_dir / 'env' / environment_type + def get_python_manager(self, directory: str | None = None): + from hatch.python.core import PythonManager + + configured_dir = directory or self.config.dirs.python + if configured_dir == 'shared': + return PythonManager(Path.home() / '.pythons') + elif configured_dir == 'isolated': + return PythonManager(self.data_dir / 'pythons') + else: + return PythonManager(Path(configured_dir).expand()) + + @cached_property + def shell_data(self) -> tuple[str, str]: + import shellingham + + try: + return shellingham.detect_shell() + except shellingham.ShellDetectionFailure: + path = self.platform.default_shell + return Path(path).stem, path + def abort(self, text='', code=1, **kwargs): if text: self.display_error(text, **kwargs) diff --git a/src/hatch/cli/python/__init__.py b/src/hatch/cli/python/__init__.py new file mode 100644 index 000000000..7097be521 --- /dev/null +++ b/src/hatch/cli/python/__init__.py @@ -0,0 +1,17 @@ +import click + +from hatch.cli.python.install import install +from hatch.cli.python.remove import remove +from hatch.cli.python.show import show +from hatch.cli.python.update import update + + +@click.group(short_help='Manage Python installations') +def python(): + pass + + +python.add_command(install) +python.add_command(remove) +python.add_command(show) +python.add_command(update) diff --git a/src/hatch/cli/python/install.py b/src/hatch/cli/python/install.py new file mode 100644 index 000000000..df46a5010 --- /dev/null +++ b/src/hatch/cli/python/install.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +if TYPE_CHECKING: + from hatch.cli.application import Application + + +def ensure_path_public(path: str, shells: list[str]) -> bool: + import userpath + + if userpath.in_current_path(path) or userpath.in_new_path(path, shells): + return True + + userpath.append(path, shells=shells) + return False + + +@click.command(short_help='Install Python distributions') +@click.argument('names', required=True, nargs=-1) +@click.option('--private', is_flag=True, help='Do not add distributions to the user PATH') +@click.option('--update', '-u', is_flag=True, help='Update existing installations') +@click.option( + '--dir', '-d', 'directory', help='The directory in which to install distributions, overriding configuration' +) +@click.pass_obj +def install(app: Application, *, names: tuple[str, ...], private: bool, update: bool, directory: str | None): + """ + Install Python distributions. + + You may select `all` to install all compatible distributions: + + \b + ``` + hatch python install all + ``` + """ + from hatch.errors import PythonDistributionResolutionError, PythonDistributionUnknownError + from hatch.python.distributions import ORDERED_DISTRIBUTIONS + from hatch.python.resolve import get_distribution + + shells = [] + if not private and not app.platform.windows: + shell_name, _ = app.shell_data + shells.append(shell_name) + + manager = app.get_python_manager(directory) + installed = manager.get_installed() + selection = ORDERED_DISTRIBUTIONS if 'all' in names else names + unknown = [] + compatible = [] + incompatible = [] + for name in selection: + if name in installed: + compatible.append(name) + continue + + try: + get_distribution(name) + except PythonDistributionUnknownError: + unknown.append(name) + except PythonDistributionResolutionError: + incompatible.append(name) + else: + compatible.append(name) + + if unknown: + app.abort(f'Unknown distributions: {", ".join(unknown)}') + elif incompatible and (not compatible or 'all' not in names): + app.abort(f'Incompatible distributions: {", ".join(incompatible)}') + + directories_made_public = [] + for name in compatible: + needs_update = False + if name in installed: + needs_update = installed[name].needs_update() + if not needs_update: + app.display_warning(f'The latest version is already installed: {name}') + continue + elif not (update or app.confirm(f'Update {name}?')): + app.abort(f'Distribution is already installed: {name}') + + with app.status(f'{"Updating" if needs_update else "Installing"} {name}'): + dist = manager.install(name) + if not private: + python_directory = str(dist.python_path.parent) + if not ensure_path_public(python_directory, shells=shells): + directories_made_public.append(python_directory) + + app.display_success(f'{"Updated" if needs_update else "Installed"} {name} @ {dist.path}') + + if directories_made_public: + multiple = len(directories_made_public) > 1 + app.display( + f'\nThe following director{"ies" if multiple else "y"} ha{"ve" if multiple else "s"} ' + f'been added to your PATH (pending a shell restart):\n' + ) + for directory in directories_made_public: + app.display(directory) diff --git a/src/hatch/cli/python/remove.py b/src/hatch/cli/python/remove.py new file mode 100644 index 000000000..c3acd06b5 --- /dev/null +++ b/src/hatch/cli/python/remove.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +if TYPE_CHECKING: + from hatch.cli.application import Application + + +@click.command(short_help='Remove Python distributions') +@click.argument('names', required=True, nargs=-1) +@click.option('--dir', '-d', 'directory', help='The directory in which distributions reside') +@click.pass_obj +def remove(app: Application, *, names: tuple[str, ...], directory: str | None): + """ + Remove Python distributions. + + You may select `all` to remove all installed distributions: + + \b + ``` + hatch python remove all + ``` + """ + manager = app.get_python_manager(directory) + installed = manager.get_installed() + selection = tuple(installed) if 'all' in names else names + for name in selection: + if name not in installed: + app.display_warning(f'Distribution is not installed: {name}') + continue + + dist = installed[name] + with app.status(f'Removing {name}'): + manager.remove(dist) diff --git a/src/hatch/cli/python/show.py b/src/hatch/cli/python/show.py new file mode 100644 index 000000000..f9e31232d --- /dev/null +++ b/src/hatch/cli/python/show.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +if TYPE_CHECKING: + from hatch.cli.application import Application + + +@click.command(short_help='Show the available Python distributions') +@click.option('--ascii', 'force_ascii', is_flag=True, help='Whether or not to only use ASCII characters') +@click.option('--dir', '-d', 'directory', help='The directory in which distributions reside') +@click.pass_obj +def show(app: Application, *, force_ascii: bool, directory: str | None): + """Show the available Python distributions.""" + from hatch.python.resolve import get_compatible_distributions + + manager = app.get_python_manager(directory) + installed = manager.get_installed() + + installed_columns: dict[str, dict[int, str]] = {'Name': {}, 'Version': {}, 'Status': {}} + for i, (name, installed_dist) in enumerate(installed.items()): + installed_columns['Name'][i] = name + installed_columns['Version'][i] = installed_dist.version + if installed_dist.needs_update(): + installed_columns['Status'][i] = 'Update available' + + available_columns: dict[str, dict[int, str]] = {'Name': {}, 'Version': {}} + for i, (name, dist) in enumerate(get_compatible_distributions().items()): + if name in installed: + continue + + available_columns['Name'][i] = name + available_columns['Version'][i] = dist.version.base_version + + app.display_table('Installed', installed_columns, show_lines=True, force_ascii=force_ascii) + app.display_table('Available', available_columns, show_lines=True, force_ascii=force_ascii) diff --git a/src/hatch/cli/python/update.py b/src/hatch/cli/python/update.py new file mode 100644 index 000000000..ad57e986a --- /dev/null +++ b/src/hatch/cli/python/update.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from hatch.cli.python.install import install + +if TYPE_CHECKING: + from hatch.cli.application import Application + + +@click.command(short_help='Update Python distributions') +@click.argument('names', required=True, nargs=-1) +@click.option('--dir', '-d', 'directory', help='The directory in which distributions reside') +@click.pass_context +def update(ctx: click.Context, *, names: tuple[str, ...], directory: str | None): + """ + Update Python distributions. + + You may select `all` to update all installed distributions: + + \b + ``` + hatch python update all + ``` + """ + app: Application = ctx.obj + + manager = app.get_python_manager(directory) + installed = manager.get_installed() + selection = tuple(installed) if 'all' in names else names + + not_installed = [name for name in selection if name not in installed] + if not_installed: + app.abort(f'Distributions not installed: {", ".join(not_installed)}') + + ctx.invoke(install, names=selection, directory=directory, private=True, update=True) diff --git a/src/hatch/cli/shell/__init__.py b/src/hatch/cli/shell/__init__.py index 0f56bbd21..ec8d7aa30 100644 --- a/src/hatch/cli/shell/__init__.py +++ b/src/hatch/cli/shell/__init__.py @@ -28,20 +28,9 @@ def shell(app, shell_name, shell_path, shell_args): # no cov shell_args = app.config.shell.args if not shell_path: - import shellingham - - try: - shell_name, command = shellingham.detect_shell() - except shellingham.ShellDetectionFailure: - from hatch.utils.fs import Path - - shell_path = app.platform.default_shell - shell_name = Path(shell_path).stem - else: - if app.platform.windows: - shell_path = command - else: - shell_path, *shell_args = app.platform.modules.shlex.split(command) + shell_name, shell_path = app.shell_data + if not app.platform.windows: + shell_path, *shell_args = app.platform.modules.shlex.split(shell_path) with app.project.location.as_cwd(): environment = app.get_environment() diff --git a/src/hatch/config/model.py b/src/hatch/config/model.py index 2fefa9908..2de330957 100644 --- a/src/hatch/config/model.py +++ b/src/hatch/config/model.py @@ -369,7 +369,7 @@ def python(self): self._field_python = python else: - self._field_python = self.raw_data['python'] = 'isolated' + self._field_python = self.raw_data['python'] = 'shared' return self._field_python diff --git a/src/hatch/errors/__init__.py b/src/hatch/errors/__init__.py new file mode 100644 index 000000000..cde1881cd --- /dev/null +++ b/src/hatch/errors/__init__.py @@ -0,0 +1,10 @@ +class HatchError(Exception): + pass + + +class PythonDistributionUnknownError(HatchError): + pass + + +class PythonDistributionResolutionError(HatchError): + pass diff --git a/src/hatch/python/__init__.py b/src/hatch/python/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/hatch/python/core.py b/src/hatch/python/core.py new file mode 100644 index 000000000..b69aa9e1f --- /dev/null +++ b/src/hatch/python/core.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from hatch.python.distributions import DISTRIBUTIONS, ORDERED_DISTRIBUTIONS +from hatch.python.resolve import get_distribution +from hatch.utils.fs import temp_directory + +if TYPE_CHECKING: + from hatch.python.resolve import Distribution + from hatch.utils.fs import Path + + +class InstalledDistribution: + def __init__(self, path: Path, distribution: Distribution, metadata: dict[str, Any]) -> None: + self.__path = path + self.__current_dist = distribution + self.__metadata = metadata + + @property + def path(self) -> Path: + return self.__path + + @property + def name(self) -> str: + return self.__current_dist.name + + @property + def python_path(self) -> Path: + return self.path / self.__current_dist.python_path + + @property + def version(self) -> str: + return self.__current_dist.version.base_version + + @property + def metadata(self) -> dict[str, Any]: + return self.__metadata + + def needs_update(self) -> bool: + new_dist = get_distribution(self.__current_dist.name) + return new_dist.version > self.__current_dist.version + + @classmethod + def metadata_filename(cls) -> str: + return 'hatch-dist.json' + + +class PythonManager: + def __init__(self, directory: Path) -> None: + self.__directory = directory + + @property + def directory(self) -> Path: + return self.__directory + + def get_installed(self) -> dict[str, InstalledDistribution]: + if not self.directory.is_dir(): + return {} + + import json + + installed_distributions: list[InstalledDistribution] = [] + for path in self.directory.iterdir(): + if not (path.name in DISTRIBUTIONS and path.is_dir()): + continue + + metadata_file = path / InstalledDistribution.metadata_filename() + if not metadata_file.is_file(): + continue + + metadata = json.loads(metadata_file.read_text()) + distribution = get_distribution(path.name, source=metadata.get('source', '')) + if not (path / distribution.python_path).is_file(): + continue + + installed_distributions.append(InstalledDistribution(path, distribution, metadata)) + + installed_distributions.sort(key=lambda d: ORDERED_DISTRIBUTIONS.index(d.name)) + return {dist.name: dist for dist in installed_distributions} + + def install(self, identifier: str) -> InstalledDistribution: + import json + + from hatch.utils.network import download_file + + dist = get_distribution(identifier) + path = self.directory / identifier + self.directory.ensure_dir_exists() + + with temp_directory() as temp_dir: + archive_path = temp_dir / dist.archive_name + unpack_path = temp_dir / identifier + download_file(archive_path, dist.source, follow_redirects=True) + dist.unpack(archive_path, unpack_path) + + backup_path = path.with_suffix('.bak') + if backup_path.is_dir(): + backup_path.wait_for_dir_removed() + + if path.is_dir(): + path.replace(backup_path) + + try: + unpack_path.replace(path) + except OSError: + import shutil + + try: + shutil.move(str(unpack_path), str(path)) + except OSError: + path.wait_for_dir_removed() + if backup_path.is_dir(): + backup_path.replace(path) + + raise + + metadata = {'source': dist.source, 'python_path': dist.python_path} + metadata_file = path / InstalledDistribution.metadata_filename() + metadata_file.write_text(json.dumps(metadata, indent=2)) + + return InstalledDistribution(path, dist, metadata) + + def remove(self, dist: InstalledDistribution) -> None: + dist.path.wait_for_dir_removed() diff --git a/src/hatch/python/distributions.py b/src/hatch/python/distributions.py new file mode 100644 index 000000000..3aac05ec7 --- /dev/null +++ b/src/hatch/python/distributions.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +# fmt: off +ORDERED_DISTRIBUTIONS: tuple[str, ...] = ( + '3.7', + '3.8', + '3.9', + '3.10', + '3.11', + 'pypy2.7', + 'pypy3.9', + 'pypy3.10', +) +DISTRIBUTIONS: dict[str, dict[tuple[str, ...], str]] = { + '3.11': { + ('linux', 'aarch64', 'gnu', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-aarch64-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'ppc64le', 'gnu', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-ppc64le-unknown-linux-gnu-install_only.tar.gz', + ('linux', 's390x', 'gnu', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-s390x-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'i686', 'gnu', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-i686-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'x86_64', 'gnu', 'v1'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-x86_64-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'x86_64', 'gnu', 'v2'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-x86_64_v2-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'x86_64', 'gnu', 'v3'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-x86_64_v3-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'x86_64', 'gnu', 'v4'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-x86_64_v4-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'x86_64', 'musl', 'v1'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-x86_64-unknown-linux-musl-install_only.tar.gz', + ('linux', 'x86_64', 'musl', 'v2'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-x86_64_v2-unknown-linux-musl-install_only.tar.gz', + ('linux', 'x86_64', 'musl', 'v3'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-x86_64_v3-unknown-linux-musl-install_only.tar.gz', + ('linux', 'x86_64', 'musl', 'v4'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-x86_64_v4-unknown-linux-musl-install_only.tar.gz', + ('windows', 'i386', 'msvc', 'shared'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-i686-pc-windows-msvc-shared-install_only.tar.gz', + ('windows', 'i386', 'msvc', 'static'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-i686-pc-windows-msvc-static-install_only.tar.gz', + ('windows', 'amd64', 'msvc', 'shared'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-x86_64-pc-windows-msvc-shared-install_only.tar.gz', + ('windows', 'amd64', 'msvc', 'static'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-x86_64-pc-windows-msvc-static-install_only.tar.gz', + ('macos', 'arm64', '', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-aarch64-apple-darwin-install_only.tar.gz', + ('macos', 'x86_64', '', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.11.5%2B20230826-x86_64-apple-darwin-install_only.tar.gz', + }, + '3.10': { + ('linux', 'aarch64', 'gnu', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-aarch64-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'ppc64le', 'gnu', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-ppc64le-unknown-linux-gnu-install_only.tar.gz', + ('linux', 's390x', 'gnu', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-s390x-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'i686', 'gnu', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-i686-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'x86_64', 'gnu', 'v1'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-x86_64-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'x86_64', 'gnu', 'v2'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-x86_64_v2-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'x86_64', 'gnu', 'v3'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-x86_64_v3-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'x86_64', 'gnu', 'v4'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-x86_64_v4-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'x86_64', 'musl', 'v1'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-x86_64-unknown-linux-musl-install_only.tar.gz', + ('linux', 'x86_64', 'musl', 'v2'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-x86_64_v2-unknown-linux-musl-install_only.tar.gz', + ('linux', 'x86_64', 'musl', 'v3'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-x86_64_v3-unknown-linux-musl-install_only.tar.gz', + ('linux', 'x86_64', 'musl', 'v4'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-x86_64_v4-unknown-linux-musl-install_only.tar.gz', + ('windows', 'i386', 'msvc', 'shared'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-i686-pc-windows-msvc-shared-install_only.tar.gz', + ('windows', 'i386', 'msvc', 'static'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-i686-pc-windows-msvc-static-install_only.tar.gz', + ('windows', 'amd64', 'msvc', 'shared'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-x86_64-pc-windows-msvc-shared-install_only.tar.gz', + ('windows', 'amd64', 'msvc', 'static'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-x86_64-pc-windows-msvc-static-install_only.tar.gz', + ('macos', 'arm64', '', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-aarch64-apple-darwin-install_only.tar.gz', + ('macos', 'x86_64', '', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.10.13%2B20230826-x86_64-apple-darwin-install_only.tar.gz', + }, + '3.9': { + ('linux', 'aarch64', 'gnu', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-aarch64-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'ppc64le', 'gnu', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-ppc64le-unknown-linux-gnu-install_only.tar.gz', + ('linux', 's390x', 'gnu', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-s390x-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'i686', 'gnu', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-i686-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'x86_64', 'gnu', 'v1'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-x86_64-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'x86_64', 'gnu', 'v2'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-x86_64_v2-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'x86_64', 'gnu', 'v3'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-x86_64_v3-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'x86_64', 'gnu', 'v4'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-x86_64_v4-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'x86_64', 'musl', 'v1'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-x86_64-unknown-linux-musl-install_only.tar.gz', + ('linux', 'x86_64', 'musl', 'v2'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-x86_64_v2-unknown-linux-musl-install_only.tar.gz', + ('linux', 'x86_64', 'musl', 'v3'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-x86_64_v3-unknown-linux-musl-install_only.tar.gz', + ('linux', 'x86_64', 'musl', 'v4'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-x86_64_v4-unknown-linux-musl-install_only.tar.gz', + ('windows', 'i386', 'msvc', 'shared'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-i686-pc-windows-msvc-shared-install_only.tar.gz', + ('windows', 'i386', 'msvc', 'static'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-i686-pc-windows-msvc-static-install_only.tar.gz', + ('windows', 'amd64', 'msvc', 'shared'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-x86_64-pc-windows-msvc-shared-install_only.tar.gz', + ('windows', 'amd64', 'msvc', 'static'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-x86_64-pc-windows-msvc-static-install_only.tar.gz', + ('macos', 'arm64', '', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-aarch64-apple-darwin-install_only.tar.gz', + ('macos', 'x86_64', '', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.9.18%2B20230826-x86_64-apple-darwin-install_only.tar.gz', + }, + '3.8': { + ('linux', 'aarch64', 'gnu', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.8.17%2B20230826-aarch64-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'i686', 'gnu', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.8.17%2B20230826-i686-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'x86_64', 'gnu', 'v1'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.8.17%2B20230826-x86_64-unknown-linux-gnu-install_only.tar.gz', + ('linux', 'x86_64', 'musl', 'v1'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.8.17%2B20230826-x86_64-unknown-linux-musl-install_only.tar.gz', + ('windows', 'i386', 'msvc', 'shared'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.8.17%2B20230826-i686-pc-windows-msvc-shared-install_only.tar.gz', + ('windows', 'i386', 'msvc', 'static'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.8.17%2B20230826-i686-pc-windows-msvc-static-install_only.tar.gz', + ('windows', 'amd64', 'msvc', 'shared'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.8.17%2B20230826-x86_64-pc-windows-msvc-shared-install_only.tar.gz', + ('windows', 'amd64', 'msvc', 'static'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.8.17%2B20230826-x86_64-pc-windows-msvc-static-install_only.tar.gz', + ('macos', 'arm64', '', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.8.17%2B20230826-aarch64-apple-darwin-install_only.tar.gz', + ('macos', 'x86_64', '', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20230826/cpython-3.8.17%2B20230826-x86_64-apple-darwin-install_only.tar.gz', + }, + '3.7': { + ('linux', 'x86_64', 'gnu', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20200822/cpython-3.7.9-x86_64-unknown-linux-gnu-pgo-20200823T0036.tar.zst', + ('linux', 'x86_64', 'musl', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20200822/cpython-3.7.9-x86_64-unknown-linux-musl-noopt-20200823T0036.tar.zst', + ('windows', 'i386', 'msvc', 'shared'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20200822/cpython-3.7.9-i686-pc-windows-msvc-shared-pgo-20200823T0159.tar.zst', + ('windows', 'i386', 'msvc', 'static'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20200822/cpython-3.7.9-i686-pc-windows-msvc-static-noopt-20200823T0221.tar.zst', + ('windows', 'amd64', 'msvc', 'shared'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20200822/cpython-3.7.9-x86_64-pc-windows-msvc-shared-pgo-20200823T0118.tar.zst', + ('windows', 'amd64', 'msvc', 'static'): + 'https://github.com/indygreg/python-build-standalone/releases/download/20200822/cpython-3.7.9-x86_64-pc-windows-msvc-static-noopt-20200823T0153.tar.zst', + ('macos', 'x86_64', '', ''): + 'https://github.com/indygreg/python-build-standalone/releases/download/20200823/cpython-3.7.9-x86_64-apple-darwin-pgo-20200823T2228.tar.zst', + }, + 'pypy3.10': { + ('linux', 'aarch64', 'gnu', ''): + 'https://downloads.python.org/pypy/pypy3.10-v7.3.12-aarch64.tar.bz2', + ('linux', 'x86_64', 'gnu', ''): + 'https://downloads.python.org/pypy/pypy3.10-v7.3.12-linux64.tar.bz2', + ('windows', 'amd64', 'msvc', ''): + 'https://downloads.python.org/pypy/pypy3.10-v7.3.12-win64.zip', + ('macos', 'arm64', '', ''): + 'https://downloads.python.org/pypy/pypy3.10-v7.3.12-macos_arm64.tar.bz2', + ('macos', 'x86_64', '', ''): + 'https://downloads.python.org/pypy/pypy3.10-v7.3.12-macos_x86_64.tar.bz2', + }, + 'pypy3.9': { + ('linux', 'aarch64', 'gnu', ''): + 'https://downloads.python.org/pypy/pypy3.9-v7.3.12-aarch64.tar.bz2', + ('linux', 'x86_64', 'gnu', ''): + 'https://downloads.python.org/pypy/pypy3.9-v7.3.12-linux64.tar.bz2', + ('windows', 'amd64', 'msvc', ''): + 'https://downloads.python.org/pypy/pypy3.9-v7.3.12-win64.zip', + ('macos', 'arm64', '', ''): + 'https://downloads.python.org/pypy/pypy3.9-v7.3.12-macos_arm64.tar.bz2', + ('macos', 'x86_64', '', ''): + 'https://downloads.python.org/pypy/pypy3.9-v7.3.12-macos_x86_64.tar.bz2', + }, + 'pypy2.7': { + ('linux', 'aarch64', 'gnu', ''): + 'https://downloads.python.org/pypy/pypy2.7-v7.3.12-aarch64.tar.bz2', + ('linux', 'x86_64', 'gnu', ''): + 'https://downloads.python.org/pypy/pypy2.7-v7.3.12-linux64.tar.bz2', + ('windows', 'amd64', 'msvc', ''): + 'https://downloads.python.org/pypy/pypy2.7-v7.3.12-win64.zip', + ('macos', 'arm64', '', ''): + 'https://downloads.python.org/pypy/pypy2.7-v7.3.12-macos_arm64.tar.bz2', + ('macos', 'x86_64', '', ''): + 'https://downloads.python.org/pypy/pypy2.7-v7.3.12-macos_x86_64.tar.bz2', + }, +} diff --git a/src/hatch/python/resolve.py b/src/hatch/python/resolve.py new file mode 100644 index 000000000..680a22412 --- /dev/null +++ b/src/hatch/python/resolve.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import os +import platform +import sys +from abc import ABC, abstractmethod +from functools import cached_property +from typing import TYPE_CHECKING + +from hatch.errors import PythonDistributionResolutionError, PythonDistributionUnknownError +from hatch.python.distributions import DISTRIBUTIONS, ORDERED_DISTRIBUTIONS + +if TYPE_CHECKING: + from packaging.version import Version + + from hatch.utils.fs import Path + + +class Distribution(ABC): + def __init__(self, name: str, source: str) -> None: + self.__name = name + self.__source = source + + @property + def name(self) -> str: + return self.__name + + @property + def source(self) -> str: + return self.__source + + @cached_property + def archive_name(self) -> str: + return self.source.rsplit('/', 1)[-1] + + def unpack(self, archive: Path, directory: Path) -> None: + if self.source.endswith('.zip'): + import zipfile + + with zipfile.ZipFile(archive, 'r') as zf: + zf.extractall(directory) + elif self.source.endswith('.tar.gz'): + import tarfile + + with tarfile.open(archive, 'r:gz') as tf: + tf.extractall(directory) + elif self.source.endswith('.tar.bz2'): + import tarfile + + with tarfile.open(archive, 'r:bz2') as tf: + tf.extractall(directory) + elif self.source.endswith('.tar.zst'): + import tarfile + + import zstandard + + with open(archive, 'rb') as ifh: + dctx = zstandard.ZstdDecompressor() + with dctx.stream_reader(ifh) as reader, tarfile.open(mode='r|', fileobj=reader) as tf: + tf.extractall(directory) + else: + message = f'Unknown archive type: {archive}' + raise ValueError(message) + + @property + @abstractmethod + def version(self) -> Version: + pass + + @property + @abstractmethod + def python_path(self) -> str: + pass + + +class CPythonStandaloneDistribution(Distribution): + @cached_property + def version(self) -> Version: + from packaging.version import Version + + *_, remaining = self.source.partition('/download/') + version, *_ = remaining.partition('/') + return Version(f'0!{version}') + + @cached_property + def python_path(self) -> str: + if self.name == '3.7': + if sys.platform == 'win32': + return r'python\install\python.exe' + else: + return 'python/install/bin/python3' + elif sys.platform == 'win32': + return r'python\python.exe' + else: + return 'python/bin/python3' + + +class PyPyOfficialDistribution(Distribution): + @cached_property + def version(self) -> Version: + from packaging.version import Version + + *_, remaining = self.source.partition('/pypy/') + _, version, *_ = remaining.split('-') + return Version(f'0!{version[1:]}') + + @cached_property + def python_path(self) -> str: + directory = self.archive_name + for extension in ('.tar.bz2', '.zip'): + if directory.endswith(extension): + directory = directory[: -len(extension)] + break + + if sys.platform == 'win32': + return rf'{directory}\pypy.exe' + else: + return f'{directory}/bin/pypy' + + +def get_distribution(name: str, source: str = '', variant: str = '') -> Distribution: + if source: + return _get_distribution_class(source)(name, source) + elif name not in DISTRIBUTIONS: + message = f'Unknown distribution: {name}' + raise PythonDistributionUnknownError(message) + + arch = platform.machine().lower() + if sys.platform == 'win32': + system = 'windows' + abi = 'msvc' + elif sys.platform == 'darwin': + system = 'macos' + abi = '' + else: + system = 'linux' + abi = 'gnu' if any(platform.libc_ver()) else 'musl' + + if not variant: + variant = _get_default_variant(name, system, arch, abi) + + key = (system, arch, abi, variant) + + keys: dict[tuple, str] = DISTRIBUTIONS[name] + if key not in keys: + message = f'Could not find a default source for {name=} {system=} {arch=} {abi=} {variant=}' + raise PythonDistributionResolutionError(message) + + source = keys[key] + return _get_distribution_class(source)(name, source) + + +def get_compatible_distributions() -> dict[str, Distribution]: + distributions: dict[str, Distribution] = {} + for name in ORDERED_DISTRIBUTIONS: + try: + dist = get_distribution(name) + except PythonDistributionResolutionError: + pass + else: + distributions[name] = dist + + return distributions + + +def _get_default_variant(name: str, system: str, arch: str, abi: str) -> str: + variant = os.environ.get(f'HATCH_PYTHON_VARIANT_{system.upper()}', '').lower() + if variant: + return variant + + if name[0].isdigit(): + if system == 'windows' and abi == 'msvc': + return 'shared' + elif system == 'linux' and arch == 'x86_64': + if name == '3.8': + return 'v1' + elif name != '3.7': + return 'v3' + + return '' + + +def _get_distribution_class(source: str) -> type[Distribution]: + if source.startswith('https://github.com/indygreg/python-build-standalone/releases/download/'): + return CPythonStandaloneDistribution + elif source.startswith('https://downloads.python.org/pypy/'): + return PyPyOfficialDistribution + else: + message = f'Unknown distribution source: {source}' + raise ValueError(message) diff --git a/src/hatch/utils/fs.py b/src/hatch/utils/fs.py index ed480244a..d02857423 100644 --- a/src/hatch/utils/fs.py +++ b/src/hatch/utils/fs.py @@ -40,10 +40,6 @@ def ensure_parent_dir_exists(self) -> None: def expand(self) -> Path: return Path(os.path.expanduser(os.path.expandvars(self))) - def resolve(self, strict: bool = False) -> Path: # noqa: FBT001, FBT002 - # https://bugs.python.org/issue38671 - return Path(os.path.realpath(self)) - def remove(self) -> None: if self.is_file(): os.remove(self) @@ -52,6 +48,20 @@ def remove(self) -> None: shutil.rmtree(self, ignore_errors=False) + def wait_for_dir_removed(self, timeout: int = 5) -> None: + import shutil + import time + + for _ in range(timeout * 2): + if self.is_dir(): + shutil.rmtree(self, ignore_errors=True) + time.sleep(0.5) + else: + return + + if self.is_dir(): + shutil.rmtree(self, ignore_errors=False) + def write_atomic(self, data: str | bytes, *args: Any, **kwargs: Any) -> None: from tempfile import mkstemp @@ -92,6 +102,12 @@ def temp_hide(self) -> Generator[Path, None, None]: with suppress(FileNotFoundError): shutil.move(str(temp_path), self) + if sys.version_info[:2] < (3, 10): + + def resolve(self, strict: bool = False) -> Path: # noqa: FBT001, FBT002 + # https://bugs.python.org/issue38671 + return Path(os.path.realpath(self)) + @contextmanager def temp_directory() -> Generator[Path, None, None]: diff --git a/src/hatch/utils/network.py b/src/hatch/utils/network.py index 4e665c6e3..999639ddb 100644 --- a/src/hatch/utils/network.py +++ b/src/hatch/utils/network.py @@ -1,5 +1,8 @@ from __future__ import annotations +import time +from contextlib import contextmanager +from secrets import choice from typing import TYPE_CHECKING, Any import httpx @@ -7,8 +10,30 @@ if TYPE_CHECKING: from hatch.utils.fs import Path +MINIMUM_SLEEP = 2 +MAXIMUM_SLEEP = 20 + + +@contextmanager +def streaming_response(*args: Any, **kwargs: Any) -> httpx.Response: + attempts = 0 + while True: + attempts += 1 + try: + with httpx.stream(*args, **kwargs) as response: + response.raise_for_status() + yield response + + break + except httpx.HTTPError: + sleep = min(MAXIMUM_SLEEP, MINIMUM_SLEEP * 2**attempts) + if sleep == MAXIMUM_SLEEP: + raise + + time.sleep(choice(range(sleep + 1))) + def download_file(path: Path, *args: Any, **kwargs: Any) -> None: - with path.open(mode='wb', buffering=0) as f, httpx.stream('GET', *args, **kwargs) as response: + with path.open(mode='wb', buffering=0) as f, streaming_response('GET', *args, **kwargs) as response: for chunk in response.iter_bytes(16384): f.write(chunk) diff --git a/tests/cli/config/test_show.py b/tests/cli/config/test_show.py index 809ad8d2c..69882a8dc 100644 --- a/tests/cli/config/test_show.py +++ b/tests/cli/config/test_show.py @@ -17,7 +17,7 @@ def test_default_scrubbed(hatch, config_file, helpers, default_cache_dir, defaul [dirs] project = [] - python = "isolated" + python = "shared" data = "{default_data_directory}" cache = "{default_cache_directory}" @@ -71,7 +71,7 @@ def test_reveal(hatch, config_file, helpers, default_cache_dir, default_data_dir [dirs] project = [] - python = "isolated" + python = "shared" data = "{default_data_directory}" cache = "{default_cache_directory}" diff --git a/tests/cli/python/__init__.py b/tests/cli/python/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cli/python/conftest.py b/tests/cli/python/conftest.py new file mode 100644 index 000000000..c5c6aea7c --- /dev/null +++ b/tests/cli/python/conftest.py @@ -0,0 +1,30 @@ +import secrets + +import pytest + + +@pytest.fixture(autouse=True) +def default_shells(platform): + return [] if platform.windows else ['sh'] + + +@pytest.fixture(autouse=True) +def isolated_python_directory(config_file): + config_file.model.dirs.python = 'isolated' + config_file.save() + + +@pytest.fixture(autouse=True) +def path_append(mocker): + return mocker.patch('userpath.append') + + +@pytest.fixture(autouse=True) +def disable_path_detectors(mocker): + mocker.patch('userpath.in_current_path', return_value=False) + mocker.patch('userpath.in_new_path', return_value=False) + + +@pytest.fixture +def dist_name(compatible_python_distributions): + return secrets.choice(compatible_python_distributions) diff --git a/tests/cli/python/test_install.py b/tests/cli/python/test_install.py new file mode 100644 index 000000000..10b5ddc61 --- /dev/null +++ b/tests/cli/python/test_install.py @@ -0,0 +1,293 @@ +import json +import secrets + +import pytest + +from hatch.python.core import InstalledDistribution +from hatch.python.distributions import ORDERED_DISTRIBUTIONS +from hatch.python.resolve import get_distribution +from hatch.utils.structures import EnvVars + +from .utils import downgrade_distribution_metadata, write_distribution + + +def test_unknown(hatch, helpers, path_append, mocker): + install = mocker.patch('hatch.python.core.PythonManager.install') + + result = hatch('python', 'install', 'foo', 'bar') + + assert result.exit_code == 1, result.output + assert result.output == helpers.dedent( + """ + Unknown distributions: foo, bar + """ + ) + + install.assert_not_called() + path_append.assert_not_called() + + +def test_incompatible_single(hatch, helpers, path_append, platform, dist_name, mocker): + install = mocker.patch('hatch.python.core.PythonManager.install') + + with EnvVars({f'HATCH_PYTHON_VARIANT_{platform.name.upper()}': 'foo'}): + result = hatch('python', 'install', dist_name) + + assert result.exit_code == 1, result.output + assert result.output == helpers.dedent( + f""" + Incompatible distributions: {dist_name} + """ + ) + + install.assert_not_called() + path_append.assert_not_called() + + +def test_incompatible_all(hatch, helpers, path_append, platform, mocker): + install = mocker.patch('hatch.python.core.PythonManager.install') + + with EnvVars({f'HATCH_PYTHON_VARIANT_{platform.name.upper()}': 'foo'}): + result = hatch('python', 'install', 'all') + + assert result.exit_code == 1, result.output + assert result.output == helpers.dedent( + f""" + Incompatible distributions: {', '.join(ORDERED_DISTRIBUTIONS)} + """ + ) + + install.assert_not_called() + path_append.assert_not_called() + + +@pytest.mark.requires_internet +def test_installation( + hatch, helpers, temp_dir_data, platform, path_append, default_shells, compatible_python_distributions +): + selection = [name for name in compatible_python_distributions if not name.startswith('pypy')] + dist_name = secrets.choice(selection) + result = hatch('python', 'install', dist_name) + + install_dir = temp_dir_data / 'data' / 'pythons' / dist_name + metadata_file = install_dir / InstalledDistribution.metadata_filename() + python_path = install_dir / json.loads(metadata_file.read_text())['python_path'] + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + Installing {dist_name} + Installed {dist_name} @ {install_dir} + + The following directory has been added to your PATH (pending a shell restart): + + {python_path.parent} + """ + ) + + assert python_path.is_file() + + output = platform.check_command_output([python_path, '-c', 'import sys;print(sys.executable)']).strip() + assert output == str(python_path) + + output = platform.check_command_output([python_path, '--version']).strip() + assert output.startswith(f'Python {dist_name}.') + + path_append.assert_called_once_with(str(python_path.parent), shells=default_shells) + + +def test_already_installed_latest(hatch, helpers, temp_dir_data, path_append, dist_name, mocker): + install = mocker.patch('hatch.python.core.PythonManager.install') + install_dir = temp_dir_data / 'data' / 'pythons' + write_distribution(install_dir, dist_name) + + result = hatch('python', 'install', dist_name) + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + The latest version is already installed: {dist_name} + """ + ) + + install.assert_not_called() + path_append.assert_not_called() + + +def test_already_installed_update_disabled(hatch, helpers, temp_dir_data, path_append, dist_name, mocker): + install = mocker.patch('hatch.python.core.PythonManager.install') + install_dir = temp_dir_data / 'data' / 'pythons' + write_distribution(install_dir, dist_name) + downgrade_distribution_metadata(install_dir / dist_name) + + result = hatch('python', 'install', dist_name, input='n\n') + + assert result.exit_code == 1, result.output + assert result.output == helpers.dedent( + f""" + Update {dist_name}? [y/N]: n + Distribution is already installed: {dist_name} + """ + ) + + install.assert_not_called() + path_append.assert_not_called() + + +def test_already_installed_update_prompt(hatch, helpers, temp_dir_data, path_append, default_shells, dist_name, mocker): + install_dir = temp_dir_data / 'data' / 'pythons' + write_distribution(install_dir, dist_name) + + dist_dir = install_dir / dist_name + metadata = downgrade_distribution_metadata(dist_dir) + python_path = dist_dir / metadata['python_path'] + install = mocker.patch( + 'hatch.python.core.PythonManager.install', return_value=mocker.MagicMock(path=dist_dir, python_path=python_path) + ) + + result = hatch('python', 'install', dist_name, input='y\n') + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + Update {dist_name}? [y/N]: y + Updating {dist_name} + Updated {dist_name} @ {dist_dir} + + The following directory has been added to your PATH (pending a shell restart): + + {python_path.parent} + """ + ) + + install.assert_called_once_with(dist_name) + path_append.assert_called_once_with(str(python_path.parent), shells=default_shells) + + +def test_already_installed_update_flag(hatch, helpers, temp_dir_data, path_append, default_shells, dist_name, mocker): + install_dir = temp_dir_data / 'data' / 'pythons' + write_distribution(install_dir, dist_name) + + dist_dir = install_dir / dist_name + metadata = downgrade_distribution_metadata(dist_dir) + python_path = dist_dir / metadata['python_path'] + install = mocker.patch( + 'hatch.python.core.PythonManager.install', return_value=mocker.MagicMock(path=dist_dir, python_path=python_path) + ) + + result = hatch('python', 'install', '--update', dist_name) + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + Updating {dist_name} + Updated {dist_name} @ {dist_dir} + + The following directory has been added to your PATH (pending a shell restart): + + {python_path.parent} + """ + ) + + install.assert_called_once_with(dist_name) + path_append.assert_called_once_with(str(python_path.parent), shells=default_shells) + + +@pytest.mark.parametrize('detector', ['in_current_path', 'in_new_path']) +def test_already_in_path(hatch, helpers, temp_dir_data, path_append, mocker, detector, dist_name): + mocker.patch(f'userpath.{detector}', return_value=True) + dist_dir = temp_dir_data / 'data' / 'pythons' / dist_name + python_path = dist_dir / get_distribution(dist_name).python_path + install = mocker.patch( + 'hatch.python.core.PythonManager.install', return_value=mocker.MagicMock(path=dist_dir, python_path=python_path) + ) + + result = hatch('python', 'install', dist_name) + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + Installing {dist_name} + Installed {dist_name} @ {dist_dir} + """ + ) + + install.assert_called_once_with(dist_name) + path_append.assert_not_called() + + +def test_private(hatch, helpers, temp_dir_data, path_append, dist_name, mocker): + dist_dir = temp_dir_data / 'data' / 'pythons' / dist_name + python_path = dist_dir / get_distribution(dist_name).python_path + install = mocker.patch( + 'hatch.python.core.PythonManager.install', return_value=mocker.MagicMock(path=dist_dir, python_path=python_path) + ) + + result = hatch('python', 'install', '--private', dist_name) + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + Installing {dist_name} + Installed {dist_name} @ {dist_dir} + """ + ) + + install.assert_called_once_with(dist_name) + path_append.assert_not_called() + + +def test_specific_location(hatch, helpers, temp_dir_data, path_append, dist_name, mocker): + install_dir = temp_dir_data / 'foo' / 'bar' / 'baz' + dist_dir = install_dir / dist_name + python_path = dist_dir / get_distribution(dist_name).python_path + install = mocker.patch( + 'hatch.python.core.PythonManager.install', return_value=mocker.MagicMock(path=dist_dir, python_path=python_path) + ) + + result = hatch('python', 'install', '--private', '-d', str(install_dir), dist_name) + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + Installing {dist_name} + Installed {dist_name} @ {dist_dir} + """ + ) + + install.assert_called_once_with(dist_name) + path_append.assert_not_called() + + +def test_all(hatch, temp_dir_data, path_append, default_shells, mocker, compatible_python_distributions): + mocked_dists = [] + for name in compatible_python_distributions: + dist_dir = temp_dir_data / 'data' / 'pythons' / name + python_path = dist_dir / get_distribution(name).python_path + mocked_dists.append(mocker.MagicMock(path=dist_dir, python_path=python_path)) + + install = mocker.patch('hatch.python.core.PythonManager.install', side_effect=mocked_dists) + + result = hatch('python', 'install', 'all') + + assert result.exit_code == 0, result.output + + expected_lines = [] + for dist in mocked_dists: + expected_lines.append(f'Installing {dist.path.name}') + expected_lines.append(f'Installed {dist.path.name} @ {dist.path}') + + expected_lines.append('') + expected_lines.append('The following directories have been added to your PATH (pending a shell restart):') + expected_lines.append('') + + for dist in mocked_dists: + expected_lines.append(str(dist.python_path.parent)) + expected_lines.append('') + + assert result.output == '\n'.join(expected_lines) + + assert install.call_args_list == [mocker.call(name) for name in compatible_python_distributions] + assert path_append.call_args_list == [ + mocker.call(str(dist.python_path.parent), shells=default_shells) for dist in mocked_dists + ] diff --git a/tests/cli/python/test_remove.py b/tests/cli/python/test_remove.py new file mode 100644 index 000000000..b1bfe1ee2 --- /dev/null +++ b/tests/cli/python/test_remove.py @@ -0,0 +1,65 @@ +from .utils import write_distribution + + +def test_not_installed(hatch, helpers): + result = hatch('python', 'remove', '3.9', '3.10') + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + """ + Distribution is not installed: 3.9 + Distribution is not installed: 3.10 + """ + ) + + +def test_basic(hatch, helpers, temp_dir_data): + install_dir = temp_dir_data / 'data' / 'pythons' + for name in ('3.9', '3.10'): + write_distribution(install_dir, name) + + result = hatch('python', 'remove', '3.9') + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + """ + Removing 3.9 + """ + ) + + assert not (install_dir / '3.9').exists() + assert (install_dir / '3.10').is_dir() + + +def test_specific_location(hatch, helpers, temp_dir_data, dist_name): + install_dir = temp_dir_data / 'foo' / 'bar' / 'baz' + write_distribution(install_dir, dist_name) + + result = hatch('python', 'remove', '-d', str(install_dir), dist_name) + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + Removing {dist_name} + """ + ) + + assert not any(install_dir.iterdir()) + + +def test_all(hatch, temp_dir_data): + installed_distributions = ('3.9', '3.10', '3.11') + for name in installed_distributions: + install_dir = temp_dir_data / 'data' / 'pythons' + write_distribution(install_dir, name) + + result = hatch('python', 'remove', 'all') + + assert result.exit_code == 0, result.output + + expected_lines = [] + for name in installed_distributions: + expected_lines.append(f'Removing {name}') + expected_lines.append('') + + assert result.output == '\n'.join(expected_lines) diff --git a/tests/cli/python/test_show.py b/tests/cli/python/test_show.py new file mode 100644 index 000000000..6bdc194e7 --- /dev/null +++ b/tests/cli/python/test_show.py @@ -0,0 +1,141 @@ +from rich.box import ASCII_DOUBLE_HEAD +from rich.console import Console +from rich.table import Table + +from hatch.python.resolve import get_compatible_distributions + +from .utils import downgrade_distribution_metadata, downgrade_version, write_distribution + + +def render_table(title, rows): + console = Console(force_terminal=False, no_color=True, legacy_windows=False) + table = Table(title=title, show_lines=True, title_style='', box=ASCII_DOUBLE_HEAD, safe_box=True) + + for column in rows[0]: + table.add_column(column, style='bold') + + for row in rows[1:]: + table.add_row(*row) + + with console.capture() as capture: + console.print(table) + + return capture.get() + + +def test_nothing_installed(hatch): + compatible_distributions = get_compatible_distributions() + available_table = render_table( + 'Available', + [ + ['Name', 'Version'], + *[[d.name, d.version.base_version] for d in compatible_distributions.values()], + ], + ) + + result = hatch('python', 'show', '--ascii') + + assert result.exit_code == 0, result.output + assert result.output == available_table + + +def test_some_installed(hatch, temp_dir_data, dist_name): + install_dir = temp_dir_data / 'data' / 'pythons' + write_distribution(install_dir, dist_name) + + compatible_distributions = get_compatible_distributions() + installed_distribution = compatible_distributions.pop(dist_name) + installed_table = render_table( + 'Installed', + [ + ['Name', 'Version'], + [dist_name, installed_distribution.version.base_version], + ], + ) + available_table = render_table( + 'Available', + [ + ['Name', 'Version'], + *[[d.name, d.version.base_version] for d in compatible_distributions.values()], + ], + ) + + result = hatch('python', 'show', '--ascii') + + assert result.exit_code == 0, result.output + assert result.output == installed_table + available_table + + +def test_all_installed(hatch, temp_dir_data): + install_dir = temp_dir_data / 'data' / 'pythons' + compatible_distributions = get_compatible_distributions() + for dist_name in compatible_distributions: + write_distribution(install_dir, dist_name) + + installed_table = render_table( + 'Installed', + [ + ['Name', 'Version'], + *[[d.name, d.version.base_version] for d in compatible_distributions.values()], + ], + ) + + result = hatch('python', 'show', '--ascii') + + assert result.exit_code == 0, result.output + assert result.output == installed_table + + +def test_specific_location(hatch, temp_dir_data, dist_name): + install_dir = temp_dir_data / 'foo' / 'bar' / 'baz' + write_distribution(install_dir, dist_name) + + compatible_distributions = get_compatible_distributions() + installed_distribution = compatible_distributions.pop(dist_name) + installed_table = render_table( + 'Installed', + [ + ['Name', 'Version'], + [dist_name, installed_distribution.version.base_version], + ], + ) + available_table = render_table( + 'Available', + [ + ['Name', 'Version'], + *[[d.name, d.version.base_version] for d in compatible_distributions.values()], + ], + ) + + result = hatch('python', 'show', '--ascii', '-d', str(install_dir)) + + assert result.exit_code == 0, result.output + assert result.output == installed_table + available_table + + +def test_outdated(hatch, temp_dir_data, dist_name): + install_dir = temp_dir_data / 'data' / 'pythons' + write_distribution(install_dir, dist_name) + downgrade_distribution_metadata(install_dir / dist_name) + + compatible_distributions = get_compatible_distributions() + installed_distribution = compatible_distributions.pop(dist_name) + installed_table = render_table( + 'Installed', + [ + ['Name', 'Version', 'Status'], + [dist_name, downgrade_version(installed_distribution.version.base_version), 'Update available'], + ], + ) + available_table = render_table( + 'Available', + [ + ['Name', 'Version'], + *[[d.name, d.version.base_version] for d in compatible_distributions.values()], + ], + ) + + result = hatch('python', 'show', '--ascii') + + assert result.exit_code == 0, result.output + assert result.output == installed_table + available_table diff --git a/tests/cli/python/test_update.py b/tests/cli/python/test_update.py new file mode 100644 index 000000000..770f4689e --- /dev/null +++ b/tests/cli/python/test_update.py @@ -0,0 +1,94 @@ +from .utils import downgrade_distribution_metadata, write_distribution + + +def test_not_installed(hatch, helpers): + result = hatch('python', 'update', '3.9', '3.10') + + assert result.exit_code == 1, result.output + assert result.output == helpers.dedent( + """ + Distributions not installed: 3.9, 3.10 + """ + ) + + +def test_basic(hatch, helpers, temp_dir_data, path_append, dist_name, mocker): + install_dir = temp_dir_data / 'data' / 'pythons' + write_distribution(install_dir, dist_name) + + dist_dir = install_dir / dist_name + metadata = downgrade_distribution_metadata(dist_dir) + python_path = dist_dir / metadata['python_path'] + install = mocker.patch( + 'hatch.python.core.PythonManager.install', return_value=mocker.MagicMock(path=dist_dir, python_path=python_path) + ) + + result = hatch('python', 'update', dist_name) + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + Updating {dist_name} + Updated {dist_name} @ {dist_dir} + """ + ) + + install.assert_called_once_with(dist_name) + path_append.assert_not_called() + + +def test_specific_location(hatch, helpers, temp_dir_data, path_append, dist_name, mocker): + install = mocker.patch('hatch.python.core.PythonManager.install') + install_dir = temp_dir_data / 'foo' / 'bar' / 'baz' + write_distribution(install_dir, dist_name) + + dist_dir = install_dir / dist_name + metadata = downgrade_distribution_metadata(dist_dir) + python_path = dist_dir / metadata['python_path'] + install = mocker.patch( + 'hatch.python.core.PythonManager.install', return_value=mocker.MagicMock(path=dist_dir, python_path=python_path) + ) + + result = hatch('python', 'update', '-d', str(install_dir), dist_name) + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + Updating {dist_name} + Updated {dist_name} @ {dist_dir} + """ + ) + + install.assert_called_once_with(dist_name) + path_append.assert_not_called() + + +def test_all(hatch, temp_dir_data, path_append, mocker): + installed_distributions = ('3.9', '3.10', '3.11') + + mocked_dists = [] + for name in installed_distributions: + install_dir = temp_dir_data / 'data' / 'pythons' + write_distribution(install_dir, name) + + dist_dir = install_dir / name + metadata = downgrade_distribution_metadata(dist_dir) + python_path = dist_dir / metadata['python_path'] + mocked_dists.append(mocker.MagicMock(path=dist_dir, python_path=python_path)) + + install = mocker.patch('hatch.python.core.PythonManager.install', side_effect=mocked_dists) + + result = hatch('python', 'update', 'all') + + assert result.exit_code == 0, result.output + + expected_lines = [] + for dist in mocked_dists: + expected_lines.append(f'Updating {dist.path.name}') + expected_lines.append(f'Updated {dist.path.name} @ {dist.path}') + expected_lines.append('') + + assert result.output == '\n'.join(expected_lines) + + assert install.call_args_list == [mocker.call(name) for name in installed_distributions] + path_append.assert_not_called() diff --git a/tests/cli/python/utils.py b/tests/cli/python/utils.py new file mode 100644 index 000000000..8debb95bc --- /dev/null +++ b/tests/cli/python/utils.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from hatch.python.core import InstalledDistribution +from hatch.python.resolve import get_distribution + +if TYPE_CHECKING: + from hatch.utils.fs import Path + + +def write_distribution(directory: Path, name: str): + dist = get_distribution(name) + path = directory / dist.name + path.ensure_dir_exists() + python_path = path / dist.python_path + python_path.parent.ensure_dir_exists() + python_path.touch() + metadata_file = path / InstalledDistribution.metadata_filename() + metadata_file.write_text(json.dumps({'source': dist.source, 'python_path': dist.python_path})) + + +def downgrade_distribution_metadata(dist_dir: Path): + metadata_file = dist_dir / InstalledDistribution.metadata_filename() + metadata = json.loads(metadata_file.read_text()) + dist = InstalledDistribution(dist_dir, get_distribution(dist_dir.name), metadata) + + source = metadata['source'] + python_path = metadata['python_path'] + version = dist.version + new_version = downgrade_version(version) + new_source = source.replace(version, new_version) + metadata['source'] = new_source + + # We also modify the Python path because some directory structures are determined + # by the archive name which is itself determined by the source + metadata['python_path'] = python_path.replace(version, new_version) + if python_path != metadata['python_path']: + new_python_path = dist_dir / metadata['python_path'] + new_python_path.parent.ensure_dir_exists() + (dist_dir / python_path).rename(new_python_path) + + metadata_file.write_text(json.dumps(metadata)) + return metadata + + +def downgrade_version(version: str) -> str: + major_version = version.split('.')[0] + return version.replace(major_version, str(int(major_version) - 1), 1) diff --git a/tests/config/test_model.py b/tests/config/test_model.py index e5f1608d3..dde1df69c 100644 --- a/tests/config/test_model.py +++ b/tests/config/test_model.py @@ -16,7 +16,7 @@ def test_default(default_cache_dir, default_data_dir): 'dirs': { 'project': [], 'env': {}, - 'python': 'isolated', + 'python': 'shared', 'data': str(default_data_dir), 'cache': str(default_cache_dir), }, @@ -348,14 +348,14 @@ def test_default(self, default_cache_dir, default_data_dir): default_data_directory = str(default_data_dir) assert config.dirs.project == config.dirs.project == [] assert config.dirs.env == config.dirs.env == {} - assert config.dirs.python == config.dirs.python == 'isolated' + assert config.dirs.python == config.dirs.python == 'shared' assert config.dirs.cache == config.dirs.cache == default_cache_directory assert config.dirs.data == config.dirs.data == default_data_directory assert config.raw_data == { 'dirs': { 'project': [], 'env': {}, - 'python': 'isolated', + 'python': 'shared', 'data': default_data_directory, 'cache': default_cache_directory, }, diff --git a/tests/conftest.py b/tests/conftest.py index cb11271c6..b0d791b16 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -83,6 +83,11 @@ def isolation() -> Generator[Path, None, None]: 'COLUMNS': '80', 'LINES': '24', } + if PLATFORM.windows: + default_env_vars['COMSPEC'] = 'cmd.exe' + else: + default_env_vars['SHELL'] = 'sh' + with d.as_cwd(default_env_vars): os.environ.pop(AppEnvVars.ENV_ACTIVE, None) yield d @@ -175,6 +180,13 @@ def python_on_path(): return Path(sys.executable).stem +@pytest.fixture(scope='session') +def compatible_python_distributions(): + from hatch.python.resolve import get_compatible_distributions + + return tuple(get_compatible_distributions()) + + @pytest.fixture(scope='session') def devpi(tmp_path_factory, worker_id): import platform diff --git a/tests/python/__init__.py b/tests/python/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/python/test_core.py b/tests/python/test_core.py new file mode 100644 index 000000000..475c8640f --- /dev/null +++ b/tests/python/test_core.py @@ -0,0 +1,80 @@ +import json + +import pytest + +from hatch.python.core import InstalledDistribution, PythonManager +from hatch.python.distributions import DISTRIBUTIONS, ORDERED_DISTRIBUTIONS +from hatch.python.resolve import get_distribution + + +@pytest.mark.requires_internet +@pytest.mark.parametrize('name', ORDERED_DISTRIBUTIONS) +def test_installation(temp_dir, platform, name): + # Ensure the source and any parent directories get created + manager = PythonManager(temp_dir / 'foo' / 'bar') + dist = manager.install(name) + + python_path = dist.python_path + assert python_path.is_file() + + output = platform.check_command_output([python_path, '-c', 'import sys;print(sys.executable)']).strip() + assert output == str(python_path) + + major_minor = name.replace('pypy', '') + + output = platform.check_command_output([python_path, '--version']).strip() + + assert output.startswith(f'Python {major_minor}.') + if name.startswith('pypy'): + assert 'PyPy' in output + + +class TestGetInstalled: + def test_source_does_not_exist(self, temp_dir): + manager = PythonManager(temp_dir / 'foo') + + assert manager.get_installed() == {} + + def test_not_a_directory(self, temp_dir): + manager = PythonManager(temp_dir) + + dist = get_distribution('3.10') + path = temp_dir / dist.name + path.touch() + + assert manager.get_installed() == {} + + def test_no_metadata_file(self, temp_dir): + manager = PythonManager(temp_dir) + + dist = get_distribution('3.10') + path = temp_dir / dist.name + path.mkdir() + + assert manager.get_installed() == {} + + def test_no_python_path(self, temp_dir): + manager = PythonManager(temp_dir) + + dist = get_distribution('3.10') + path = temp_dir / dist.name + path.mkdir() + metadata_file = path / InstalledDistribution.metadata_filename() + metadata_file.write_text(json.dumps({'source': dist.source})) + + assert manager.get_installed() == {} + + def test_order(self, temp_dir): + manager = PythonManager(temp_dir) + + for name in DISTRIBUTIONS: + dist = get_distribution(name) + path = temp_dir / dist.name + path.mkdir() + metadata_file = path / InstalledDistribution.metadata_filename() + metadata_file.write_text(json.dumps({'source': dist.source})) + python_path = path / dist.python_path + python_path.parent.ensure_dir_exists() + python_path.touch() + + assert tuple(manager.get_installed()) == ORDERED_DISTRIBUTIONS diff --git a/tests/python/test_resolve.py b/tests/python/test_resolve.py new file mode 100644 index 000000000..b6deb4fbd --- /dev/null +++ b/tests/python/test_resolve.py @@ -0,0 +1,60 @@ +import pytest + +from hatch.errors import PythonDistributionResolutionError, PythonDistributionUnknownError +from hatch.python.resolve import get_distribution +from hatch.utils.structures import EnvVars + + +class TestErrors: + def test_unknown_distribution(self): + with pytest.raises(PythonDistributionUnknownError, match='Unknown distribution: foo'): + get_distribution('foo') + + def test_resolution_error(self, platform): + with EnvVars({f'HATCH_PYTHON_VARIANT_{platform.name.upper()}': 'foo'}), pytest.raises( + PythonDistributionResolutionError, + match=f"Could not find a default source for name='3.11' system='{platform.name}' arch=", + ): + get_distribution('3.11') + + +class TestDistributionVersions: + def test_cpython_standalone(self): + url = 'https://github.com/indygreg/python-build-standalone/releases/download/20230507/cpython-3.11.3%2B20230507-aarch64-unknown-linux-gnu-install_only.tar.gz' # noqa: E501 + dist = get_distribution('3.11', url) + version = dist.version + + assert version.epoch == 0 + assert version.base_version == '20230507' + + def test_pypy(self): + url = 'https://downloads.python.org/pypy/pypy3.10-v7.3.12-aarch64.tar.bz2' + dist = get_distribution('pypy3.10', url) + version = dist.version + + assert version.epoch == 0 + assert version.base_version == '7.3.12' + + +@pytest.mark.parametrize( + 'system, variant', + [ + ('windows', 'shared'), + ('windows', 'static'), + ('linux', 'v1'), + ('linux', 'v2'), + ('linux', 'v3'), + ('linux', 'v4'), + ], +) +def test_variants(platform, system, variant): + if platform.name != system: + pytest.skip(f'Skipping test for: {system}') + + with EnvVars({f'HATCH_PYTHON_VARIANT_{system.upper()}': variant}): + dist = get_distribution('3.11') + + if system == 'linux' and variant == 'v1': + assert variant not in dist.source + else: + assert variant in dist.source