Source code for meta_package_manager.managers.uv

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

from __future__ import annotations

import logging
import os
import re

from extra_platforms import ALL_PLATFORMS

from ..manager import PackageManager

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterator

    from ..package import Package


[docs] class UVBase(PackageManager): """Virtual base shared by the `UV` and `UVX` managers defined below.""" homepage_url = "https://docs.astral.sh/uv" logo = "uv" requirement = ">=0.5.0" """[0.5.0](https://github.com/astral-sh/uv/releases/tag/0.5.0) is the first version to introduce `pip list --outdated` command. """ platforms = ALL_PLATFORMS # Declare this manager as virtual, i.e. not tied to a real CLI. virtual = True cooldown_env_var = "UV_EXCLUDE_NEWER" """uv honors a release-age cooldown through its `exclude-newer` resolver option. `UV_EXCLUDE_NEWER` mirrors the `--exclude-newer` flag and is read by every resolving subcommand (`pip install`, `pip list --outdated`, `tool install`, `tool upgrade`), so a single environment variable covers install, upgrade and outdated at once. uv accepts an RFC 3339 timestamp, which is exactly what the default {meth}`meta_package_manager.execution.CLIExecutor.cooldown_env_value` produces. ```{note} The environment variable is the only mechanism that reaches these subcommands, which is why the cutoff is injected rather than written to a config file. [`uv pip` and `uv tool` operate at the user level and ignore project-local configuration](https://docs.astral.sh/uv/concepts/configuration-files/): a `[tool.uv]` table in the `pyproject.toml` of whatever directory the user happens to stand in is never consulted. Only user-level and system-level `uv.toml` are, and `UV_EXCLUDE_NEWER` outranks both. ``` ```{caution} Because the variable outranks those files, injecting it *replaces* any standing `exclude-newer` the user configured rather than tightening it: a cooldown looser than their own policy silently widens it for the duration of the run. See {doc}`/cooldown`, section "The cooldown is authoritative, not a floor". ``` uv exposes no environment variable for the companion `--exclude-newer-package` flag, so a per-package exemption cannot be injected the way this cutoff is. See uv's [`exclude-newer` setting](https://docs.astral.sh/uv/reference/settings/#exclude-newer). """ pre_args = ("--color", "never", "--no-progress") """ - `--color color-choice` Control colors in output [default: `auto`] Possible values: - `auto`: Enables colored output only when the output is going to a terminal or TTY with support - `always`: Enables colored output regardless of the detected environment - `never`: Disables colored output - `--no-progress` Hide all progress outputs. For example, spinners or progress bars. """ version_regexes = (r"uv\s+(?P<version>\S+)",) """ ```{code-block} shell-session $ uv --version uv 0.2.21 (ebfe6d8fc 2024-07-03) ``` """ def _build_package_spec(self, package_id: str, version: str | None = None) -> str: """Build package specification with optional version constraint.""" package_specs = package_id if version: package_specs += f"=={version}" return package_specs
[docs] class UV(UVBase): """Python packages managed with uv's `uv pip` interface. Installed and outdated packages are read with `uv pip list` (adding `--outdated` and `--format=json`), acting on whatever environment uv resolves, exactly as a bare `uv pip` call in the same shell would. The `--outdated` listing sets the `>=0.5.0` version floor, the first uv release to ship it. The release-age cooldown rides on uv's `--exclude-newer` resolver option, which every resolving subcommand honors, so one cutoff covers install, upgrade and outdated together. ```{hint} Package specs are passed unquoted, working around [uv parse failures on quoted specs](https://github.com/kdeldycke/meta-package-manager/issues/1653). ``` """ name = "Python uv" @property def installed(self) -> Iterator[Package]: """Fetch installed packages. ```{code-block} shell-session $ uv --color never --no-progress pip list --format=json [ { "name": "markupsafe", "version": "2.1.5" }, { "name": "meta-package-manager", "version": "5.17.0", "editable_project_location": "/Users/kde/meta-package-manager" }, { "name": "myst-parser", "version": "3.0.1" } ] ``` """ output = self.run_cli("pip", "list", "--format=json", must_succeed=True) yield from self.parse_json_items( output, fields={"package_id": "name", "installed_version": "version"}, ) @property def outdated(self) -> Iterator[Package]: """Fetch outdated packages. ```{code-block} shell-session $ uv --color never --no-progress pip list --outdated --format=json [ { "name": "lark-parser", "version": "0.7.8", "latest_version": "0.12.0", "latest_filetype": "wheel" }, { "name": "types-setuptools", "version": "75.3.0.20241107", "latest_version": "75.3.0.20241112", "latest_filetype": "wheel" } ] ``` """ output = self.run_cli( "pip", "list", "--outdated", "--format=json", must_succeed=True ) yield from self.parse_json_items( output, fields={ "package_id": "name", "installed_version": "version", "latest_version": "latest_version", }, )
[docs] def install(self, package_id: str, version: str | None = None) -> str: """Install one package. ```{code-block} shell-session $ uv --color never --no-progress pip install arrow ``` """ package_specs = self._build_package_spec(package_id, version) return self.run_cli("pip", "install", package_specs)
[docs] def upgrade_one_cli( self, package_id: str, version: str | None = None, ) -> tuple[str, ...]: """Generates the CLI to upgrade the package provided as parameter. ```{code-block} shell-session $ uv --color never --no-progress pip install --upgrade arrow ``` """ package_specs = self._build_package_spec(package_id, version) return self.build_cli("pip", "install", "--upgrade", package_specs)
[docs] def remove(self, package_id: str) -> str: """Remove one package. ```{code-block} shell-session $ uv --color never --no-progress pip uninstall arrow ``` """ return self.run_cli("pip", "uninstall", package_id)
[docs] def cleanup_cache(self) -> None: """Removes things we don't need anymore. ```{warning} Skipped when `mpm` itself was launched by `uv run` or `uvx`: the parent `uv` process keeps a lock on its cache for as long as its child lives, so each cache command below would wait on its own ancestor for `UV_LOCK_TIMEOUT` (300 seconds by default), fail, and stall the whole cleanup for ten minutes. `uv` advertises itself to its children through the `UV` environment variable, which is the marker detected here. ``` ```{code-block} shell-session $ uv --color never --no-progress cache clean Clearing cache at: /Users/kde/Library/Caches/uv Removed 97279 files (2.0GiB) ``` ```{code-block} shell-session $ uv --color never --no-progress cache prune No cache found at: /Users/kde/.cache/uv ``` """ if os.environ.get("UV"): logging.warning( "mpm runs under uv, which locks its own cache: skip cache cleanup.", extra={"label": self.id}, ) return self.run_cli("cache", "clean") self.run_cli("cache", "prune")
[docs] class UVX(UVBase): """uv's tool manager for isolated Python applications, like `pipx`. mpm drives the `uv tool` subcommands; each application lives in its own venv. Installed and outdated tools are parsed from the plain-text `tool list` and `tool list --outdated` output: unlike the `uv pip` interface, `uv tool` emits no JSON. The `--outdated` listing sets the `>=0.10.10` version floor, the first uv release to ship it. The release-age cooldown rides on uv's `--exclude-newer` resolver option, covering install, upgrade and outdated through one cutoff. ```{hint} Package specs are passed unquoted, working around [uv parse failures on quoted specs](https://github.com/kdeldycke/meta-package-manager/issues/1653). ``` """ name = "Python uvx" homepage_url = "https://docs.astral.sh/uv/guides/tools/" brewfile_entry_type = "uv" """`uv` is mapped to Homebrew Bundle's `uv` entry type, which installs via `uv tool install` β€” the same mechanism {class}`UVX` wraps. The pip-style {class}`UV` manager intentionally has no Brewfile mapping: its packages live inside a Python environment, not as top-level tools, so the semantics do not round-trip through `brew bundle`. """ requirement = ">=0.10.10" """[0.10.10](https://github.com/astral-sh/uv/releases/tag/0.10.10) is the first version to introduce `tool list --outdated` command. """ cli_names = ("uv",) _INSTALLED_REGEXP = re.compile(r"^(?P<package_id>\S+)\s+v(?P<version>\S+)$") _OUTDATED_REGEXP = re.compile( r"^(?P<package_id>\S+)\s+v(?P<version>\S+)\s+\[latest:\s+(?P<latest>\S+)\]$", ) @property def installed(self) -> Iterator[Package]: """Fetch installed packages. ```{code-block} shell-session $ uv --color never --no-progress tool list pycowsay v0.0.0.1 - pycowsay ``` """ output = self.run_cli("tool", "list") if output: for line in output.splitlines(): match = self._INSTALLED_REGEXP.match(line) if match: yield self.package( id=match.group("package_id"), installed_version=match.group("version"), ) @property def outdated(self) -> Iterator[Package]: """Fetch outdated packages. ```{code-block} shell-session $ uv --color never --no-progress tool list --outdated pycowsay v0.0.0.1 [latest: 0.0.0.2] - pycowsay ``` """ output = self.run_cli("tool", "list", "--outdated", must_succeed=True) if output: for line in output.splitlines(): match = self._OUTDATED_REGEXP.match(line) if match: yield self.package( id=match.group("package_id"), installed_version=match.group("version"), latest_version=match.group("latest"), )
[docs] def install(self, package_id: str, version: str | None = None) -> str: """Install one package. ```{code-block} shell-session $ uv --color never --no-progress tool install pycowsay ``` """ package_specs = self._build_package_spec(package_id, version) return self.run_cli("tool", "install", package_specs)
[docs] def upgrade_all_cli(self) -> tuple[str, ...]: """Generates the CLI to upgrade all packages. ```{code-block} shell-session $ uv --color never --no-progress tool upgrade --all Updated pycowsay v0.0.0.1 -> v0.0.0.2 - pycowsay ``` """ return self.build_cli("tool", "upgrade", "--all")
[docs] def upgrade_one_cli( self, package_id: str, version: str | None = None, ) -> tuple[str, ...]: """Generates the CLI to upgrade the package provided as parameter. ```{code-block} shell-session $ uv --color never --no-progress tool upgrade pycowsay ``` """ package_specs = self._build_package_spec(package_id, version) return self.build_cli("tool", "upgrade", package_specs)
[docs] def remove(self, package_id: str) -> str: """Remove one package. ```{code-block} shell-session $ uv --color never --no-progress tool uninstall pycowsay ``` """ return self.run_cli("tool", "uninstall", package_id)