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:
parent
a69317872e
commit
c51ac59e59
11 changed files with 424 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
19
modules/misc/news/2026/06/2026-06-16_12-00-00.nix
Normal file
19
modules/misc/news/2026/06/2026-06-16_12-00-00.nix
Normal 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.
|
||||
'';
|
||||
}
|
||||
|
|
@ -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}
|
||||
''}
|
||||
''
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
''
|
||||
];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
18
tests/modules/programs/uv/duplicate-assertion.nix
Normal file
18
tests/modules/programs/uv/duplicate-assertion.nix
Normal 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".
|
||||
''
|
||||
];
|
||||
}
|
||||
14
tests/modules/programs/uv/package-null-assertion.nix
Normal file
14
tests/modules/programs/uv/package-null-assertion.nix
Normal 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.
|
||||
''
|
||||
];
|
||||
}
|
||||
28
tests/modules/programs/uv/python-prune.nix
Normal file
28
tests/modules/programs/uv/python-prune.nix
Normal 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'
|
||||
'';
|
||||
}
|
||||
43
tests/modules/programs/uv/python.nix
Normal file
43
tests/modules/programs/uv/python.nix
Normal 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'
|
||||
'';
|
||||
}
|
||||
27
tests/modules/programs/uv/tool-prune.nix
Normal file
27
tests/modules/programs/uv/tool-prune.nix
Normal 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'"
|
||||
'';
|
||||
}
|
||||
30
tests/modules/programs/uv/tool.nix
Normal file
30
tests/modules/programs/uv/tool.nix
Normal 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'
|
||||
'';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue