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 <packages>`
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.
This commit is contained in:
Peter Bittner 2026-06-17 19:16:08 +02:00 committed by Austin Horstman
parent a69317872e
commit c51ac59e59
11 changed files with 424 additions and 5 deletions

View file

@ -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

View file

@ -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.
'';
}

View file

@ -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
<https://docs.astral.sh/uv/concepts/python-versions/> 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
<https://docs.astral.sh/uv/concepts/tools/> 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}
''}
''
);
}
);
}

View file

@ -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.
''
];
}

View file

@ -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;
}

View file

@ -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".
''
];
}

View file

@ -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.
''
];
}

View file

@ -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'
'';
}

View file

@ -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'
'';
}

View file

@ -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'"
'';
}

View file

@ -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'
'';
}