Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 31 additions & 9 deletions crates/uv-bench/benches/workspace_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const EXCLUDE_NEWER: &str = "2024-08-08";

/// Mirroring the airflow workspace size at time of writing.
const MEMBER_COUNT: usize = 127;
const EXCLUDE_COUNT: usize = 128;
const OPTIONAL_DEPENDENCY_GROUP_COUNT: usize = 122;
const DEPENDENCY_GROUP_COUNT: usize = 64;
const UNUSED_ROOT_TABLE_COUNT: usize = 128;
Expand All @@ -36,7 +37,7 @@ fn provider_requirement(member_index: usize) -> String {

/// Create a synthetic workspace with a root and many members, returning the directories to run
/// discovery from.
fn create_workspace(root: &Path) -> Vec<PathBuf> {
fn create_workspace(root: &Path, exclude_count: usize) -> Vec<PathBuf> {
let mut discovery_roots = Vec::with_capacity(MEMBER_COUNT + 1);
discovery_roots.push(root.to_path_buf());

Expand All @@ -53,13 +54,13 @@ fn create_workspace(root: &Path) -> Vec<PathBuf> {
discovery_roots.push(member_root);
}

fs_err::write(root.join("pyproject.toml"), root_pyproject())
fs_err::write(root.join("pyproject.toml"), root_pyproject(exclude_count))
.expect("Failed to write workspace root pyproject.toml");

discovery_roots
}

fn root_pyproject() -> String {
fn root_pyproject(exclude_count: usize) -> String {
let dependencies: Vec<String> = (0..MEMBER_COUNT).map(provider_requirement).collect();

let optional_dependencies: toml::Table = (0..OPTIONAL_DEPENDENCY_GROUP_COUNT)
Expand Down Expand Up @@ -104,6 +105,16 @@ fn root_pyproject() -> String {
})
.collect();

let mut workspace = toml::toml! {
members = ["packages/*"]
};
if exclude_count > 0 {
let excludes: Vec<String> = (0..exclude_count)
.map(|exclude_index| format!("packages/excluded-{exclude_index:03}"))
.collect();
workspace.insert("exclude".to_string(), toml::Value::from(excludes));
}

// Generate some unrelated work for the toml parser, mimicking real tool configuration.
let generated: toml::Table = (0..UNUSED_ROOT_TABLE_COUNT)
.map(|table_index| {
Expand Down Expand Up @@ -168,9 +179,7 @@ fn root_pyproject() -> String {
[tool.uv]
package = false
sources = (sources)

[tool.uv.workspace]
members = ["packages/*"]
workspace = (workspace)

[tool.linter]
generated = (generated)
Expand Down Expand Up @@ -258,16 +267,28 @@ fn member_pyproject(member_index: usize) -> String {
}

fn discover_workspace_from_all_members(c: &mut Criterion<WallTime>) {
discover_workspace(c, "discover_workspace_from_all_members", 0);
}

fn discover_workspace_from_all_members_with_excludes(c: &mut Criterion<WallTime>) {
discover_workspace(
c,
"discover_workspace_from_all_members_with_excludes",
EXCLUDE_COUNT,
);
}

fn discover_workspace(c: &mut Criterion<WallTime>, name: &str, exclude_count: usize) {
let dir = tempfile::tempdir().expect("Failed to create temporary directory");
let discovery_roots = create_workspace(dir.path());
let discovery_roots = create_workspace(dir.path(), exclude_count);
let cache = Cache::from_path(dir.path().join(".uv-cache"));
let options = DiscoveryOptions::default();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to create Tokio runtime");

c.bench_function("discover_workspace_from_all_members", |b| {
c.bench_function(name, |b| {
b.iter(|| {
let workspace_cache = WorkspaceCache::default();
for root in &discovery_roots {
Expand All @@ -287,7 +308,7 @@ fn discover_workspace_from_all_members(c: &mut Criterion<WallTime>) {

fn run_python_version_synthetic_workspace(c: &mut Criterion<WallTime>) {
let workspace_dir = tempfile::tempdir().expect("Failed to create temporary directory");
create_workspace(workspace_dir.path());
create_workspace(workspace_dir.path(), 0);

let cache_dir = workspace_dir.path().join(".uv-cache");
let runtime = tokio::runtime::Builder::new_current_thread()
Expand Down Expand Up @@ -365,6 +386,7 @@ fn run_cli(
criterion_group!(
workspace_discovery,
discover_workspace_from_all_members,
discover_workspace_from_all_members_with_excludes,
run_python_version_synthetic_workspace
);
criterion_main!(workspace_discovery);
163 changes: 152 additions & 11 deletions crates/uv-workspace/src/workspace.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Resolve the current [`ProjectWorkspace`] or [`Workspace`].

use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use std::error::Error;
use std::fmt;
Expand All @@ -8,7 +9,7 @@ use std::hash::BuildHasherDefault;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use glob::{GlobError, PatternError, glob};
use glob::{GlobError, Pattern, PatternError, glob};
use itertools::Itertools;
use rustc_hash::{FxHashSet, FxHasher};
use tracing::{debug, trace, warn};
Expand Down Expand Up @@ -1158,6 +1159,9 @@ impl Workspace {
);
}

// Prepare exclusions only after finding a member that is not explicitly ignored.
let mut exclusions = None;

// Add all other workspace members.
for member_glob in workspace_definition.clone().members.unwrap_or_default() {
// Normalize the member glob to remove leading `./` and other relative path components
Expand Down Expand Up @@ -1205,7 +1209,14 @@ impl Workspace {
}

// If the member is excluded, ignore it.
if is_excluded_from_workspace(&member_root, workspace_root, workspace_definition)? {
if exclusions
.get_or_insert_with(|| {
WorkspaceExclusions::new(workspace_root, workspace_definition)
})
.as_ref()
.map_err(WorkspaceError::clone)?
.matches(&member_root)
{
debug!(
"Ignoring workspace member: `{}`",
member_root.simplified_display()
Expand Down Expand Up @@ -1946,21 +1957,77 @@ fn is_excluded_from_workspace(
workspace_root: &Path,
workspace: &ToolUvWorkspace,
) -> Result<bool, WorkspaceError> {
for exclude_glob in workspace.exclude.iter().flatten() {
// Normalize the exclude glob to remove leading `./` and other relative path components
Ok(WorkspaceExclusions::new(workspace_root, workspace)?.matches(project_path))
}

/// Compiled workspace exclusion patterns.
#[derive(Debug)]
struct WorkspaceExclusions<'workspace> {
workspace_root: &'workspace Path,
patterns: Vec<WorkspaceExclusion<'workspace>>,
}

/// A workspace exclusion that can reuse its parsed pattern or requires normalization.
#[derive(Debug)]
enum WorkspaceExclusion<'workspace> {
Relative(&'workspace Pattern),
Absolute(Pattern),
}

impl<'workspace> WorkspaceExclusions<'workspace> {
/// Compile the normalized workspace exclusion patterns.
fn new(
workspace_root: &'workspace Path,
workspace: &'workspace ToolUvWorkspace,
) -> Result<Self, WorkspaceError> {
let patterns = workspace
.exclude
.iter()
.flatten()
.map(|exclude_glob| Self::compile_pattern(workspace_root, exclude_glob))
.collect::<Result<_, _>>()?;

Ok(Self {
workspace_root,
patterns,
})
}

/// Return whether any workspace exclusion matches the project path.
fn matches(&self, project_path: &Path) -> bool {
let relative_path = project_path
.simplified()
.strip_prefix(self.workspace_root.simplified())
.ok();

self.patterns.iter().any(|pattern| match pattern {
WorkspaceExclusion::Relative(pattern) => {
relative_path.is_some_and(|relative_path| pattern.matches_path(relative_path))
}
WorkspaceExclusion::Absolute(pattern) => pattern.matches_path(project_path),
})
}

/// Reuse an already compiled relative pattern or compile its normalized absolute equivalent.
fn compile_pattern(
workspace_root: &Path,
exclude_glob: &'workspace Pattern,
) -> Result<WorkspaceExclusion<'workspace>, WorkspaceError> {
// Normalize the exclude glob to remove leading `./` and other relative path components.
let normalized_glob = normalize_path(Path::new(exclude_glob.as_str()));
let absolute_glob = PathBuf::from(glob::Pattern::escape(
if matches!(&normalized_glob, Cow::Borrowed(_)) && normalized_glob.is_relative() {
return Ok(WorkspaceExclusion::Relative(exclude_glob));
}

let absolute_glob = PathBuf::from(Pattern::escape(
workspace_root.simplified().to_string_lossy().as_ref(),
))
.join(normalized_glob.as_ref());
let absolute_glob = absolute_glob.to_string_lossy();
let exclude_pattern = glob::Pattern::new(&absolute_glob)
.map_err(|err| WorkspaceErrorKind::Pattern(absolute_glob.to_string(), err))?;
if exclude_pattern.matches_path(project_path) {
return Ok(true);
}
Pattern::new(&absolute_glob)
.map(WorkspaceExclusion::Absolute)
.map_err(|err| WorkspaceErrorKind::Pattern(absolute_glob.to_string(), err).into())
}
Ok(false)
}

/// Check if we're in the `tool.uv.workspace.members` of a workspace.
Expand Down Expand Up @@ -3298,6 +3365,80 @@ mod tests {
Ok(())
}

#[tokio::test]
async fn exclude_package_with_normalized_glob_and_escaped_root() -> Result<()> {
let temp_dir = tempfile::TempDir::new()?;
let temp_dir_root = ChildPath::new(temp_dir.path());
let root = temp_dir_root.child("workspace[glob]?");

root.child("pyproject.toml").write_str(
r#"
[project]
name = "albatross"
version = "0.1.0"
requires-python = ">=3.12"

[tool.uv.workspace]
members = ["./packages/*", "../external-*"]
exclude = [
"packages/excluded-borrowed-*",
"./ignored/../packages/excluded",
"./packages/./excluded-glob-*",
"../external-excluded",
]
"#,
)?;

for member in [
"included",
"excluded",
"excluded-glob-one",
"excluded-borrowed-one",
] {
root.child("packages")
.child(member)
.child("pyproject.toml")
.write_str(&format!(
r#"
[project]
name = "{member}"
version = "0.1.0"
requires-python = ">=3.12"
"#,
))?;
}

for member in ["external-included", "external-excluded"] {
temp_dir_root
.child(member)
.child("pyproject.toml")
.write_str(&format!(
r#"
[project]
name = "{member}"
version = "0.1.0"
requires-python = ">=3.12"
"#,
))?;
}

let (project, _) = temporary_test(root.as_ref())
.await
.map_err(|(error, _)| error)?;
assert_json_snapshot!(
project.workspace().packages().keys().collect::<Vec<_>>(),
@r#"
[
"albatross",
"external-included",
"included"
]
"#
);

Ok(())
}

#[test]
fn read_dependency_groups() {
let toml = r#"
Expand Down
Loading