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
// 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, &[]) {

View file

@ -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<Item = Requirement> + '_ {
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<Item = Requirement> + '_ {
/// Otherwise, returns an empty list.
pub fn non_project_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
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<Item = &pep508_rs::Requirement<VerbatimParsedUrl>> {
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(_))
}
}

View file

@ -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 => (),
}

View file

@ -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::<Vec<_>>();
let requirements = workspace.non_project_requirements().collect::<Vec<_>>();
let overrides = workspace.overrides().into_iter().collect::<Vec<_>>();
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
};

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())?;
(requires_python, "project")
}
VirtualProject::Virtual(workspace) => {
VirtualProject::NonProject(workspace) => {
debug!(
"Discovered virtual workspace at: {}",
workspace.install_path().display()

View file

@ -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]

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
/// 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]
"###);

View file

@ -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]
"###);