Skip to content

Add support for package-level conflicts in workspaces#14906

Merged
zanieb merged 31 commits intomainfrom
zb/package-conflicts
Aug 8, 2025
Merged

Add support for package-level conflicts in workspaces#14906
zanieb merged 31 commits intomainfrom
zb/package-conflicts

Conversation

@zanieb
Copy link
Copy Markdown
Member

@zanieb zanieb commented Jul 25, 2025

Revives #9130

Previously, we allowed scoping conflicting extras or groups to specific packages, e.g. ,{ package = "foo", extra = "bar" } for a conflict in foo[bar]. Now, we allow dropping the extra or group bit and using { package = "foo" } directly which declares a conflict with foo's production dependencies.

This means you can declare conflicts between workspace members, e.g.:

[tool.uv]
conflicts = [[{ package = "foo" }, { package = "bar" }]]

would not allow foo and bar to be installed at the same time.

Similarly, a conflict can be declared between a package and a group:

[tool.uv]
conflicts = [[{ package = "foo" }, { group = "lint" }]]

which would mean, e.g., that --only-group lint would be required for the invocation.

As with our existing support for conflicting extras, there are edge-cases here where the resolver will not fail even if there are conflicts that render a particular install target unusable. There's test coverage for some of these. We'll still error at install-time when the conflicting groups are selected. Due to the likelihood of bugs in this feature, I've marked it as a preview feature.

I would not recommend reading the commits as there's some slop from not wanting to rebase Andrew's branch.

BurntSushi and others added 22 commits November 15, 2024 07:30
This makes a lot more sense as a name IMO. And I think also works better
for the immediate future, where I plan to add a `Project` kind.
Basically, this new conflict kind means that the entire project
conflicts with a dependency group or an extra.

This just adds the variant. In the next commit, we'll actually
make it work.
Supporting project level conflicts ends up being pretty tricky, mostly
because depenedency groups are represented as dependencies of the
project you're trying to declare a conflict for. So by filtering out the
project in the fork for the conflicting group, you end up filtering out
the group itself too.

To work-around this, we add a `parent` field to `PubGrubDependency`, and
use this to filter based on project conflicts. This lets us do "delayed"
filtering by one level.

The rest of the changes in this commit are for reporting errors
when you try to activate the group without disabling the project.
There was a fair bit of support for project level conflicts missing from
`UniversalMarker`. I think that's because my initial PR pre-dated the
beefing up of `UniversalMarker`.
@zanieb zanieb temporarily deployed to uv-test-registries July 25, 2025 20:31 — with GitHub Actions Inactive
@zanieb zanieb force-pushed the zb/package-conflicts branch from 62a084f to 7927fb3 Compare July 25, 2025 20:36
@zanieb zanieb temporarily deployed to uv-test-registries July 25, 2025 20:38 — with GitHub Actions Inactive
@zanieb zanieb force-pushed the zb/package-conflicts branch from 84fdb18 to 1dc7840 Compare July 25, 2025 21:14
@zanieb zanieb temporarily deployed to uv-test-registries July 25, 2025 21:16 — with GitHub Actions Inactive
Copy link
Copy Markdown
Member

@BurntSushi BurntSushi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, I think this LGTM. I think it would still be good to get @charliermarsh's review on the parent package stuff I added (I left a comment noting where).

I was curious what else you did here to make the remaining cases work?

/// group as a dependency of that package. So if you filter out the package
/// in a fork due to a conflict, you also filter out the group. Therefore,
/// we introduce this parent field to enable "delayed" filtering.
pub(crate) parent: Option<PackageName>,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC, this is the thing I was most uncertain about @charliermarsh

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also not 100% sure on the implications here...

");

Ok(())
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zanieb These locks LGTM I think. What did you do to make this case work?

Copy link
Copy Markdown
Member Author

@zanieb zanieb Jul 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To get detect_conflicts to pass, I needed to add

if !packages.contains(item.package()) {
// Ignore items that are not in the install targets
continue;
}

which required, more broadly, understanding the subset of package we were reasoning about during conflict detection.

Then there were some minor problems in the forking logic. The main thing was

// We should not filter entire projects unless they're a top-level dependency
// Otherwise, we'll fail to solve for children of the project, like extras
ConflictKindRef::Project => {
if dep.parent.is_some() {
return true;
}
}

{ package = "example" },
# TODO(zanieb): Technically, we shouldn't need to include the extra in the list of
# conflicts however, the resolver forking algorithm is not currently sophisticated
# enough to pick this up by itself
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh interesting. Could this be added outside of the resolver automatically? Hmm, no, I don't think so. It could only be done in the "direct" case I think? Is there a test for the indirect/transitive case? I looked below but I don't think I see this work-around used anywhere else.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked into that a bit but decided to cut scope. It seems quite plausible to update the resolver to handle this case when forking.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is lock_conflicting_workspace_members_depends_transitive_extra but in that case there's a conflict that should not be resolved. I didn't add a transitive case like this one — I felt like I was going to end up playing whackamole with a bunch of complicated test cases. I think if it wasn't in preview, I wouldn't be comfortable without doing a lot more testing.

const JSON_OUTPUT = 1 << 2;
const PYLOCK = 1 << 3;
const ADD_BOUNDS = 1 << 4;
const PACKAGE_CONFLICTS = 1 << 5;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the thinking behind releasing this under a flag?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's in the summary

As with our existing support for conflicting extras, there are edge-cases here where the resolver will not fail even if there are conflicts that render a particular install target unusable. There's test coverage for some of these. We'll still error at install-time when the conflicting groups are selected. Due to the likelihood of bugs in this feature, I've marked it as a preview feature.

I'm just not sure we'll be able to meet our bar for correctness here in the first iteration.

match conflicting_item.kind() {
// We should not filter entire projects unless they're a top-level dependency
// Otherwise, we'll fail to solve for children of the project, like extras
ConflictKindRef::Project => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: use a matches! since there's only one active branch here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted it to be exhaustive so it needs to be revisited if we add another kind, but I don't feel strongly.

/// group as a dependency of that package. So if you filter out the package
/// in a fork due to a conflict, you also filter out the group. Therefore,
/// we introduce this parent field to enable "delayed" filtering.
pub(crate) parent: Option<PackageName>,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also not 100% sure on the implications here...

@zanieb zanieb added the preview Experimental behavior label Aug 7, 2025
@zanieb zanieb changed the title Add support for package-level conflicts Add support for package-level conflicts in workspaces Aug 7, 2025
@zanieb zanieb temporarily deployed to uv-test-registries August 7, 2025 14:04 — with GitHub Actions Inactive
@zanieb zanieb force-pushed the zb/package-conflicts branch from 39f6ac4 to 9f23e5f Compare August 8, 2025 12:13
@zanieb zanieb temporarily deployed to uv-test-registries August 8, 2025 12:16 — with GitHub Actions Inactive
@zanieb zanieb merged commit 8f71d23 into main Aug 8, 2025
95 checks passed
@zanieb zanieb deleted the zb/package-conflicts branch August 8, 2025 12:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

preview Experimental behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants