From 600e01647a121d4215a3162cefaf19552be043a1 Mon Sep 17 00:00:00 2001 From: woutdenolf Date: Fri, 28 Aug 2026 10:48:39 +0200 Subject: [PATCH] ewoks install: support the pixi package manager --- .github/workflows/test.yml | 2 +- doc/howtoguides/requirements.rst | 27 +-- doc/reference/glossary.rst | 2 +- doc/tutorials/install.rst | 1 + doc/tutorials/install/pixi.rst | 166 +++++++++++++++++ doc/tutorials/install/uv.rst | 3 +- src/ewoks/_requirements/pixi.py | 174 ++++++++++++++++++ src/ewoks/_requirements/utils/_supported.py | 2 + src/ewoks/_requirements/utils/base_manager.py | 14 +- .../_requirements/utils/conda_channel.py | 13 ++ src/ewoks/_requirements/utils/environment.py | 5 +- .../_requirements/utils/toml_dependencies.py | 40 ++++ src/ewoks/_requirements/uv.py | 8 + src/ewoks/tests/requirements/managers.py | 24 +++ src/ewoks/tests/requirements/test_managers.py | 8 +- src/ewoks/tests/requirements/test_pixi.py | 43 +++++ .../requirements/test_toml_dependencies.py | 27 +++ src/ewoks/tests/requirements/utils.py | 3 + 18 files changed, 537 insertions(+), 25 deletions(-) create mode 100644 doc/tutorials/install/pixi.rst create mode 100644 src/ewoks/_requirements/pixi.py create mode 100644 src/ewoks/_requirements/utils/conda_channel.py create mode 100644 src/ewoks/_requirements/utils/toml_dependencies.py create mode 100644 src/ewoks/tests/requirements/test_pixi.py create mode 100644 src/ewoks/tests/requirements/test_toml_dependencies.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 21f2927..6e4540d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,7 @@ jobs: ${{ matrix.extra-pytest-warnings }} enable-coverage: ${{ matrix.enable-coverage }} jupyter-platform-dirs: "1" - package-managers: "uv" + package-managers: "uv pixi" codecov-flags: "unit" strategy: fail-fast: false diff --git a/doc/howtoguides/requirements.rst b/doc/howtoguides/requirements.rst index e8826ab..b783d5b 100644 --- a/doc/howtoguides/requirements.rst +++ b/doc/howtoguides/requirements.rst @@ -19,7 +19,7 @@ What is stored :term:`package manager` can recreate the environment from this list. * ``manager``: the :term:`package manager` that generated the requirements, with the content of the files it needs to recreate the environment: ``requirements.txt`` for pip-venv, - ``pyproject.toml`` and ``uv.lock`` for uv. + ``pyproject.toml`` and ``uv.lock`` for uv, ``pixi.toml`` and ``pixi.lock`` for pixi. Use ``--exclude-requirements`` to store nothing. @@ -85,27 +85,30 @@ The default name is the identifier of the :term:`workflow`, which is the ``id`` ``graph`` field. A :term:`workflow` without an identifier gets a name derived from its content. -Without ``--env-root`` the :term:`package manager` decides where the environment goes. venv -and uv create an environment wherever they are told to, so for pip-venv and uv ewoks uses -``~/.ewoks/envs``. +Without ``--env-root`` the :term:`package manager` decides where the environment goes. venv, +uv and pixi create an environment wherever they are told to, so for pip-venv, uv and pixi +ewoks uses ``~/.ewoks/envs``. An environment that already exists is installed in, which adds the requirements to what is already there. Use ``--clean`` to remove it first. Only a directory that contains a python environment is removed. The environment of a :term:`workflow` is a directory: for pip-venv it is a virtual -environment and for uv a project with the environment in ``.venv``. ``ewoks execute --env`` -takes that directory, not the python interpreter inside it. +environment, for uv a project with the environment in ``.venv`` and for pixi a workspace with +the environment in ``.pixi/envs/default``. ``ewoks execute --env`` takes that directory, not +the python interpreter inside it. Limitations ----------- -* ``--python-version`` is a request: uv can provide any python version but pip-venv can only - use the version of the python interpreter that creates the environment. A warning is emitted - when the version cannot be provided. -* uv resolves the ``distributions`` into a lock file, which requires access to the package - index. When that fails, a warning is emitted and the requirements are stored without files: - the environment is then recreated from the ``distributions`` list. +* ``--in-place`` is only supported by pip-venv and uv. The other package managers only install + in an environment they created themselves. +* ``--python-version`` is a request: uv can provide any python version, pixi provides the patch + versions built by its channel and pip-venv can only use the version of the python interpreter + that creates the environment. A warning is emitted when the version cannot be provided. +* uv and pixi resolve the ``distributions`` into a lock file, which requires access to the + package index. When that fails, a warning is emitted and the requirements are stored without + files: the environment is then recreated from the ``distributions`` list. * A lock file is only read by a :term:`package manager` that understands its format version, so reproducing an environment can require a version of the tool that is at least as recent as the one that generated the requirements. diff --git a/doc/reference/glossary.rst b/doc/reference/glossary.rst index 4e4e8c1..f2a0375 100644 --- a/doc/reference/glossary.rst +++ b/doc/reference/glossary.rst @@ -29,7 +29,7 @@ Glossary An execution engine is the underlying software used to execute the :term:`workflow`. :term:`Ewoks` supports multiple execution engines: pypushflow, orange, dask and the ewoks internal excution engine. Package manager - A package manager creates python environments and installs packages in them. :term:`Ewoks` uses package managers to store the environment in which a :term:`workflow` was created and to recreate it: pip with venv and uv. + A package manager creates python environments and installs packages in them. :term:`Ewoks` uses package managers to store the environment in which a :term:`workflow` was created and to recreate it: pip with venv, uv and pixi. blissdata `Blissdata `_ is an API for accessing data from BLISS in memory. diff --git a/doc/tutorials/install.rst b/doc/tutorials/install.rst index 7fbfff0..2d1ad38 100644 --- a/doc/tutorials/install.rst +++ b/doc/tutorials/install.rst @@ -22,6 +22,7 @@ of the supported ones install/pip_venv install/uv + install/pixi The producer and the re-producer do not need the same :term:`package manager`: the requirements contain the installed python packages, which any :term:`package manager` can diff --git a/doc/tutorials/install/pixi.rst b/doc/tutorials/install/pixi.rst new file mode 100644 index 0000000..3a420dd --- /dev/null +++ b/doc/tutorials/install/pixi.rst @@ -0,0 +1,166 @@ +.. _install_pixi: + +pixi +==== + +End-to-end walk-through of `workflow installation and execution <../install.rst>`_ +with `pixi `_. + +Pixi works with workspaces instead of environments: its commands need a directory with a +``pixi.toml`` file, selected with ``--manifest-path``. Like conda it installs python itself +and it takes its packages from the conda channels, with the packages of the python package +index in a section of their own. + +Install pixi +------------ + +.. tabs:: + + .. group-tab:: Linux + + .. code-block:: bash + + export PIXI_HOME=$HOME/.pixi # default location + export PIXI_NO_PATH_UPDATE=true + curl -fsSL https://pixi.sh/install.sh | sh + export PATH=$PIXI_HOME/bin:$PATH + + .. group-tab:: macOS + + .. code-block:: bash + + export PIXI_HOME=$HOME/.pixi # default location + export PIXI_NO_PATH_UPDATE=true + curl -fsSL https://pixi.sh/install.sh | sh + export PATH=$PIXI_HOME/bin:$PATH + + .. group-tab:: Windows + + .. code-block:: powershell + + $env:PIXI_HOME = "$env:USERPROFILE\.pixi" # default location + $env:PIXI_NO_PATH_UPDATE = "true" + irm -useb https://pixi.sh/install.ps1 | iex + $env:PATH = "$env:PIXI_HOME\bin;$env:PATH" + +Producer side +------------- + +Create a workspace, install :term:`ewoks` in it and store a :term:`workflow` with the packages +of that workspace as its ``requirements`` + +.. tabs:: + + .. group-tab:: Linux + + .. code-block:: bash + + pixi init ewoks_producer + pixi add --manifest-path ewoks_producer/pixi.toml python pip + pixi add --manifest-path ewoks_producer/pixi.toml --pypi ewoks + pixi run --manifest-path ewoks_producer/pixi.toml ewoks convert demo demo.json --test + + .. group-tab:: macOS + + .. code-block:: bash + + pixi init ewoks_producer + pixi add --manifest-path ewoks_producer/pixi.toml python pip + pixi add --manifest-path ewoks_producer/pixi.toml --pypi ewoks + pixi run --manifest-path ewoks_producer/pixi.toml ewoks convert demo demo.json --test + + .. group-tab:: Windows + + .. code-block:: powershell + + pixi init ewoks_producer + pixi add --manifest-path ewoks_producer\pixi.toml python pip + pixi add --manifest-path ewoks_producer\pixi.toml --pypi ewoks + pixi run --manifest-path ewoks_producer\pixi.toml ewoks convert demo demo.json --test + +Install any package that provides :term:`tasks ` instead of +``ewoks`` for a real :term:`workflow`. ``pixi run`` executes in the current directory, so the +workflow paths are relative to it and not to the workspace. + +Re-producer side +---------------- + +Only ``ewoks`` itself is needed to recreate the environment of ``demo.json`` + +.. tabs:: + + .. group-tab:: Linux + + .. code-block:: bash + + pixi init ewoks_reproducer + pixi add --manifest-path ewoks_reproducer/pixi.toml python pip + pixi add --manifest-path ewoks_reproducer/pixi.toml --pypi ewoks + pixi run --manifest-path ewoks_reproducer/pixi.toml ewoks install demo.json --yes --env-root ewoks_envs + + .. group-tab:: macOS + + .. code-block:: bash + + pixi init ewoks_reproducer + pixi add --manifest-path ewoks_reproducer/pixi.toml python pip + pixi add --manifest-path ewoks_reproducer/pixi.toml --pypi ewoks + pixi run --manifest-path ewoks_reproducer/pixi.toml ewoks install demo.json --yes --env-root ewoks_envs + + .. group-tab:: Windows + + .. code-block:: powershell + + pixi init ewoks_reproducer + pixi add --manifest-path ewoks_reproducer\pixi.toml python pip + pixi add --manifest-path ewoks_reproducer\pixi.toml --pypi ewoks + pixi run --manifest-path ewoks_reproducer\pixi.toml ewoks install demo.json --yes --env-root ewoks_envs + +The environment of the :term:`workflow` is a pixi workspace in ``ewoks_envs/demo`` with the +environment in its ``.pixi/envs/default`` directory. + +Execute the workflow +-------------------- + +.. tabs:: + + .. group-tab:: Linux + + .. code-block:: bash + + pixi run --manifest-path ewoks_reproducer/pixi.toml ewoks execute --env ewoks_envs/demo demo.json --outputs=all + + .. group-tab:: macOS + + .. code-block:: bash + + pixi run --manifest-path ewoks_reproducer/pixi.toml ewoks execute --env ewoks_envs/demo demo.json --outputs=all + + .. group-tab:: Windows + + .. code-block:: powershell + + pixi run --manifest-path ewoks_reproducer\pixi.toml ewoks execute --env ewoks_envs\demo demo.json --outputs=all + +Clean up +-------- + +.. tabs:: + + .. group-tab:: Linux + + .. code-block:: bash + + rm -rf ewoks_producer ewoks_reproducer ewoks_envs demo.json + + .. group-tab:: macOS + + .. code-block:: bash + + rm -rf ewoks_producer ewoks_reproducer ewoks_envs demo.json + + .. group-tab:: Windows + + .. code-block:: powershell + + Remove-Item -Recurse -Force ewoks_producer, ewoks_reproducer, ewoks_envs, demo.json diff --git a/doc/tutorials/install/uv.rst b/doc/tutorials/install/uv.rst index 6b3bbd9..11db413 100644 --- a/doc/tutorials/install/uv.rst +++ b/doc/tutorials/install/uv.rst @@ -3,7 +3,8 @@ uv == -End-to-end walk-through of `workflow installation and execution <../install.rst>`_ with `uv `_. +End-to-end walk-through of `workflow installation and execution <../install.rst>`_ +with `uv `_. Install uv ---------- diff --git a/src/ewoks/_requirements/pixi.py b/src/ewoks/_requirements/pixi.py new file mode 100644 index 0000000..bca62fe --- /dev/null +++ b/src/ewoks/_requirements/pixi.py @@ -0,0 +1,174 @@ +import json +import logging +import os +import platform +import re +import sys +from pathlib import Path +from typing import Dict +from typing import Literal +from typing import Mapping +from typing import Optional +from typing import Sequence + +from pydantic import Field + +from .utils import conda_channel +from .utils import requirements_txt +from .utils import toml_dependencies +from .utils.base_manager import BaseManager +from .utils.base_manager import BaseManagerInfo +from .utils.base_manager import BaseRequirements +from .utils.environment import Environment +from .utils.files import read_files +from .utils.files import temporary_files +from .utils.files import write_files +from .utils.metadata import models + +logger = logging.getLogger(__name__) + +_MANIFEST_FILENAME = "pixi.toml" +_LOCK_FILENAME = "pixi.lock" +_WORKSPACE_NAME = "ewoks-workflow-requirements" + + +class PixiManagerInfo(BaseManagerInfo): + name: Literal["pixi"] = Field( + default="pixi", + description="Environments described and created by a `pixi` workspace.", + examples=["pixi"], + ) + + +class PixiRequirements(BaseRequirements): + manager: PixiManagerInfo + + +class PixiManager(BaseManager): + """Uses a pixi workspace: `pixi lock` describes an environment with a + `pixi.toml` and a `pixi.lock` file, `pixi install` reproduces the environment + from those files. Unlike the python-only package managers this includes + non-python dependencies. + + The workspace is generated from the installed python distributions instead of + being taken from a pixi workspace of the current environment. This keeps it + self-contained: no local project to build. + + The command invokes pixi. For example `PixiManager("/path/to/pixi")`. + """ + + NAME = "pixi" + PRIORITY = 5 + REQUIREMENTS_MODEL = PixiRequirements + COMMAND_EXAMPLE = "pixi" + ENVIRONMENT_SUBDIR = os.path.join(".pixi", "envs", "default") + + def __init__(self, *command: str) -> None: + if not command: + command = ("pixi",) + super().__init__(*command) + + def version(self) -> Optional[str]: + """Returns None when this manager is not available.""" + try: + output = self._check_output("--version") + except RuntimeError: + return None + # For example "pixi 0.63.2" + 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.""" + return ( + "PIXI_PROJECT_ROOT" in os.environ + or "PIXI_ENVIRONMENT_NAME" in os.environ + or _is_workspace_environment(Path(sys.prefix)) + ) + + def create_environment( + self, location: Path, python_version: Optional[str] = None + ) -> Environment: + """ + :raises RuntimeError: creation failed + """ + if not python_version: + python_version = platform.python_version() + manifest = self._manifest([], python_version) + write_files(location, {_MANIFEST_FILENAME: manifest}) + self._install_workspace(location) + return self.environment(location) + + def _files_from_distributions( + self, distributions: Sequence[models.Distribution], python_version: str + ) -> Dict[str, str]: + """ + :raises RuntimeError: the requirements cannot be resolved + """ + requirements = requirements_txt.manifest_requirements(distributions) + files = {_MANIFEST_FILENAME: self._manifest(requirements, python_version)} + + with temporary_files(files) as directory: + self._check_call("lock", "--manifest-path", directory / _MANIFEST_FILENAME) + lock = read_files(directory, _LOCK_FILENAME) + + return {**files, **lock} + + def _install_files( + self, files: Mapping[str, str], environment: Environment + ) -> None: + write_files(environment.location, files) + self._install_workspace(environment.location) + + def _add_ewoks( + self, requirements: BaseRequirements, environment: Environment + ) -> None: + # The workspace pins all versions so they cannot change + self._check_call( + "add", + "--pypi", + "ewoks", + "--manifest-path", + environment.location / _MANIFEST_FILENAME, + ) + + def _install_workspace(self, location: Path) -> None: + self._check_call("install", "--manifest-path", location / _MANIFEST_FILENAME) + + def _manifest(self, requirements: Sequence[str], python_version: str) -> str: + """Content of a `pixi.toml` file with python distributions as pypi + dependencies. + """ + pypi_dependencies = "".join( + f"{line}\n" for line in toml_dependencies.dependency_lines(requirements) + ) + python = conda_channel.python_specifier(python_version) + return f"""[workspace] +name = {json.dumps(_WORKSPACE_NAME)} +channels = ["conda-forge"] +platforms = [{json.dumps(self._platform())}] + +[dependencies] +python = {json.dumps(python)} + +[pypi-dependencies] +{pypi_dependencies}""" + + def _platform(self) -> str: + """Platform of the current environment, for example `linux-64`. + + :raises RuntimeError: platform unknown + """ + output = self._check_output("info", "--json") + try: + return json.loads(output)["platform"] + except (ValueError, KeyError): + raise RuntimeError("Cannot determine the pixi platform") from None + + +def _is_workspace_environment(prefix: Path) -> bool: + """Environment is managed by a pixi workspace.""" + parts = prefix.parts + return len(parts) > 2 and parts[-3] == ".pixi" and parts[-2] == "envs" diff --git a/src/ewoks/_requirements/utils/_supported.py b/src/ewoks/_requirements/utils/_supported.py index 0e5c43a..ae9eab9 100644 --- a/src/ewoks/_requirements/utils/_supported.py +++ b/src/ewoks/_requirements/utils/_supported.py @@ -3,6 +3,7 @@ from typing import Type from ..pip_venv import PipVenvManager +from ..pixi import PixiManager from ..uv import UvManager from .base_manager import BaseManager @@ -12,5 +13,6 @@ def get_supported_managers() -> Dict[str, Type[BaseManager]]: managers = [ PipVenvManager, UvManager, + PixiManager, ] return {manager_cls.NAME: manager_cls for manager_cls in managers} diff --git a/src/ewoks/_requirements/utils/base_manager.py b/src/ewoks/_requirements/utils/base_manager.py index 9ea6eb9..e91c3a9 100644 --- a/src/ewoks/_requirements/utils/base_manager.py +++ b/src/ewoks/_requirements/utils/base_manager.py @@ -26,7 +26,7 @@ class BaseManagerInfo(models.BaseModel): name: str = Field( description="Package manager that generated the requirements.", - examples=["pip-venv", "uv"], + examples=["pip-venv", "uv", "pixi"], ) version: str = Field( description="Version of the package manager.", examples=["25.0.1"] @@ -36,8 +36,8 @@ class BaseManagerInfo(models.BaseModel): description=( "Content of the files the package manager needs to reproduce the " "environment: 'requirements.txt' for pip-venv, 'pyproject.toml' and " - "'uv.lock' for uv. Empty when the package manager could not generate " - "them." + "'uv.lock' for uv, 'pixi.toml' and 'pixi.lock' for pixi. Empty when " + "the package manager could not generate them." ), examples=[{"requirements.txt": "ewoks==7.0.0\nnetworkx==3.4.2\n"}], ) @@ -184,9 +184,11 @@ def environment_location( return Path(root or self.environments_root()).expanduser() / name def environments_root(self) -> Path: - """Root directory in which the package manager creates named environments. - Package managers that create an environment wherever they are told to (venv - and uv) use a directory of ewoks. + f"""Root directory in which the package manager creates named environments. + + Package managers venv, uv and pixi allow the environment location to be + specified. For those managers the default location for environments created + by `ewoks` is `{EWOKS_ENVIRONMENTS_ROOT}`. """ return EWOKS_ENVIRONMENTS_ROOT diff --git a/src/ewoks/_requirements/utils/conda_channel.py b/src/ewoks/_requirements/utils/conda_channel.py new file mode 100644 index 0000000..fa873d6 --- /dev/null +++ b/src/ewoks/_requirements/utils/conda_channel.py @@ -0,0 +1,13 @@ +"""Requirements of the package managers that install from conda channels.""" + + +def python_specifier(python_version: str) -> str: + """Conda match specification for a python version. + + Only the major and minor version are pinned because a conda channel does not + build every patch release of python (for example none after end-of-life). + """ + if not python_version: + return "*" + major_minor = ".".join(python_version.split(".")[:2]) + return f"{major_minor}.*" diff --git a/src/ewoks/_requirements/utils/environment.py b/src/ewoks/_requirements/utils/environment.py index e26da99..b1141ba 100644 --- a/src/ewoks/_requirements/utils/environment.py +++ b/src/ewoks/_requirements/utils/environment.py @@ -14,6 +14,7 @@ ENVIRONMENT_SUBDIRS: Tuple[str, ...] = ( "", # environment prefix, for example created by `python -m venv` ".venv", # uv project + os.path.join(".pixi", "envs", "default"), # pixi project ) @@ -100,8 +101,8 @@ def python_executable(prefix: Path) -> Path: def _interpreters() -> Tuple[Path, ...]: """Interpreter locations inside an environment prefix, the first one being where - a virtual environment has it. On Windows an environment that is not virtual (a - python installation) has it in the prefix itself. + a virtual environment has it. On Windows the other environments (a python + installation and pixi) have it in the prefix itself. """ if sys.platform == "win32": return (Path("Scripts", "python.exe"), Path("python.exe")) diff --git a/src/ewoks/_requirements/utils/toml_dependencies.py b/src/ewoks/_requirements/utils/toml_dependencies.py new file mode 100644 index 0000000..38d2517 --- /dev/null +++ b/src/ewoks/_requirements/utils/toml_dependencies.py @@ -0,0 +1,40 @@ +"""Dependencies of a TOML manifest: `name = spec` entries in which the +specification is a version constraint, an URL or a git reference. +""" + +import json +import logging +from typing import List +from typing import Sequence + +from packaging.requirements import InvalidRequirement +from packaging.requirements import Requirement + +logger = logging.getLogger(__name__) + + +def dependency_lines(requirements: Sequence[str]) -> List[str]: + """Dependency entries for requirements in PEP 508 format.""" + lines = [] + for requirement in requirements: + try: + parsed = Requirement(requirement) + except InvalidRequirement: + logger.warning("Skip invalid requirement %r", requirement) + continue + lines.append(f"{json.dumps(parsed.name)} = {_specification(parsed)}") + return lines + + +def _specification(requirement: Requirement) -> str: + """Version, URL or git reference of a dependency.""" + if requirement.url: + if requirement.url.startswith("git+"): + url, _, revision = requirement.url[len("git+") :].rpartition("@") + if url: + return f"{{ git = {json.dumps(url)}, rev = {json.dumps(revision)} }}" + return f"{{ git = {json.dumps(revision)} }}" + return f"{{ url = {json.dumps(requirement.url)} }}" + if requirement.specifier: + return json.dumps(str(requirement.specifier)) + return '"*"' diff --git a/src/ewoks/_requirements/uv.py b/src/ewoks/_requirements/uv.py index f4f04d6..aeedc37 100644 --- a/src/ewoks/_requirements/uv.py +++ b/src/ewoks/_requirements/uv.py @@ -83,6 +83,14 @@ def is_active(self) -> bool: or _is_project(Path(sys.prefix).parent) ) + @classmethod + def installed_distribution(cls, distribution: models.Distribution) -> bool: + """The distribution was installed by this package manager.""" + installer = cls._installer(distribution) + # PyPI distributions of a pixi workspace are installed by the uv that pixi + # vendors ("uv-pixi") + return "uv" in installer and "pixi" not in installer + def create_environment( self, location: Path, python_version: Optional[str] = None ) -> Environment: diff --git a/src/ewoks/tests/requirements/managers.py b/src/ewoks/tests/requirements/managers.py index 6d7a2fe..b419db1 100644 --- a/src/ewoks/tests/requirements/managers.py +++ b/src/ewoks/tests/requirements/managers.py @@ -12,6 +12,7 @@ from typing import Type from ..._requirements.pip_venv import PipVenvManager +from ..._requirements.pixi import PixiManager from ..._requirements.utils.base_manager import BaseManager from ..._requirements.utils.metadata import models from ..._requirements.utils.requirements_txt import REQUIREMENTS_FILENAME @@ -29,6 +30,9 @@ class ManagerCase: SLOW: bool = False """Creating an environment takes tens of seconds.""" + CHANNEL_PYTHON: bool = False + """Python comes from a conda channel, which decides the patch version.""" + def command(self) -> Tuple[str, ...]: """Command that invokes the package manager (the default when empty).""" return tuple() @@ -78,9 +82,29 @@ def native_files( return dict() +class PixiCase(ManagerCase): + NAME = "pixi" + MANAGER_CLS = PixiManager + INSTALLER = "uv-pixi" # pixi installs PyPI distributions with its own uv + SLOW = True + CHANNEL_PYTHON = True + + def native_files( + self, distributions: Sequence[models.Distribution], python_version: str + ) -> Dict[str, str]: + # Resolving a lock file requires the package index + try: + return PixiManager()._files_from_distributions( + distributions, python_version + ) + except RuntimeError: + return dict() + + MANAGER_CASES: List[ManagerCase] = [ PipVenvCase(), UvCase(), + PixiCase(), ] FAST_MANAGER_CASES: List[ManagerCase] = [ diff --git a/src/ewoks/tests/requirements/test_managers.py b/src/ewoks/tests/requirements/test_managers.py index a1bd2fa..ffe9fd0 100644 --- a/src/ewoks/tests/requirements/test_managers.py +++ b/src/ewoks/tests/requirements/test_managers.py @@ -16,6 +16,7 @@ from .managers import ManagerCase from .utils import DISTRIBUTIONS from .utils import EWOKS_DEPENDENCIES +from .utils import PYTHON_MINOR_VERSION from .utils import PYTHON_VERSION from .utils import assert_installed from .utils import assert_not_installed @@ -89,9 +90,12 @@ def test_gather_requirements(manager, manager_case, monkeypatch): assert isinstance(requirements.manager.files, dict) -def test_create_environment(environment): +def test_create_environment(manager_case, environment): assert environment.exists() - assert environment.python_version() == PYTHON_VERSION + if manager_case.CHANNEL_PYTHON: + assert environment.python_version().startswith(f"{PYTHON_MINOR_VERSION}.") + else: + assert environment.python_version() == PYTHON_VERSION assert Environment.from_location(environment.location) == environment assert_not_installed(environment, DISTRIBUTIONS) diff --git a/src/ewoks/tests/requirements/test_pixi.py b/src/ewoks/tests/requirements/test_pixi.py new file mode 100644 index 0000000..3f6bcb6 --- /dev/null +++ b/src/ewoks/tests/requirements/test_pixi.py @@ -0,0 +1,43 @@ +"""Tests specific to the pixi package manager.""" + +import sys + +import pytest + +from ..._requirements.pixi import PixiManager +from ..._requirements.utils.conda_channel import python_specifier +from .utils import DISTRIBUTIONS +from .utils import PYTHON_VERSION +from .utils import REQUIREMENTS + + +def test_workspace_environment(monkeypatch, tmp_path): + """The environment of a pixi workspace is inside the `.pixi` directory.""" + location = tmp_path / "workspace" + prefix = location / ".pixi" / "envs" / "default" + + assert PixiManager().environment(location).prefix == prefix + + monkeypatch.setattr(sys, "prefix", str(prefix)) + assert PixiManager().is_active() + + +def test_is_not_active(monkeypatch, tmp_path): + monkeypatch.delenv("PIXI_PROJECT_ROOT", raising=False) + monkeypatch.delenv("PIXI_ENVIRONMENT_NAME", raising=False) + monkeypatch.setattr(sys, "prefix", str(tmp_path)) + assert not PixiManager().is_active() + + +@pytest.mark.skipif(PixiManager().version() is None, reason="pixi is not installed") +def test_manifest(): + manifest = PixiManager()._manifest(REQUIREMENTS, PYTHON_VERSION) + + assert f'python = "{python_specifier(PYTHON_VERSION)}"' in manifest + for dist in DISTRIBUTIONS: + assert f'"{dist.name}" = "=={dist.version}"' in manifest + assert "platforms = [" in manifest + + +def test_version_not_available(): + assert PixiManager("pixi-does-not-exist").version() is None diff --git a/src/ewoks/tests/requirements/test_toml_dependencies.py b/src/ewoks/tests/requirements/test_toml_dependencies.py new file mode 100644 index 0000000..18357e0 --- /dev/null +++ b/src/ewoks/tests/requirements/test_toml_dependencies.py @@ -0,0 +1,27 @@ +"""Tests of the dependencies of a TOML manifest.""" + +from ..._requirements.utils import toml_dependencies +from .utils import DISTRIBUTIONS +from .utils import REQUIREMENTS + + +def test_dependency_lines(): + """Dependencies of a TOML manifest are not PEP 508 requirements.""" + requirements = [ + *REQUIREMENTS, + "mypackag1", + "mypackage2 @ git+https://host/group/repo.git@123", + "mypackage3 @ https://host/mypackage3-1.0-py3-none-any.whl", + ] + + assert toml_dependencies.dependency_lines(requirements) == [ + *(f'"{dist.name}" = "=={dist.version}"' for dist in DISTRIBUTIONS), + '"mypackag1" = "*"', + '"mypackage2" = { git = "https://host/group/repo.git", rev = "123" }', + '"mypackage3" = { url = "https://host/mypackage3-1.0-py3-none-any.whl" }', + ] + + +def test_invalid_dependency(caplog): + assert toml_dependencies.dependency_lines(["git+https://host/repo"]) == [] + assert "Skip invalid requirement" in caplog.text diff --git a/src/ewoks/tests/requirements/utils.py b/src/ewoks/tests/requirements/utils.py index d8b62ee..16295bd 100644 --- a/src/ewoks/tests/requirements/utils.py +++ b/src/ewoks/tests/requirements/utils.py @@ -18,6 +18,9 @@ runtime is used because a package manager can only provide a python version that is available on the machine.""" +PYTHON_MINOR_VERSION = ".".join(PYTHON_VERSION.split(".")[:2]) +"""Major and minor version of `PYTHON_VERSION`.""" + def _pinned(name: str, versions: Mapping[Tuple[int, int], str]) -> models.Distribution: """Distribution pinned to a version the running python supports. The versions