From 08e212d50863df4eb91c1894bc289a0092aab162 Mon Sep 17 00:00:00 2001 From: alclark Date: Wed, 21 Oct 2026 14:40:25 +0200 Subject: [PATCH 01/15] adding environment field to manager for pixi --- src/ewoks/_requirements/pixi.py | 20 +++++++++++++++++++ src/ewoks/_requirements/utils/base_manager.py | 15 +++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/ewoks/_requirements/pixi.py b/src/ewoks/_requirements/pixi.py index 6a0b9de..94e2103 100644 --- a/src/ewoks/_requirements/pixi.py +++ b/src/ewoks/_requirements/pixi.py @@ -42,6 +42,14 @@ class PixiManagerInfo(BaseManagerInfo): description="Environments described and created by a `pixi` workspace.", examples=["pixi"], ) + environment: str = Field( + default="default", + description=( + "Environment of the pixi workspace in which the requirements were " + "generated." + ), + examples=["default"], + ) class PixiRequirements(BaseRequirements): @@ -135,6 +143,18 @@ def _gather_files( # No pixi workspace: no files return super()._gather_files(distributions, python_version) + def _gather_extra_fields(self) -> Dict[str, str]: + name = os.environ.get("PIXI_ENVIRONMENT_NAME") + if not name: + prefix = Path(sys.prefix) + if _is_workspace_environment(prefix): + # A pixi environment looks like /.pixi/envs/ + name = prefix.name + if not name: + # The manager model provides the default environment + return dict() + return {"environment": name} + def _files_from_distributions( self, distributions: Sequence[models.Distribution], python_version: str ) -> Dict[str, str]: diff --git a/src/ewoks/_requirements/utils/base_manager.py b/src/ewoks/_requirements/utils/base_manager.py index 4c0ba77..938cedc 100644 --- a/src/ewoks/_requirements/utils/base_manager.py +++ b/src/ewoks/_requirements/utils/base_manager.py @@ -120,9 +120,11 @@ class BaseManager: An implementation provides `version`, `is_active`, `create_environment`, `_files_from_distributions`, `_install_files` and `_add_ewoks`. Package managers that can inspect a python environment also override `_gather_files`. Package - managers that keep named environments in a directory of their own also override - `environments_root`. Package managers that are not named after the tool that - installs the distributions also override `installed_distribution`. + managers with extra fields in their manager model also override + `_gather_extra_fields`. Package managers that keep named environments in a + directory of their own also override `environments_root`. Package managers that + are not named after the tool that installs the distributions also override + `installed_distribution`. """ NAME = NotImplemented @@ -295,6 +297,13 @@ def _gather_files( """ return dict() + def _gather_extra_fields(self) -> Dict[str, str]: + """Values of the fields a package manager adds to its manager model, + besides name, version and files. Package managers that add fields + override this. + """ + return dict() + @abstractmethod def _files_from_distributions( self, distributions: Sequence[models.Distribution], python_version: str From b3a46c6a385fa38d9a069f2effd279f8333cf547 Mon Sep 17 00:00:00 2001 From: alclark Date: Wed, 21 Oct 2026 14:57:10 +0200 Subject: [PATCH 02/15] reordering the manager fields so that files is always last --- src/ewoks/_requirements/utils/base_manager.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ewoks/_requirements/utils/base_manager.py b/src/ewoks/_requirements/utils/base_manager.py index 938cedc..e703e7a 100644 --- a/src/ewoks/_requirements/utils/base_manager.py +++ b/src/ewoks/_requirements/utils/base_manager.py @@ -11,6 +11,7 @@ from typing import Union from pydantic import Field +from pydantic import model_serializer from . import process from .environment import Environment @@ -44,6 +45,17 @@ class BaseManagerInfo(models.BaseModel): examples=[{"requirements.txt": "ewoks==7.0.0\nnetworkx==3.4.2\n"}], ) + @model_serializer(mode="wrap") + def _files_last(self, handler): + """The files are long: write them after the other manager fields.""" + # pydantic gives the serializer a handler function. Calling handler + # returns the model as a dict, with the fields in the order they are defined + data = handler(self) + if "files" in data: + # Removing and adding "files" again moves it to the end of the dict + data["files"] = data.pop("files") + return data + class BaseRequirements(models.BaseModel): ewoks: Optional[models.EwoksInfo] = Field( From 6ebafabdaf9db7baa2123980048d9caf4dd961a6 Mon Sep 17 00:00:00 2001 From: alclark Date: Thu, 22 Oct 2026 13:39:35 +0200 Subject: [PATCH 03/15] install the named pixi environment --- src/ewoks/_requirements/__init__.py | 4 ++- src/ewoks/_requirements/pixi.py | 31 ++++++++++++++----- src/ewoks/_requirements/utils/base_manager.py | 8 +++++ 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/ewoks/_requirements/__init__.py b/src/ewoks/_requirements/__init__.py index 475e288..27c586c 100644 --- a/src/ewoks/_requirements/__init__.py +++ b/src/ewoks/_requirements/__init__.py @@ -193,8 +193,10 @@ def install_requirements( installed = False if native: + files_environment = manager.files_environment(requirements, environment) try: - manager.install_files(requirements, environment) + manager.install_files(requirements, files_environment) + environment = files_environment installed = True except Exception: logger.exception( diff --git a/src/ewoks/_requirements/pixi.py b/src/ewoks/_requirements/pixi.py index 94e2103..af0f08a 100644 --- a/src/ewoks/_requirements/pixi.py +++ b/src/ewoks/_requirements/pixi.py @@ -42,11 +42,11 @@ class PixiManagerInfo(BaseManagerInfo): description="Environments described and created by a `pixi` workspace.", examples=["pixi"], ) - environment: str = Field( + pixi_environment_name: str = Field( default="default", description=( - "Environment of the pixi workspace in which the requirements were " - "generated." + "Name of the environment of the pixi workspace in which the " + "requirements were generated." ), examples=["default"], ) @@ -127,6 +127,15 @@ def create_environment( self._install_workspace(location) return self.environment(location) + def files_environment( + self, requirements: BaseRequirements, environment: Environment + ) -> Environment: + if not isinstance(requirements, PixiRequirements): + return environment + name = requirements.manager.pixi_environment_name + subdir = os.path.join(".pixi", "envs", name) + return Environment.at_location(environment.location, subdir) + def _gather_files( self, distributions: List[models.Distribution], python_version: str ) -> Dict[str, str]: @@ -144,6 +153,7 @@ def _gather_files( return super()._gather_files(distributions, python_version) def _gather_extra_fields(self) -> Dict[str, str]: + # pixi sets PIXI_ENVIRONMENT_NAME in `pixi run` and `pixi shell` name = os.environ.get("PIXI_ENVIRONMENT_NAME") if not name: prefix = Path(sys.prefix) @@ -153,7 +163,7 @@ def _gather_extra_fields(self) -> Dict[str, str]: if not name: # The manager model provides the default environment return dict() - return {"environment": name} + return {"pixi_environment_name": name} def _files_from_distributions( self, distributions: Sequence[models.Distribution], python_version: str @@ -174,22 +184,27 @@ def _install_files( self, files: Mapping[str, str], environment: Environment ) -> None: write_files(environment.location, files) - self._install_workspace(environment.location) + self._install_workspace(environment.location, environment.prefix.name) def _add_ewoks( self, requirements: BaseRequirements, environment: Environment ) -> None: - # The workspace pins all versions so they cannot change + # pixi keeps the locked versions of the workflow requirements, unless ewoks + # needs different versions of them self._check_call( "add", "--pypi", "ewoks", "--manifest-path", environment.location, + "--environment", + environment.prefix.name, + "--no-install", ) + self._install_workspace(environment.location, environment.prefix.name) - def _install_workspace(self, location: Path) -> None: - self._check_call("install", "--manifest-path", location) + def _install_workspace(self, location: Path, name: str = "default") -> None: + self._check_call("install", "--manifest-path", location, "--environment", name) def _manifest(self, requirements: Sequence[str], python_version: str) -> str: """Content of a `pixi.toml` file with python distributions as pypi diff --git a/src/ewoks/_requirements/utils/base_manager.py b/src/ewoks/_requirements/utils/base_manager.py index e703e7a..75674cd 100644 --- a/src/ewoks/_requirements/utils/base_manager.py +++ b/src/ewoks/_requirements/utils/base_manager.py @@ -199,6 +199,14 @@ def environment(self, location: Union[str, Path]) -> Environment: """Environment the manager creates at this location.""" return Environment.at_location(location, self.ENVIRONMENT_SUBDIR) + def files_environment( + self, requirements: BaseRequirements, environment: Environment + ) -> Environment: + """Environment in which the files of the requirements are installed. + Package managers whose files describe several environments override this. + """ + return environment + def environment_location( self, name: str, root: Optional[Union[str, Path]] = None ) -> Path: From 88602951e235b424958cc7a4f6ab01485d1ca19c Mon Sep 17 00:00:00 2001 From: alclark Date: Thu, 22 Oct 2026 13:58:12 +0200 Subject: [PATCH 04/15] adding additional tests related to the pixi environment name --- src/ewoks/tests/requirements/test_pixi.py | 36 +++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/ewoks/tests/requirements/test_pixi.py b/src/ewoks/tests/requirements/test_pixi.py index 6128389..992713c 100644 --- a/src/ewoks/tests/requirements/test_pixi.py +++ b/src/ewoks/tests/requirements/test_pixi.py @@ -5,11 +5,13 @@ import pytest from ..._requirements.pixi import PixiManager +from ..._requirements.pixi import PixiRequirements from ..._requirements.utils.conda_channel import python_specifier from ..._requirements.utils.files import write_files from .utils import DISTRIBUTIONS from .utils import PYTHON_VERSION from .utils import REQUIREMENTS +from .utils import manager_requirements def test_workspace_environment(monkeypatch, tmp_path): @@ -57,6 +59,40 @@ def test_is_not_active(monkeypatch, tmp_path): assert not PixiManager().is_active() +def test_pixi_environment_name_from_variable(monkeypatch, tmp_path): + """pixi run and pixi shell set the name of the environment.""" + monkeypatch.setenv("PIXI_ENVIRONMENT_NAME", "test") + monkeypatch.setattr(sys, "prefix", str(tmp_path / ".pixi" / "envs" / "default")) + assert PixiManager()._gather_extra_fields() == {"pixi_environment_name": "test"} + + +def test_pixi_environment_name_from_prefix(monkeypatch, tmp_path): + """Without the variable, the name comes from the environment prefix.""" + monkeypatch.delenv("PIXI_ENVIRONMENT_NAME", raising=False) + monkeypatch.setattr(sys, "prefix", str(tmp_path / ".pixi" / "envs" / "test")) + assert PixiManager()._gather_extra_fields() == {"pixi_environment_name": "test"} + + +def test_pixi_environment_name_default(monkeypatch, tmp_path): + """Outside a pixi workspace the environment is "default".""" + monkeypatch.delenv("PIXI_ENVIRONMENT_NAME", raising=False) + monkeypatch.setattr(sys, "prefix", str(tmp_path)) + assert PixiManager()._gather_extra_fields() == {} + requirements = PixiRequirements(**manager_requirements("pixi")) + assert requirements.manager.pixi_environment_name == "default" + + +def test_files_environment(tmp_path): + """The files are installed in the environment of the requirements.""" + data = manager_requirements("pixi") + data["manager"]["pixi_environment_name"] = "test" + requirements = PixiRequirements(**data) + manager = PixiManager() + environment = manager.files_environment(requirements, manager.environment(tmp_path)) + assert environment.location == tmp_path + assert environment.prefix == tmp_path / ".pixi" / "envs" / "test" + + @pytest.mark.skipif(PixiManager().version() is None, reason="pixi is not installed") def test_manifest(): manifest = PixiManager()._manifest(REQUIREMENTS, PYTHON_VERSION) From 85a69dc64df0012c6668ee8b09f3a303ba95c092 Mon Sep 17 00:00:00 2001 From: alclark Date: Mon, 2 Nov 2026 17:10:12 +0100 Subject: [PATCH 05/15] find the named pixi environment when a workspace has no default environment --- src/ewoks/_requirements/utils/environment.py | 22 +++++++++++++++++-- .../tests/requirements/test_environment.py | 20 +++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/ewoks/_requirements/utils/environment.py b/src/ewoks/_requirements/utils/environment.py index 4f82d31..198495e 100644 --- a/src/ewoks/_requirements/utils/environment.py +++ b/src/ewoks/_requirements/utils/environment.py @@ -42,14 +42,32 @@ def at_location(cls, location: Union[str, Path], subdir: str = "") -> "Environme @classmethod def from_location(cls, location: Union[str, Path]) -> "Environment": - """Environment found inside the location. + """Environment found inside the location. A pixi workspace without a + default environment provides its only environment. - :raises ValueError: no python interpreter found + :raises ValueError: no python interpreter found or several pixi environments """ for subdir in ENVIRONMENT_SUBDIRS: environment = cls.at_location(location, subdir) if environment.python.is_file(): return environment + + # A pixi environment looks like /.pixi/envs/ + environments = [] + for prefix in (Path(location) / ".pixi" / "envs").glob("*"): + subdir = os.path.join(".pixi", "envs", prefix.name) + environment = cls.at_location(location, subdir) + if environment.python.is_file(): + environments.append(environment) + if len(environments) == 1: + return environments[0] + if environments: + names = ", ".join(environment.prefix.name for environment in environments) + raise ValueError( + f"Several pixi environments found in '{location}' ({names}): use the " + f"prefix of one of them, for example '{environments[0].prefix}'" + ) + raise ValueError(f"No python environment found in '{location}'") @property diff --git a/src/ewoks/tests/requirements/test_environment.py b/src/ewoks/tests/requirements/test_environment.py index b2abfa0..9559ad3 100644 --- a/src/ewoks/tests/requirements/test_environment.py +++ b/src/ewoks/tests/requirements/test_environment.py @@ -36,3 +36,23 @@ def test_environment_layout(platform, relative, subdir, monkeypatch, tmp_path): def test_no_environment(tmp_path): with pytest.raises(ValueError, match="No python environment found"): Environment.from_location(tmp_path) + + +@pytest.mark.parametrize( + "names, expected", + [(["test"], "test"), (["default", "test"], "default"), (["test", "docs"], None)], +) +def test_pixi_environments(names, expected, monkeypatch, tmp_path): + """A pixi workspace provides its default or its only environment.""" + monkeypatch.setattr(sys, "platform", "linux") + envs = tmp_path / ".pixi" / "envs" + for name in names: + python = envs / name / "bin" / "python" + python.parent.mkdir(parents=True) + python.touch() + + if expected is None: + with pytest.raises(ValueError, match="Several pixi environments"): + Environment.from_location(tmp_path) + else: + assert Environment.from_location(tmp_path).prefix == envs / expected From 731213b5ce7f93aacde5757bbf404487215edd91 Mon Sep 17 00:00:00 2001 From: alclark Date: Tue, 3 Nov 2026 12:08:25 +0100 Subject: [PATCH 06/15] change how the pixi environment is selected --- src/ewoks/_requirements/utils/environment.py | 51 ++++++++++++++----- .../tests/requirements/test_environment.py | 14 +++-- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/src/ewoks/_requirements/utils/environment.py b/src/ewoks/_requirements/utils/environment.py index 198495e..1e2e4eb 100644 --- a/src/ewoks/_requirements/utils/environment.py +++ b/src/ewoks/_requirements/utils/environment.py @@ -3,6 +3,7 @@ import sys from dataclasses import dataclass from pathlib import Path +from typing import Dict from typing import Optional from typing import Tuple from typing import Union @@ -14,7 +15,6 @@ ENVIRONMENT_SUBDIRS: Tuple[str, ...] = ( "", # environment prefix, for example created by `python -m venv` ".venv", # uv and poetry project - os.path.join(".pixi", "envs", "default"), # pixi project ) @@ -42,30 +42,40 @@ def at_location(cls, location: Union[str, Path], subdir: str = "") -> "Environme @classmethod def from_location(cls, location: Union[str, Path]) -> "Environment": - """Environment found inside the location. A pixi workspace without a - default environment provides its only environment. + """Environment found inside the location. A pixi workspace provides its only + environment, or its default environment when it has several. :raises ValueError: no python interpreter found or several pixi environments + without a default one """ for subdir in ENVIRONMENT_SUBDIRS: environment = cls.at_location(location, subdir) if environment.python.is_file(): return environment - # A pixi environment looks like /.pixi/envs/ - environments = [] - for prefix in (Path(location) / ".pixi" / "envs").glob("*"): - subdir = os.path.join(".pixi", "envs", prefix.name) - environment = cls.at_location(location, subdir) - if environment.python.is_file(): - environments.append(environment) + environments = _pixi_environments(location) if len(environments) == 1: - return environments[0] + return list(environments.values())[0] if environments: - names = ", ".join(environment.prefix.name for environment in environments) + env_message = ( + f"Several pixi environments found in '{location}': " + f"{', '.join(environments)}" + ) + # Example of a path to pass, other than the default environment + named_env_paths = [ + environment.prefix + for name, environment in environments.items() + if name != "default" + ] + example = f"for example '--env {named_env_paths[0]}'" + if "default" in environments: + logger.warning( + f"{env_message}\nUsing the default environment. To use another " + f"one, rerun with its path in --env, {example}" + ) + return environments["default"] raise ValueError( - f"Several pixi environments found in '{location}' ({names}): use the " - f"prefix of one of them, for example '{environments[0].prefix}'" + f"{env_message}\nRerun with the path of one of them in --env, {example}" ) raise ValueError(f"No python environment found in '{location}'") @@ -105,6 +115,19 @@ def _python_output(self, code: str) -> str: return process.check_output(self.python, "-c", code).strip() +def _pixi_environments(location: Union[str, Path]) -> Dict[str, Environment]: + """Environments of a pixi workspace by name, which look like + /.pixi/envs/. + """ + environments = {} + for prefix in (Path(location) / ".pixi" / "envs").glob("*"): + subdir = os.path.join(".pixi", "envs", prefix.name) + environment = Environment.at_location(location, subdir) + if environment.python.is_file(): + environments[prefix.name] = environment + return environments + + def python_executable(prefix: Path) -> Path: """Python interpreter of an environment prefix. The interpreter of a virtual environment is returned when the environment does not exist yet. diff --git a/src/ewoks/tests/requirements/test_environment.py b/src/ewoks/tests/requirements/test_environment.py index 9559ad3..da8a565 100644 --- a/src/ewoks/tests/requirements/test_environment.py +++ b/src/ewoks/tests/requirements/test_environment.py @@ -40,10 +40,17 @@ def test_no_environment(tmp_path): @pytest.mark.parametrize( "names, expected", - [(["test"], "test"), (["default", "test"], "default"), (["test", "docs"], None)], + [ + (["default"], "default"), + (["test"], "test"), + (["default", "test"], "default"), + (["test", "docs"], None), + ], ) -def test_pixi_environments(names, expected, monkeypatch, tmp_path): - """A pixi workspace provides its default or its only environment.""" +def test_pixi_environments(names, expected, monkeypatch, tmp_path, caplog): + """A pixi workspace provides its only environment, or its default environment + with a warning when it has several. + """ monkeypatch.setattr(sys, "platform", "linux") envs = tmp_path / ".pixi" / "envs" for name in names: @@ -56,3 +63,4 @@ def test_pixi_environments(names, expected, monkeypatch, tmp_path): Environment.from_location(tmp_path) else: assert Environment.from_location(tmp_path).prefix == envs / expected + assert ("Several pixi environments" in caplog.text) == (len(names) > 1) From 1a58a7a0243b8f563b491578051adc7e83ae8c57 Mon Sep 17 00:00:00 2001 From: alclark Date: Tue, 3 Nov 2026 15:13:30 +0100 Subject: [PATCH 07/15] print the python environment used when running ewoks execute --env --- src/ewoks/__main__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ewoks/__main__.py b/src/ewoks/__main__.py index 0c4903c..240b318 100644 --- a/src/ewoks/__main__.py +++ b/src/ewoks/__main__.py @@ -295,6 +295,8 @@ def command_in_environment( ) -> Literal[0, 1]: """Run the command in another python environment.""" environment = Environment.from_location(location) + # Flush so that this line comes before the output of the subprocess + print(f"Running in the python environment {environment.prefix}", flush=True) arguments = _remove_option(argv[1:], "--env") return subprocess.call( # noqa: S603 - Arguments of this command [environment.python, "-m", "ewoks", *arguments] From e8a0a1e0532ec2a1d30217b3455ede3df8bb8f2e Mon Sep 17 00:00:00 2001 From: alclark Date: Tue, 3 Nov 2026 16:34:38 +0100 Subject: [PATCH 08/15] removing creation of an empty default environment for pixi --- src/ewoks/_requirements/__init__.py | 2 +- src/ewoks/_requirements/pixi.py | 10 +--------- src/ewoks/_requirements/utils/base_manager.py | 20 ++++++++++++++----- src/ewoks/tests/requirements/managers.py | 4 ++++ src/ewoks/tests/requirements/test_managers.py | 18 +++++++++++------ 5 files changed, 33 insertions(+), 21 deletions(-) diff --git a/src/ewoks/_requirements/__init__.py b/src/ewoks/_requirements/__init__.py index 27c586c..31f9f0b 100644 --- a/src/ewoks/_requirements/__init__.py +++ b/src/ewoks/_requirements/__init__.py @@ -213,7 +213,7 @@ def install_requirements( ) if not installed: - manager.install_distributions(requirements, environment) + manager.install_distributions(requirements, environment, python_version) if ensure_ewoks: manager.ensure_ewoks(requirements, environment) diff --git a/src/ewoks/_requirements/pixi.py b/src/ewoks/_requirements/pixi.py index af0f08a..6ac53e0 100644 --- a/src/ewoks/_requirements/pixi.py +++ b/src/ewoks/_requirements/pixi.py @@ -1,7 +1,6 @@ import json import logging import os -import platform import re import sys import tempfile @@ -117,14 +116,7 @@ def is_active(self) -> bool: 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) + # pixi creates the environment in `_install_workspace` return self.environment(location) def files_environment( diff --git a/src/ewoks/_requirements/utils/base_manager.py b/src/ewoks/_requirements/utils/base_manager.py index 75674cd..d13560c 100644 --- a/src/ewoks/_requirements/utils/base_manager.py +++ b/src/ewoks/_requirements/utils/base_manager.py @@ -1,5 +1,6 @@ import importlib.metadata import logging +import platform from abc import abstractmethod from pathlib import Path from textwrap import indent @@ -265,10 +266,14 @@ def install_files( raise def install_distributions( - self, requirements: BaseRequirements, environment: Environment + self, + requirements: BaseRequirements, + environment: Environment, + python_version: Optional[str] = None, ) -> None: - """Install the python distributions of the requirements in an existing - environment. + """Install the python distributions of the requirements in an environment. + `python_version` is used when neither the requirements nor the environment + provide a python version. Otherwise, the version of the running python is used. :raises ValueError: no distributions provided to install :raises RuntimeError: installation failed @@ -276,8 +281,13 @@ def install_distributions( if not requirements.distributions: raise ValueError("No distributions provided to install") # Requirements that do not specify a python version are described for the - # python version of the environment they are installed in - python_version = requirements.python.version or environment.python_version() + # python version of the environment they are installed in. + if requirements.python.version: + python_version = requirements.python.version + elif environment.exists(): + python_version = environment.python_version() + elif not python_version: + python_version = platform.python_version() try: files = self._files_from_distributions( requirements.distributions, python_version diff --git a/src/ewoks/tests/requirements/managers.py b/src/ewoks/tests/requirements/managers.py index 74dd463..6a9e59d 100644 --- a/src/ewoks/tests/requirements/managers.py +++ b/src/ewoks/tests/requirements/managers.py @@ -36,6 +36,9 @@ class ManagerCase: CHANNEL_PYTHON: bool = False """Python comes from a conda channel, which decides the patch version.""" + CREATES_ENVIRONMENT: bool = True + """The environment exists before the requirements are installed.""" + def command(self) -> Tuple[str, ...]: """Command that invokes the package manager (the default when empty).""" return tuple() @@ -132,6 +135,7 @@ class PixiCase(ManagerCase): INSTALLER = "uv-pixi" # pixi installs PyPI distributions with its own uv SLOW = True CHANNEL_PYTHON = True + CREATES_ENVIRONMENT = False def native_files( self, distributions: Sequence[models.Distribution], python_version: str diff --git a/src/ewoks/tests/requirements/test_managers.py b/src/ewoks/tests/requirements/test_managers.py index 07d55b5..2bcc102 100644 --- a/src/ewoks/tests/requirements/test_managers.py +++ b/src/ewoks/tests/requirements/test_managers.py @@ -93,13 +93,19 @@ def test_gather_requirements(manager, manager_case, monkeypatch): def test_create_environment(manager_case, environment): - assert environment.exists() - if manager_case.CHANNEL_PYTHON: - assert environment.python_version().startswith(f"{PYTHON_MINOR_VERSION}.") + if manager_case.CREATES_ENVIRONMENT: + assert environment.exists() + 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) else: - assert environment.python_version() == PYTHON_VERSION - assert Environment.from_location(environment.location) == environment - assert_not_installed(environment, DISTRIBUTIONS) + # The environment is created when the requirements are installed + assert not environment.exists() def test_install_native(manager, manager_case, environment): From c9cbc7bfc689c0a25f9be5c95cbe7ae1fc341411 Mon Sep 17 00:00:00 2001 From: alclark Date: Tue, 3 Nov 2026 16:42:45 +0100 Subject: [PATCH 09/15] adding ewoks to the default feature so that older pixi versions work --- src/ewoks/_requirements/pixi.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ewoks/_requirements/pixi.py b/src/ewoks/_requirements/pixi.py index 6ac53e0..908e32e 100644 --- a/src/ewoks/_requirements/pixi.py +++ b/src/ewoks/_requirements/pixi.py @@ -182,15 +182,13 @@ def _add_ewoks( self, requirements: BaseRequirements, environment: Environment ) -> None: # pixi keeps the locked versions of the workflow requirements, unless ewoks - # needs different versions of them + # needs different versions of them. self._check_call( "add", "--pypi", "ewoks", "--manifest-path", environment.location, - "--environment", - environment.prefix.name, "--no-install", ) self._install_workspace(environment.location, environment.prefix.name) From be752924f18215419040b0979289b3a2ca55c3b6 Mon Sep 17 00:00:00 2001 From: alclark Date: Tue, 3 Nov 2026 16:55:59 +0100 Subject: [PATCH 10/15] pixi: takes the environment name from the running python version --- src/ewoks/_requirements/pixi.py | 13 ++++--------- src/ewoks/tests/requirements/test_pixi.py | 9 +++++---- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/ewoks/_requirements/pixi.py b/src/ewoks/_requirements/pixi.py index 908e32e..adf0ee2 100644 --- a/src/ewoks/_requirements/pixi.py +++ b/src/ewoks/_requirements/pixi.py @@ -145,17 +145,12 @@ def _gather_files( return super()._gather_files(distributions, python_version) def _gather_extra_fields(self) -> Dict[str, str]: - # pixi sets PIXI_ENVIRONMENT_NAME in `pixi run` and `pixi shell` - name = os.environ.get("PIXI_ENVIRONMENT_NAME") - if not name: - prefix = Path(sys.prefix) - if _is_workspace_environment(prefix): - # A pixi environment looks like /.pixi/envs/ - name = prefix.name - if not name: + prefix = Path(sys.prefix) + if not _is_workspace_environment(prefix): # The manager model provides the default environment return dict() - return {"pixi_environment_name": name} + # A pixi environment looks like /.pixi/envs/ + return {"pixi_environment_name": prefix.name} def _files_from_distributions( self, distributions: Sequence[models.Distribution], python_version: str diff --git a/src/ewoks/tests/requirements/test_pixi.py b/src/ewoks/tests/requirements/test_pixi.py index 992713c..f0fc9de 100644 --- a/src/ewoks/tests/requirements/test_pixi.py +++ b/src/ewoks/tests/requirements/test_pixi.py @@ -59,10 +59,11 @@ def test_is_not_active(monkeypatch, tmp_path): assert not PixiManager().is_active() -def test_pixi_environment_name_from_variable(monkeypatch, tmp_path): - """pixi run and pixi shell set the name of the environment.""" - monkeypatch.setenv("PIXI_ENVIRONMENT_NAME", "test") - monkeypatch.setattr(sys, "prefix", str(tmp_path / ".pixi" / "envs" / "default")) +def test_pixi_environment_name_not_from_variable(monkeypatch, tmp_path): + """The name comes from the running python, not from an environment that pixi + activated for another interpreter.""" + monkeypatch.setenv("PIXI_ENVIRONMENT_NAME", "other") + monkeypatch.setattr(sys, "prefix", str(tmp_path / ".pixi" / "envs" / "test")) assert PixiManager()._gather_extra_fields() == {"pixi_environment_name": "test"} From dc3d948598397e8f9cc2124d84656dd5c5141630 Mon Sep 17 00:00:00 2001 From: alclark Date: Tue, 3 Nov 2026 17:16:33 +0100 Subject: [PATCH 11/15] ruff: reformat test_managaers.py --- src/ewoks/tests/requirements/test_managers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ewoks/tests/requirements/test_managers.py b/src/ewoks/tests/requirements/test_managers.py index 2bcc102..04dacb7 100644 --- a/src/ewoks/tests/requirements/test_managers.py +++ b/src/ewoks/tests/requirements/test_managers.py @@ -96,9 +96,7 @@ def test_create_environment(manager_case, environment): if manager_case.CREATES_ENVIRONMENT: assert environment.exists() if manager_case.CHANNEL_PYTHON: - assert environment.python_version().startswith( - f"{PYTHON_MINOR_VERSION}." - ) + assert environment.python_version().startswith(f"{PYTHON_MINOR_VERSION}.") else: assert environment.python_version() == PYTHON_VERSION assert Environment.from_location(environment.location) == environment From 7c87cd3dc5d91ca05f7ff7b1fc38ca7cbfb90a8b Mon Sep 17 00:00:00 2001 From: alclark Date: Wed, 4 Nov 2026 09:33:38 +0100 Subject: [PATCH 12/15] minor edits related to pixi --- src/ewoks/_requirements/pixi.py | 2 +- src/ewoks/_requirements/utils/base_manager.py | 3 ++- src/ewoks/tests/requirements/test_pixi.py | 4 +--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/ewoks/_requirements/pixi.py b/src/ewoks/_requirements/pixi.py index adf0ee2..6c486e5 100644 --- a/src/ewoks/_requirements/pixi.py +++ b/src/ewoks/_requirements/pixi.py @@ -188,7 +188,7 @@ def _add_ewoks( ) self._install_workspace(environment.location, environment.prefix.name) - def _install_workspace(self, location: Path, name: str = "default") -> None: + def _install_workspace(self, location: Path, name: str) -> None: self._check_call("install", "--manifest-path", location, "--environment", name) def _manifest(self, requirements: Sequence[str], python_version: str) -> str: diff --git a/src/ewoks/_requirements/utils/base_manager.py b/src/ewoks/_requirements/utils/base_manager.py index d13560c..d205779 100644 --- a/src/ewoks/_requirements/utils/base_manager.py +++ b/src/ewoks/_requirements/utils/base_manager.py @@ -273,7 +273,8 @@ def install_distributions( ) -> None: """Install the python distributions of the requirements in an environment. `python_version` is used when neither the requirements nor the environment - provide a python version. Otherwise, the version of the running python is used. + provide a python version, and the version of the running python when + `python_version` is not given. :raises ValueError: no distributions provided to install :raises RuntimeError: installation failed diff --git a/src/ewoks/tests/requirements/test_pixi.py b/src/ewoks/tests/requirements/test_pixi.py index f0fc9de..189fd46 100644 --- a/src/ewoks/tests/requirements/test_pixi.py +++ b/src/ewoks/tests/requirements/test_pixi.py @@ -68,15 +68,13 @@ def test_pixi_environment_name_not_from_variable(monkeypatch, tmp_path): def test_pixi_environment_name_from_prefix(monkeypatch, tmp_path): - """Without the variable, the name comes from the environment prefix.""" - monkeypatch.delenv("PIXI_ENVIRONMENT_NAME", raising=False) + """The name comes from the environment prefix.""" monkeypatch.setattr(sys, "prefix", str(tmp_path / ".pixi" / "envs" / "test")) assert PixiManager()._gather_extra_fields() == {"pixi_environment_name": "test"} def test_pixi_environment_name_default(monkeypatch, tmp_path): """Outside a pixi workspace the environment is "default".""" - monkeypatch.delenv("PIXI_ENVIRONMENT_NAME", raising=False) monkeypatch.setattr(sys, "prefix", str(tmp_path)) assert PixiManager()._gather_extra_fields() == {} requirements = PixiRequirements(**manager_requirements("pixi")) From bf8509b8afa241d7b239f285f2a9408c1c5b0315 Mon Sep 17 00:00:00 2001 From: alclark Date: Wed, 4 Nov 2026 10:50:17 +0100 Subject: [PATCH 13/15] readding work deleted during rebase to main and adding a new test to catch this in the future --- src/ewoks/_requirements/utils/base_manager.py | 7 ++++++- src/ewoks/tests/requirements/test_pixi.py | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/ewoks/_requirements/utils/base_manager.py b/src/ewoks/_requirements/utils/base_manager.py index d205779..a3f47cf 100644 --- a/src/ewoks/_requirements/utils/base_manager.py +++ b/src/ewoks/_requirements/utils/base_manager.py @@ -192,7 +192,12 @@ def gather_requirements(self) -> BaseRequirements: metadata["distributions"], metadata["python"]["version"] ) - manager = dict(name=self.NAME, version=manager_version, files=files) + manager = dict( + name=self.NAME, + version=manager_version, + files=files, + **self._gather_extra_fields(), + ) ewoks = dict(version=importlib.metadata.version("ewoks")) return self.REQUIREMENTS_MODEL(ewoks=ewoks, manager=manager, **metadata) diff --git a/src/ewoks/tests/requirements/test_pixi.py b/src/ewoks/tests/requirements/test_pixi.py index 189fd46..15be475 100644 --- a/src/ewoks/tests/requirements/test_pixi.py +++ b/src/ewoks/tests/requirements/test_pixi.py @@ -81,6 +81,14 @@ def test_pixi_environment_name_default(monkeypatch, tmp_path): assert requirements.manager.pixi_environment_name == "default" +def test_gather_pixi_environment_name(monkeypatch, tmp_path): + """The gathered requirements contain the name of the environment.""" + monkeypatch.setattr(PixiManager, "version", lambda self: "0.82.1") + monkeypatch.setattr(sys, "prefix", str(tmp_path / ".pixi" / "envs" / "test")) + requirements = PixiManager().gather_requirements() + assert requirements.manager.pixi_environment_name == "test" + + def test_files_environment(tmp_path): """The files are installed in the environment of the requirements.""" data = manager_requirements("pixi") From d976b9a3e0da0546352a892c3ab9282f087080b9 Mon Sep 17 00:00:00 2001 From: alclark Date: Thu, 5 Nov 2026 09:24:01 +0100 Subject: [PATCH 14/15] small changes needed so pixi code works on windows --- src/ewoks/_requirements/pixi.py | 28 +++++++++++++++++--- src/ewoks/_requirements/utils/environment.py | 2 +- src/ewoks/tests/requirements/test_pixi.py | 14 ++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/ewoks/_requirements/pixi.py b/src/ewoks/_requirements/pixi.py index 6c486e5..2430e32 100644 --- a/src/ewoks/_requirements/pixi.py +++ b/src/ewoks/_requirements/pixi.py @@ -72,7 +72,6 @@ class PixiManager(BaseManager): 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: @@ -119,14 +118,22 @@ def create_environment( # pixi creates the environment in `_install_workspace` return self.environment(location) + def environment(self, location: Union[str, Path]) -> Environment: + """The default environment of the pixi workspace in `location`. + + Only the location is known here, not the requirements. When pixi files + are found while gathering the requirements, `files_environment` + switches to the files' environment. + """ + return _workspace_environment(location, "default") + def files_environment( self, requirements: BaseRequirements, environment: Environment ) -> Environment: if not isinstance(requirements, PixiRequirements): return environment name = requirements.manager.pixi_environment_name - subdir = os.path.join(".pixi", "envs", name) - return Environment.at_location(environment.location, subdir) + return _workspace_environment(environment.location, name) def _gather_files( self, distributions: List[models.Distribution], python_version: str @@ -222,6 +229,21 @@ def _platform(self) -> str: raise RuntimeError("Cannot determine the pixi platform") from None +def _workspace_environment(location: Union[str, Path], name: str) -> Environment: + """Return the environment at `/.pixi/envs/`. + + The environment may not exist yet, so the python path is built instead of + searched for. + """ + location = Path(os.path.abspath(location)) + prefix = location / ".pixi" / "envs" / name + if sys.platform == "win32": + python = prefix / "python.exe" + else: + python = prefix / "bin" / "python" + return Environment(location=location, python=python) + + def _is_workspace_environment(prefix: Path) -> bool: """Environment is managed by a pixi workspace.""" parts = prefix.parts diff --git a/src/ewoks/_requirements/utils/environment.py b/src/ewoks/_requirements/utils/environment.py index 1e2e4eb..54b5f01 100644 --- a/src/ewoks/_requirements/utils/environment.py +++ b/src/ewoks/_requirements/utils/environment.py @@ -120,7 +120,7 @@ def _pixi_environments(location: Union[str, Path]) -> Dict[str, Environment]: /.pixi/envs/. """ environments = {} - for prefix in (Path(location) / ".pixi" / "envs").glob("*"): + for prefix in sorted((Path(location) / ".pixi" / "envs").glob("*")): subdir = os.path.join(".pixi", "envs", prefix.name) environment = Environment.at_location(location, subdir) if environment.python.is_file(): diff --git a/src/ewoks/tests/requirements/test_pixi.py b/src/ewoks/tests/requirements/test_pixi.py index 15be475..a63d82f 100644 --- a/src/ewoks/tests/requirements/test_pixi.py +++ b/src/ewoks/tests/requirements/test_pixi.py @@ -100,6 +100,20 @@ def test_files_environment(tmp_path): assert environment.prefix == tmp_path / ".pixi" / "envs" / "test" +def test_windows_interpreter(monkeypatch, tmp_path): + """On Windows the interpreter is in the prefix, also before pixi creates the + environment.""" + monkeypatch.setattr(sys, "platform", "win32") + data = manager_requirements("pixi") + data["manager"]["pixi_environment_name"] = "test" + requirements = PixiRequirements(**data) + manager = PixiManager() + environment = manager.environment(tmp_path) + assert environment.python == tmp_path / ".pixi" / "envs" / "default" / "python.exe" + environment = manager.files_environment(requirements, environment) + assert environment.python == tmp_path / ".pixi" / "envs" / "test" / "python.exe" + + @pytest.mark.skipif(PixiManager().version() is None, reason="pixi is not installed") def test_manifest(): manifest = PixiManager()._manifest(REQUIREMENTS, PYTHON_VERSION) From f23b6b1c058b664a2f2af420a9fd668dcad45c6b Mon Sep 17 00:00:00 2001 From: clarka34 <16638904+clarka34@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:10:15 +0100 Subject: [PATCH 15/15] Apply suggestions from code review Co-authored-by: Wout De Nolf --- src/ewoks/_requirements/pixi.py | 14 +++++++------- src/ewoks/_requirements/utils/base_manager.py | 2 -- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/ewoks/_requirements/pixi.py b/src/ewoks/_requirements/pixi.py index 2430e32..efaeacc 100644 --- a/src/ewoks/_requirements/pixi.py +++ b/src/ewoks/_requirements/pixi.py @@ -115,16 +115,11 @@ def is_active(self) -> bool: def create_environment( self, location: Path, python_version: Optional[str] = None ) -> Environment: - # pixi creates the environment in `_install_workspace` + """Environment that pixi creates when it installs the workspace.""" return self.environment(location) def environment(self, location: Union[str, Path]) -> Environment: - """The default environment of the pixi workspace in `location`. - - Only the location is known here, not the requirements. When pixi files - are found while gathering the requirements, `files_environment` - switches to the files' environment. - """ + """The default environment of the pixi workspace in `location`.""" return _workspace_environment(location, "default") def files_environment( @@ -194,6 +189,11 @@ def _add_ewoks( "--no-install", ) self._install_workspace(environment.location, environment.prefix.name) + if not environment.distribution_version("ewoks"): + raise RuntimeError( + f"ewoks was not installed in the pixi environment " + f"'{environment.prefix.name}': add ewoks to one of its features" + ) def _install_workspace(self, location: Path, name: str) -> None: self._check_call("install", "--manifest-path", location, "--environment", name) diff --git a/src/ewoks/_requirements/utils/base_manager.py b/src/ewoks/_requirements/utils/base_manager.py index a3f47cf..bd6df96 100644 --- a/src/ewoks/_requirements/utils/base_manager.py +++ b/src/ewoks/_requirements/utils/base_manager.py @@ -49,8 +49,6 @@ class BaseManagerInfo(models.BaseModel): @model_serializer(mode="wrap") def _files_last(self, handler): """The files are long: write them after the other manager fields.""" - # pydantic gives the serializer a handler function. Calling handler - # returns the model as a dict, with the fields in the order they are defined data = handler(self) if "files" in data: # Removing and adding "files" again moves it to the end of the dict