Conflicting extras and groups¶
Warning
The lockfile shape for conflicts may still change.
Some optional dependency sets are mutually exclusive: a CPU build and a GPU build of the same stack, or testing variants pinned to different tool versions. nab cannot put two such sets in one resolution, and a lock that tried to would fail the emit-time disjointness check. Declaring the conflict tells nab to keep them apart.
Declaring a conflict¶
Conflicts live in [tool.nab].conflicts. Each entry is a set of
members that are mutually exclusive with each other. A member is an
extra or a dependency group:
[tool.nab]
conflicts = [
[{ extra = "cpu" }, { extra = "gpu" }],
]
The bare list form means at most one of the members may be active, so selecting neither is fine (extras are opt-in). A set may mix extras and groups, and you can declare several independent sets:
[tool.nab]
conflicts = [
[{ group = "black22" }, { group = "black23" }, { group = "black24" }],
[{ group = "isort5" }, { group = "isort6" }, { group = "isort7" }],
]
To require a choice rather than merely forbidding co-selection, use the
table form and name the policy as a value. The three policies mirror
Gentoo’s REQUIRED_USE operators:
[tool.nab]
conflicts = [
{ members = [{ extra = "cpu" }, { extra = "gpu" }], policy = "at-most-one" }, # ?? (default)
{ members = [{ extra = "cpu" }, { extra = "gpu" }], policy = "exactly-one" }, # ^^
{ members = [{ group = "a" }, { group = "b" }], policy = "at-least-one" }, # ||
]
A table without a policy key defaults to at-most-one, the same as
the bare-list form.
Extra and group names are normalised (PEP 685 / PEP 735), so the spelling here does not have to match the table key exactly.
In a workspace, conflicts is scoped to the pyproject being locked.
Declaring conflicts in the workspace root does not propagate to a
nab lock packages/<member>/pyproject.toml; each member declares
its own. See Lock a workspace for the full
list of keys that flow vs stay local.
Forking co-selected members¶
When the selection activates two or more members of a set, nab does not
reject it: it forks the resolve. For example nab lock --extras cpu gpu,
or nab lock --all-groups over the black* groups above, resolves each
member separately and writes every result into one lockfile. This is the
same in specific and universal mode; the resolve mode does not change how
a conflict is handled. Each fork’s pins carry a marker selecting that
member:
[[packages]]
name = "black"
version = "22.1.0"
marker = "... and \"black22\" in dependency_groups"
[[packages]]
name = "black"
version = "23.12.0"
marker = "... and \"black23\" in dependency_groups"
The requirements formats name a fork with the {selection} variable in
an --output template (--output 'req-{selection}.txt' writes
req-extra-cpu.txt and req-extra-gpu.txt), since two forks of one
tuple share every other axis and would otherwise collide onto one file.
When several sets are engaged at once, the forks are the cartesian
product across them (one member chosen per set), so black{22,23,24}
crossed with isort{5,6,7} is nine forks. Non-conflicting selections
stay active in every fork.
Forking needs one member per fork. If a single selection forces two members of one set together, no fork can separate them, so the resolve is refused before any network work:
$ nab lock --extras all
error: in [tool.nab]: extra 'cpu', extra 'gpu' cannot be selected together: declared mutually exclusive (at-most-one) in [tool.nab].conflicts
This happens when an umbrella extra self-references both members
(all = ["proj[cpu]", "proj[gpu]"]) or an umbrella group includes both
member groups. Co-selecting the members directly still forks; only the
all-in-one umbrella, which cannot resolve disjointly, is rejected.
The require-one policies still raise. Declaring exactly-one or
at-least-one and selecting none of its members is rejected before the
resolve, regardless of mode; only the co-selection case forks.
Groups named in [tool.nab].default-groups count as part of the
selection for every conflict check. A project with
default-groups = ["a"] and a policy = "exactly-one" set over
groups a and b
satisfies the minimum without passing --groups, and a --groups b
on top of that default activates two members of an exclusive set,
which then forks into two.
A dependency required by every member of a set but not by the base
keeps its membership marker, so it does not install when no member is
selected (relevant under at-most-one, which permits selecting none).
A base resolve names the deps that install regardless of the
selection, which is how the writer tells the two apart. When the
forks of one environment pin a base dependency at different versions,
no single entry can serve the no-member context; the writer raises a
DivergentBaseDependencyError rather than emit a lock that silently
skips the dependency. When a single dependency is required by every
member of two or more engaged sets at once, its marker is the
conjunction across those sets; this stays correct for one set, the
common case.
The lockfile stays within PEP 751: the membership markers use the
standard extras and dependency_groups variables, and the install
context that would activate two members of one set is pruned by the
declared conflict, so the two entries never collide for a reader. A
collision that is not covered by a declared conflict still raises a
DisjointnessError, now with a hint pointing at the conflicts key.
Trade-offs¶
Property |
nab (matrix fork) |
uv (conflict markers) |
|---|---|---|
Lockfile encoding |
Standard PEP 751 |
Bespoke |
Mechanism |
Each member is a separate resolution under a marker. |
Synthetic activation variables fork the single solve. |
Cost |
Re-resolves a near-identical universe per fork. |
Shared until a fork diverges. |
The cost row reflects how the matrix model works (see universal resolution): it re-resolves per fork. In return the lock stays within PEP 751, with no custom conflict-marker grammar.