Sync all packages in a virtual workspace (#4636)

## Summary

This PR dodges some of the bigger issues raised by
https://github.com/astral-sh/uv/pull/4554 and
https://github.com/astral-sh/uv/pull/4555 by _not_ changing any of the
bigger semantics around syncing and instead merely changing virtual
workspace roots to sync all packages in the workspace (rather than
erroring due to being unable to find a project).

Closes #4541.
This commit is contained in:
Charlie Marsh 2024-06-29 12:43:59 -04:00 committed by GitHub
parent af9c2e60aa
commit 0bb99952f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 205 additions and 54 deletions

1
Cargo.lock generated
View file

@ -4738,6 +4738,7 @@ dependencies = [
"anyhow",
"distribution-filename",
"distribution-types",
"either",
"fs-err",
"futures",
"glob",

View file

@ -31,6 +31,7 @@ uv-types = { workspace = true }
uv-warnings = { workspace = true }
anyhow = { workspace = true }
either = { workspace = true }
fs-err = { workspace = true }
futures = { workspace = true }
glob = { workspace = true }

View file

@ -4,7 +4,7 @@ pub use error::Error;
pub use index::{BuiltWheelIndex, RegistryWheelIndex};
pub use metadata::{ArchiveMetadata, Metadata, RequiresDist, DEV_DEPENDENCIES};
pub use reporter::Reporter;
pub use workspace::{ProjectWorkspace, Workspace, WorkspaceError, WorkspaceMember};
pub use workspace::{ProjectWorkspace, VirtualProject, Workspace, WorkspaceError, WorkspaceMember};
mod archive;
mod distribution_database;

View file

@ -3,6 +3,7 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use either::Either;
use glob::{glob, GlobError, PatternError};
use rustc_hash::FxHashSet;
use tracing::{debug, trace};
@ -839,6 +840,112 @@ fn is_excluded_from_workspace(
Ok(false)
}
/// A project that can be synced.
///
/// The project could be a package within a workspace, a real workspace root, or even a virtual
/// workspace root.
#[derive(Debug)]
pub enum VirtualProject {
/// A project (which could be within a workspace, or an implicit workspace root).
Project(ProjectWorkspace),
/// A virtual workspace root.
Virtual(Workspace),
}
impl VirtualProject {
/// 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).
pub async fn discover(
path: &Path,
stop_discovery_at: Option<&Path>,
) -> Result<Self, WorkspaceError> {
let project_root = path
.ancestors()
.take_while(|path| {
// Only walk up the given directory, if any.
stop_discovery_at
.map(|stop_discovery_at| stop_discovery_at != *path)
.unwrap_or(true)
})
.find(|path| path.join("pyproject.toml").is_file())
.ok_or(WorkspaceError::MissingPyprojectToml)?;
debug!(
"Found project root: `{}`",
project_root.simplified_display()
);
// Read the current `pyproject.toml`.
let pyproject_path = project_root.join("pyproject.toml");
let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
let pyproject_toml = PyProjectToml::from_string(contents)
.map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
if let Some(project) = pyproject_toml.project.as_ref() {
// If the `pyproject.toml` contains a `[project]` table, it's a project.
let project = ProjectWorkspace::from_project(
project_root,
project,
&pyproject_toml,
stop_discovery_at,
)
.await?;
Ok(Self::Project(project))
} else if let Some(workspace) = pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.workspace.as_ref())
{
// Otherwise, if it contains a `tool.uv.workspace` table, it's a virtual workspace.
let project_path = absolutize_path(project_root)
.map_err(WorkspaceError::Normalize)?
.to_path_buf();
let workspace = Workspace::collect_members(
project_path,
workspace.clone(),
pyproject_toml,
None,
stop_discovery_at,
)
.await?;
Ok(Self::Virtual(workspace))
} else {
Err(WorkspaceError::MissingProject(pyproject_path))
}
}
/// Return the [`Workspace`] of the project.
pub fn workspace(&self) -> &Workspace {
match self {
VirtualProject::Project(project) => project.workspace(),
VirtualProject::Virtual(workspace) => workspace,
}
}
/// Return the [`PackageName`] of the project.
pub fn packages(&self) -> impl Iterator<Item = &PackageName> {
match self {
VirtualProject::Project(project) => {
Either::Left(std::iter::once(project.project_name()))
}
VirtualProject::Virtual(workspace) => Either::Right(workspace.packages().keys()),
}
}
/// Return the [`PackageName`] of the project, if it's not a virtual workspace.
pub fn project_name(&self) -> Option<&PackageName> {
match self {
VirtualProject::Project(project) => Some(project.project_name()),
VirtualProject::Virtual(_) => None,
}
}
}
#[cfg(test)]
#[cfg(unix)] // Avoid path escaping for the unit tests
mod tests {

View file

@ -24,6 +24,7 @@ use pep508_rs::{MarkerEnvironment, MarkerTree, VerbatimUrl, VerbatimUrlError};
use platform_tags::{TagCompatibility, TagPriority, Tags};
use pypi_types::{HashDigest, ParsedArchiveUrl, ParsedGitUrl};
use uv_configuration::ExtrasSpecification;
use uv_distribution::VirtualProject;
use uv_git::{GitReference, GitSha, RepositoryReference, ResolvedRepositoryReference};
use uv_normalize::{ExtraName, GroupName, PackageName};
@ -317,36 +318,37 @@ impl Lock {
/// Convert the [`Lock`] to a [`Resolution`] using the given marker environment, tags, and root.
pub fn to_resolution(
&self,
workspace_root: &Path,
project: &VirtualProject,
marker_env: &MarkerEnvironment,
tags: &Tags,
root_name: &PackageName,
extras: &ExtrasSpecification,
dev: &[GroupName],
) -> Result<Resolution, LockError> {
let mut queue: VecDeque<(&Distribution, Option<&ExtraName>)> = VecDeque::new();
let mut seen = FxHashSet::default();
// Add the root distribution to the queue.
let root = self
.find_by_name(root_name)
.expect("found too many distributions matching root")
.expect("could not find root");
// Add the workspace packages to the queue.
for root_name in project.packages() {
let root = self
.find_by_name(root_name)
.expect("found too many distributions matching root")
.expect("could not find root");
// Add the base package.
queue.push_back((root, None));
// Add the base package.
queue.push_back((root, None));
// Add any extras.
match extras {
ExtrasSpecification::None => {}
ExtrasSpecification::All => {
for extra in root.optional_dependencies.keys() {
queue.push_back((root, Some(extra)));
// Add any extras.
match extras {
ExtrasSpecification::None => {}
ExtrasSpecification::All => {
for extra in root.optional_dependencies.keys() {
queue.push_back((root, Some(extra)));
}
}
}
ExtrasSpecification::Some(extras) => {
for extra in extras {
queue.push_back((root, Some(extra)));
ExtrasSpecification::Some(extras) => {
for extra in extras {
queue.push_back((root, Some(extra)));
}
}
}
}
@ -377,7 +379,8 @@ impl Lock {
}
}
let name = dist.id.name.clone();
let resolved_dist = ResolvedDist::Installable(dist.to_dist(workspace_root, tags)?);
let resolved_dist =
ResolvedDist::Installable(dist.to_dist(project.workspace().root(), tags)?);
map.insert(name, resolved_dist);
}
let diagnostics = vec![];

View file

@ -7,7 +7,7 @@ use uv_configuration::{Concurrency, ExtrasSpecification, PreviewMode, SetupPyStr
use uv_dispatch::BuildDispatch;
use uv_distribution::pyproject::{DependencyType, Source, SourceError};
use uv_distribution::pyproject_mut::PyProjectTomlMut;
use uv_distribution::{DistributionDatabase, ProjectWorkspace, Workspace};
use uv_distribution::{DistributionDatabase, ProjectWorkspace, VirtualProject, Workspace};
use uv_git::GitResolver;
use uv_normalize::PackageName;
use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification};
@ -225,8 +225,7 @@ pub(crate) async fn add(
let dev = true;
project::sync::do_sync(
project.project_name(),
project.workspace().root(),
&VirtualProject::Project(project),
&venv,
&lock,
extras,

View file

@ -6,7 +6,7 @@ use uv_client::Connectivity;
use uv_configuration::{Concurrency, ExtrasSpecification, PreviewMode};
use uv_distribution::pyproject::DependencyType;
use uv_distribution::pyproject_mut::PyProjectTomlMut;
use uv_distribution::{ProjectWorkspace, Workspace};
use uv_distribution::{ProjectWorkspace, VirtualProject, Workspace};
use uv_toolchain::{ToolchainPreference, ToolchainRequest};
use uv_warnings::{warn_user, warn_user_once};
@ -117,8 +117,7 @@ pub(crate) async fn remove(
let dev = true;
project::sync::do_sync(
project.project_name(),
project.workspace().root(),
&VirtualProject::Project(project),
&venv,
&lock,
extras,

View file

@ -11,7 +11,7 @@ use uv_cache::Cache;
use uv_cli::ExternalCommand;
use uv_client::{BaseClientBuilder, Connectivity};
use uv_configuration::{Concurrency, ExtrasSpecification, PreviewMode};
use uv_distribution::{ProjectWorkspace, Workspace, WorkspaceError};
use uv_distribution::{VirtualProject, Workspace, WorkspaceError};
use uv_normalize::PackageName;
use uv_requirements::RequirementsSource;
use uv_toolchain::{
@ -56,14 +56,14 @@ pub(crate) async fn run(
let project = if let Some(package) = package {
// We need a workspace, but we don't need to have a current package, we can be e.g. in
// the root of a virtual workspace and then switch into the selected package.
Some(
Some(VirtualProject::Project(
Workspace::discover(&std::env::current_dir()?, None)
.await?
.with_current_project(package.clone())
.with_context(|| format!("Package `{package}` not found in workspace"))?,
)
))
} else {
match ProjectWorkspace::discover(&std::env::current_dir()?, None).await {
match VirtualProject::discover(&std::env::current_dir()?, None).await {
Ok(project) => Some(project),
Err(WorkspaceError::MissingPyprojectToml) => None,
Err(err) => return Err(err.into()),
@ -71,11 +71,17 @@ pub(crate) async fn run(
};
let interpreter = if let Some(project) = project {
debug!(
"Discovered project `{}` at: {}",
project.project_name(),
project.workspace().root().display()
);
if let Some(project_name) = project.project_name() {
debug!(
"Discovered project `{project_name}` at: {}",
project.workspace().root().display()
);
} else {
debug!(
"Discovered virtual workspace at: {}",
project.workspace().root().display()
);
}
let venv = project::init_environment(
project.workspace(),
@ -102,8 +108,7 @@ pub(crate) async fn run(
)
.await?;
project::sync::do_sync(
project.project_name(),
project.workspace().root(),
&project,
&venv,
&lock,
extras,

View file

@ -1,15 +1,12 @@
use std::path::Path;
use anyhow::Result;
use uv_cache::Cache;
use uv_client::{Connectivity, FlatIndexClient, RegistryClientBuilder};
use uv_configuration::{Concurrency, ExtrasSpecification, PreviewMode, SetupPyStrategy};
use uv_dispatch::BuildDispatch;
use uv_distribution::{ProjectWorkspace, DEV_DEPENDENCIES};
use uv_distribution::{VirtualProject, DEV_DEPENDENCIES};
use uv_git::GitResolver;
use uv_installer::SitePackages;
use uv_normalize::PackageName;
use uv_resolver::{FlatIndex, InMemoryIndex, Lock};
use uv_toolchain::{PythonEnvironment, ToolchainPreference, ToolchainRequest};
use uv_types::{BuildIsolation, HashStrategy, InFlight};
@ -41,8 +38,8 @@ pub(crate) async fn sync(
warn_user_once!("`uv sync` is experimental and may change without warning.");
}
// Find the project requirements.
let project = ProjectWorkspace::discover(&std::env::current_dir()?, None).await?;
// Identify the project
let project = VirtualProject::discover(&std::env::current_dir()?, None).await?;
// Discover or create the virtual environment.
let venv = project::init_environment(
@ -65,8 +62,7 @@ pub(crate) async fn sync(
// Perform the sync operation.
do_sync(
project.project_name(),
project.workspace().root(),
&project,
&venv,
&lock,
extras,
@ -88,8 +84,7 @@ pub(crate) async fn sync(
/// Sync a lockfile with an environment.
#[allow(clippy::too_many_arguments)]
pub(super) async fn do_sync(
project_name: &PackageName,
workspace_root: &Path,
project: &VirtualProject,
venv: &PythonEnvironment,
lock: &Lock,
extras: ExtrasSpecification,
@ -136,8 +131,7 @@ pub(super) async fn do_sync(
let tags = venv.interpreter().tags()?;
// Read the lockfile.
let resolution =
lock.to_resolution(workspace_root, markers, tags, project_name, &extras, &dev)?;
let resolution = lock.to_resolution(project, markers, tags, &extras, &dev)?;
// Initialize the registry client.
let client = RegistryClientBuilder::new(cache.clone())

View file

@ -341,6 +341,7 @@ fn test_uv_run_with_package_virtual_workspace() -> Result<()> {
"Using Python 3.12.[X] interpreter at: [PYTHON]",
));
// Run from the `bird-feeder` member.
uv_snapshot!(filters, context
.run()
.arg("--preview")
@ -370,10 +371,10 @@ fn test_uv_run_with_package_virtual_workspace() -> Result<()> {
uv_snapshot!(context.filters(), universal_windows_filters=true, context
.run()
.arg("--preview")
.arg("--package")
.arg("albatross")
.arg("packages/albatross/check_installed_albatross.py")
.current_dir(&work_dir), @r###"
.arg("--package")
.arg("albatross")
.arg("packages/albatross/check_installed_albatross.py")
.current_dir(&work_dir), @r###"
success: true
exit_code: 0
----- stdout -----
@ -391,6 +392,47 @@ fn test_uv_run_with_package_virtual_workspace() -> Result<()> {
Ok(())
}
/// Check that `uv run` works from a virtual workspace root, which should sync all packages in the
/// workspace.
#[test]
fn test_uv_run_virtual_workspace_root() -> Result<()> {
let context = TestContext::new("3.12");
let work_dir = context.temp_dir.join("albatross-virtual-workspace");
copy_dir_ignore(
workspaces_dir().join("albatross-virtual-workspace"),
&work_dir,
)?;
uv_snapshot!(context.filters(), universal_windows_filters=true, context
.run()
.arg("--preview")
.arg("packages/albatross/check_installed_albatross.py")
.current_dir(&work_dir), @r###"
success: true
exit_code: 0
----- stdout -----
Success
----- stderr -----
Using Python 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtualenv at: .venv
Resolved 8 packages in [TIME]
Prepared 7 packages in [TIME]
Installed 7 packages in [TIME]
+ albatross==0.1.0 (from file://[TEMP_DIR]/albatross-virtual-workspace/packages/albatross)
+ anyio==4.3.0
+ bird-feeder==1.0.0 (from file://[TEMP_DIR]/albatross-virtual-workspace/packages/bird-feeder)
+ idna==3.6
+ seeds==1.0.0 (from file://[TEMP_DIR]/albatross-virtual-workspace/packages/seeds)
+ sniffio==1.3.1
+ tqdm==4.66.2
"###
);
Ok(())
}
/// Check that `uv run --package` works in a root workspace.
#[test]
fn test_uv_run_with_package_root_workspace() -> Result<()> {