From c51ac59e59f2a7362d71d1d01ab68b2c09d09347 Mon Sep 17 00:00:00 2001 From: Peter Bittner Date: Wed, 17 Jun 2026 19:16:08 +0200 Subject: [PATCH] uv: manage Python versions and tools Add programs.uv.python.{versions,default,prune} and programs.uv.tool.{packages,prune} to install uv-managed Python versions and tools during activation. Install requests are passed verbatim to uv. Unpinned entries track the latest release on each activation while pinned ones stay put: Python requests without a patch component are installed with `uv python install --upgrade` (exact patches, which uv refuses to upgrade, get a plain install), and tools are refreshed with `uv tool upgrade ` scoped to the configured list. Both upgrades are scoped to the managed entries, so installs Home Manager does not own are left untouched. prune makes a set declarative by running `uv ... uninstall --all` before reinstalling the listed entries. The activation blocks run after linkGeneration so uv sees the freshly linked uv.toml, and a null programs.uv.package is rejected once the module emits activation commands. --- docs/release-notes/rl-2611.md | 7 + .../misc/news/2026/06/2026-06-16_12-00-00.nix | 19 ++ modules/programs/uv.nix | 218 +++++++++++++++++- .../uv/default-not-installed-assertion.nix | 18 ++ tests/modules/programs/uv/default.nix | 7 + .../programs/uv/duplicate-assertion.nix | 18 ++ .../programs/uv/package-null-assertion.nix | 14 ++ tests/modules/programs/uv/python-prune.nix | 28 +++ tests/modules/programs/uv/python.nix | 43 ++++ tests/modules/programs/uv/tool-prune.nix | 27 +++ tests/modules/programs/uv/tool.nix | 30 +++ 11 files changed, 424 insertions(+), 5 deletions(-) create mode 100644 modules/misc/news/2026/06/2026-06-16_12-00-00.nix create mode 100644 tests/modules/programs/uv/default-not-installed-assertion.nix create mode 100644 tests/modules/programs/uv/duplicate-assertion.nix create mode 100644 tests/modules/programs/uv/package-null-assertion.nix create mode 100644 tests/modules/programs/uv/python-prune.nix create mode 100644 tests/modules/programs/uv/python.nix create mode 100644 tests/modules/programs/uv/tool-prune.nix create mode 100644 tests/modules/programs/uv/tool.nix diff --git a/docs/release-notes/rl-2611.md b/docs/release-notes/rl-2611.md index d659b4bf2..972377d3b 100644 --- a/docs/release-notes/rl-2611.md +++ b/docs/release-notes/rl-2611.md @@ -7,6 +7,13 @@ section is therefore not final. This release has the following notable changes: +- The [](#opt-programs.uv.enable) module can now install uv-managed Python + versions and tools through the new `programs.uv.python.versions`, + `programs.uv.python.default`, and `programs.uv.tool.packages` options. Unpinned + entries track the latest release on each activation while pinned ones stay put, + and `programs.uv.python.prune` / `programs.uv.tool.prune` make the managed set + fully declarative by removing versions and tools that are no longer listed. + ## State Version Changes {#sec-release-26.11-state-version-changes} The state version in this release includes the changes below. These diff --git a/modules/misc/news/2026/06/2026-06-16_12-00-00.nix b/modules/misc/news/2026/06/2026-06-16_12-00-00.nix new file mode 100644 index 000000000..9bb9a8e9d --- /dev/null +++ b/modules/misc/news/2026/06/2026-06-16_12-00-00.nix @@ -0,0 +1,19 @@ +{ config, ... }: + +{ + time = "2026-06-16T12:00:00+00:00"; + condition = config.programs.uv.enable; + message = '' + The 'programs.uv' module can now manage uv-installed Python versions and + tools declaratively. + + Use {option}`programs.uv.python.versions` to install Python versions + (with {option}`programs.uv.python.default` selecting the default interpreter + per implementation) and {option}`programs.uv.tool.packages` to install + tools. Unpinned entries track the latest release on each activation, while + pinned ones (e.g. `"3.12.4"` or `"black==24.1.0"`) stay put. Set + {option}`programs.uv.python.prune` or {option}`programs.uv.tool.prune` to + make the managed set fully declarative, removing versions and tools that are + no longer listed. + ''; +} diff --git a/modules/programs/uv.nix b/modules/programs/uv.nix index bde598907..a383aabc9 100644 --- a/modules/programs/uv.nix +++ b/modules/programs/uv.nix @@ -12,11 +12,19 @@ let mkPackageOption mkOption literalExpression + types + optionalString + escapeShellArg + escapeShellArgs + concatMapStringsSep ; tomlFormat = pkgs.formats.toml { }; cfg = config.programs.uv; + pyCfg = cfg.python; + toolCfg = cfg.tool; + in { meta.maintainers = with lib.maintainers; [ @@ -47,13 +55,213 @@ in for more information. ''; }; - }; - config = lib.mkIf cfg.enable { - home.packages = lib.mkIf (cfg.package != null) [ cfg.package ]; + python = { + versions = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ + "3.13" + "3.12" + "pypy@3.11" + ]; + description = '' + Python versions to install with `uv python` during activation. Each + entry is passed verbatim to {command}`uv python install`, so any + request it accepts works (e.g. `"3.13"`, `"3.12.4"`, `"pypy@3.11"`, + `"cpython-3.14.5+freethreaded"`). - xdg.configFile."uv/uv.toml" = lib.mkIf (cfg.settings != { }) { - source = tomlFormat.generate "uv-config" cfg.settings; + Entries without a patch component (e.g. `"3.13"`, `"pypy@3.11"`) are + installed with {command}`--upgrade`, so on every activation they track + the latest patch release. Entries pinned to an exact patch (e.g. + `"3.12.4"`) are installed as requested and never upgraded, since uv + rejects upgrading them. See + for more + information. + ''; + }; + + default = mkOption { + type = types.coercedTo types.str lib.singleton (types.listOf types.str); + default = [ ]; + example = [ + "3.13" + "pypy@3.11" + ]; + description = '' + Versions from {option}`programs.uv.python.versions` to set as default + Python versions, each installed with + {command}`uv python install --default`. + + {command}`--default` provides the unversioned executable for the + request's implementation, so defaults of different implementations do + not conflict: a CPython request provides {command}`python` and + {command}`python3`, a PyPy request provides {command}`pypy` and + {command}`pypy3`, a GraalPy request provides {command}`graalpy` and + {command}`graalpy3`. List one request per implementation to set them + all. A bare string is accepted as a single-element list. + ''; + }; + + prune = mkOption { + type = types.bool; + default = false; + description = '' + Whether to make the set of managed Python versions fully declarative. + + When enabled, {command}`uv python uninstall --all` is run before + installing {option}`programs.uv.python.versions`, so versions that are + no longer listed are removed. + + ::: {.warning} + uv has no declarative install command, so this uninstalls and + reinstalls all listed versions on every activation, which is slow. + Versions installed manually with {command}`uv python install` are also + removed. + ::: + ''; + }; + }; + + tool = { + packages = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ + "ruff" + "black==24.1.0" + "poetry[plugin]" + ]; + description = '' + Tools to install with `uv tool` during activation. Each entry is passed + verbatim to {command}`uv tool install`, so version specifiers and extras + work (e.g. `"black==24.1.0"`, `"poetry[plugin]"`). + + On every activation {command}`uv tool upgrade` is run for the listed + tools, which upgrades them to the latest version allowed by the + constraints they were installed with (e.g. `"black==24.1.0"` stays + pinned). Tools that are not listed are left untouched. See + for more information. + ''; + }; + + prune = mkOption { + type = types.bool; + default = false; + description = '' + Whether to make the set of installed tools fully declarative. + + When enabled, {command}`uv tool uninstall --all` is run before + installing {option}`programs.uv.tool.packages`, so tools that are no + longer listed are removed. + + ::: {.warning} + uv has no declarative install command, so this uninstalls and + reinstalls all listed tools on every activation, which is slow. Tools + installed manually with {command}`uv tool install` are also removed. + ::: + ''; + }; }; }; + + config = lib.mkIf cfg.enable ( + let + uvBin = if cfg.package != null then lib.getExe cfg.package else "uv"; + in + { + home.packages = lib.mkIf (cfg.package != null) [ cfg.package ]; + + xdg.configFile."uv/uv.toml" = lib.mkIf (cfg.settings != { }) { + source = tomlFormat.generate "uv-config" cfg.settings; + }; + + assertions = + let + duplicates = xs: lib.unique (builtins.filter (x: lib.count (y: y == x) xs > 1) xs); + noDuplicates = name: xs: { + assertion = duplicates xs == [ ]; + message = '' + programs.uv.${name} contains duplicate entries: ${ + concatMapStringsSep ", " (x: ''"${x}"'') (duplicates xs) + }. + ''; + }; + in + [ + (noDuplicates "python.versions" pyCfg.versions) + (noDuplicates "python.default" pyCfg.default) + (noDuplicates "tool.packages" toolCfg.packages) + { + assertion = lib.all (d: builtins.elem d pyCfg.versions) pyCfg.default; + message = '' + Every programs.uv.python.default entry must also be listed, spelled + identically, in programs.uv.python.versions. + ''; + } + { + assertion = + cfg.package != null + || (pyCfg.versions == [ ] && !pyCfg.prune && toolCfg.packages == [ ] && !toolCfg.prune); + message = '' + `programs.uv.package` cannot be null when `programs.uv.python` or + `programs.uv.tool` manages installations during activation. + ''; + } + ]; + + home.activation.uvPython = lib.mkIf (pyCfg.versions != [ ] || pyCfg.prune) ( + # Run after linkGeneration so uv sees the freshly linked uv.toml. + lib.hm.dag.entryAfter [ "linkGeneration" ] ( + let + # uv only upgrades major/minor requests; an exact patch (a + # `MAJOR.MINOR.PATCH` triple, e.g. "3.12.4" or + # "cpython-3.14.5+freethreaded") is rejected by `--upgrade`. So + # unpinned entries are installed with `--upgrade` (tracking the + # latest patch on every activation) and pinned entries with a plain + # install (left as requested). + hasPatch = v: builtins.match ".*[0-9]+[.][0-9]+[.][0-9]+.*" v != null; + upgradeFlag = v: optionalString (!hasPatch v) "--upgrade "; + + # Defaults are installed (with --default) one per entry up front; the + # rest follow, batched by whether they are upgraded. Every default is + # guaranteed to be in versions by the assertion above, so each + # version is installed exactly once. + rest = builtins.filter (v: !builtins.elem v pyCfg.default) pyCfg.versions; + restUpgrade = builtins.filter (v: !hasPatch v) rest; + restPinned = builtins.filter hasPatch rest; + in + '' + ${optionalString pyCfg.prune '' + run ${uvBin} python uninstall $VERBOSE_ARG --all + ''} + ${concatMapStringsSep "\n" ( + d: "run ${uvBin} python install $VERBOSE_ARG --default ${upgradeFlag d}${escapeShellArg d}" + ) pyCfg.default} + ${optionalString (restUpgrade != [ ]) '' + run ${uvBin} python install $VERBOSE_ARG --upgrade ${escapeShellArgs restUpgrade} + ''} + ${optionalString (restPinned != [ ]) '' + run ${uvBin} python install $VERBOSE_ARG ${escapeShellArgs restPinned} + ''} + '' + ) + ); + + home.activation.uvTool = lib.mkIf (toolCfg.packages != [ ] || toolCfg.prune) ( + # Run after linkGeneration so uv sees the freshly linked uv.toml. + lib.hm.dag.entryAfter [ "linkGeneration" ] '' + ${optionalString toolCfg.prune '' + run ${uvBin} tool uninstall $VERBOSE_ARG --all + ''} + ${concatMapStringsSep "\n" ( + t: "run ${uvBin} tool install $VERBOSE_ARG ${escapeShellArg t}" + ) toolCfg.packages} + ${optionalString (toolCfg.packages != [ ]) '' + run ${uvBin} tool upgrade $VERBOSE_ARG ${escapeShellArgs toolCfg.packages} + ''} + '' + ); + } + ); } diff --git a/tests/modules/programs/uv/default-not-installed-assertion.nix b/tests/modules/programs/uv/default-not-installed-assertion.nix new file mode 100644 index 000000000..3588a6a6d --- /dev/null +++ b/tests/modules/programs/uv/default-not-installed-assertion.nix @@ -0,0 +1,18 @@ +{ + programs.uv = { + enable = true; + python = { + versions = [ "3.12" ]; + default = [ "3.13" ]; + }; + }; + + test.stubs.uv.name = "uv"; + + test.asserts.assertions.expected = [ + '' + Every programs.uv.python.default entry must also be listed, spelled + identically, in programs.uv.python.versions. + '' + ]; +} diff --git a/tests/modules/programs/uv/default.nix b/tests/modules/programs/uv/default.nix index c37bf75fc..6794fa34b 100644 --- a/tests/modules/programs/uv/default.nix +++ b/tests/modules/programs/uv/default.nix @@ -1,4 +1,11 @@ { + uv-default-not-installed-assertion = ./default-not-installed-assertion.nix; + uv-duplicate-assertion = ./duplicate-assertion.nix; uv-example-settings = ./example-settings.nix; uv-no-settings = ./no-settings.nix; + uv-package-null-assertion = ./package-null-assertion.nix; + uv-python = ./python.nix; + uv-python-prune = ./python-prune.nix; + uv-tool = ./tool.nix; + uv-tool-prune = ./tool-prune.nix; } diff --git a/tests/modules/programs/uv/duplicate-assertion.nix b/tests/modules/programs/uv/duplicate-assertion.nix new file mode 100644 index 000000000..8c5652ccb --- /dev/null +++ b/tests/modules/programs/uv/duplicate-assertion.nix @@ -0,0 +1,18 @@ +{ + programs.uv = { + enable = true; + python.versions = [ + "3.12" + "3.13" + "3.12" + ]; + }; + + test.stubs.uv.name = "uv"; + + test.asserts.assertions.expected = [ + '' + programs.uv.python.versions contains duplicate entries: "3.12". + '' + ]; +} diff --git a/tests/modules/programs/uv/package-null-assertion.nix b/tests/modules/programs/uv/package-null-assertion.nix new file mode 100644 index 000000000..7144f1ad2 --- /dev/null +++ b/tests/modules/programs/uv/package-null-assertion.nix @@ -0,0 +1,14 @@ +{ + programs.uv = { + enable = true; + package = null; + tool.packages = [ "ruff" ]; + }; + + test.asserts.assertions.expected = [ + '' + `programs.uv.package` cannot be null when `programs.uv.python` or + `programs.uv.tool` manages installations during activation. + '' + ]; +} diff --git a/tests/modules/programs/uv/python-prune.nix b/tests/modules/programs/uv/python-prune.nix new file mode 100644 index 000000000..e661af81b --- /dev/null +++ b/tests/modules/programs/uv/python-prune.nix @@ -0,0 +1,28 @@ +{ + programs.uv = { + enable = true; + python = { + versions = [ + "3.12" + "3.13" + ]; + default = "3.13"; + prune = true; + }; + }; + + test.stubs.uv.name = "uv"; + + nmt.script = '' + # With prune, all managed versions are removed before (re)installing the + # listed ones, making the set fully declarative. A bare string for `default` + # is coerced to a single-element list, installed with `--default`. Both + # requests are major/minor, so they install with `--upgrade`. + assertFileContains activate \ + 'run @uv@/bin/uv python uninstall $VERBOSE_ARG --all' + assertFileContains activate \ + 'run @uv@/bin/uv python install $VERBOSE_ARG --default --upgrade 3.13' + assertFileContains activate \ + 'run @uv@/bin/uv python install $VERBOSE_ARG --upgrade 3.12' + ''; +} diff --git a/tests/modules/programs/uv/python.nix b/tests/modules/programs/uv/python.nix new file mode 100644 index 000000000..60a5bcf58 --- /dev/null +++ b/tests/modules/programs/uv/python.nix @@ -0,0 +1,43 @@ +{ + programs.uv = { + enable = true; + python = { + versions = [ + "3.12" + "3.13" + "3.11.9" + "pypy@3.11" + ]; + # One default per implementation; uv keeps `python` and `pypy` separate. + default = [ + "3.13" + "pypy@3.11" + ]; + }; + }; + + test.stubs.uv.name = "uv"; + + nmt.script = '' + # Each default is installed first with `--default`, one call per entry, so + # per-implementation executables (python, pypy) are both set. They have no + # patch component, so they also track the latest patch via `--upgrade`. + assertFileContains activate \ + 'run @uv@/bin/uv python install $VERBOSE_ARG --default --upgrade 3.13' + assertFileContains activate \ + 'run @uv@/bin/uv python install $VERBOSE_ARG --default --upgrade pypy@3.11' + + # Remaining major/minor requests are installed with `--upgrade`, tracking the + # latest patch; default versions are not reinstalled. + assertFileContains activate \ + 'run @uv@/bin/uv python install $VERBOSE_ARG --upgrade 3.12' + + # Exact-patch pins are installed as requested, never upgraded (uv rejects + # upgrading them). + assertFileContains activate \ + 'run @uv@/bin/uv python install $VERBOSE_ARG 3.11.9' + + # Without prune the performant path is used: no uninstall/reinstall churn. + assertFileNotRegex activate 'python uninstall' + ''; +} diff --git a/tests/modules/programs/uv/tool-prune.nix b/tests/modules/programs/uv/tool-prune.nix new file mode 100644 index 000000000..b36d0b708 --- /dev/null +++ b/tests/modules/programs/uv/tool-prune.nix @@ -0,0 +1,27 @@ +{ + programs.uv = { + enable = true; + tool = { + packages = [ + "ruff" + "black==24.1.0" + ]; + prune = true; + }; + }; + + test.stubs.uv.name = "uv"; + + nmt.script = '' + # With prune, all tools are removed before (re)installing the listed ones, + # making the set fully declarative. + assertFileContains activate \ + 'run @uv@/bin/uv tool uninstall $VERBOSE_ARG --all' + assertFileContains activate \ + 'run @uv@/bin/uv tool install $VERBOSE_ARG ruff' + assertFileContains activate \ + "run @uv@/bin/uv tool install \$VERBOSE_ARG 'black==24.1.0'" + assertFileContains activate \ + "run @uv@/bin/uv tool upgrade \$VERBOSE_ARG ruff 'black==24.1.0'" + ''; +} diff --git a/tests/modules/programs/uv/tool.nix b/tests/modules/programs/uv/tool.nix new file mode 100644 index 000000000..ea07bad62 --- /dev/null +++ b/tests/modules/programs/uv/tool.nix @@ -0,0 +1,30 @@ +{ + programs.uv = { + enable = true; + tool.packages = [ + "ruff" + "black==24.1.0" + "poetry[plugin]" + ]; + }; + + test.stubs.uv.name = "uv"; + + nmt.script = '' + # Each tool is installed individually, preserving extras and specifiers. + assertFileContains activate \ + 'run @uv@/bin/uv tool install $VERBOSE_ARG ruff' + assertFileContains activate \ + "run @uv@/bin/uv tool install \$VERBOSE_ARG 'black==24.1.0'" + assertFileContains activate \ + "run @uv@/bin/uv tool install \$VERBOSE_ARG 'poetry[plugin]'" + + # Upgrading is delegated to uv but scoped to the configured tools, so tools + # installed outside this option are untouched; per-tool constraints hold. + assertFileContains activate \ + "run @uv@/bin/uv tool upgrade \$VERBOSE_ARG ruff 'black==24.1.0' 'poetry[plugin]'" + + # Without prune the performant path is used: no uninstall/reinstall churn. + assertFileNotRegex activate 'tool uninstall' + ''; +}