mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 13:25:00 +00:00
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:
parent
af9c2e60aa
commit
0bb99952f6
10 changed files with 205 additions and 54 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -4738,6 +4738,7 @@ dependencies = [
|
|||
"anyhow",
|
||||
"distribution-filename",
|
||||
"distribution-types",
|
||||
"either",
|
||||
"fs-err",
|
||||
"futures",
|
||||
"glob",
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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![];
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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<()> {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue