diff --git a/crates/uv-resolver/src/lock.rs b/crates/uv-resolver/src/lock.rs index b3b014ba3..0da9f4a7a 100644 --- a/crates/uv-resolver/src/lock.rs +++ b/crates/uv-resolver/src/lock.rs @@ -454,7 +454,7 @@ impl Lock { } // 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 dependency in project.group(group) { if dependency.marker.evaluate(marker_env, &[]) { diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 1fef22d8d..bb0987dac 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -75,7 +75,7 @@ impl Workspace { /// Find the workspace containing the given path. /// /// 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`: /// * 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: /// * 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. + /// + /// 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( path: &Path, options: &DiscoveryOptions<'_>, @@ -157,7 +167,7 @@ impl Workspace { 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. let current_project = pyproject_toml .project @@ -232,19 +242,14 @@ impl Workspace { } } - /// Returns `true` if the workspace has a virtual root. - pub fn is_virtual(&self) -> bool { + /// Returns `true` if the workspace has a (legacy) non-project root. + pub fn is_non_project(&self) -> bool { !self .packages .values() .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. pub fn members_requirements(&self) -> impl Iterator + '_ { 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 /// any of the workspace members. /// - /// For virtual workspaces, returns the dev dependencies in the workspace root, which are - /// the only dependencies that are not part of the workspace members. + /// For workspaces with non-project roots, returns the dev dependencies in the corresponding + /// `pyproject.toml`. /// - /// For non-virtual workspaces, returns an empty list. - pub fn root_requirements(&self) -> impl Iterator + '_ { + /// Otherwise, returns an empty list. + pub fn non_project_requirements(&self) -> impl Iterator + '_ { if self .packages .values() .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 - // any root-only requirements. + // If the workspace has an explicit root, the root is a member, so we don't need to + // include any root-only requirements. Either::Left(std::iter::empty()) } else { - // Otherwise, return the dev dependencies in the workspace root. + // Otherwise, return the dev dependencies in the non-project workspace root. Either::Right( self.pyproject_toml .tool @@ -1166,14 +1171,14 @@ fn is_included_in_workspace( /// 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. +/// 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. #[derive(Debug, Clone)] 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), - /// A virtual workspace root. - Virtual(Workspace), + /// A (legacy) non-project workspace root. + NonProject(Workspace), } impl VirtualProject { @@ -1227,7 +1232,8 @@ impl VirtualProject { .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. + // Otherwise, if it contains a `tool.uv.workspace` table, it's a non-project workspace + // root. let project_path = std::path::absolute(project_root) .map_err(WorkspaceError::Normalize)? .clone(); @@ -1243,7 +1249,7 @@ impl VirtualProject { ) .await?; - Ok(Self::Virtual(workspace)) + Ok(Self::NonProject(workspace)) } else { Err(WorkspaceError::MissingProject(pyproject_path)) } @@ -1258,10 +1264,10 @@ impl VirtualProject { VirtualProject::Project(project) => Some(VirtualProject::Project( project.with_pyproject_toml(pyproject_toml)?, )), - VirtualProject::Virtual(workspace) => { - // If the project is virtual, the root isn't a member, so we can just update the - // top-level `pyproject.toml`. - Some(VirtualProject::Virtual(Workspace { + VirtualProject::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 { pyproject_toml, ..workspace.clone() })) @@ -1273,7 +1279,7 @@ impl VirtualProject { pub fn root(&self) -> &Path { match self { 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 { match self { 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 { match self { VirtualProject::Project(project) => project.workspace(), - VirtualProject::Virtual(workspace) => workspace, + VirtualProject::NonProject(workspace) => workspace, } } @@ -1299,7 +1305,7 @@ impl VirtualProject { VirtualProject::Project(project) => { 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> { match self { 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()) } - VirtualProject::Virtual(workspace) => { - // For virtual projects, we might have dev dependencies that are attached to the + VirtualProject::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 { 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> { match self { VirtualProject::Project(project) => Some(project.project_name()), - VirtualProject::Virtual(_) => None, + VirtualProject::NonProject(_) => None, } } /// Returns `true` if the project is a virtual workspace root. - pub fn is_virtual(&self) -> bool { - matches!(self, VirtualProject::Virtual(_)) + pub fn is_non_project(&self) -> bool { + matches!(self, VirtualProject::NonProject(_)) } } diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 9c97d42d8..991d8694c 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -200,14 +200,14 @@ pub(crate) async fn add( VirtualProject::discover(&CWD, &DiscoveryOptions::default()).await? }; - // For virtual projects, allow dev dependencies, but nothing else. - if project.is_virtual() { + // For non-project workspace roots, allow dev dependencies, but nothing else. + if project.is_non_project() { match dependency_type { 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(_) => { - 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 => (), } diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 37ca4f70e..53153a78f 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -27,7 +27,7 @@ use uv_resolver::{ ResolverMarkers, SatisfiesResult, }; 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 crate::commands::pip::loggers::{DefaultResolveLogger, ResolveLogger, SummaryResolveLogger}; @@ -248,7 +248,7 @@ async fn do_lock( } = settings; // Collect the requirements, etc. - let requirements = workspace.root_requirements().collect::>(); + let requirements = workspace.non_project_requirements().collect::>(); let overrides = workspace.overrides().into_iter().collect::>(); let constraints = workspace.constraints(); 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 any members are added or removed, it will inherently mismatch. If the member is // renamed, it will also mismatch. - if members.len() == 1 && !workspace.is_virtual() { + if members.len() == 1 && !workspace.is_non_project() { members.clear(); } @@ -313,19 +313,15 @@ async fn do_lock( if requires_python.is_unbounded() { let default = 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 } else { let default = RequiresPython::greater_than_equal_version(&interpreter.python_minor_version()); - if workspace.only_virtual() { - debug!("No `requires-python` in virtual-only workspace. Defaulting to `{default}`."); - } else { - warn_user!( - "No `requires-python` value found in the workspace. Defaulting to `{default}`." - ); - } + warn_user_once!( + "No `requires-python` value found in the workspace. Defaulting to `{default}`." + ); default }; diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index 553762f72..0f5b4681e 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -250,7 +250,7 @@ fn assert_pin_compatible_with_project(pin: &Pin, virtual_project: &VirtualProjec let requires_python = find_requires_python(project_workspace.workspace())?; (requires_python, "project") } - VirtualProject::Virtual(workspace) => { + VirtualProject::NonProject(workspace) => { debug!( "Discovered virtual workspace at: {}", workspace.install_path().display() diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs index 81a796865..c22e21363 100644 --- a/crates/uv/tests/edit.rs +++ b/crates/uv/tests/edit.rs @@ -3511,9 +3511,9 @@ fn add_lower_bound_local() -> Result<()> { Ok(()) } -/// Add dependencies to a virtual workspace root. +/// Add dependencies to a (legacy) non-project workspace root. #[test] -fn add_virtual() -> Result<()> { +fn add_non_project() -> Result<()> { let context = TestContext::new("3.12"); let pyproject_toml = context.temp_dir.child("pyproject.toml"); @@ -3530,7 +3530,7 @@ fn add_virtual() -> Result<()> { ----- stdout ----- ----- 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 @@ -3541,7 +3541,7 @@ fn add_virtual() -> Result<()> { ----- stdout ----- ----- 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. @@ -3551,6 +3551,7 @@ fn add_virtual() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] diff --git a/crates/uv/tests/lock.rs b/crates/uv/tests/lock.rs index 7caef8f66..d016fba15 100644 --- a/crates/uv/tests/lock.rs +++ b/crates/uv/tests/lock.rs @@ -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 /// next run. #[test] -fn lock_remove_member_virtual() -> Result<()> { +fn lock_remove_member_non_project() -> Result<()> { let context = TestContext::new("3.12"); // Create a virtual workspace root. @@ -9592,6 +9592,7 @@ fn lock_remove_member_virtual() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. Resolved in [TIME] 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 ----- ----- stderr ----- + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. Resolved in [TIME] Removed anyio v4.3.0 Removed idna v3.6 @@ -10656,9 +10658,9 @@ fn lock_overlapping_environment() -> Result<()> { Ok(()) } -/// Lock a virtual project with forked dev dependencies. +/// Lock a (legacy) non-project workspace root with forked dev dependencies. #[test] -fn lock_virtual_fork() -> Result<()> { +fn lock_non_project_fork() -> Result<()> { let context = TestContext::new("3.10"); let pyproject_toml = context.temp_dir.child("pyproject.toml"); @@ -10681,6 +10683,7 @@ fn lock_virtual_fork() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.10`. Resolved 6 packages in [TIME] "###); @@ -10787,6 +10790,7 @@ fn lock_virtual_fork() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.10`. Resolved 6 packages in [TIME] "###); @@ -10798,6 +10802,7 @@ fn lock_virtual_fork() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.10`. Resolved 6 packages in [TIME] "###); @@ -10822,6 +10827,7 @@ fn lock_virtual_fork() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.10`. Resolved 7 packages in [TIME] Added iniconfig v2.0.0 "###); @@ -10845,9 +10851,9 @@ fn lock_virtual_fork() -> Result<()> { Ok(()) } -/// Lock a virtual project with a conditional dependency. +/// Lock a (legacy) non-project workspace root with a conditional dependency. #[test] -fn lock_virtual_conditional() -> Result<()> { +fn lock_non_project_conditional() -> Result<()> { let context = TestContext::new("3.12"); let pyproject_toml = context.temp_dir.child("pyproject.toml"); @@ -10867,6 +10873,7 @@ fn lock_virtual_conditional() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. Resolved 3 packages in [TIME] "###); @@ -10927,6 +10934,7 @@ fn lock_virtual_conditional() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. Resolved 3 packages in [TIME] "###); @@ -10938,6 +10946,7 @@ fn lock_virtual_conditional() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. Resolved 3 packages in [TIME] "###); diff --git a/crates/uv/tests/sync.rs b/crates/uv/tests/sync.rs index b77033840..803c5f24b 100644 --- a/crates/uv/tests/sync.rs +++ b/crates/uv/tests/sync.rs @@ -198,6 +198,7 @@ fn empty() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. Resolved in [TIME] Audited in [TIME] "###); @@ -211,6 +212,7 @@ fn empty() -> Result<()> { ----- stdout ----- ----- stderr ----- + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. Resolved in [TIME] Audited in [TIME] "###);