Allow uv sync --package without copying member pyproject.toml (#6943)

## Summary

Closes https://github.com/astral-sh/uv/issues/6935.
This commit is contained in:
Charlie Marsh 2024-09-02 17:01:50 -04:00 committed by GitHub
parent 6897001fee
commit f9c04581e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 145 additions and 59 deletions

View file

@ -35,7 +35,7 @@ use uv_fs::{relative_to, PortablePath, PortablePathBuf};
use uv_git::{GitReference, GitSha, RepositoryReference, ResolvedRepositoryReference};
use uv_normalize::{ExtraName, GroupName, PackageName};
use uv_types::BuildContext;
use uv_workspace::{VirtualProject, Workspace};
use uv_workspace::{InstallTarget, Workspace};
pub use crate::lock::requirements_txt::RequirementsTxtExport;
pub use crate::lock::tree::TreeDisplay;
@ -426,7 +426,7 @@ impl Lock {
/// Convert the [`Lock`] to a [`Resolution`] using the given marker environment, tags, and root.
pub fn to_resolution(
&self,
project: &VirtualProject,
project: InstallTarget<'_>,
marker_env: &ResolverMarkerEnvironment,
tags: &Tags,
extras: &ExtrasSpecification,
@ -439,8 +439,12 @@ impl Lock {
for root_name in project.packages() {
let root = self
.find_by_name(root_name)
.expect("found too many packages matching root")
.expect("could not find root");
.map_err(|_| LockErrorKind::MultipleRootPackages {
name: root_name.clone(),
})?
.ok_or_else(|| LockErrorKind::MissingRootPackage {
name: root_name.clone(),
})?;
// Add the base package.
queue.push_back((root, None));
@ -466,10 +470,15 @@ impl Lock {
for group in dev {
for dependency in project.group(group) {
if dependency.marker.evaluate(marker_env, &[]) {
let root_name = &dependency.name;
let root = self
.find_by_markers(&dependency.name, marker_env)
.expect("found too many packages matching root")
.expect("could not find root");
.find_by_markers(root_name, marker_env)
.map_err(|_| LockErrorKind::MultipleRootPackages {
name: root_name.clone(),
})?
.ok_or_else(|| LockErrorKind::MissingRootPackage {
name: root_name.clone(),
})?;
// Add the base package.
queue.push_back((root, None));
@ -3605,6 +3614,19 @@ enum LockErrorKind {
/// An error that occurs when converting a URL to a path
#[error("failed to convert URL to path")]
UrlToPath,
/// An error that occurs when multiple packages with the same
/// name were found when identifying the root packages.
#[error("found multiple packages matching `{name}`")]
MultipleRootPackages {
/// The ID of the package.
name: PackageName,
},
/// An error that occurs when a root package can't be found.
#[error("could not find root package `{name}`")]
MissingRootPackage {
/// The ID of the package.
name: PackageName,
},
}
/// An error that occurs when a source string could not be parsed.

View file

@ -1,6 +1,6 @@
pub use workspace::{
check_nested_workspaces, DiscoveryOptions, MemberDiscovery, ProjectWorkspace, VirtualProject,
Workspace, WorkspaceError, WorkspaceMember,
check_nested_workspaces, DiscoveryOptions, InstallTarget, MemberDiscovery, ProjectWorkspace,
VirtualProject, Workspace, WorkspaceError, WorkspaceMember,
};
pub mod pyproject;

View file

@ -1219,7 +1219,7 @@ fn is_included_in_workspace(
Ok(false)
}
/// A project that can be synced.
/// A project that can be discovered.
///
/// The project could be a package within a workspace, a real workspace root, or a (legacy)
/// non-project workspace root, which can define its own dev dependencies.
@ -1311,13 +1311,13 @@ impl VirtualProject {
#[must_use]
pub fn with_pyproject_toml(self, pyproject_toml: PyProjectToml) -> Option<Self> {
match self {
VirtualProject::Project(project) => Some(VirtualProject::Project(
project.with_pyproject_toml(pyproject_toml)?,
)),
VirtualProject::NonProject(workspace) => {
Self::Project(project) => {
Some(Self::Project(project.with_pyproject_toml(pyproject_toml)?))
}
Self::NonProject(workspace) => {
// If this is a non-project workspace root, then by definition the root isn't a
// member, so we can just update the top-level `pyproject.toml`.
Some(VirtualProject::NonProject(Workspace {
Some(Self::NonProject(Workspace {
pyproject_toml,
..workspace.clone()
}))
@ -1328,38 +1328,77 @@ impl VirtualProject {
/// Return the root of the project.
pub fn root(&self) -> &Path {
match self {
VirtualProject::Project(project) => project.project_root(),
VirtualProject::NonProject(workspace) => workspace.install_path(),
Self::Project(project) => project.project_root(),
Self::NonProject(workspace) => workspace.install_path(),
}
}
/// Return the [`PyProjectToml`] of the project.
pub fn pyproject_toml(&self) -> &PyProjectToml {
match self {
VirtualProject::Project(project) => project.current_project().pyproject_toml(),
VirtualProject::NonProject(workspace) => &workspace.pyproject_toml,
Self::Project(project) => project.current_project().pyproject_toml(),
Self::NonProject(workspace) => &workspace.pyproject_toml,
}
}
/// Return the [`Workspace`] of the project.
pub fn workspace(&self) -> &Workspace {
match self {
VirtualProject::Project(project) => project.workspace(),
VirtualProject::NonProject(workspace) => workspace,
Self::Project(project) => project.workspace(),
Self::NonProject(workspace) => workspace,
}
}
/// Return the [`PackageName`] of the project.
/// Return the [`PackageName`] of the project, if available.
pub fn project_name(&self) -> Option<&PackageName> {
match self {
VirtualProject::Project(project) => Some(project.project_name()),
VirtualProject::NonProject(_) => None,
}
}
/// Returns `true` if the project is a virtual workspace root.
pub fn is_non_project(&self) -> bool {
matches!(self, VirtualProject::NonProject(_))
}
}
/// A target that can be installed.
#[derive(Debug, Clone, Copy)]
pub enum InstallTarget<'env> {
/// A project (which could be a workspace root or member).
Project(&'env ProjectWorkspace),
/// A (legacy) non-project workspace root.
NonProject(&'env Workspace),
/// A frozen member within a [`Workspace`].
FrozenMember(&'env Workspace, &'env PackageName),
}
impl<'env> InstallTarget<'env> {
/// Create an [`InstallTarget`] for a frozen member within a workspace.
pub fn frozen_member(project: &'env VirtualProject, package_name: &'env PackageName) -> Self {
Self::FrozenMember(project.workspace(), package_name)
}
/// Return the [`Workspace`] of the target.
pub fn workspace(&self) -> &Workspace {
match self {
Self::Project(project) => project.workspace(),
Self::NonProject(workspace) => workspace,
Self::FrozenMember(workspace, _) => workspace,
}
}
/// Return the [`PackageName`] of the target.
pub fn packages(&self) -> impl Iterator<Item = &PackageName> {
match self {
VirtualProject::Project(project) => {
Either::Left(std::iter::once(project.project_name()))
}
VirtualProject::NonProject(workspace) => Either::Right(workspace.packages().keys()),
Self::Project(project) => Either::Left(std::iter::once(project.project_name())),
Self::NonProject(workspace) => Either::Right(workspace.packages().keys()),
Self::FrozenMember(_, package_name) => Either::Left(std::iter::once(*package_name)),
}
}
/// Return the [`VirtualProject`] dependencies for the given group name.
/// Return the [`InstallTarget`] dependencies for the given group name.
///
/// Returns dependencies that apply to the workspace root, but not any of its members. As such,
/// only returns a non-empty iterator for virtual workspaces, which can include dev dependencies
@ -1369,11 +1408,11 @@ impl VirtualProject {
name: &GroupName,
) -> impl Iterator<Item = &pep508_rs::Requirement<VerbatimParsedUrl>> {
match self {
VirtualProject::Project(_) => {
Self::Project(_) | Self::FrozenMember(..) => {
// For projects, dev dependencies are attached to the members.
Either::Left(std::iter::empty())
}
VirtualProject::NonProject(workspace) => {
Self::NonProject(workspace) => {
// For non-projects, we might have dev dependencies that are attached to the
// workspace root (which isn't a member).
if name == &*DEV_DEPENDENCIES {
@ -1395,17 +1434,22 @@ impl VirtualProject {
}
}
/// Return the [`PackageName`] of the project, if available.
/// Return the [`PackageName`] of the target, if available.
pub fn project_name(&self) -> Option<&PackageName> {
match self {
VirtualProject::Project(project) => Some(project.project_name()),
VirtualProject::NonProject(_) => None,
Self::Project(project) => Some(project.project_name()),
Self::NonProject(_) => None,
Self::FrozenMember(_, package_name) => Some(package_name),
}
}
}
/// Returns `true` if the project is a virtual workspace root.
pub fn is_non_project(&self) -> bool {
matches!(self, VirtualProject::NonProject(_))
impl<'env> From<&'env VirtualProject> for InstallTarget<'env> {
fn from(project: &'env VirtualProject) -> Self {
match project {
VirtualProject::Project(project) => Self::Project(project),
VirtualProject::NonProject(workspace) => Self::NonProject(workspace),
}
}
}

View file

@ -30,7 +30,7 @@ use uv_types::{BuildIsolation, HashStrategy};
use uv_warnings::warn_user_once;
use uv_workspace::pyproject::{DependencyType, Source, SourceError};
use uv_workspace::pyproject_mut::{ArrayEdit, DependencyTarget, PyProjectTomlMut};
use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace};
use uv_workspace::{DiscoveryOptions, InstallTarget, VirtualProject, Workspace};
use crate::commands::pip::loggers::{
DefaultInstallLogger, DefaultResolveLogger, SummaryResolveLogger,
@ -668,7 +668,7 @@ pub(crate) async fn add(
let install_options = InstallOptions::default();
if let Err(err) = project::sync::do_sync(
&project,
InstallTarget::from(&project),
&venv,
&lock,
&extras,

View file

@ -13,7 +13,7 @@ use uv_scripts::Pep723Script;
use uv_warnings::{warn_user, warn_user_once};
use uv_workspace::pyproject::DependencyType;
use uv_workspace::pyproject_mut::{DependencyTarget, PyProjectTomlMut};
use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace};
use uv_workspace::{DiscoveryOptions, InstallTarget, VirtualProject, Workspace};
use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger};
use crate::commands::pip::operations::Modifications;
@ -196,7 +196,7 @@ pub(crate) async fn remove(
let state = SharedState::default();
project::sync::do_sync(
&project,
InstallTarget::from(&project),
&venv,
&lock,
&extras,

View file

@ -26,7 +26,7 @@ use uv_python::{
use uv_requirements::{RequirementsSource, RequirementsSpecification};
use uv_scripts::Pep723Script;
use uv_warnings::warn_user;
use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceError};
use uv_workspace::{DiscoveryOptions, InstallTarget, VirtualProject, Workspace, WorkspaceError};
use crate::commands::pip::loggers::{
DefaultInstallLogger, DefaultResolveLogger, SummaryInstallLogger, SummaryResolveLogger,
@ -432,7 +432,7 @@ pub(crate) async fn run(
uv_resolver::ResolveError::NoSolution(err),
))) => {
let report = miette::Report::msg(format!("{err}")).context(err.header());
anstream::eprint!("{report:?}");
eprint!("{report:?}");
return Ok(ExitStatus::Failure);
}
Err(err) => return Err(err.into()),
@ -441,7 +441,7 @@ pub(crate) async fn run(
let install_options = InstallOptions::default();
project::sync::do_sync(
&project,
InstallTarget::from(&project),
&venv,
result.lock(),
&extras,

View file

@ -14,7 +14,7 @@ use uv_normalize::{PackageName, DEV_DEPENDENCIES};
use uv_python::{PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest};
use uv_resolver::{FlatIndex, Lock};
use uv_types::{BuildIsolation, HashStrategy};
use uv_workspace::{DiscoveryOptions, MemberDiscovery, VirtualProject, Workspace};
use uv_workspace::{DiscoveryOptions, InstallTarget, MemberDiscovery, VirtualProject, Workspace};
use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger, InstallLogger};
use crate::commands::pip::operations::Modifications;
@ -45,14 +45,7 @@ pub(crate) async fn sync(
printer: Printer,
) -> Result<ExitStatus> {
// Identify the project.
let project = if let Some(package) = package {
VirtualProject::Project(
Workspace::discover(&CWD, &DiscoveryOptions::default())
.await?
.with_current_project(package.clone())
.with_context(|| format!("Package `{package}` not found in workspace"))?,
)
} else if frozen {
let project = if frozen {
VirtualProject::discover(
&CWD,
&DiscoveryOptions {
@ -61,13 +54,27 @@ pub(crate) async fn sync(
},
)
.await?
} else if let Some(package) = package.as_ref() {
VirtualProject::Project(
Workspace::discover(&CWD, &DiscoveryOptions::default())
.await?
.with_current_project(package.clone())
.with_context(|| format!("Package `{package}` not found in workspace"))?,
)
} else {
VirtualProject::discover(&CWD, &DiscoveryOptions::default()).await?
};
// Identify the target.
let target = if let Some(package) = package.as_ref().filter(|_| frozen) {
InstallTarget::frozen_member(&project, package)
} else {
InstallTarget::from(&project)
};
// Discover or create the virtual environment.
let venv = project::get_or_init_environment(
project.workspace(),
target.workspace(),
python.as_deref().map(PythonRequest::parse),
python_preference,
python_downloads,
@ -81,7 +88,7 @@ pub(crate) async fn sync(
let lock = match do_safe_lock(
locked,
frozen,
project.workspace(),
target.workspace(),
venv.interpreter(),
settings.as_ref().into(),
Box::new(DefaultResolveLogger),
@ -109,7 +116,7 @@ pub(crate) async fn sync(
// Perform the sync operation.
do_sync(
&project,
target,
&venv,
&lock,
&extras,
@ -133,7 +140,7 @@ pub(crate) async fn sync(
/// Sync a lockfile with an environment.
#[allow(clippy::fn_params_excessive_bools)]
pub(super) async fn do_sync(
project: &VirtualProject,
target: InstallTarget<'_>,
venv: &PythonEnvironment,
lock: &Lock,
extras: &ExtrasSpecification,
@ -205,14 +212,14 @@ pub(super) async fn do_sync(
let tags = venv.interpreter().tags()?;
// Read the lockfile.
let resolution = lock.to_resolution(project, &markers, tags, extras, &dev)?;
let resolution = lock.to_resolution(target, &markers, tags, extras, &dev)?;
// Always skip virtual projects, which shouldn't be built or installed.
let resolution = apply_no_virtual_project(resolution);
// Filter resolution based on install-specific options.
let resolution =
install_options.filter_resolution(resolution, project.project_name(), lock.members());
install_options.filter_resolution(resolution, target.project_name(), lock.members());
// Add all authenticated sources to the cache.
for url in index_locations.urls() {

View file

@ -1125,14 +1125,27 @@ fn no_install_workspace() -> Result<()> {
+ sniffio==1.3.1
"###);
// Unless `--package` is used.
// Even if `--package` is used.
uv_snapshot!(context.filters(), context.sync().arg("--package").arg("child").arg("--no-install-workspace").arg("--frozen"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Uninstalled 3 packages in [TIME]
- anyio==3.7.0
- idna==3.6
- sniffio==1.3.1
"###);
// Unless the package doesn't exist.
uv_snapshot!(context.filters(), context.sync().arg("--package").arg("fake").arg("--no-install-workspace").arg("--frozen"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Workspace member `[TEMP_DIR]/child` is missing a `pyproject.toml` (matches: `child`)
error: could not find root package `fake`
"###);
// But we do require the root `pyproject.toml`.