Rename virtual workspace roots to non-project workspace roots (#6717)

## Summary

Closes https://github.com/astral-sh/uv/issues/6709.
This commit is contained in:
Charlie Marsh 2024-08-27 17:36:40 -04:00 committed by GitHub
parent c1e831881b
commit 3ee6ca31f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 78 additions and 64 deletions

View file

@ -454,7 +454,7 @@ impl Lock {
} }
// Add any dependency groups that are exclusive to the workspace root (e.g., dev // Add any dependency groups that are exclusive to the workspace root (e.g., dev
// dependencies in virtual workspaces). // dependencies in (legacy) non-project workspace roots).
for group in dev { for group in dev {
for dependency in project.group(group) { for dependency in project.group(group) {
if dependency.marker.evaluate(marker_env, &[]) { if dependency.marker.evaluate(marker_env, &[]) {

View file

@ -75,7 +75,7 @@ impl Workspace {
/// Find the workspace containing the given path. /// Find the workspace containing the given path.
/// ///
/// Unlike the [`ProjectWorkspace`] discovery, this does not require a current project. It also /// Unlike the [`ProjectWorkspace`] discovery, this does not require a current project. It also
/// always uses absolute path, i.e. this method only supports discovering the main workspace. /// always uses absolute path, i.e., this method only supports discovering the main workspace.
/// ///
/// Steps of workspace discovery: Start by looking at the closest `pyproject.toml`: /// Steps of workspace discovery: Start by looking at the closest `pyproject.toml`:
/// * If it's an explicit workspace root: Collect workspace from this root, we're done. /// * If it's an explicit workspace root: Collect workspace from this root, we're done.
@ -83,6 +83,16 @@ impl Workspace {
/// * Otherwise, try to find an explicit workspace root above: /// * Otherwise, try to find an explicit workspace root above:
/// * If an explicit workspace root exists: Collect workspace from this root, we're done. /// * If an explicit workspace root exists: Collect workspace from this root, we're done.
/// * If there is no explicit workspace: We have a single project workspace, we're done. /// * If there is no explicit workspace: We have a single project workspace, we're done.
///
/// Note that there are two kinds of workspace roots: projects, and (legacy) non-project roots.
/// The non-project roots lack a `[project]` table, and so are not themselves projects, as in:
/// ```toml
/// [tool.uv.workspace]
/// members = ["packages/*"]
///
/// [tool.uv]
/// dev-dependencies = ["ruff"]
/// ```
pub async fn discover( pub async fn discover(
path: &Path, path: &Path,
options: &DiscoveryOptions<'_>, options: &DiscoveryOptions<'_>,
@ -157,7 +167,7 @@ impl Workspace {
check_nested_workspaces(&workspace_root, options); check_nested_workspaces(&workspace_root, options);
// Unlike in `ProjectWorkspace` discovery, we might be in a virtual workspace root without // Unlike in `ProjectWorkspace` discovery, we might be in a legacy non-project root without
// being in any specific project. // being in any specific project.
let current_project = pyproject_toml let current_project = pyproject_toml
.project .project
@ -232,19 +242,14 @@ impl Workspace {
} }
} }
/// Returns `true` if the workspace has a virtual root. /// Returns `true` if the workspace has a (legacy) non-project root.
pub fn is_virtual(&self) -> bool { pub fn is_non_project(&self) -> bool {
!self !self
.packages .packages
.values() .values()
.any(|member| *member.root() == self.install_path) .any(|member| *member.root() == self.install_path)
} }
/// Returns `true` if the workspace consists solely of a virtual root.
pub fn only_virtual(&self) -> bool {
self.packages.is_empty()
}
/// Returns the set of requirements that include all packages in the workspace. /// Returns the set of requirements that include all packages in the workspace.
pub fn members_requirements(&self) -> impl Iterator<Item = Requirement> + '_ { pub fn members_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
self.packages.values().filter_map(|member| { self.packages.values().filter_map(|member| {
@ -289,21 +294,21 @@ impl Workspace {
/// Returns any requirements that are exclusive to the workspace root, i.e., not included in /// Returns any requirements that are exclusive to the workspace root, i.e., not included in
/// any of the workspace members. /// any of the workspace members.
/// ///
/// For virtual workspaces, returns the dev dependencies in the workspace root, which are /// For workspaces with non-project roots, returns the dev dependencies in the corresponding
/// the only dependencies that are not part of the workspace members. /// `pyproject.toml`.
/// ///
/// For non-virtual workspaces, returns an empty list. /// Otherwise, returns an empty list.
pub fn root_requirements(&self) -> impl Iterator<Item = Requirement> + '_ { pub fn non_project_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
if self if self
.packages .packages
.values() .values()
.any(|member| *member.root() == self.install_path) .any(|member| *member.root() == self.install_path)
{ {
// If the workspace is non-virtual, the root is a member, so we don't need to include // If the workspace has an explicit root, the root is a member, so we don't need to
// any root-only requirements. // include any root-only requirements.
Either::Left(std::iter::empty()) Either::Left(std::iter::empty())
} else { } else {
// Otherwise, return the dev dependencies in the workspace root. // Otherwise, return the dev dependencies in the non-project workspace root.
Either::Right( Either::Right(
self.pyproject_toml self.pyproject_toml
.tool .tool
@ -1166,14 +1171,14 @@ fn is_included_in_workspace(
/// A project that can be synced. /// A project that can be synced.
/// ///
/// The project could be a package within a workspace, a real workspace root, or even a virtual /// The project could be a package within a workspace, a real workspace root, or a (legacy)
/// workspace root. /// non-project workspace root, which can define its own dev dependencies.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum VirtualProject { pub enum VirtualProject {
/// A project (which could be within a workspace, or an implicit workspace root). /// A project (which could be a workspace root or member).
Project(ProjectWorkspace), Project(ProjectWorkspace),
/// A virtual workspace root. /// A (legacy) non-project workspace root.
Virtual(Workspace), NonProject(Workspace),
} }
impl VirtualProject { impl VirtualProject {
@ -1227,7 +1232,8 @@ impl VirtualProject {
.and_then(|tool| tool.uv.as_ref()) .and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.workspace.as_ref()) .and_then(|uv| uv.workspace.as_ref())
{ {
// Otherwise, if it contains a `tool.uv.workspace` table, it's a virtual workspace. // Otherwise, if it contains a `tool.uv.workspace` table, it's a non-project workspace
// root.
let project_path = std::path::absolute(project_root) let project_path = std::path::absolute(project_root)
.map_err(WorkspaceError::Normalize)? .map_err(WorkspaceError::Normalize)?
.clone(); .clone();
@ -1243,7 +1249,7 @@ impl VirtualProject {
) )
.await?; .await?;
Ok(Self::Virtual(workspace)) Ok(Self::NonProject(workspace))
} else { } else {
Err(WorkspaceError::MissingProject(pyproject_path)) Err(WorkspaceError::MissingProject(pyproject_path))
} }
@ -1258,10 +1264,10 @@ impl VirtualProject {
VirtualProject::Project(project) => Some(VirtualProject::Project( VirtualProject::Project(project) => Some(VirtualProject::Project(
project.with_pyproject_toml(pyproject_toml)?, project.with_pyproject_toml(pyproject_toml)?,
)), )),
VirtualProject::Virtual(workspace) => { VirtualProject::NonProject(workspace) => {
// If the project is virtual, the root isn't a member, so we can just update the // If this is a non-project workspace root, then by definition the root isn't a
// top-level `pyproject.toml`. // member, so we can just update the top-level `pyproject.toml`.
Some(VirtualProject::Virtual(Workspace { Some(VirtualProject::NonProject(Workspace {
pyproject_toml, pyproject_toml,
..workspace.clone() ..workspace.clone()
})) }))
@ -1273,7 +1279,7 @@ impl VirtualProject {
pub fn root(&self) -> &Path { pub fn root(&self) -> &Path {
match self { match self {
VirtualProject::Project(project) => project.project_root(), VirtualProject::Project(project) => project.project_root(),
VirtualProject::Virtual(workspace) => workspace.install_path(), VirtualProject::NonProject(workspace) => workspace.install_path(),
} }
} }
@ -1281,7 +1287,7 @@ impl VirtualProject {
pub fn pyproject_toml(&self) -> &PyProjectToml { pub fn pyproject_toml(&self) -> &PyProjectToml {
match self { match self {
VirtualProject::Project(project) => project.current_project().pyproject_toml(), VirtualProject::Project(project) => project.current_project().pyproject_toml(),
VirtualProject::Virtual(workspace) => &workspace.pyproject_toml, VirtualProject::NonProject(workspace) => &workspace.pyproject_toml,
} }
} }
@ -1289,7 +1295,7 @@ impl VirtualProject {
pub fn workspace(&self) -> &Workspace { pub fn workspace(&self) -> &Workspace {
match self { match self {
VirtualProject::Project(project) => project.workspace(), VirtualProject::Project(project) => project.workspace(),
VirtualProject::Virtual(workspace) => workspace, VirtualProject::NonProject(workspace) => workspace,
} }
} }
@ -1299,7 +1305,7 @@ impl VirtualProject {
VirtualProject::Project(project) => { VirtualProject::Project(project) => {
Either::Left(std::iter::once(project.project_name())) Either::Left(std::iter::once(project.project_name()))
} }
VirtualProject::Virtual(workspace) => Either::Right(workspace.packages().keys()), VirtualProject::NonProject(workspace) => Either::Right(workspace.packages().keys()),
} }
} }
@ -1314,11 +1320,11 @@ impl VirtualProject {
) -> impl Iterator<Item = &pep508_rs::Requirement<VerbatimParsedUrl>> { ) -> impl Iterator<Item = &pep508_rs::Requirement<VerbatimParsedUrl>> {
match self { match self {
VirtualProject::Project(_) => { VirtualProject::Project(_) => {
// For non-virtual projects, dev dependencies are attached to the members. // For projects, dev dependencies are attached to the members.
Either::Left(std::iter::empty()) Either::Left(std::iter::empty())
} }
VirtualProject::Virtual(workspace) => { VirtualProject::NonProject(workspace) => {
// For virtual projects, we might have dev dependencies that are attached to the // For non-projects, we might have dev dependencies that are attached to the
// workspace root (which isn't a member). // workspace root (which isn't a member).
if name == &*DEV_DEPENDENCIES { if name == &*DEV_DEPENDENCIES {
Either::Right( Either::Right(
@ -1339,17 +1345,17 @@ impl VirtualProject {
} }
} }
/// Return the [`PackageName`] of the project, if it's not a virtual workspace root. /// Return the [`PackageName`] of the project, if available.
pub fn project_name(&self) -> Option<&PackageName> { pub fn project_name(&self) -> Option<&PackageName> {
match self { match self {
VirtualProject::Project(project) => Some(project.project_name()), VirtualProject::Project(project) => Some(project.project_name()),
VirtualProject::Virtual(_) => None, VirtualProject::NonProject(_) => None,
} }
} }
/// Returns `true` if the project is a virtual workspace root. /// Returns `true` if the project is a virtual workspace root.
pub fn is_virtual(&self) -> bool { pub fn is_non_project(&self) -> bool {
matches!(self, VirtualProject::Virtual(_)) matches!(self, VirtualProject::NonProject(_))
} }
} }

View file

@ -200,14 +200,14 @@ pub(crate) async fn add(
VirtualProject::discover(&CWD, &DiscoveryOptions::default()).await? VirtualProject::discover(&CWD, &DiscoveryOptions::default()).await?
}; };
// For virtual projects, allow dev dependencies, but nothing else. // For non-project workspace roots, allow dev dependencies, but nothing else.
if project.is_virtual() { if project.is_non_project() {
match dependency_type { match dependency_type {
DependencyType::Production => { DependencyType::Production => {
anyhow::bail!("Found a virtual workspace root, but virtual projects do not support production dependencies (instead, use: `{}`)", "uv add --dev".green()) bail!("Found a non-project workspace root; production dependencies are unsupported (instead, use: `{}`)", "uv add --dev".green())
} }
DependencyType::Optional(_) => { DependencyType::Optional(_) => {
anyhow::bail!("Found a virtual workspace root, but virtual projects do not support optional dependencies (instead, use: `{}`)", "uv add --dev".green()) bail!("Found a non-project workspace root; optional dependencies are unsupported (instead, use: `{}`)", "uv add --dev".green())
} }
DependencyType::Dev => (), DependencyType::Dev => (),
} }

View file

@ -27,7 +27,7 @@ use uv_resolver::{
ResolverMarkers, SatisfiesResult, ResolverMarkers, SatisfiesResult,
}; };
use uv_types::{BuildContext, BuildIsolation, EmptyInstalledPackages, HashStrategy}; use uv_types::{BuildContext, BuildIsolation, EmptyInstalledPackages, HashStrategy};
use uv_warnings::warn_user; use uv_warnings::{warn_user, warn_user_once};
use uv_workspace::{DiscoveryOptions, Workspace}; use uv_workspace::{DiscoveryOptions, Workspace};
use crate::commands::pip::loggers::{DefaultResolveLogger, ResolveLogger, SummaryResolveLogger}; use crate::commands::pip::loggers::{DefaultResolveLogger, ResolveLogger, SummaryResolveLogger};
@ -248,7 +248,7 @@ async fn do_lock(
} = settings; } = settings;
// Collect the requirements, etc. // Collect the requirements, etc.
let requirements = workspace.root_requirements().collect::<Vec<_>>(); let requirements = workspace.non_project_requirements().collect::<Vec<_>>();
let overrides = workspace.overrides().into_iter().collect::<Vec<_>>(); let overrides = workspace.overrides().into_iter().collect::<Vec<_>>();
let constraints = workspace.constraints(); let constraints = workspace.constraints();
let dev = vec![DEV_DEPENDENCIES.clone()]; let dev = vec![DEV_DEPENDENCIES.clone()];
@ -262,7 +262,7 @@ async fn do_lock(
// If this is a non-virtual project with a single member, we can omit it from the lockfile. // If this is a non-virtual project with a single member, we can omit it from the lockfile.
// If any members are added or removed, it will inherently mismatch. If the member is // If any members are added or removed, it will inherently mismatch. If the member is
// renamed, it will also mismatch. // renamed, it will also mismatch.
if members.len() == 1 && !workspace.is_virtual() { if members.len() == 1 && !workspace.is_non_project() {
members.clear(); members.clear();
} }
@ -313,19 +313,15 @@ async fn do_lock(
if requires_python.is_unbounded() { if requires_python.is_unbounded() {
let default = let default =
RequiresPython::greater_than_equal_version(&interpreter.python_minor_version()); RequiresPython::greater_than_equal_version(&interpreter.python_minor_version());
warn_user!("The workspace `requires-python` value does not contain a lower bound: `{requires_python}`. Set a lower bound to indicate the minimum compatible Python version (e.g., `{default}`)."); warn_user_once!("The workspace `requires-python` value does not contain a lower bound: `{requires_python}`. Set a lower bound to indicate the minimum compatible Python version (e.g., `{default}`).");
} }
requires_python requires_python
} else { } else {
let default = let default =
RequiresPython::greater_than_equal_version(&interpreter.python_minor_version()); RequiresPython::greater_than_equal_version(&interpreter.python_minor_version());
if workspace.only_virtual() { warn_user_once!(
debug!("No `requires-python` in virtual-only workspace. Defaulting to `{default}`."); "No `requires-python` value found in the workspace. Defaulting to `{default}`."
} else { );
warn_user!(
"No `requires-python` value found in the workspace. Defaulting to `{default}`."
);
}
default default
}; };

View file

@ -250,7 +250,7 @@ fn assert_pin_compatible_with_project(pin: &Pin, virtual_project: &VirtualProjec
let requires_python = find_requires_python(project_workspace.workspace())?; let requires_python = find_requires_python(project_workspace.workspace())?;
(requires_python, "project") (requires_python, "project")
} }
VirtualProject::Virtual(workspace) => { VirtualProject::NonProject(workspace) => {
debug!( debug!(
"Discovered virtual workspace at: {}", "Discovered virtual workspace at: {}",
workspace.install_path().display() workspace.install_path().display()

View file

@ -3511,9 +3511,9 @@ fn add_lower_bound_local() -> Result<()> {
Ok(()) Ok(())
} }
/// Add dependencies to a virtual workspace root. /// Add dependencies to a (legacy) non-project workspace root.
#[test] #[test]
fn add_virtual() -> Result<()> { fn add_non_project() -> Result<()> {
let context = TestContext::new("3.12"); let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml"); let pyproject_toml = context.temp_dir.child("pyproject.toml");
@ -3530,7 +3530,7 @@ fn add_virtual() -> Result<()> {
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
error: Found a virtual workspace root, but virtual projects do not support production dependencies (instead, use: `uv add --dev`) error: Found a non-project workspace root; production dependencies are unsupported (instead, use: `uv add --dev`)
"###); "###);
// Adding `iniconfig` as optional should fail, since virtual workspace roots don't support // Adding `iniconfig` as optional should fail, since virtual workspace roots don't support
@ -3541,7 +3541,7 @@ fn add_virtual() -> Result<()> {
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
error: Found a virtual workspace root, but virtual projects do not support optional dependencies (instead, use: `uv add --dev`) error: Found a non-project workspace root; optional dependencies are unsupported (instead, use: `uv add --dev`)
"###); "###);
// Adding `iniconfig` as a dev dependency should succeed. // Adding `iniconfig` as a dev dependency should succeed.
@ -3551,6 +3551,7 @@ fn add_virtual() -> Result<()> {
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`.
Resolved 1 package in [TIME] Resolved 1 package in [TIME]
Prepared 1 package in [TIME] Prepared 1 package in [TIME]
Installed 1 package in [TIME] Installed 1 package in [TIME]

View file

@ -9463,7 +9463,7 @@ fn lock_new_constraints() -> Result<()> {
/// Lock a `pyproject.toml`, add a new constraint, and ensure that the lockfile is updated on the /// Lock a `pyproject.toml`, add a new constraint, and ensure that the lockfile is updated on the
/// next run. /// next run.
#[test] #[test]
fn lock_remove_member_virtual() -> Result<()> { fn lock_remove_member_non_project() -> Result<()> {
let context = TestContext::new("3.12"); let context = TestContext::new("3.12");
// Create a virtual workspace root. // Create a virtual workspace root.
@ -9592,6 +9592,7 @@ fn lock_remove_member_virtual() -> Result<()> {
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`.
Resolved in [TIME] Resolved in [TIME]
error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`. error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`.
"###); "###);
@ -9603,6 +9604,7 @@ fn lock_remove_member_virtual() -> Result<()> {
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`.
Resolved in [TIME] Resolved in [TIME]
Removed anyio v4.3.0 Removed anyio v4.3.0
Removed idna v3.6 Removed idna v3.6
@ -10656,9 +10658,9 @@ fn lock_overlapping_environment() -> Result<()> {
Ok(()) Ok(())
} }
/// Lock a virtual project with forked dev dependencies. /// Lock a (legacy) non-project workspace root with forked dev dependencies.
#[test] #[test]
fn lock_virtual_fork() -> Result<()> { fn lock_non_project_fork() -> Result<()> {
let context = TestContext::new("3.10"); let context = TestContext::new("3.10");
let pyproject_toml = context.temp_dir.child("pyproject.toml"); let pyproject_toml = context.temp_dir.child("pyproject.toml");
@ -10681,6 +10683,7 @@ fn lock_virtual_fork() -> Result<()> {
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.10`.
Resolved 6 packages in [TIME] Resolved 6 packages in [TIME]
"###); "###);
@ -10787,6 +10790,7 @@ fn lock_virtual_fork() -> Result<()> {
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.10`.
Resolved 6 packages in [TIME] Resolved 6 packages in [TIME]
"###); "###);
@ -10798,6 +10802,7 @@ fn lock_virtual_fork() -> Result<()> {
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.10`.
Resolved 6 packages in [TIME] Resolved 6 packages in [TIME]
"###); "###);
@ -10822,6 +10827,7 @@ fn lock_virtual_fork() -> Result<()> {
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.10`.
Resolved 7 packages in [TIME] Resolved 7 packages in [TIME]
Added iniconfig v2.0.0 Added iniconfig v2.0.0
"###); "###);
@ -10845,9 +10851,9 @@ fn lock_virtual_fork() -> Result<()> {
Ok(()) Ok(())
} }
/// Lock a virtual project with a conditional dependency. /// Lock a (legacy) non-project workspace root with a conditional dependency.
#[test] #[test]
fn lock_virtual_conditional() -> Result<()> { fn lock_non_project_conditional() -> Result<()> {
let context = TestContext::new("3.12"); let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml"); let pyproject_toml = context.temp_dir.child("pyproject.toml");
@ -10867,6 +10873,7 @@ fn lock_virtual_conditional() -> Result<()> {
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`.
Resolved 3 packages in [TIME] Resolved 3 packages in [TIME]
"###); "###);
@ -10927,6 +10934,7 @@ fn lock_virtual_conditional() -> Result<()> {
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`.
Resolved 3 packages in [TIME] Resolved 3 packages in [TIME]
"###); "###);
@ -10938,6 +10946,7 @@ fn lock_virtual_conditional() -> Result<()> {
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`.
Resolved 3 packages in [TIME] Resolved 3 packages in [TIME]
"###); "###);

View file

@ -198,6 +198,7 @@ fn empty() -> Result<()> {
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`.
Resolved in [TIME] Resolved in [TIME]
Audited in [TIME] Audited in [TIME]
"###); "###);
@ -211,6 +212,7 @@ fn empty() -> Result<()> {
----- stdout ----- ----- stdout -----
----- stderr ----- ----- stderr -----
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`.
Resolved in [TIME] Resolved in [TIME]
Audited in [TIME] Audited in [TIME]
"###); "###);