diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3450f9c..048a29c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,6 +32,7 @@ jobs: ${{ matrix.extra-pytest-warnings }} enable-coverage: ${{ matrix.enable-coverage }} jupyter-platform-dirs: "1" + package-managers: "uv" codecov-flags: "unit" strategy: fail-fast: false diff --git a/doc/howtoguides/requirements.rst b/doc/howtoguides/requirements.rst index 9c4c8d1..e8826ab 100644 --- a/doc/howtoguides/requirements.rst +++ b/doc/howtoguides/requirements.rst @@ -18,7 +18,8 @@ What is stored installed from the python package index, the git commit or the archive it came from. Any :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. + of the files it needs to recreate the environment: ``requirements.txt`` for pip-venv, + ``pyproject.toml`` and ``uv.lock`` for uv. Use ``--exclude-requirements`` to store nothing. @@ -37,7 +38,7 @@ one explicitly .. code-block:: bash - ewoks convert demo demo.json --test --package-manager-name pip-venv + ewoks convert demo demo.json --test --package-manager-name uv ``ewoks install`` uses the :term:`package manager` that generated the requirements, unless it is not installed on the machine. It installs the stored files of that :term:`package manager`. @@ -47,15 +48,15 @@ confirmed separately unless ``--yes`` is provided .. code-block:: bash - ewoks install demo.json --yes --package-manager-name pip-venv + ewoks install demo.json --yes --package-manager-name uv ``--package-manager-command`` provides the command that invokes the tool, for example when it is not on the ``PATH`` or when a faster implementation should be used .. code-block:: bash - ewoks install demo.json --yes --package-manager-name pip-venv \ - --package-manager-command /path/to/python + ewoks install demo.json --yes --package-manager-name uv \ + --package-manager-command /path/to/uv Choose the environment ---------------------- @@ -85,21 +86,28 @@ The default name is the identifier of the :term:`workflow`, which is the ``id`` content. Without ``--env-root`` the :term:`package manager` decides where the environment goes. venv -creates an environment wherever it is told to, so for pip-venv ewoks uses ``~/.ewoks/envs``. +and uv create an environment wherever they are told to, so for pip-venv and uv 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. ``ewoks execute --env`` takes that directory, not the python interpreter inside -it. +environment and for uv a project with the environment in ``.venv``. ``ewoks execute --env`` +takes that directory, not the python interpreter inside it. Limitations ----------- -* ``--python-version`` is a request: 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. +* ``--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. +* 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. * A package installed from a local directory cannot be recreated elsewhere. This is reported as a warning when the requirements are generated. diff --git a/doc/reference/glossary.rst b/doc/reference/glossary.rst index d506527..4e4e8c1 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. + 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. 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 aea0d2f..7fbfff0 100644 --- a/doc/tutorials/install.rst +++ b/doc/tutorials/install.rst @@ -14,12 +14,14 @@ There are two sides to this * the *re-producer* receives the :term:`workflow`, recreates the environment and executes the :term:`workflow` in it. -Both sides use a :term:`package manager`. The walk-through below does both sides +Both sides use a :term:`package manager`. The walk-throughs below do the same thing with each +of the supported ones .. toctree:: :maxdepth: 1 install/pip_venv + install/uv 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 @@ -68,7 +70,7 @@ and the command to remove it again Remove : rm -rf ewoks_envs/demo Without ``--yes`` you are asked to confirm after the packages have been listed. The -walk-through uses ``--env-root`` to create the environment in the working directory. Without +walk-throughs use ``--env-root`` to create the environment in the working directory. Without it the environment is created where the :term:`package manager` creates named environments. Execute the workflow diff --git a/doc/tutorials/install/uv.rst b/doc/tutorials/install/uv.rst new file mode 100644 index 0000000..6b3bbd9 --- /dev/null +++ b/doc/tutorials/install/uv.rst @@ -0,0 +1,162 @@ +.. _install_uv: + +uv +== + +End-to-end walk-through of `workflow installation and execution <../install.rst>`_ with `uv `_. + +Install uv +---------- + +.. tabs:: + + .. group-tab:: Linux + + .. code-block:: bash + + export UV_INSTALL_DIR=$HOME/.local/uv/bin # default location + curl -LsSf https://astral.sh/uv/install.sh | INSTALLER_NO_MODIFY_PATH=1 sh + export PATH=$UV_INSTALL_DIR:$PATH + + .. group-tab:: macOS + + .. code-block:: bash + + export UV_INSTALL_DIR=$HOME/.local/uv/bin # default location + curl -LsSf https://astral.sh/uv/install.sh | INSTALLER_NO_MODIFY_PATH=1 sh + export PATH=$UV_INSTALL_DIR:$PATH + + .. group-tab:: Windows + + .. code-block:: powershell + + $env:UV_INSTALL_DIR = "$env:USERPROFILE\.local\uv\bin" # default location + irm https://astral.sh/uv/install.ps1 | iex + $env:PATH = "$env:UV_INSTALL_DIR;$env:PATH" + +Producer side +------------- + +Create an environment, install :term:`ewoks` in it and store a :term:`workflow` with the +packages of that environment as its ``requirements`` + +.. tabs:: + + .. group-tab:: Linux + + .. code-block:: bash + + uv venv ewoks_producer + source ewoks_producer/bin/activate + uv pip install ewoks + ewoks convert demo demo.json --test + deactivate + + .. group-tab:: macOS + + .. code-block:: bash + + uv venv ewoks_producer + source ewoks_producer/bin/activate + uv pip install ewoks + ewoks convert demo demo.json --test + deactivate + + .. group-tab:: Windows + + .. code-block:: powershell + + uv venv ewoks_producer + ewoks_producer\Scripts\activate.ps1 + uv pip install ewoks + ewoks convert demo demo.json --test + deactivate + +Install any package that provides :term:`tasks ` instead of +``ewoks`` for a real :term:`workflow`. + +Re-producer side +---------------- + +Only ``ewoks`` itself is needed to recreate the environment of ``demo.json`` + +.. tabs:: + + .. group-tab:: Linux + + .. code-block:: bash + + uv venv ewoks_reproducer + source ewoks_reproducer/bin/activate + uv pip install ewoks + ewoks install demo.json --yes --env-root ewoks_envs + + .. group-tab:: macOS + + .. code-block:: bash + + uv venv ewoks_reproducer + source ewoks_reproducer/bin/activate + uv pip install ewoks + ewoks install demo.json --yes --env-root ewoks_envs + + .. group-tab:: Windows + + .. code-block:: powershell + + uv venv ewoks_reproducer + ewoks_reproducer\Scripts\activate.ps1 + uv pip install ewoks + ewoks install demo.json --yes --env-root ewoks_envs + +The environment of the :term:`workflow` is a uv project in ``ewoks_envs/demo`` with the +virtual environment in its ``.venv`` directory. + +Execute the workflow +-------------------- + +.. tabs:: + + .. group-tab:: Linux + + .. code-block:: bash + + ewoks execute --env ewoks_envs/demo demo.json --outputs=all + + .. group-tab:: macOS + + .. code-block:: bash + + ewoks execute --env ewoks_envs/demo demo.json --outputs=all + + .. group-tab:: Windows + + .. code-block:: powershell + + ewoks execute --env ewoks_envs\demo demo.json --outputs=all + +Clean up +-------- + +.. tabs:: + + .. group-tab:: Linux + + .. code-block:: bash + + deactivate + rm -rf ewoks_producer ewoks_reproducer ewoks_envs demo.json + + .. group-tab:: macOS + + .. code-block:: bash + + deactivate + rm -rf ewoks_producer ewoks_reproducer ewoks_envs demo.json + + .. group-tab:: Windows + + .. code-block:: powershell + + deactivate + Remove-Item -Recurse -Force ewoks_producer, ewoks_reproducer, ewoks_envs, demo.json diff --git a/src/ewoks/_requirements/utils/_supported.py b/src/ewoks/_requirements/utils/_supported.py index 348c9e9..0e5c43a 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 ..uv import UvManager from .base_manager import BaseManager @@ -10,5 +11,6 @@ def get_supported_managers() -> Dict[str, Type[BaseManager]]: managers = [ PipVenvManager, + UvManager, ] 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 f27f867..9ea6eb9 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"], + examples=["pip-venv", "uv"], ) version: str = Field( description="Version of the package manager.", examples=["25.0.1"] @@ -35,8 +35,9 @@ class BaseManagerInfo(models.BaseModel): default_factory=dict, description=( "Content of the files the package manager needs to reproduce the " - "environment: 'requirements.txt' for pip-venv. Empty when the package " - "manager could not generate them." + "environment: 'requirements.txt' for pip-venv, 'pyproject.toml' and " + "'uv.lock' for uv. Empty when the package manager could not generate " + "them." ), examples=[{"requirements.txt": "ewoks==7.0.0\nnetworkx==3.4.2\n"}], ) @@ -184,8 +185,8 @@ def environment_location( 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) - use a directory of ewoks. + Package managers that create an environment wherever they are told to (venv + and uv) use a directory of ewoks. """ return EWOKS_ENVIRONMENTS_ROOT diff --git a/src/ewoks/_requirements/utils/environment.py b/src/ewoks/_requirements/utils/environment.py index 8ce113a..e26da99 100644 --- a/src/ewoks/_requirements/utils/environment.py +++ b/src/ewoks/_requirements/utils/environment.py @@ -13,6 +13,7 @@ ENVIRONMENT_SUBDIRS: Tuple[str, ...] = ( "", # environment prefix, for example created by `python -m venv` + ".venv", # uv project ) diff --git a/src/ewoks/_requirements/utils/metadata/models.py b/src/ewoks/_requirements/utils/metadata/models.py index f1893da..a29ca8a 100644 --- a/src/ewoks/_requirements/utils/metadata/models.py +++ b/src/ewoks/_requirements/utils/metadata/models.py @@ -82,5 +82,5 @@ class Distribution(BaseModel): installer: Optional[str] = Field( default=None, description="Tool that installed the distribution.", - examples=["pip"], + examples=["pip", "uv"], ) diff --git a/src/ewoks/_requirements/uv.py b/src/ewoks/_requirements/uv.py new file mode 100644 index 0000000..f4f04d6 --- /dev/null +++ b/src/ewoks/_requirements/uv.py @@ -0,0 +1,204 @@ +import json +import os +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 requirements_txt +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 + +_PYPROJECT_FILENAME = "pyproject.toml" +_LOCK_FILENAME = "uv.lock" +_CONSTRAINTS_FILENAME = "constraints.txt" +_PROJECT_NAME = "ewoks-workflow-requirements" + + +class UvManagerInfo(BaseManagerInfo): + name: Literal["uv"] = Field( + default="uv", + description="Environments described and created by a `uv` project.", + examples=["uv"], + ) + + +class UvRequirements(BaseRequirements): + manager: UvManagerInfo + + +class UvManager(BaseManager): + """Uses the `uv` project interface: `uv lock` describes an environment with a + `pyproject.toml` and a `uv.lock` file, `uv sync` reproduces the environment + from those files. + + The project is generated from the installed python distributions instead of + being taken from a `uv` project of the current environment. This keeps it + self-contained: no workspace members and no local project to build. + + The command invokes uv. For example `UvManager("/path/to/uv")`. + """ + + NAME = "uv" + PRIORITY = 1 + REQUIREMENTS_MODEL = UvRequirements + COMMAND_EXAMPLE = "uv" + ENVIRONMENT_SUBDIR = ".venv" + CAN_INSTALL_IN_PLACE = True + + def __init__(self, *command: str) -> None: + if not command: + command = ("uv",) + 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 "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.""" + return ( + "UV" in os.environ + or _created_by_uv(Path(sys.prefix)) + or _is_project(Path(sys.prefix).parent) + ) + + def create_environment( + self, location: Path, python_version: Optional[str] = None + ) -> Environment: + """ + :raises RuntimeError: creation failed + """ + environment = self.environment(location) + # Without a python request, uv selects one of its own interpreters + python = python_version or sys.executable + self._check_call("venv", "--python", python, environment.prefix) + return environment + + def _files_from_distributions( + self, distributions: Sequence[models.Distribution], python_version: str + ) -> Dict[str, str]: + """ + :raises RuntimeError: the requirements cannot be resolved + """ + pyproject = _pyproject( + requirements_txt.manifest_requirements(distributions), python_version + ) + files = {_PYPROJECT_FILENAME: pyproject} + + with temporary_files(files) as directory: + self._check_call("lock", "--project", directory) + lock = read_files(directory, _LOCK_FILENAME) + + return {**files, **lock} + + def _install_files( + self, files: Mapping[str, str], environment: Environment + ) -> None: + if self._owns(environment): + write_files(environment.location, files) + self._sync(environment.location) + return + + # An environment that is not a uv project can only be filled through the + # pip-compatible interface of uv: `uv sync` always targets '/.venv' + with temporary_files(files) as directory: + filename = directory / requirements_txt.REQUIREMENTS_FILENAME + self._check_call( + "export", + "--frozen", + "--no-hashes", + "--quiet", + "--project", + directory, + "--output-file", + filename, + ) + self._pip_install(environment, "-r", filename) + + def _add_ewoks( + self, requirements: BaseRequirements, environment: Environment + ) -> None: + if self._owns(environment): + # The project pins all versions so they cannot change + self._check_call("add", "--project", environment.location, "ewoks") + return + + constraints = requirements_txt.version_constraints(requirements.distributions) + if not constraints: + self._pip_install(environment, "ewoks") + return + with temporary_files({_CONSTRAINTS_FILENAME: "\n".join(constraints)}) as tmpdir: + self._pip_install( + environment, + "ewoks", + "--constraint", + tmpdir / _CONSTRAINTS_FILENAME, + ) + + def _sync(self, location: Path) -> None: + self._check_call( + "sync", "--frozen", "--no-install-project", "--project", location + ) + + def _pip_install(self, environment: Environment, *arguments: str) -> None: + self._check_call( + "pip", + "install", + "--no-cache", + "--python", + environment.python, + *arguments, + ) + + +def _pyproject(requirements: Sequence[str], python_version: str) -> str: + """Content of a `pyproject.toml` file for a project that is not a package.""" + dependencies = "".join(f" {json.dumps(req)},\n" for req in requirements) + if python_version: + python = f'requires-python = "=={python_version}"\n' + else: + python = "" + return f"""[project] +name = {json.dumps(_PROJECT_NAME)} +version = "0.0.0" +{python}dependencies = [ +{dependencies}] + +[tool.uv] +package = false +""" + + +def _is_project(directory: Path) -> bool: + """Directory is a uv project.""" + return (directory / _LOCK_FILENAME).is_file() + + +def _created_by_uv(prefix: Path) -> bool: + """Environment is a virtual environment created by uv.""" + try: + with open(prefix / "pyvenv.cfg", "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/tests/requirements/conftest.py b/src/ewoks/tests/requirements/conftest.py index 7c92c11..b19556f 100644 --- a/src/ewoks/tests/requirements/conftest.py +++ b/src/ewoks/tests/requirements/conftest.py @@ -92,6 +92,13 @@ def fast_environment(fast_manager_case, tmp_path) -> Iterator[Environment]: yield from _environment(fast_manager_case, tmp_path) +@pytest.fixture(params=FAST_MANAGER_CASES, ids=_FAST_IDS) +def source_manager_case(request) -> ManagerCase: + """Repeats the test for every package manager that could have generated the + requirements to be installed.""" + return _manager_case(request) + + def _manager_case(request) -> ManagerCase: case: ManagerCase = request.param if case.manager().version() is None: diff --git a/src/ewoks/tests/requirements/managers.py b/src/ewoks/tests/requirements/managers.py index 0f16d11..6d7a2fe 100644 --- a/src/ewoks/tests/requirements/managers.py +++ b/src/ewoks/tests/requirements/managers.py @@ -15,6 +15,7 @@ from ..._requirements.utils.base_manager import BaseManager from ..._requirements.utils.metadata import models from ..._requirements.utils.requirements_txt import REQUIREMENTS_FILENAME +from ..._requirements.uv import UvManager class ManagerCase: @@ -62,8 +63,24 @@ def native_files( return {REQUIREMENTS_FILENAME: requirements} +class UvCase(ManagerCase): + NAME = "uv" + MANAGER_CLS = UvManager + INSTALLER = "uv" + + def native_files( + self, distributions: Sequence[models.Distribution], python_version: str + ) -> Dict[str, str]: + # Resolving a lock file requires the package index + try: + return UvManager()._files_from_distributions(distributions, python_version) + except RuntimeError: + return dict() + + MANAGER_CASES: List[ManagerCase] = [ PipVenvCase(), + UvCase(), ] FAST_MANAGER_CASES: List[ManagerCase] = [ diff --git a/src/ewoks/tests/requirements/test_cross_manager.py b/src/ewoks/tests/requirements/test_cross_manager.py new file mode 100644 index 0000000..dc37b43 --- /dev/null +++ b/src/ewoks/tests/requirements/test_cross_manager.py @@ -0,0 +1,40 @@ +"""Tests of installing requirements generated by another package manager.""" + +import pytest + +from ..._requirements.utils.parse import parse_requirements +from .utils import DISTRIBUTIONS +from .utils import PYTHON_VERSION +from .utils import assert_installed +from .utils import manager_requirements + + +def test_install_foreign_requirements( + source_manager_case, manager, manager_case, environment +): + """Requirements generated by one package manager, installed by another one. + + The files of the package manager that generated the requirements are ignored: + the installed python distributions are used to generate the files of the + package manager that installs them. + """ + if source_manager_case.NAME == manager_case.NAME: + pytest.skip("not a foreign package manager") + + files = source_manager_case.native_files(DISTRIBUTIONS, PYTHON_VERSION) + if not files: + pytest.skip(f"{source_manager_case.NAME} cannot generate its requirement files") + requirements = parse_requirements( + manager_requirements( + source_manager_case.NAME, + files=files, + distributions=DISTRIBUTIONS, + python_version=PYTHON_VERSION, + ) + ) + + assert not manager.is_native(requirements, environment) + + manager.install_distributions(requirements, environment) + + assert_installed(environment, DISTRIBUTIONS) diff --git a/src/ewoks/tests/requirements/test_managers.py b/src/ewoks/tests/requirements/test_managers.py index 43c9f4b..a1bd2fa 100644 --- a/src/ewoks/tests/requirements/test_managers.py +++ b/src/ewoks/tests/requirements/test_managers.py @@ -8,6 +8,7 @@ from ..._requirements.utils import base_manager from ..._requirements.utils import detect from ..._requirements.utils._supported import get_supported_managers +from ..._requirements.utils.detect import get_in_place_installer from ..._requirements.utils.detect import get_manager from ..._requirements.utils.environment import Environment from ..._requirements.utils.parse import parse_requirements @@ -206,6 +207,17 @@ def test_install_in_place_not_supported(manager_case, monkeypatch): install_requirements(requirements, manager_name=manager_case.NAME) +def test_in_place_installer(manager_case, monkeypatch): + """A package manager that cannot install in the current environment is not + used to do so.""" + monkeypatch.setattr(manager_case.MANAGER_CLS, "CAN_INSTALL_IN_PLACE", False) + + manager = get_in_place_installer() + + assert manager.CAN_INSTALL_IN_PLACE + assert not isinstance(manager, manager_case.MANAGER_CLS) + + def test_ewoks_already_installed(manager, manager_case): """Ewoks is installed in the current python environment.""" requirements = parse_requirements(manager_requirements(manager_case.NAME)) diff --git a/src/ewoks/tests/requirements/test_uv.py b/src/ewoks/tests/requirements/test_uv.py new file mode 100644 index 0000000..4869eba --- /dev/null +++ b/src/ewoks/tests/requirements/test_uv.py @@ -0,0 +1,85 @@ +"""Tests specific to the uv package manager.""" + +import os +import shutil +import sys +from pathlib import Path +from typing import Iterator + +import pytest + +from ..._requirements.utils.environment import Environment +from ..._requirements.uv import UvManager +from .utils import PYTHON_VERSION + + +@pytest.fixture +def uv_environment(tmp_path) -> Iterator[Environment]: + """Environment of a uv project.""" + if UvManager().version() is None: + pytest.skip("uv is not installed") + location = tmp_path / "environment" + yield UvManager().create_environment(location) + shutil.rmtree(location, ignore_errors=True) + + +@pytest.fixture +def no_uv_environment(monkeypatch, tmp_path) -> Iterator[Path]: + """Python environment that was not created by uv and is not a uv project.""" + monkeypatch.delenv("UV", raising=False) + prefix = tmp_path / "project" / "prefix" + prefix.mkdir(parents=True) + 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 prefix + + +def test_default_command(): + manager = UvManager() + assert manager._cmd_args == ("uv",) + assert repr(manager) == "UvManager(uv)" + + +def test_version_not_available(): + assert UvManager("uv-does-not-exist").version() is None + + +def test_files_not_available(caplog): + """Without a lock file the environment is reproduced from the distributions.""" + manager = UvManager("uv-does-not-exist") + + assert manager._gather_files([], PYTHON_VERSION) == {} + + assert "uv cannot describe the current python environment" in caplog.text + + +def test_project_environment(uv_environment): + """The environment of a uv project is the `.venv` directory.""" + assert uv_environment.prefix == uv_environment.location / ".venv" + assert Environment.from_location(uv_environment.location) == uv_environment + + +def test_is_active_uv_environment(uv_environment, monkeypatch): + monkeypatch.delenv("UV", raising=False) + monkeypatch.setattr(sys, "prefix", str(uv_environment.prefix)) + 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_active_uv_project(no_uv_environment, monkeypatch): + """The environment is inside a uv project.""" + assert not UvManager().is_active() + with open(no_uv_environment.parent / "uv.lock", "w", encoding="utf-8") as fh: + fh.write("version = 1\n") + assert UvManager().is_active() + + +def test_is_not_active(no_uv_environment): + assert not UvManager().is_active()