mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 13:25:00 +00:00
Support reading dependency-groups from pyproject.tomls with no project (#13742)
Some checks failed
CI / Determine changes (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / cargo shear (push) Has been cancelled
CI / typos (push) Has been cancelled
CI / mkdocs (push) Has been cancelled
CI / cargo clippy | ubuntu (push) Has been cancelled
CI / cargo clippy | windows (push) Has been cancelled
CI / cargo dev generate-all (push) Has been cancelled
CI / cargo test | ubuntu (push) Has been cancelled
CI / cargo test | macos (push) Has been cancelled
CI / cargo test | windows (push) Has been cancelled
CI / check windows trampoline | aarch64 (push) Has been cancelled
CI / check windows trampoline | i686 (push) Has been cancelled
CI / check windows trampoline | x86_64 (push) Has been cancelled
CI / test windows trampoline | i686 (push) Has been cancelled
CI / test windows trampoline | x86_64 (push) Has been cancelled
CI / build binary | windows aarch64 (push) Has been cancelled
CI / build binary | linux libc (push) Has been cancelled
CI / build binary | linux musl (push) Has been cancelled
CI / build binary | macos aarch64 (push) Has been cancelled
CI / build binary | macos x86_64 (push) Has been cancelled
CI / build binary | windows x86_64 (push) Has been cancelled
CI / cargo build (msrv) (push) Has been cancelled
CI / build binary | freebsd (push) Has been cancelled
CI / ecosystem test | pydantic/pydantic-core (push) Has been cancelled
CI / ecosystem test | prefecthq/prefect (push) Has been cancelled
CI / ecosystem test | pallets/flask (push) Has been cancelled
CI / smoke test | linux (push) Has been cancelled
CI / check system | alpine (push) Has been cancelled
CI / smoke test | macos (push) Has been cancelled
CI / smoke test | windows x86_64 (push) Has been cancelled
CI / smoke test | windows aarch64 (push) Has been cancelled
CI / integration test | conda on ubuntu (push) Has been cancelled
CI / integration test | deadsnakes python3.9 on ubuntu (push) Has been cancelled
CI / integration test | free-threaded on windows (push) Has been cancelled
CI / integration test | pypy on ubuntu (push) Has been cancelled
CI / integration test | pypy on windows (push) Has been cancelled
CI / integration test | graalpy on ubuntu (push) Has been cancelled
CI / integration test | graalpy on windows (push) Has been cancelled
CI / integration test | pyodide on ubuntu (push) Has been cancelled
CI / integration test | github actions (push) Has been cancelled
CI / integration test | free-threaded python on github actions (push) Has been cancelled
CI / integration test | determine publish changes (push) Has been cancelled
CI / integration test | uv publish (push) Has been cancelled
CI / integration test | uv_build (push) Has been cancelled
CI / check cache | ubuntu (push) Has been cancelled
CI / check cache | macos aarch64 (push) Has been cancelled
CI / check system | python on debian (push) Has been cancelled
CI / check system | python on fedora (push) Has been cancelled
CI / check system | python on ubuntu (push) Has been cancelled
CI / check system | python on rocky linux 8 (push) Has been cancelled
CI / check system | python on rocky linux 9 (push) Has been cancelled
CI / check system | graalpy on ubuntu (push) Has been cancelled
CI / check system | pypy on ubuntu (push) Has been cancelled
CI / check system | pyston (push) Has been cancelled
CI / check system | python on macos aarch64 (push) Has been cancelled
CI / check system | python3.10 on windows x86 (push) Has been cancelled
CI / check system | python3.13 on windows x86-64 (push) Has been cancelled
CI / check system | x86-64 python3.13 on windows aarch64 (push) Has been cancelled
CI / check system | windows registry (push) Has been cancelled
CI / check system | python3.12 via chocolatey (push) Has been cancelled
CI / check system | python3.9 via pyenv (push) Has been cancelled
CI / check system | python3.13 (push) Has been cancelled
CI / check system | homebrew python on macos aarch64 (push) Has been cancelled
CI / check system | python on macos x86-64 (push) Has been cancelled
CI / check system | python3.10 on windows x86-64 (push) Has been cancelled
CI / benchmarks | walltime aarch64 linux (push) Has been cancelled
CI / benchmarks | instrumented (push) Has been cancelled
CI / check system | conda3.11 on macos aarch64 (push) Has been cancelled
CI / check system | conda3.8 on macos aarch64 (push) Has been cancelled
CI / check system | conda3.11 on linux x86-64 (push) Has been cancelled
CI / check system | conda3.8 on linux x86-64 (push) Has been cancelled
CI / check system | conda3.11 on windows x86-64 (push) Has been cancelled
CI / check system | conda3.8 on windows x86-64 (push) Has been cancelled
CI / check system | amazonlinux (push) Has been cancelled
CI / check system | embedded python3.10 on windows x86-64 (push) Has been cancelled
Some checks failed
CI / Determine changes (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / cargo shear (push) Has been cancelled
CI / typos (push) Has been cancelled
CI / mkdocs (push) Has been cancelled
CI / cargo clippy | ubuntu (push) Has been cancelled
CI / cargo clippy | windows (push) Has been cancelled
CI / cargo dev generate-all (push) Has been cancelled
CI / cargo test | ubuntu (push) Has been cancelled
CI / cargo test | macos (push) Has been cancelled
CI / cargo test | windows (push) Has been cancelled
CI / check windows trampoline | aarch64 (push) Has been cancelled
CI / check windows trampoline | i686 (push) Has been cancelled
CI / check windows trampoline | x86_64 (push) Has been cancelled
CI / test windows trampoline | i686 (push) Has been cancelled
CI / test windows trampoline | x86_64 (push) Has been cancelled
CI / build binary | windows aarch64 (push) Has been cancelled
CI / build binary | linux libc (push) Has been cancelled
CI / build binary | linux musl (push) Has been cancelled
CI / build binary | macos aarch64 (push) Has been cancelled
CI / build binary | macos x86_64 (push) Has been cancelled
CI / build binary | windows x86_64 (push) Has been cancelled
CI / cargo build (msrv) (push) Has been cancelled
CI / build binary | freebsd (push) Has been cancelled
CI / ecosystem test | pydantic/pydantic-core (push) Has been cancelled
CI / ecosystem test | prefecthq/prefect (push) Has been cancelled
CI / ecosystem test | pallets/flask (push) Has been cancelled
CI / smoke test | linux (push) Has been cancelled
CI / check system | alpine (push) Has been cancelled
CI / smoke test | macos (push) Has been cancelled
CI / smoke test | windows x86_64 (push) Has been cancelled
CI / smoke test | windows aarch64 (push) Has been cancelled
CI / integration test | conda on ubuntu (push) Has been cancelled
CI / integration test | deadsnakes python3.9 on ubuntu (push) Has been cancelled
CI / integration test | free-threaded on windows (push) Has been cancelled
CI / integration test | pypy on ubuntu (push) Has been cancelled
CI / integration test | pypy on windows (push) Has been cancelled
CI / integration test | graalpy on ubuntu (push) Has been cancelled
CI / integration test | graalpy on windows (push) Has been cancelled
CI / integration test | pyodide on ubuntu (push) Has been cancelled
CI / integration test | github actions (push) Has been cancelled
CI / integration test | free-threaded python on github actions (push) Has been cancelled
CI / integration test | determine publish changes (push) Has been cancelled
CI / integration test | uv publish (push) Has been cancelled
CI / integration test | uv_build (push) Has been cancelled
CI / check cache | ubuntu (push) Has been cancelled
CI / check cache | macos aarch64 (push) Has been cancelled
CI / check system | python on debian (push) Has been cancelled
CI / check system | python on fedora (push) Has been cancelled
CI / check system | python on ubuntu (push) Has been cancelled
CI / check system | python on rocky linux 8 (push) Has been cancelled
CI / check system | python on rocky linux 9 (push) Has been cancelled
CI / check system | graalpy on ubuntu (push) Has been cancelled
CI / check system | pypy on ubuntu (push) Has been cancelled
CI / check system | pyston (push) Has been cancelled
CI / check system | python on macos aarch64 (push) Has been cancelled
CI / check system | python3.10 on windows x86 (push) Has been cancelled
CI / check system | python3.13 on windows x86-64 (push) Has been cancelled
CI / check system | x86-64 python3.13 on windows aarch64 (push) Has been cancelled
CI / check system | windows registry (push) Has been cancelled
CI / check system | python3.12 via chocolatey (push) Has been cancelled
CI / check system | python3.9 via pyenv (push) Has been cancelled
CI / check system | python3.13 (push) Has been cancelled
CI / check system | homebrew python on macos aarch64 (push) Has been cancelled
CI / check system | python on macos x86-64 (push) Has been cancelled
CI / check system | python3.10 on windows x86-64 (push) Has been cancelled
CI / benchmarks | walltime aarch64 linux (push) Has been cancelled
CI / benchmarks | instrumented (push) Has been cancelled
CI / check system | conda3.11 on macos aarch64 (push) Has been cancelled
CI / check system | conda3.8 on macos aarch64 (push) Has been cancelled
CI / check system | conda3.11 on linux x86-64 (push) Has been cancelled
CI / check system | conda3.8 on linux x86-64 (push) Has been cancelled
CI / check system | conda3.11 on windows x86-64 (push) Has been cancelled
CI / check system | conda3.8 on windows x86-64 (push) Has been cancelled
CI / check system | amazonlinux (push) Has been cancelled
CI / check system | embedded python3.10 on windows x86-64 (push) Has been cancelled
(or legacy tool.uv.workspace). This cleaves out a dedicated SourcedDependencyGroups type based on RequiresDist but with only the DependencyGroup handling implemented. This allows `uv pip` to read `dependency-groups` from pyproject.tomls that only have that table defined, per PEP 735, and as implemented by `pip`. However we want our implementation to respect various uv features when they're available: * `tool.uv.sources` * `tool.uv.index` * `tool.uv.dependency-groups.mygroup.requires-python` (#13735) As such we want to opportunistically detect "as much as possible" while doing as little as possible when things are missing. The issue with the old RequiresDist path was that it fundamentally wanted to build the package, and if `[project]` was missing it would try to desperately run setuptools on the pyproject.toml to try to find metadata and make a hash of things. At the same time, the old code also put in a lot of effort to try to pretend that `uv pip` dependency-groups worked like `uv` dependency-groups with defaults and non-only semantics, only to separate them back out again. By explicitly separating them out, we confidently get the expected behaviour. Note that dependency-group support is still included in RequiresDist, as some `uv` paths still use it. It's unclear to me if those paths want this same treatment -- for now I conclude no. Fixes #13138
This commit is contained in:
parent
5021840919
commit
ff9c2c35d7
12 changed files with 470 additions and 97 deletions
|
@ -26,7 +26,11 @@ impl std::fmt::Display for SourceAnnotation {
|
|||
write!(f, "{project_name} ({})", path.portable_display())
|
||||
}
|
||||
RequirementOrigin::Group(path, project_name, group) => {
|
||||
write!(f, "{project_name} ({}:{group})", path.portable_display())
|
||||
if let Some(project_name) = project_name {
|
||||
write!(f, "{project_name} ({}:{group})", path.portable_display())
|
||||
} else {
|
||||
write!(f, "({}:{group})", path.portable_display())
|
||||
}
|
||||
}
|
||||
RequirementOrigin::Workspace => {
|
||||
write!(f, "(workspace)")
|
||||
|
@ -45,11 +49,15 @@ impl std::fmt::Display for SourceAnnotation {
|
|||
}
|
||||
RequirementOrigin::Group(path, project_name, group) => {
|
||||
// Group is not used for override
|
||||
write!(
|
||||
f,
|
||||
"--override {project_name} ({}:{group})",
|
||||
path.portable_display()
|
||||
)
|
||||
if let Some(project_name) = project_name {
|
||||
write!(
|
||||
f,
|
||||
"--override {project_name} ({}:{group})",
|
||||
path.portable_display()
|
||||
)
|
||||
} else {
|
||||
write!(f, "--override ({}:{group})", path.portable_display())
|
||||
}
|
||||
}
|
||||
RequirementOrigin::Workspace => {
|
||||
write!(f, "--override (workspace)")
|
||||
|
|
|
@ -4,7 +4,7 @@ pub use error::Error;
|
|||
pub use index::{BuiltWheelIndex, RegistryWheelIndex};
|
||||
pub use metadata::{
|
||||
ArchiveMetadata, BuildRequires, FlatRequiresDist, LoweredRequirement, LoweringError, Metadata,
|
||||
MetadataError, RequiresDist,
|
||||
MetadataError, RequiresDist, SourcedDependencyGroups,
|
||||
};
|
||||
pub use reporter::Reporter;
|
||||
pub use source::prune;
|
||||
|
|
208
crates/uv-distribution/src/metadata/dependency_groups.rs
Normal file
208
crates/uv-distribution/src/metadata/dependency_groups.rs
Normal file
|
@ -0,0 +1,208 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use uv_configuration::SourceStrategy;
|
||||
use uv_distribution_types::{IndexLocations, Requirement};
|
||||
use uv_normalize::{GroupName, PackageName};
|
||||
use uv_workspace::dependency_groups::FlatDependencyGroups;
|
||||
use uv_workspace::pyproject::{Sources, ToolUvSources};
|
||||
use uv_workspace::{
|
||||
DiscoveryOptions, MemberDiscovery, VirtualProject, WorkspaceCache, WorkspaceError,
|
||||
};
|
||||
|
||||
use crate::metadata::{GitWorkspaceMember, LoweredRequirement, MetadataError};
|
||||
|
||||
/// Like [`crate::RequiresDist`] but only supporting dependency-groups.
|
||||
///
|
||||
/// PEP 735 says:
|
||||
///
|
||||
/// > A pyproject.toml file with only `[dependency-groups]` and no other tables is valid.
|
||||
///
|
||||
/// This is a special carveout to enable users to adopt dependency-groups without having
|
||||
/// to learn about projects. It is supported by `pip install --group`, and thus interfaces
|
||||
/// like `uv pip install --group` must also support it for interop and conformance.
|
||||
///
|
||||
/// On paper this is trivial to support because dependency-groups are so self-contained
|
||||
/// that they're basically a `requirements.txt` embedded within a pyproject.toml, so it's
|
||||
/// fine to just grab that section and handle it independently.
|
||||
///
|
||||
/// However several uv extensions make this complicated, notably, as of this writing:
|
||||
///
|
||||
/// * tool.uv.sources
|
||||
/// * tool.uv.index
|
||||
///
|
||||
/// These fields may also be present in the pyproject.toml, and, critically,
|
||||
/// may be defined and inherited in a parent workspace pyproject.toml.
|
||||
///
|
||||
/// Therefore, we need to gracefully degrade from a full workspacey situation all
|
||||
/// the way down to one of these stub pyproject.tomls the PEP defines. This is why
|
||||
/// we avoid going through `RequiresDist` -- we don't want to muddy up the "compile a package"
|
||||
/// logic with support for non-project/workspace pyproject.tomls, and we don't want to
|
||||
/// muddy this logic up with setuptools fallback modes that `RequiresDist` wants.
|
||||
///
|
||||
/// (We used to shove this feature into that path, and then we would see there's no metadata
|
||||
/// and try to run setuptools to try to desperately find any metadata, and then error out.)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SourcedDependencyGroups {
|
||||
pub name: Option<PackageName>,
|
||||
pub dependency_groups: BTreeMap<GroupName, Box<[Requirement]>>,
|
||||
}
|
||||
|
||||
impl SourcedDependencyGroups {
|
||||
/// Lower by considering `tool.uv` in `pyproject.toml` if present, used for Git and directory
|
||||
/// dependencies.
|
||||
pub async fn from_virtual_project(
|
||||
pyproject_path: &Path,
|
||||
git_member: Option<&GitWorkspaceMember<'_>>,
|
||||
locations: &IndexLocations,
|
||||
source_strategy: SourceStrategy,
|
||||
cache: &WorkspaceCache,
|
||||
) -> Result<Self, MetadataError> {
|
||||
let discovery = DiscoveryOptions {
|
||||
stop_discovery_at: git_member.map(|git_member| {
|
||||
git_member
|
||||
.fetch_root
|
||||
.parent()
|
||||
.expect("git checkout has a parent")
|
||||
.to_path_buf()
|
||||
}),
|
||||
members: match source_strategy {
|
||||
SourceStrategy::Enabled => MemberDiscovery::default(),
|
||||
SourceStrategy::Disabled => MemberDiscovery::None,
|
||||
},
|
||||
};
|
||||
|
||||
// The subsequent API takes an absolute path to the dir the pyproject is in
|
||||
let empty = PathBuf::new();
|
||||
let absolute_pyproject_path =
|
||||
std::path::absolute(pyproject_path).map_err(WorkspaceError::Normalize)?;
|
||||
let project_dir = absolute_pyproject_path.parent().unwrap_or(&empty);
|
||||
let project = VirtualProject::discover_defaulted(project_dir, &discovery, cache).await?;
|
||||
|
||||
// Collect the dependency groups.
|
||||
let dependency_groups =
|
||||
FlatDependencyGroups::from_pyproject_toml(project.root(), project.pyproject_toml())?;
|
||||
|
||||
// If sources/indexes are disabled we can just stop here
|
||||
let SourceStrategy::Enabled = source_strategy else {
|
||||
return Ok(Self {
|
||||
name: project.project_name().cloned(),
|
||||
dependency_groups: dependency_groups
|
||||
.into_iter()
|
||||
.map(|(name, group)| {
|
||||
let requirements = group
|
||||
.requirements
|
||||
.into_iter()
|
||||
.map(Requirement::from)
|
||||
.collect();
|
||||
(name, requirements)
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
};
|
||||
|
||||
// Collect any `tool.uv.index` entries.
|
||||
let empty = vec![];
|
||||
let project_indexes = project
|
||||
.pyproject_toml()
|
||||
.tool
|
||||
.as_ref()
|
||||
.and_then(|tool| tool.uv.as_ref())
|
||||
.and_then(|uv| uv.index.as_deref())
|
||||
.unwrap_or(&empty);
|
||||
|
||||
// Collect any `tool.uv.sources` and `tool.uv.dev_dependencies` from `pyproject.toml`.
|
||||
let empty = BTreeMap::default();
|
||||
let project_sources = project
|
||||
.pyproject_toml()
|
||||
.tool
|
||||
.as_ref()
|
||||
.and_then(|tool| tool.uv.as_ref())
|
||||
.and_then(|uv| uv.sources.as_ref())
|
||||
.map(ToolUvSources::inner)
|
||||
.unwrap_or(&empty);
|
||||
|
||||
// Now that we've resolved the dependency groups, we can validate that each source references
|
||||
// a valid extra or group, if present.
|
||||
Self::validate_sources(project_sources, &dependency_groups)?;
|
||||
|
||||
// Lower the dependency groups.
|
||||
let dependency_groups = dependency_groups
|
||||
.into_iter()
|
||||
.map(|(name, group)| {
|
||||
let requirements = group
|
||||
.requirements
|
||||
.into_iter()
|
||||
.flat_map(|requirement| {
|
||||
let requirement_name = requirement.name.clone();
|
||||
let group = name.clone();
|
||||
let extra = None;
|
||||
LoweredRequirement::from_requirement(
|
||||
requirement,
|
||||
project.project_name(),
|
||||
project.root(),
|
||||
project_sources,
|
||||
project_indexes,
|
||||
extra,
|
||||
Some(&group),
|
||||
locations,
|
||||
project.workspace(),
|
||||
git_member,
|
||||
)
|
||||
.map(move |requirement| match requirement {
|
||||
Ok(requirement) => Ok(requirement.into_inner()),
|
||||
Err(err) => Err(MetadataError::GroupLoweringError(
|
||||
group.clone(),
|
||||
requirement_name.clone(),
|
||||
Box::new(err),
|
||||
)),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Box<_>, _>>()?;
|
||||
Ok::<(GroupName, Box<_>), MetadataError>((name, requirements))
|
||||
})
|
||||
.collect::<Result<BTreeMap<_, _>, _>>()?;
|
||||
|
||||
Ok(Self {
|
||||
name: project.project_name().cloned(),
|
||||
dependency_groups,
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate the sources.
|
||||
///
|
||||
/// If a source is requested with `group`, ensure that the relevant dependency is
|
||||
/// present in the relevant `dependency-groups` section.
|
||||
fn validate_sources(
|
||||
sources: &BTreeMap<PackageName, Sources>,
|
||||
dependency_groups: &FlatDependencyGroups,
|
||||
) -> Result<(), MetadataError> {
|
||||
for (name, sources) in sources {
|
||||
for source in sources.iter() {
|
||||
if let Some(group) = source.group() {
|
||||
// If the group doesn't exist at all, error.
|
||||
let Some(flat_group) = dependency_groups.get(group) else {
|
||||
return Err(MetadataError::MissingSourceGroup(
|
||||
name.clone(),
|
||||
group.clone(),
|
||||
));
|
||||
};
|
||||
|
||||
// If there is no such requirement with the group, error.
|
||||
if !flat_group
|
||||
.requirements
|
||||
.iter()
|
||||
.any(|requirement| requirement.name == *name)
|
||||
{
|
||||
return Err(MetadataError::IncompleteSourceGroup(
|
||||
name.clone(),
|
||||
group.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -12,11 +12,13 @@ use uv_workspace::dependency_groups::DependencyGroupError;
|
|||
use uv_workspace::{WorkspaceCache, WorkspaceError};
|
||||
|
||||
pub use crate::metadata::build_requires::BuildRequires;
|
||||
pub use crate::metadata::dependency_groups::SourcedDependencyGroups;
|
||||
pub use crate::metadata::lowering::LoweredRequirement;
|
||||
pub use crate::metadata::lowering::LoweringError;
|
||||
pub use crate::metadata::requires_dist::{FlatRequiresDist, RequiresDist};
|
||||
|
||||
mod build_requires;
|
||||
mod dependency_groups;
|
||||
mod lowering;
|
||||
mod requires_dist;
|
||||
|
||||
|
|
|
@ -12,8 +12,8 @@ pub enum RequirementOrigin {
|
|||
File(PathBuf),
|
||||
/// The requirement was provided via a local project (e.g., a `pyproject.toml` file).
|
||||
Project(PathBuf, PackageName),
|
||||
/// The requirement was provided via a local project (e.g., a `pyproject.toml` file).
|
||||
Group(PathBuf, PackageName, GroupName),
|
||||
/// The requirement was provided via a local project's group (e.g., a `pyproject.toml` file).
|
||||
Group(PathBuf, Option<PackageName>, GroupName),
|
||||
/// The requirement was provided via a workspace.
|
||||
Workspace,
|
||||
}
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::{borrow::Cow, collections::BTreeMap};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use futures::TryStreamExt;
|
||||
use futures::stream::FuturesOrdered;
|
||||
use url::Url;
|
||||
|
||||
use uv_configuration::{DependencyGroups, ExtrasSpecification};
|
||||
use uv_configuration::ExtrasSpecification;
|
||||
use uv_distribution::{DistributionDatabase, FlatRequiresDist, Reporter, RequiresDist};
|
||||
use uv_distribution_types::Requirement;
|
||||
use uv_distribution_types::{
|
||||
|
@ -37,8 +37,6 @@ pub struct SourceTreeResolution {
|
|||
pub struct SourceTreeResolver<'a, Context: BuildContext> {
|
||||
/// The extras to include when resolving requirements.
|
||||
extras: &'a ExtrasSpecification,
|
||||
/// The groups to include when resolving requirements.
|
||||
groups: &'a BTreeMap<PathBuf, DependencyGroups>,
|
||||
/// The hash policy to enforce.
|
||||
hasher: &'a HashStrategy,
|
||||
/// The in-memory index for resolving dependencies.
|
||||
|
@ -51,14 +49,12 @@ impl<'a, Context: BuildContext> SourceTreeResolver<'a, Context> {
|
|||
/// Instantiate a new [`SourceTreeResolver`] for a given set of `source_trees`.
|
||||
pub fn new(
|
||||
extras: &'a ExtrasSpecification,
|
||||
groups: &'a BTreeMap<PathBuf, DependencyGroups>,
|
||||
hasher: &'a HashStrategy,
|
||||
index: &'a InMemoryIndex,
|
||||
database: DistributionDatabase<'a, Context>,
|
||||
) -> Self {
|
||||
Self {
|
||||
extras,
|
||||
groups,
|
||||
hasher,
|
||||
index,
|
||||
database,
|
||||
|
@ -101,46 +97,17 @@ impl<'a, Context: BuildContext> SourceTreeResolver<'a, Context> {
|
|||
|
||||
let mut requirements = Vec::new();
|
||||
|
||||
// Resolve any groups associated with this path
|
||||
let default_groups = DependencyGroups::default();
|
||||
let groups = self.groups.get(path).unwrap_or(&default_groups);
|
||||
|
||||
// Flatten any transitive extras and include dependencies
|
||||
// (unless something like --only-group was passed)
|
||||
if groups.prod() {
|
||||
requirements.extend(
|
||||
FlatRequiresDist::from_requirements(metadata.requires_dist, &metadata.name)
|
||||
.into_iter()
|
||||
.map(|requirement| Requirement {
|
||||
origin: Some(origin.clone()),
|
||||
marker: requirement.marker.simplify_extras(&extras),
|
||||
..requirement
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Apply dependency-groups
|
||||
for (group_name, group) in &metadata.dependency_groups {
|
||||
if groups.contains(group_name) {
|
||||
requirements.extend(group.iter().cloned().map(|group| Requirement {
|
||||
origin: Some(RequirementOrigin::Group(
|
||||
path.to_path_buf(),
|
||||
metadata.name.clone(),
|
||||
group_name.clone(),
|
||||
)),
|
||||
..group
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Complain if dependency groups are named that don't appear.
|
||||
for name in groups.explicit_names() {
|
||||
if !metadata.dependency_groups.contains_key(name) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The dependency group '{name}' was not found in the project: {}",
|
||||
path.user_display()
|
||||
));
|
||||
}
|
||||
}
|
||||
requirements.extend(
|
||||
FlatRequiresDist::from_requirements(metadata.requires_dist, &metadata.name)
|
||||
.into_iter()
|
||||
.map(|requirement| Requirement {
|
||||
origin: Some(origin.clone()),
|
||||
marker: requirement.marker.simplify_extras(&extras),
|
||||
..requirement
|
||||
}),
|
||||
);
|
||||
|
||||
let requirements = requirements.into_boxed_slice();
|
||||
let project = metadata.name;
|
||||
|
|
|
@ -290,52 +290,18 @@ impl RequirementsSpecification {
|
|||
if !groups.is_empty() {
|
||||
let mut group_specs = BTreeMap::new();
|
||||
for (path, groups) in groups {
|
||||
// Conceptually pip `--group` flags just add the group referred to by the file.
|
||||
// In uv semantics this would be like `--only-group`, however if you do this:
|
||||
//
|
||||
// uv pip install -r pyproject.toml --group pyproject.toml:foo
|
||||
//
|
||||
// We don't want to discard the package listed by `-r` in the way `--only-group`
|
||||
// would. So we check to see if any other source wants to add this path, and use
|
||||
// that to determine if we're doing `--group` or `--only-group` semantics.
|
||||
//
|
||||
// Note that it's fine if a file gets referred to multiple times by
|
||||
// different-looking paths (like `./pyproject.toml` vs `pyproject.toml`). We're
|
||||
// specifically trying to disambiguate in situations where the `--group` *happens*
|
||||
// to match with an unrelated argument, and `--only-group` would be overzealous!
|
||||
let source_exists_without_group = requirement_sources
|
||||
.iter()
|
||||
.any(|source| source.source_trees.contains(&path));
|
||||
let (group, only_group) = if source_exists_without_group {
|
||||
(groups, Vec::new())
|
||||
} else {
|
||||
(Vec::new(), groups)
|
||||
};
|
||||
let group_spec = DependencyGroups::from_args(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
group,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
false,
|
||||
only_group,
|
||||
groups,
|
||||
false,
|
||||
);
|
||||
|
||||
// If we're doing `--only-group` semantics it's because only `--group` flags referred
|
||||
// to this file, and so we need to make sure to add it to the list of sources!
|
||||
if !source_exists_without_group {
|
||||
let source = Self::from_source(
|
||||
&RequirementsSource::PyprojectToml(path.clone()),
|
||||
client_builder,
|
||||
)
|
||||
.await?;
|
||||
requirement_sources.push(source);
|
||||
}
|
||||
|
||||
group_specs.insert(path, group_spec);
|
||||
}
|
||||
|
||||
spec.groups = group_specs;
|
||||
}
|
||||
|
||||
|
|
|
@ -1434,6 +1434,33 @@ impl VirtualProject {
|
|||
path: &Path,
|
||||
options: &DiscoveryOptions,
|
||||
cache: &WorkspaceCache,
|
||||
) -> Result<Self, WorkspaceError> {
|
||||
Self::discover_impl(path, options, cache, false).await
|
||||
}
|
||||
|
||||
/// Equivalent to [`VirtualProject::discover`] but consider it acceptable for
|
||||
/// both `[project]` and `[tool.uv.workspace]` to be missing.
|
||||
///
|
||||
/// If they are, we act as if an empty `[tool.uv.workspace]` was found.
|
||||
pub async fn discover_defaulted(
|
||||
path: &Path,
|
||||
options: &DiscoveryOptions,
|
||||
cache: &WorkspaceCache,
|
||||
) -> Result<Self, WorkspaceError> {
|
||||
Self::discover_impl(path, options, cache, true).await
|
||||
}
|
||||
|
||||
/// Find the current project or virtual workspace root, given the current directory.
|
||||
///
|
||||
/// Similar to calling [`ProjectWorkspace::discover`] with a fallback to [`Workspace::discover`],
|
||||
/// but avoids rereading the `pyproject.toml` (and relying on error-handling as control flow).
|
||||
///
|
||||
/// This method requires an absolute path and panics otherwise.
|
||||
async fn discover_impl(
|
||||
path: &Path,
|
||||
options: &DiscoveryOptions,
|
||||
cache: &WorkspaceCache,
|
||||
default_missing_workspace: bool,
|
||||
) -> Result<Self, WorkspaceError> {
|
||||
assert!(
|
||||
path.is_absolute(),
|
||||
|
@ -1497,6 +1524,24 @@ impl VirtualProject {
|
|||
)
|
||||
.await?;
|
||||
|
||||
Ok(Self::NonProject(workspace))
|
||||
} else if default_missing_workspace {
|
||||
// Otherwise it's a pyproject.toml that maybe contains dependency-groups
|
||||
// that we want to treat like a project/workspace to handle those uniformly
|
||||
let project_path = std::path::absolute(project_root)
|
||||
.map_err(WorkspaceError::Normalize)?
|
||||
.clone();
|
||||
|
||||
let workspace = Workspace::collect_members(
|
||||
project_path,
|
||||
ToolUvWorkspace::default(),
|
||||
pyproject_toml,
|
||||
None,
|
||||
options,
|
||||
cache,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Self::NonProject(workspace))
|
||||
} else {
|
||||
Err(WorkspaceError::MissingProject(pyproject_path))
|
||||
|
|
|
@ -254,6 +254,7 @@ pub(crate) async fn pip_install(
|
|||
if reinstall.is_none()
|
||||
&& upgrade.is_none()
|
||||
&& source_trees.is_empty()
|
||||
&& groups.is_empty()
|
||||
&& pylock.is_none()
|
||||
&& matches!(modifications, Modifications::Sufficient)
|
||||
{
|
||||
|
|
|
@ -8,7 +8,6 @@ use std::fmt::Write;
|
|||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tracing::debug;
|
||||
use uv_tool::InstalledTools;
|
||||
|
||||
use uv_cache::Cache;
|
||||
use uv_client::{BaseClientBuilder, RegistryClient};
|
||||
|
@ -17,9 +16,9 @@ use uv_configuration::{
|
|||
ExtrasSpecification, Overrides, Reinstall, Upgrade,
|
||||
};
|
||||
use uv_dispatch::BuildDispatch;
|
||||
use uv_distribution::DistributionDatabase;
|
||||
use uv_distribution::{DistributionDatabase, SourcedDependencyGroups};
|
||||
use uv_distribution_types::{
|
||||
CachedDist, Diagnostic, InstalledDist, LocalDist, NameRequirementSpecification,
|
||||
CachedDist, Diagnostic, InstalledDist, LocalDist, NameRequirementSpecification, Requirement,
|
||||
ResolutionDiagnostic, UnresolvedRequirement, UnresolvedRequirementSpecification,
|
||||
};
|
||||
use uv_distribution_types::{
|
||||
|
@ -29,7 +28,7 @@ use uv_fs::Simplified;
|
|||
use uv_install_wheel::LinkMode;
|
||||
use uv_installer::{Plan, Planner, Preparer, SitePackages};
|
||||
use uv_normalize::{GroupName, PackageName};
|
||||
use uv_pep508::MarkerEnvironment;
|
||||
use uv_pep508::{MarkerEnvironment, RequirementOrigin};
|
||||
use uv_platform_tags::Tags;
|
||||
use uv_pypi_types::{Conflicts, ResolverMarkerEnvironment};
|
||||
use uv_python::{PythonEnvironment, PythonInstallation};
|
||||
|
@ -41,7 +40,8 @@ use uv_resolver::{
|
|||
DependencyMode, Exclusions, FlatIndex, InMemoryIndex, Manifest, Options, Preference,
|
||||
Preferences, PythonRequirement, Resolver, ResolverEnvironment, ResolverOutput,
|
||||
};
|
||||
use uv_types::{HashStrategy, InFlight, InstalledPackagesProvider};
|
||||
use uv_tool::InstalledTools;
|
||||
use uv_types::{BuildContext, HashStrategy, InFlight, InstalledPackagesProvider};
|
||||
use uv_warnings::warn_user;
|
||||
|
||||
use crate::commands::pip::loggers::{DefaultInstallLogger, InstallLogger, ResolveLogger};
|
||||
|
@ -166,7 +166,6 @@ pub(crate) async fn resolve<InstalledPackages: InstalledPackagesProvider>(
|
|||
if !source_trees.is_empty() {
|
||||
let resolutions = SourceTreeResolver::new(
|
||||
extras,
|
||||
groups,
|
||||
hasher,
|
||||
index,
|
||||
DistributionDatabase::new(client, build_dispatch, concurrency.downloads),
|
||||
|
@ -212,6 +211,47 @@ pub(crate) async fn resolve<InstalledPackages: InstalledPackagesProvider>(
|
|||
);
|
||||
}
|
||||
|
||||
for (pyproject_path, groups) in groups {
|
||||
let metadata = SourcedDependencyGroups::from_virtual_project(
|
||||
pyproject_path,
|
||||
None,
|
||||
build_dispatch.locations(),
|
||||
build_dispatch.sources(),
|
||||
build_dispatch.workspace_cache(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow!(
|
||||
"Failed to read dependency groups from: {}\n{}",
|
||||
pyproject_path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
// Complain if dependency groups are named that don't appear.
|
||||
for name in groups.explicit_names() {
|
||||
if !metadata.dependency_groups.contains_key(name) {
|
||||
return Err(anyhow!(
|
||||
"The dependency group '{name}' was not found in the project: {}",
|
||||
pyproject_path.user_display()
|
||||
))?;
|
||||
}
|
||||
}
|
||||
// Apply dependency-groups
|
||||
for (group_name, group) in &metadata.dependency_groups {
|
||||
if groups.contains(group_name) {
|
||||
requirements.extend(group.iter().cloned().map(|group| Requirement {
|
||||
origin: Some(RequirementOrigin::Group(
|
||||
pyproject_path.clone(),
|
||||
metadata.name.clone(),
|
||||
group_name.clone(),
|
||||
)),
|
||||
..group
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requirements
|
||||
};
|
||||
|
||||
|
|
|
@ -15783,7 +15783,106 @@ fn invalid_group() -> Result<()> {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn project_and_group() -> Result<()> {
|
||||
fn project_and_group_workspace_inherit() -> Result<()> {
|
||||
// Checking that --project is handled properly with --group
|
||||
fn new_context() -> Result<TestContext> {
|
||||
let context = TestContext::new("3.12");
|
||||
|
||||
let pyproject_toml = context.temp_dir.child("pyproject.toml");
|
||||
pyproject_toml.write_str(
|
||||
r#"
|
||||
[project]
|
||||
name = "myproject"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = ["packages/*"]
|
||||
|
||||
[tool.uv.sources]
|
||||
pytest = { workspace = true }
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let subdir = context.temp_dir.child("packages");
|
||||
subdir.create_dir_all()?;
|
||||
|
||||
let pytest_dir = subdir.child("pytest");
|
||||
pytest_dir.create_dir_all()?;
|
||||
let pytest_toml = pytest_dir.child("pyproject.toml");
|
||||
pytest_toml.write_str(
|
||||
r#"
|
||||
[project]
|
||||
name = "pytest"
|
||||
version = "4.0.0"
|
||||
requires-python = ">=3.12"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let sniffio_dir = subdir.child("sniffio");
|
||||
sniffio_dir.create_dir_all()?;
|
||||
let sniffio_toml = sniffio_dir.child("pyproject.toml");
|
||||
sniffio_toml.write_str(
|
||||
r#"
|
||||
[project]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
requires-python = ">=3.12"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let subproject_dir = subdir.child("mysubproject");
|
||||
subproject_dir.create_dir_all()?;
|
||||
let subproject_toml = subproject_dir.child("pyproject.toml");
|
||||
subproject_toml.write_str(
|
||||
r#"
|
||||
[project]
|
||||
name = "mysubproject"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[tool.uv.sources]
|
||||
sniffio = { workspace = true }
|
||||
|
||||
[dependency-groups]
|
||||
foo = ["iniconfig", "anyio", "sniffio", "pytest"]
|
||||
"#,
|
||||
)?;
|
||||
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
// Check that the workspace's sources are discovered and consulted
|
||||
let context = new_context()?;
|
||||
uv_snapshot!(context.filters(), context.pip_compile()
|
||||
.arg("--group").arg("packages/mysubproject/pyproject.toml:foo"), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# uv pip compile --cache-dir [CACHE_DIR] --group packages/mysubproject/pyproject.toml:foo
|
||||
anyio==4.3.0
|
||||
# via mysubproject (packages/mysubproject/pyproject.toml:foo)
|
||||
idna==3.6
|
||||
# via anyio
|
||||
iniconfig==2.0.0
|
||||
# via mysubproject (packages/mysubproject/pyproject.toml:foo)
|
||||
pytest @ file://[TEMP_DIR]/packages/pytest
|
||||
# via mysubproject (packages/mysubproject/pyproject.toml:foo)
|
||||
sniffio @ file://[TEMP_DIR]/packages/sniffio
|
||||
# via
|
||||
# mysubproject (packages/mysubproject/pyproject.toml:foo)
|
||||
# anyio
|
||||
|
||||
----- stderr -----
|
||||
Resolved 5 packages in [TIME]
|
||||
");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_and_group_workspace() -> Result<()> {
|
||||
// Checking that --project is handled properly with --group
|
||||
fn new_context() -> Result<TestContext> {
|
||||
let context = TestContext::new("3.12");
|
||||
|
|
|
@ -9632,6 +9632,43 @@ fn dependency_group() -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_dependency_group() -> Result<()> {
|
||||
// testing basic `uv pip install --group` functionality
|
||||
// when the pyproject.toml is virtual
|
||||
fn new_context() -> Result<TestContext> {
|
||||
let context = TestContext::new("3.12");
|
||||
|
||||
let pyproject_toml = context.temp_dir.child("pyproject.toml");
|
||||
pyproject_toml.write_str(
|
||||
r#"
|
||||
[dependency-groups]
|
||||
foo = ["sortedcontainers"]
|
||||
bar = ["iniconfig"]
|
||||
dev = ["sniffio"]
|
||||
"#,
|
||||
)?;
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
// 'bar' using path sugar
|
||||
let context = new_context()?;
|
||||
uv_snapshot!(context.filters(), context.pip_install()
|
||||
.arg("--group").arg("bar"), @r"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
Resolved 1 package in [TIME]
|
||||
Prepared 1 package in [TIME]
|
||||
Installed 1 package in [TIME]
|
||||
+ iniconfig==2.0.0
|
||||
");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_pyproject_group() -> Result<()> {
|
||||
// `uv pip install --group` tests with multiple projects
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue