From 82a9e2ccd7221722a5c7ba54f6cdd645670a68cb Mon Sep 17 00:00:00 2001 From: woutdenolf Date: Wed, 5 Aug 2026 17:47:59 +0200 Subject: [PATCH 1/2] bug: version is a function, not a property --- src/ewoks/_requirements/utils/detect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ewoks/_requirements/utils/detect.py b/src/ewoks/_requirements/utils/detect.py index bce99e1..3d82727 100644 --- a/src/ewoks/_requirements/utils/detect.py +++ b/src/ewoks/_requirements/utils/detect.py @@ -44,7 +44,7 @@ def _select_manager(manager_name: str, manager_command: Tuple[str, ...]) -> Base raise ValueError(f"Package manager {manager_name!r} is not supported") manager = manager_cls(*manager_command) - if not manager.version: + if manager.version() is None: raise ValueError(f"Package manager {manager_name!r} is not available") return manager From d25f9cedf89c29a452100550f9a99ed88662d31c Mon Sep 17 00:00:00 2001 From: woutdenolf Date: Wed, 5 Aug 2026 18:09:42 +0200 Subject: [PATCH 2/2] ewoks install: support uv similar to pip --- CHANGELOG.md | 1 + pyproject.toml | 2 +- src/ewoks/_requirements/__init__.py | 10 ++ src/ewoks/_requirements/conda.py | 1 + src/ewoks/_requirements/pip.py | 71 ++------- src/ewoks/_requirements/pipenv.py | 1 + src/ewoks/_requirements/pixi.py | 1 + src/ewoks/_requirements/poetry.py | 1 + src/ewoks/_requirements/utils/_supported.py | 4 +- src/ewoks/_requirements/utils/base_manager.py | 7 +- .../_requirements/utils/freeze_manager.py | 81 ++++++++++ src/ewoks/_requirements/uv.py | 93 ++++++++---- src/ewoks/cli_utils/cli_arguments.py | 26 ++++ src/ewoks/cli_utils/cli_convert_utils.py | 14 +- src/ewoks/cli_utils/cli_install_utils.py | 15 +- src/ewoks/tests/requirements/conftest.py | 28 ++++ src/ewoks/tests/requirements/managers.py | 119 +++++++++++++++ src/ewoks/tests/requirements/pip/__init__.py | 0 .../requirements/pip/test_install_cli.py | 141 ------------------ .../requirements/{pip => }/test_freeze.py | 2 +- .../requirements/test_freeze_managers.py | 79 ++++++++++ .../tests/requirements/test_install_cli.py | 104 +++++++++++++ src/ewoks/tests/requirements/test_managers.py | 42 ++++++ src/ewoks/tests/requirements/test_pip.py | 35 +++++ src/ewoks/tests/requirements/test_uv.py | 69 +++++++++ src/ewoks/tests/requirements/utils.py | 21 +++ 26 files changed, 712 insertions(+), 256 deletions(-) create mode 100644 src/ewoks/_requirements/utils/freeze_manager.py create mode 100644 src/ewoks/tests/requirements/conftest.py create mode 100644 src/ewoks/tests/requirements/managers.py delete mode 100644 src/ewoks/tests/requirements/pip/__init__.py delete mode 100644 src/ewoks/tests/requirements/pip/test_install_cli.py rename src/ewoks/tests/requirements/{pip => }/test_freeze.py (97%) create mode 100644 src/ewoks/tests/requirements/test_freeze_managers.py create mode 100644 src/ewoks/tests/requirements/test_install_cli.py create mode 100644 src/ewoks/tests/requirements/test_managers.py create mode 100644 src/ewoks/tests/requirements/test_pip.py create mode 100644 src/ewoks/tests/requirements/test_uv.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 09d77d1..29e0b46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ewoks install`: add `--package-manager-name` and `--package-manager-command` arguments. - `ewoks convert`: add `--package-manager-name` and `--package-manager-command` arguments. - Add `ewoks lint` CLI command to check if a workflow is conform to the Ewoks specification. +- Support the `uv` package manager to generate and install workflow requirements. ### Removed diff --git a/pyproject.toml b/pyproject.toml index 7bab95c..9122009 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ test = [ "ipykernel", "importlib_resources", "pyqt5", - "pytest-venv", + "uv", "defusedxml", ] dev = [ diff --git a/src/ewoks/_requirements/__init__.py b/src/ewoks/_requirements/__init__.py index 6aaa98b..7d35d6d 100644 --- a/src/ewoks/_requirements/__init__.py +++ b/src/ewoks/_requirements/__init__.py @@ -1,6 +1,7 @@ """Workflow requirements.""" import logging +from typing import Dict from typing import Optional from typing import Tuple from typing import Union @@ -8,6 +9,7 @@ from ewokscore.graph import TaskGraph from .utils import parse +from .utils._supported import get_supported_managers from .utils.base_manager import BaseRequirements from .utils.detect import get_manager from .utils.metadata import last_resort @@ -15,6 +17,14 @@ logger = logging.getLogger(__file__) +def supported_managers() -> Dict[str, str]: + """Names of the supported package managers with an example command for each.""" + return { + name: manager_cls.COMMAND_EXAMPLE + for name, manager_cls in get_supported_managers().items() + } + + def add_requirements( graph: TaskGraph, manager_name: Optional[str] = None, diff --git a/src/ewoks/_requirements/conda.py b/src/ewoks/_requirements/conda.py index 449a5ad..3e17a51 100644 --- a/src/ewoks/_requirements/conda.py +++ b/src/ewoks/_requirements/conda.py @@ -29,6 +29,7 @@ class CondaManager(BaseManager): NAME = "conda" PRIORITY = 4 REQUIREMENTS_MODEL = CondaRequirements + COMMAND_EXAMPLE = "conda" def __init__(self, *command: str) -> None: if not command: diff --git a/src/ewoks/_requirements/pip.py b/src/ewoks/_requirements/pip.py index f1bd48d..8b13740 100644 --- a/src/ewoks/_requirements/pip.py +++ b/src/ewoks/_requirements/pip.py @@ -1,37 +1,34 @@ import importlib.metadata -import logging import sys -from typing import Any -from typing import Dict from typing import List from typing import Literal from typing import Optional -from .utils import pip_freeze -from .utils.base_manager import BaseManager -from .utils.base_manager import BaseManagerInfo -from .utils.base_manager import BaseRequirements +from .utils.freeze_manager import FreezeManager +from .utils.freeze_manager import FreezeManagerInfo +from .utils.freeze_manager import FreezeRequirements -logger = logging.getLogger(__name__) - -class PipManagerInfo(BaseManagerInfo): +class PipManagerInfo(FreezeManagerInfo): name: Literal["pip"] = "pip" - freeze: List[str] -class PipRequirements(BaseRequirements): +class PipRequirements(FreezeRequirements): manager: PipManagerInfo - def __info__(self) -> str: - freeze = "\n ".join(self.manager.freeze) - return f"{super().__info__()}\nRequirements:\n {freeze}" +class PipManager(FreezeManager): + """Uses the `pip` module of a python interpreter. + + The command selects the target environment. For example + `PipManager("/path/to/python", "-m", "pip")`. The current python + environment is the target when no command is provided. + """ -class PipManager(BaseManager): NAME = "pip" PRIORITY = 0 REQUIREMENTS_MODEL = PipRequirements + COMMAND_EXAMPLE = "python -m pip" def __init__(self, *command: str) -> None: if not command: @@ -49,44 +46,8 @@ def is_active(self) -> bool: """Manager is explicitly active.""" return False - def _gather_requirements(self) -> Dict[str, Any]: - freeze_output = self._check_output("freeze").strip().splitlines() - - return {"freeze": freeze_output} - - def _install_native_requirements(self, requirements: PipRequirements) -> bool: - freeze = requirements.manager.freeze - - if not freeze: - return False + def _freeze(self) -> str: + return self._check_output("freeze") - arguments = self._arguments(freeze) + def _install(self, arguments: List[str]) -> None: self._check_call("install", "--no-cache-dir", *arguments) - return True - - def _install_base_requirements(self, requirements: BaseRequirements) -> bool: - freeze = self._freeze_distributions(requirements) - if not freeze: - return False - - arguments = self._arguments(freeze) - self._check_call("install", "--no-cache-dir", *arguments) - return True - - def _freeze_distributions(self, requirements: BaseRequirements) -> List[str]: - """ - Pip freeze argument from list of distributions. - """ - freeze = [] - for dist in requirements.distributions: - lines, warnings = pip_freeze.freeze_distribution(dist) - for warning in warnings: - logger.warning(warning) - freeze.extend(lines) - return freeze - - def _arguments(self, freeze: List[str]) -> List[str]: - arguments, warnings = pip_freeze.sanitize_freeze(freeze) - for warning in warnings: - logger.warning(warning) - return arguments diff --git a/src/ewoks/_requirements/pipenv.py b/src/ewoks/_requirements/pipenv.py index 7153576..5a4b140 100644 --- a/src/ewoks/_requirements/pipenv.py +++ b/src/ewoks/_requirements/pipenv.py @@ -30,6 +30,7 @@ class PipenvManager(BaseManager): NAME = "pipenv" PRIORITY = 3 REQUIREMENTS_MODEL = PipenvRequirements + COMMAND_EXAMPLE = "python -m pipenv" def __init__(self, *command: str) -> None: if not command: diff --git a/src/ewoks/_requirements/pixi.py b/src/ewoks/_requirements/pixi.py index ebdb2cb..1c315a7 100644 --- a/src/ewoks/_requirements/pixi.py +++ b/src/ewoks/_requirements/pixi.py @@ -22,6 +22,7 @@ class PixiManager(BaseManager): NAME = "pixi" PRIORITY = 5 REQUIREMENTS_MODEL = PixiRequirements + COMMAND_EXAMPLE = "pixi" def __init__(self, *command: str) -> None: if not command: diff --git a/src/ewoks/_requirements/poetry.py b/src/ewoks/_requirements/poetry.py index b9190d9..2c23789 100644 --- a/src/ewoks/_requirements/poetry.py +++ b/src/ewoks/_requirements/poetry.py @@ -25,6 +25,7 @@ class PoetryManager(BaseManager): NAME = "poetry" PRIORITY = 2 REQUIREMENTS_MODEL = PoetryRequirements + COMMAND_EXAMPLE = "python -m poetry" def __init__(self, *command: str) -> None: if not command: diff --git a/src/ewoks/_requirements/utils/_supported.py b/src/ewoks/_requirements/utils/_supported.py index a9bec1f..37afc5c 100644 --- a/src/ewoks/_requirements/utils/_supported.py +++ b/src/ewoks/_requirements/utils/_supported.py @@ -3,20 +3,20 @@ from typing import Type from ..pip import PipManager +from ..uv import UvManager from .base_manager import BaseManager # from ..conda import CondaManager # from ..pipenv import PipenvManager # from ..pixi import PixiManager # from ..poetry import PoetryManager -# from ..uv import UvManager @lru_cache(1) def get_supported_managers() -> Dict[str, Type[BaseManager]]: managers = [ PipManager, - # UvManager, + UvManager, # PoetryManager, # PipenvManager, # CondaManager, diff --git a/src/ewoks/_requirements/utils/base_manager.py b/src/ewoks/_requirements/utils/base_manager.py index 3eccd13..87fecd0 100644 --- a/src/ewoks/_requirements/utils/base_manager.py +++ b/src/ewoks/_requirements/utils/base_manager.py @@ -57,6 +57,7 @@ class BaseManager: NAME = NotImplemented PRIORITY = NotImplemented REQUIREMENTS_MODEL = NotImplemented + COMMAND_EXAMPLE = NotImplemented # example of an associated shell command def __init__(self, *command: str) -> None: if not command: @@ -114,7 +115,7 @@ def install_requirements(self, requirements: BaseRequirements) -> None: def _install_requirements(self, requirements: BaseRequirements) -> None: reraise = None - if isinstance(requirements, self.REQUIREMENTS_MODEL): + if self._is_native(requirements): try: if self._install_native_requirements(requirements): return @@ -137,6 +138,10 @@ def _install_requirements(self, requirements: BaseRequirements) -> None: raise reraise raise ValueError("No distibutions provided to install") + def _is_native(self, requirements: BaseRequirements) -> bool: + """Requirements can be installed with the manager-specific method.""" + return isinstance(requirements, self.REQUIREMENTS_MODEL) + @abstractmethod def _gather_requirements(self) -> Dict[str, Any]: pass diff --git a/src/ewoks/_requirements/utils/freeze_manager.py b/src/ewoks/_requirements/utils/freeze_manager.py new file mode 100644 index 0000000..d046bee --- /dev/null +++ b/src/ewoks/_requirements/utils/freeze_manager.py @@ -0,0 +1,81 @@ +import logging +from abc import abstractmethod +from typing import Any +from typing import Dict +from typing import List + +from . import pip_freeze +from .base_manager import BaseManager +from .base_manager import BaseManagerInfo +from .base_manager import BaseRequirements + +logger = logging.getLogger(__name__) + + +class FreezeManagerInfo(BaseManagerInfo): + freeze: List[str] + + +class FreezeRequirements(BaseRequirements): + manager: FreezeManagerInfo + + def __info__(self) -> str: + freeze = "\n ".join(self.manager.freeze) + return f"{super().__info__()}\nRequirements:\n {freeze}" + + +class FreezeManager(BaseManager): + """Interface for package managers that describe an environment with a + requirements list in `pip freeze` format. + """ + + def _is_native(self, requirements: BaseRequirements) -> bool: + """Any requirements list in `pip freeze` format can be installed, + also when generated by another package manager. + """ + return isinstance(requirements, FreezeRequirements) + + def _gather_requirements(self) -> Dict[str, Any]: + freeze = self._freeze().strip().splitlines() + return {"freeze": freeze} + + def _install_native_requirements(self, requirements: FreezeRequirements) -> bool: + return self._install_freeze(requirements.manager.freeze) + + def _install_base_requirements(self, requirements: BaseRequirements) -> bool: + return self._install_freeze(self._freeze_distributions(requirements)) + + @abstractmethod + def _freeze(self) -> str: + """Requirements of the current environment in `pip freeze` format.""" + pass + + @abstractmethod + def _install(self, arguments: List[str]) -> None: + """Install sanitized requirements in `pip freeze` format.""" + pass + + def _install_freeze(self, freeze: List[str]) -> bool: + if not freeze: + return False + + self._install(self._arguments(freeze)) + return True + + def _freeze_distributions(self, requirements: BaseRequirements) -> List[str]: + """ + Pip freeze argument from list of distributions. + """ + freeze = [] + for dist in requirements.distributions: + lines, warnings = pip_freeze.freeze_distribution(dist) + for warning in warnings: + logger.warning(warning) + freeze.extend(lines) + return freeze + + def _arguments(self, freeze: List[str]) -> List[str]: + arguments, warnings = pip_freeze.sanitize_freeze(freeze) + for warning in warnings: + logger.warning(warning) + return arguments diff --git a/src/ewoks/_requirements/uv.py b/src/ewoks/_requirements/uv.py index f5269f3..fd58ed0 100644 --- a/src/ewoks/_requirements/uv.py +++ b/src/ewoks/_requirements/uv.py @@ -1,61 +1,92 @@ -from typing import Any -from typing import Dict +import os +import re +import sys from typing import List from typing import Literal from typing import Optional +from typing import Sequence +from typing import Tuple -from .utils.base_manager import BaseManager -from .utils.base_manager import BaseManagerInfo -from .utils.base_manager import BaseRequirements +from .utils.freeze_manager import FreezeManager +from .utils.freeze_manager import FreezeManagerInfo +from .utils.freeze_manager import FreezeRequirements -class UvManagerInfo(BaseManagerInfo): +class UvManagerInfo(FreezeManagerInfo): name: Literal["uv"] = "uv" - requirements: List[str] -class UvRequirements(BaseRequirements): +class UvRequirements(FreezeRequirements): manager: UvManagerInfo - def __info__(self) -> str: - requirements = "\n ".join(self.manager.requirements) - return f"{super().__info__()}\nRequirements:\n {requirements}" +class UvManager(FreezeManager): + """Uses the `uv pip` interface which, unlike `uv add` or `uv sync`, does not + require a uv project. + + The command may end with options for `uv pip` to select the target + environment. For example `UvManager("uv", "--python", "/path/to/python")`. + The current python environment is the target when no options are provided. + """ -class UvManager(BaseManager): NAME = "uv" PRIORITY = 1 REQUIREMENTS_MODEL = UvRequirements + COMMAND_EXAMPLE = "uv --python /path/to/python" def __init__(self, *command: str) -> None: if not command: command = ("uv",) super().__init__(*command) + self._executable, self._options = _split_options(command) + if not self._options: + self._options = ("--python", sys.executable) def version(self) -> Optional[str]: """Returns None when this manager is not available.""" try: - output = self._check_output("--version") - return output.strip().split(" ")[-1] + output = self._check_output_raw(*self._executable, "--version") except RuntimeError: return None + # For example "uv 0.12.1 (x86_64-unknown-linux-gnu)" + match = re.search(r"(\d+\.\d+(?:\.\d+)*)", output) + if not match: + return None + return match.group(1) def is_active(self) -> bool: """Manager is explicitly active.""" - pass - - def _gather_requirements(self) -> Dict[str, Any]: - output = self._check_output("pip", "freeze") - requirements = output.strip().splitlines() - - return {"requirements": requirements} - - def _install_native_requirements(self, requirements: UvRequirements) -> bool: - text = "\n".join(requirements.requirements) - with self._temporary_file(text, ".txt") as tmp_path: - self._check_call("add", "-r", tmp_path) - - return True - - def _install_base_requirements(self, requirements: BaseRequirements) -> bool: - raise NotImplementedError(f"{self.NAME} installation of python distributions") + return "UV" in os.environ or _created_by_uv(sys.prefix) + + def _freeze(self) -> str: + return self._check_output_raw( + *self._executable, "pip", "freeze", *self._options + ) + + def _install(self, arguments: List[str]) -> None: + self._check_call_raw( + *self._executable, + "pip", + "install", + "--no-cache", + *arguments, + *self._options, + ) + + +def _split_options(command: Sequence[str]) -> Tuple[Tuple[str, ...], Tuple[str, ...]]: + """Split a uv command in the executable part and the `uv pip` options.""" + for index, argument in enumerate(command): + if argument.startswith("-"): + return tuple(command[:index]), tuple(command[index:]) + return tuple(command), tuple() + + +def _created_by_uv(prefix: str) -> bool: + """Environment is a virtual environment created by uv.""" + filename = os.path.join(prefix, "pyvenv.cfg") + try: + with open(filename, "r", encoding="utf-8") as fh: + return any(line.split("=")[0].strip() == "uv" for line in fh) + except OSError: + return False diff --git a/src/ewoks/cli_utils/cli_arguments.py b/src/ewoks/cli_utils/cli_arguments.py index a5664cd..11356e8 100644 --- a/src/ewoks/cli_utils/cli_arguments.py +++ b/src/ewoks/cli_utils/cli_arguments.py @@ -3,6 +3,32 @@ from ewoksutils.cli_utils.cli_arguments import CLIArg from .._engines import get_graph_representations +from .._requirements import supported_managers + + +def package_manager_arguments(action: str) -> List[CLIArg]: + """CLI arguments to select the package manager that will `action` requirements.""" + examples = ", ".join( + f'"{command}" for {name}' for name, command in supported_managers().items() + ) + return [ + CLIArg( + "package_manager_name", + ["--package-manager-name"], + type=str.lower, + choices=list(supported_managers()), + help=f"Package manager to {action} the workflow requirements.", + ), + CLIArg( + "package_manager_command", + ["--package-manager-command"], + type=str, + help=( + f"Command of the package manager that will {action} the workflow " + f"requirements. For example {examples}." + ), + ), + ] def ewoks_load_arguments() -> List[CLIArg]: diff --git a/src/ewoks/cli_utils/cli_convert_utils.py b/src/ewoks/cli_utils/cli_convert_utils.py index a2f5b77..a2732b4 100644 --- a/src/ewoks/cli_utils/cli_convert_utils.py +++ b/src/ewoks/cli_utils/cli_convert_utils.py @@ -8,6 +8,7 @@ from .._engines import get_graph_representations from .cli_arguments import ewoks_load_arguments +from .cli_arguments import package_manager_arguments from .cli_parse import parse_destinations @@ -49,19 +50,8 @@ def convert_arguments( action="store_true", help="Do not include the packages of the current Python environment as requirements in the destination workflow.", ), - CLIArg( - "package_manager_name", - ["--package-manager-name"], - type=str, - help='Package manager name to generate requirements. For example "pip"', - ), - CLIArg( - "package_manager_command", - ["--package-manager-command"], - type=str, - help='Package manager command to generate requirements. For example "python -m pip"', - ), ] + args_list += package_manager_arguments("generate") return args_list diff --git a/src/ewoks/cli_utils/cli_install_utils.py b/src/ewoks/cli_utils/cli_install_utils.py index ca2bfdd..3d7b895 100644 --- a/src/ewoks/cli_utils/cli_install_utils.py +++ b/src/ewoks/cli_utils/cli_install_utils.py @@ -7,6 +7,8 @@ from ewoksutils.cli_utils import cli_parse from ewoksutils.cli_utils.cli_spec import CLIArg +from .cli_arguments import package_manager_arguments + logger = logging.getLogger(__name__) @@ -26,19 +28,8 @@ def install_arguments( action="store_true", help="Automatically accept installation prompts.", ), - CLIArg( - "package_manager_name", - ["--package-manager-name"], - type=str, - help='Package manager name. For example "pip"', - ), - CLIArg( - "package_manager_command", - ["--package-manager-command"], - type=str, - help='Package manager command. For example "python -m pip"', - ), ] + args_list += package_manager_arguments("install") return args_list diff --git a/src/ewoks/tests/requirements/conftest.py b/src/ewoks/tests/requirements/conftest.py new file mode 100644 index 0000000..d09be2a --- /dev/null +++ b/src/ewoks/tests/requirements/conftest.py @@ -0,0 +1,28 @@ +import shutil +from typing import Iterator + +import pytest + +from .managers import Environment +from .managers import ManagerCase + + +@pytest.fixture +def manager_case(request) -> ManagerCase: + """Package manager under test, selected with `parametrize_managers` or + `parametrize_freeze_managers`. + """ + case: ManagerCase = request.param + if case.manager().version() is None: + pytest.skip(f"{case.NAME} is not installed") + return case + + +@pytest.fixture +def environment(manager_case, tmp_path) -> Iterator[Environment]: + """Empty python environment created by the package manager under test.""" + path = tmp_path / "environment" + try: + yield manager_case.create_environment(path) + finally: + shutil.rmtree(path, ignore_errors=True) diff --git a/src/ewoks/tests/requirements/managers.py b/src/ewoks/tests/requirements/managers.py new file mode 100644 index 0000000..8153f2b --- /dev/null +++ b/src/ewoks/tests/requirements/managers.py @@ -0,0 +1,119 @@ +"""Package manager specific knowledge needed by the package manager tests. + +Add a `ManagerCase` to `MANAGER_CASES` to include a package manager in the +tests that apply to all package managers. +""" + +import subprocess +import sys +from pathlib import Path +from typing import List +from typing import Optional +from typing import Tuple +from typing import Type + +import pytest + +from ..._requirements.pip import PipManager +from ..._requirements.utils.base_manager import BaseManager +from ..._requirements.utils.freeze_manager import FreezeManager +from ..._requirements.uv import UvManager + + +class Environment: + """Python environment to gather requirements from or install requirements in.""" + + def __init__(self, path: Path) -> None: + self.path = path + + @property + def python(self) -> str: + if sys.platform == "win32": + return str(self.path / "Scripts" / "python.exe") + return str(self.path / "bin" / "python") + + def get_version(self, name: str) -> str: + """ + :raises RuntimeError: distribution is not installed + """ + code = f"import importlib.metadata; print(importlib.metadata.version({name!r}))" + try: + output = subprocess.check_output( # noqa: S603 - Trusted test command; + [self.python, "-c", code], text=True, stderr=subprocess.DEVNULL + ) + except subprocess.CalledProcessError: + raise RuntimeError(f"{name} package is not installed") from None + return output.strip() + + +class ManagerCase: + NAME: str = NotImplemented + MANAGER_CLS: Type[BaseManager] = NotImplemented + + def create_environment(self, path: Path) -> Environment: + """Create an empty python environment.""" + raise NotImplementedError + + def command(self, environment: Optional[Environment] = None) -> Tuple[str, ...]: + """Manager command targeting the environment (current environment by default).""" + raise NotImplementedError + + def manager(self, environment: Optional[Environment] = None) -> BaseManager: + return self.MANAGER_CLS(*self.command(environment)) + + def cli_command(self, environment: Optional[Environment] = None) -> str: + """Value for the `--package-manager-command` CLI argument.""" + return " ".join(self.command(environment)) + + +class PipCase(ManagerCase): + NAME = "pip" + MANAGER_CLS = PipManager + + def create_environment(self, path: Path) -> Environment: + subprocess.check_call( # noqa: S603 - Trusted test command; + [sys.executable, "-m", "venv", str(path)] + ) + return Environment(path) + + def command(self, environment: Optional[Environment] = None) -> Tuple[str, ...]: + if environment is None: + return tuple() + return (environment.python, "-m", "pip") + + +class UvCase(ManagerCase): + NAME = "uv" + MANAGER_CLS = UvManager + + def create_environment(self, path: Path) -> Environment: + subprocess.check_call( # noqa: S603 - Trusted test command; + ["uv", "venv", "--python", sys.executable, str(path)] # noqa: S607 + ) + return Environment(path) + + def command(self, environment: Optional[Environment] = None) -> Tuple[str, ...]: + if environment is None: + return tuple() + return ("uv", "--python", environment.python) + + +MANAGER_CASES: List[ManagerCase] = [PipCase(), UvCase()] + +FREEZE_MANAGER_CASES: List[ManagerCase] = [ + case for case in MANAGER_CASES if issubclass(case.MANAGER_CLS, FreezeManager) +] + + +def _parametrize(cases: List[ManagerCase]): + return pytest.mark.parametrize( + "manager_case", cases, ids=[case.NAME for case in cases], indirect=True + ) + + +parametrize_managers = _parametrize(MANAGER_CASES) +"""Repeat the tests of a module for all package managers.""" + +parametrize_freeze_managers = _parametrize(FREEZE_MANAGER_CASES) +"""Repeat the tests of a module for all package managers that use the +`pip freeze` requirements format.""" diff --git a/src/ewoks/tests/requirements/pip/__init__.py b/src/ewoks/tests/requirements/pip/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/ewoks/tests/requirements/pip/test_install_cli.py b/src/ewoks/tests/requirements/pip/test_install_cli.py deleted file mode 100644 index a4bd326..0000000 --- a/src/ewoks/tests/requirements/pip/test_install_cli.py +++ /dev/null @@ -1,141 +0,0 @@ -import json -import subprocess -import sys - -import pytest - -from ...._requirements.utils.metadata import from_pip_freeze - - -def test_install_pip_with_freeze(venv): - with pytest.raises(Exception, match="package is not installed"): - _ = venv.get_version("ewoksdata") - - requirements = from_pip_freeze.pip_freeze_requirements(["ewoksdata"]) - - graph = { - "graph": { - "schema_version": "1.1", - "id": "test_install", - "requirements": requirements, - } - } - - argv = [ - sys.executable, - "-m", - "ewoks", - "install", - "--yes", - json.dumps(graph), - "--package-manager-name", - "pip", - "--package-manager-command", - f"{venv.python} -m pip", - ] - subprocess.check_call(argv) # noqa: S603 - Trusted test command; - - assert venv.get_version("ewoksdata") - - -def test_install_pip_without_freeze(venv): - with pytest.raises(Exception, match="package is not installed"): - _ = venv.get_version("ewoksdata") - - requirements = from_pip_freeze.pip_freeze_requirements(["ewoksdata"]) - requirements["distributions"] = [ - {"name": "ewoksdata", "version": ""}, - ] - - graph = { - "graph": { - "schema_version": "1.1", - "id": "test_install", - "requirements": requirements, - } - } - - argv = [ - sys.executable, - "-m", - "ewoks", - "install", - "--yes", - json.dumps(graph), - "--package-manager-name", - "pip", - "--package-manager-command", - f"{venv.python} -m pip", - ] - subprocess.check_call(argv) # noqa: S603 - Trusted test command; - - assert venv.get_version("ewoksdata") - - -def test_install_legacy_pip_freeze(venv): - with pytest.raises(Exception, match="package is not installed"): - _ = venv.get_version("ewoksdata") - - requirements = ["ewoksdata"] - graph = { - "graph": { - "schema_version": "1.1", - "id": "test_install", - "requirements": requirements, - } - } - - argv = [ - sys.executable, - "-m", - "ewoks", - "install", - "--yes", - json.dumps(graph), - "--package-manager-name", - "pip", - "--package-manager-command", - f"{venv.python} -m pip", - ] - subprocess.check_call(argv) # noqa: S603 - Trusted test command; - - assert venv.get_version("ewoksdata") - - -def test_install_without_requirements(venv): - with pytest.raises(Exception, match="package is not installed"): - _ = venv.get_version("ewoksdata") - - nodes = [ - { - "id": 1, - "task_identifier": 'ewoksdata.tasks.normalization.Normalization"', - "task_type": "class", - }, - { - "id": 2, - "task_identifier": "path/to/my/script", - "task_type": "script", - }, # Check that unsupported task type goes through without error - ] - - graph = { - "graph": {"schema_version": "1.1", "id": "test_install"}, - "nodes": nodes, - } - - argv = [ - sys.executable, - "-m", - "ewoks", - "install", - "--yes", - json.dumps(graph), - "--package-manager-name", - "pip", - "--package-manager-command", - f"{venv.python} -m pip", - ] - subprocess.check_call(argv) # noqa: S603 - Trusted test command; - - assert venv.get_version("ewoksdata") diff --git a/src/ewoks/tests/requirements/pip/test_freeze.py b/src/ewoks/tests/requirements/test_freeze.py similarity index 97% rename from src/ewoks/tests/requirements/pip/test_freeze.py rename to src/ewoks/tests/requirements/test_freeze.py index 61b0b7f..ac9ea1d 100644 --- a/src/ewoks/tests/requirements/pip/test_freeze.py +++ b/src/ewoks/tests/requirements/test_freeze.py @@ -1,6 +1,6 @@ import pytest -from ...._requirements.utils.pip_freeze import sanitize_freeze +from ..._requirements.utils.pip_freeze import sanitize_freeze def test_normal_requirement(): diff --git a/src/ewoks/tests/requirements/test_freeze_managers.py b/src/ewoks/tests/requirements/test_freeze_managers.py new file mode 100644 index 0000000..9f396cb --- /dev/null +++ b/src/ewoks/tests/requirements/test_freeze_managers.py @@ -0,0 +1,79 @@ +"""Tests that apply to all package managers using the `pip freeze` requirements format.""" + +from typing import Dict +from typing import List +from typing import Optional + +import pytest + +from ..._requirements.utils.freeze_manager import FreezeRequirements +from ..._requirements.utils.parse import parse_requirements +from .managers import ManagerCase +from .managers import parametrize_freeze_managers +from .utils import freeze_requirements + +pytestmark = parametrize_freeze_managers + +_DISTRIBUTION = "six" +_VERSION = "1.17.0" +_FREEZE = [f"{_DISTRIBUTION}=={_VERSION}"] + + +def test_gather_freeze(manager_case, environment): + manager = manager_case.manager(environment) + assert manager.gather_requirements().manager.freeze == [] + + manager.install_requirements(_requirements(manager_case, _FREEZE)) + + assert manager.gather_requirements().manager.freeze == _FREEZE + + +def test_install_freeze(manager_case, environment): + manager = manager_case.manager(environment) + with pytest.raises(RuntimeError, match="package is not installed"): + _ = environment.get_version(_DISTRIBUTION) + + manager.install_requirements(_requirements(manager_case, _FREEZE)) + + assert environment.get_version(_DISTRIBUTION) == _VERSION + + +def test_install_distributions(manager_case, environment): + """Without a requirements list, the python distributions are installed.""" + manager = manager_case.manager(environment) + requirements = _requirements( + manager_case, [], distributions=[{"name": _DISTRIBUTION, "version": _VERSION}] + ) + + manager.install_requirements(requirements) + + assert environment.get_version(_DISTRIBUTION) == _VERSION + + +def test_install_foreign_freeze(manager_case, environment): + """Requirements generated by another package manager using the same format.""" + manager = manager_case.manager(environment) + requirements = parse_requirements(_FREEZE) # legacy 'pip freeze' list + assert requirements.manager.name == "pip" + + manager.install_requirements(requirements) + + assert environment.get_version(_DISTRIBUTION) == _VERSION + + +def test_install_nothing(manager_case, environment): + manager = manager_case.manager(environment) + requirements = _requirements(manager_case, [], distributions=[]) + + with pytest.raises(ValueError, match="No distibutions provided to install"): + manager.install_requirements(requirements) + + +def _requirements( + manager_case: ManagerCase, + freeze: List[str], + distributions: Optional[List[Dict[str, str]]] = None, +) -> FreezeRequirements: + return parse_requirements( + freeze_requirements(manager_case.NAME, freeze, distributions=distributions) + ) diff --git a/src/ewoks/tests/requirements/test_install_cli.py b/src/ewoks/tests/requirements/test_install_cli.py new file mode 100644 index 0000000..33e8dc7 --- /dev/null +++ b/src/ewoks/tests/requirements/test_install_cli.py @@ -0,0 +1,104 @@ +"""Tests of `ewoks install` for all package managers using the `pip freeze` +requirements format. +""" + +import json +import subprocess +import sys + +import pytest + +from .managers import Environment +from .managers import ManagerCase +from .managers import parametrize_freeze_managers +from .utils import freeze_requirements + +pytestmark = parametrize_freeze_managers + +_DISTRIBUTION = "ewoksdata" + + +def test_install_with_freeze(manager_case, environment): + with pytest.raises(RuntimeError, match="package is not installed"): + _ = environment.get_version(_DISTRIBUTION) + + requirements = freeze_requirements(manager_case.NAME, [_DISTRIBUTION]) + + _install(_graph(requirements), manager_case, environment) + + assert environment.get_version(_DISTRIBUTION) + + +def test_install_without_freeze(manager_case, environment): + with pytest.raises(RuntimeError, match="package is not installed"): + _ = environment.get_version(_DISTRIBUTION) + + requirements = freeze_requirements( + manager_case.NAME, [], distributions=[{"name": _DISTRIBUTION, "version": ""}] + ) + + _install(_graph(requirements), manager_case, environment) + + assert environment.get_version(_DISTRIBUTION) + + +def test_install_legacy_pip_freeze(manager_case, environment): + with pytest.raises(RuntimeError, match="package is not installed"): + _ = environment.get_version(_DISTRIBUTION) + + _install(_graph([_DISTRIBUTION]), manager_case, environment) + + assert environment.get_version(_DISTRIBUTION) + + +def test_install_without_requirements(manager_case, environment): + with pytest.raises(RuntimeError, match="package is not installed"): + _ = environment.get_version(_DISTRIBUTION) + + nodes = [ + { + "id": 1, + "task_identifier": f'{_DISTRIBUTION}.tasks.normalization.Normalization"', + "task_type": "class", + }, + { + "id": 2, + "task_identifier": "path/to/my/script", + "task_type": "script", + }, # Check that unsupported task type goes through without error + ] + + graph = { + "graph": {"schema_version": "1.1", "id": "test_install"}, + "nodes": nodes, + } + + _install(graph, manager_case, environment) + + assert environment.get_version(_DISTRIBUTION) + + +def _graph(requirements) -> dict: + return { + "graph": { + "schema_version": "1.1", + "id": "test_install", + "requirements": requirements, + } + } + + +def _install(graph: dict, manager_case: ManagerCase, environment: Environment) -> None: + argv = [ + sys.executable, + "-m", + "ewoks", + "install", + "--yes", + json.dumps(graph), + "--package-manager-name", + manager_case.NAME, + "--package-manager-command", + manager_case.cli_command(environment), + ] + subprocess.check_call(argv) # noqa: S603 - Trusted test command; diff --git a/src/ewoks/tests/requirements/test_managers.py b/src/ewoks/tests/requirements/test_managers.py new file mode 100644 index 0000000..1cd85a4 --- /dev/null +++ b/src/ewoks/tests/requirements/test_managers.py @@ -0,0 +1,42 @@ +"""Tests that apply to all package managers.""" + +import pytest + +from ..._requirements.utils.detect import get_manager +from .managers import parametrize_managers + +pytestmark = parametrize_managers + + +def test_available(manager_case): + version = manager_case.manager().version() + assert version + assert version[0].isdigit() + + +def test_not_available(manager_case, monkeypatch): + monkeypatch.setattr(manager_case.MANAGER_CLS, "version", lambda self: None) + with pytest.raises(ValueError, match="not available"): + get_manager(manager_name=manager_case.NAME) + + +def test_select_manager(manager_case, environment): + manager = get_manager( + manager_name=manager_case.NAME, + manager_command=manager_case.command(environment), + ) + assert isinstance(manager, manager_case.MANAGER_CLS) + + +def test_is_active(manager_case): + assert isinstance(manager_case.manager().is_active(), bool) + + +def test_gather_requirements(manager_case, environment): + requirements = manager_case.manager(environment).gather_requirements() + + assert isinstance(requirements, manager_case.MANAGER_CLS.REQUIREMENTS_MODEL) + assert requirements.manager.name == manager_case.NAME + assert requirements.manager.version + assert requirements.python.version + assert requirements.distributions diff --git a/src/ewoks/tests/requirements/test_pip.py b/src/ewoks/tests/requirements/test_pip.py new file mode 100644 index 0000000..0f9974f --- /dev/null +++ b/src/ewoks/tests/requirements/test_pip.py @@ -0,0 +1,35 @@ +"""Tests specific to the pip package manager.""" + +import importlib.metadata +import sys + +import pytest + +from ..._requirements.pip import PipManager + +pytestmark = pytest.mark.skipif( + PipManager().version() is None, reason="pip is not installed" +) + + +def test_default_command(): + manager = PipManager() + assert manager._cmd_args == (sys.executable, "-m", "pip") + assert repr(manager) == f"PipManager({sys.executable}, -m, pip)" + + +def test_version(): + assert PipManager().version() == importlib.metadata.version("pip") + + +def test_version_not_available(monkeypatch): + def not_found(name: str) -> str: + raise importlib.metadata.PackageNotFoundError(name) + + monkeypatch.setattr(importlib.metadata, "version", not_found) + assert PipManager().version() is None + + +def test_never_active(): + """Pip is used when no other package manager is detected.""" + assert PipManager().is_active() is False diff --git a/src/ewoks/tests/requirements/test_uv.py b/src/ewoks/tests/requirements/test_uv.py new file mode 100644 index 0000000..b5d6bf0 --- /dev/null +++ b/src/ewoks/tests/requirements/test_uv.py @@ -0,0 +1,69 @@ +"""Tests specific to the uv package manager.""" + +import os +import shutil +import sys +from typing import Iterator + +import pytest + +from ..._requirements.uv import UvManager +from .managers import Environment +from .managers import UvCase + + +@pytest.fixture +def uv_environment(tmp_path) -> Iterator[Environment]: + if UvManager().version() is None: + pytest.skip("uv is not installed") + path = tmp_path / "environment" + try: + yield UvCase().create_environment(path) + finally: + shutil.rmtree(path, ignore_errors=True) + + +@pytest.fixture +def no_uv_environment(monkeypatch, tmp_path) -> Iterator[str]: + """Python environment that was not created by uv.""" + monkeypatch.delenv("UV", raising=False) + prefix = tmp_path / "prefix" + prefix.mkdir() + with open(prefix / "pyvenv.cfg", "w", encoding="utf-8") as fh: + fh.write(f"home = {os.path.dirname(sys.executable)}\n") + monkeypatch.setattr(sys, "prefix", str(prefix)) + yield str(prefix) + + +def test_default_command(): + manager = UvManager() + assert manager._executable == ("uv",) + assert manager._options == ("--python", sys.executable) + + +def test_command_options(): + manager = UvManager("/path/to/uv", "--python", "/path/to/python") + assert manager._executable == ("/path/to/uv",) + assert manager._options == ("--python", "/path/to/python") + assert repr(manager) == "UvManager(/path/to/uv, --python, /path/to/python)" + + +def test_version_not_available(): + assert UvManager("uv-does-not-exist").version() is None + + +def test_is_active_uv_environment(uv_environment, monkeypatch): + monkeypatch.delenv("UV", raising=False) + monkeypatch.setattr(sys, "prefix", str(uv_environment.path)) + assert UvManager().is_active() + + +def test_is_active_uv_run(no_uv_environment, monkeypatch): + """`uv run` provides the uv executable through the environment.""" + assert not UvManager().is_active() + monkeypatch.setenv("UV", "/path/to/uv") + assert UvManager().is_active() + + +def test_is_not_active(no_uv_environment): + assert not UvManager().is_active() diff --git a/src/ewoks/tests/requirements/utils.py b/src/ewoks/tests/requirements/utils.py index 651d016..ca459ea 100644 --- a/src/ewoks/tests/requirements/utils.py +++ b/src/ewoks/tests/requirements/utils.py @@ -1,5 +1,11 @@ +from typing import Any +from typing import Dict +from typing import List +from typing import Optional + from ewokscore.graph import TaskGraph +from ..._requirements.utils.metadata import from_pip_freeze from ..._requirements.utils.parse import parse_requirements @@ -11,3 +17,18 @@ def assert_in_graph_requirements(graph: TaskGraph, *distribution_names) -> None: existing = {distribution.name for distribution in requirements.distributions} not_existing = set(distribution_names) - existing assert not not_existing, f"{sorted(not_existing)} not in requirements" + + +def freeze_requirements( + manager_name: str, + freeze: List[str], + distributions: Optional[List[Dict[str, str]]] = None, +) -> Dict[str, Any]: + """Workflow requirements as generated by a package manager that uses the + `pip freeze` requirements format. + """ + requirements = dict(from_pip_freeze.pip_freeze_requirements(freeze)) + requirements["manager"] = {"name": manager_name, "version": "", "freeze": freeze} + if distributions is not None: + requirements["distributions"] = distributions + return requirements