diff --git a/Cargo.lock b/Cargo.lock index 242fe8462..fb08f7ec4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4775,6 +4775,7 @@ dependencies = [ "pypi-types", "rayon", "rustc-hash", + "same-file", "serde", "tempfile", "thiserror", diff --git a/crates/uv-distribution/src/workspace.rs b/crates/uv-distribution/src/workspace.rs index ae1a6a5fc..c91f96360 100644 --- a/crates/uv-distribution/src/workspace.rs +++ b/crates/uv-distribution/src/workspace.rs @@ -1,4 +1,4 @@ -//! Resolve the current [`ProjectWorkspace`]. +//! Resolve the current [`ProjectWorkspace`] or [`Workspace`]. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -17,18 +17,24 @@ use crate::pyproject::{PyProjectToml, Source, ToolUvWorkspace}; #[derive(thiserror::Error, Debug)] pub enum WorkspaceError { + // Workspace structure errors. #[error("No `pyproject.toml` found in current directory or any parent directory")] MissingPyprojectToml, + #[error("No `project` table found in: `{}`", _0.simplified_display())] + MissingProject(PathBuf), + #[error("No workspace found for: `{}`", _0.simplified_display())] + MissingWorkspace(PathBuf), + #[error("pyproject.toml section is declared as dynamic, but must be static: `{0}`")] + DynamicNotAllowed(&'static str), #[error("Failed to find directories for glob: `{0}`")] Pattern(String, #[source] PatternError), + // Syntax and other errors. #[error("Invalid glob in `tool.uv.workspace.members`: `{0}`")] Glob(String, #[source] GlobError), #[error(transparent)] Io(#[from] std::io::Error), #[error("Failed to parse: `{}`", _0.user_display())] Toml(PathBuf, #[source] Box), - #[error("No `project` table found in: `{}`", _0.simplified_display())] - MissingProject(PathBuf), #[error("Failed to normalize workspace member path")] Normalize(#[source] std::io::Error), } @@ -48,6 +54,105 @@ pub struct Workspace { } impl Workspace { + /// Find the workspace containing the given path. + /// + /// Unlike the [`ProjectWorkspace`] discovery, this does not require a current project. + /// + /// 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 also not a project: Error, must be either a workspace root or a project. + /// * 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. + pub async fn discover( + path: &Path, + stop_discovery_at: Option<&Path>, + ) -> Result { + let project_root = path + .ancestors() + .find(|path| path.join("pyproject.toml").is_file()) + .ok_or(WorkspaceError::MissingPyprojectToml)?; + + let pyproject_path = project_root.join("pyproject.toml"); + let contents = fs_err::tokio::read_to_string(&pyproject_path).await?; + let pyproject_toml: PyProjectToml = toml::from_str(&contents) + .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?; + + let project_path = absolutize_path(project_root) + .map_err(WorkspaceError::Normalize)? + .to_path_buf(); + + // Check if the current project is also an explicit workspace root. + let explicit_root = pyproject_toml + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.workspace.as_ref()) + .map(|workspace| { + ( + project_path.clone(), + workspace.clone(), + pyproject_toml.clone(), + ) + }); + + let (workspace_root, workspace_definition, workspace_pyproject_toml) = + if let Some(workspace) = explicit_root { + workspace + } else if pyproject_toml.project.is_none() { + return Err(WorkspaceError::MissingProject(project_path)); + } else if let Some(workspace) = find_workspace(&project_path, stop_discovery_at).await? + { + workspace + } else { + return Err(WorkspaceError::MissingWorkspace(project_path)); + }; + + debug!( + "Found workspace root: `{}`", + workspace_root.simplified_display() + ); + + // Unlike in `ProjectWorkspace` discovery, we might be in a virtual workspace root without + // being in any specific project. + let current_project = pyproject_toml + .project + .clone() + .map(|project| (project.name.clone(), project_path, pyproject_toml)); + Self::collect_members( + workspace_root, + workspace_definition, + workspace_pyproject_toml, + current_project, + stop_discovery_at, + ) + .await + } + + /// Set the current project to the given workspace member. + /// + /// Returns `None` if the package is not part of the workspace. + pub fn with_current_project(self, package_name: PackageName) -> Option { + let member = self.packages.get(&package_name)?; + let extras = member + .pyproject_toml + .project + .as_ref() + .and_then(|project| project.optional_dependencies.as_ref()) + .map(|optional_dependencies| { + let mut extras = optional_dependencies.keys().cloned().collect::>(); + extras.sort_unstable(); + extras + }) + .unwrap_or_default(); + Some(ProjectWorkspace { + project_root: member.root().clone(), + project_name: package_name, + extras, + workspace: self, + }) + } + /// The path to the workspace root, the directory containing the top level `pyproject.toml` with /// the `uv.tool.workspace`, or the `pyproject.toml` in an implicit single workspace project. pub fn root(&self) -> &PathBuf { @@ -63,6 +168,123 @@ impl Workspace { pub fn sources(&self) -> &BTreeMap { &self.sources } + + /// Collect the workspace member projects from the `members` and `excludes` entries. + async fn collect_members( + workspace_root: PathBuf, + workspace_definition: ToolUvWorkspace, + workspace_pyproject_toml: PyProjectToml, + current_project: Option<(PackageName, PathBuf, PyProjectToml)>, + stop_discovery_at: Option<&Path>, + ) -> Result { + let mut workspace_members = BTreeMap::new(); + // Avoid reading a `pyproject.toml` more than once. + let mut seen = FxHashSet::default(); + + // Add the project at the workspace root, if it exists and if it's distinct from the current + // project. + if current_project + .as_ref() + .map(|(_, path, _)| path != &workspace_root) + .unwrap_or(true) + { + if let Some(project) = &workspace_pyproject_toml.project { + let pyproject_path = workspace_root.join("pyproject.toml"); + let contents = fs_err::read_to_string(&pyproject_path)?; + let pyproject_toml = toml::from_str(&contents) + .map_err(|err| WorkspaceError::Toml(pyproject_path, Box::new(err)))?; + + debug!( + "Adding root workspace member: {}", + workspace_root.simplified_display() + ); + + seen.insert(workspace_root.clone()); + workspace_members.insert( + project.name.clone(), + WorkspaceMember { + root: workspace_root.clone(), + pyproject_toml, + }, + ); + }; + } + + // The current project is a workspace member, especially in a single project workspace. + if let Some((project_name, project_path, project)) = current_project { + debug!( + "Adding current workspace member: {}", + project_path.simplified_display() + ); + + seen.insert(project_path.clone()); + workspace_members.insert( + project_name, + WorkspaceMember { + root: project_path.clone(), + pyproject_toml: project.clone(), + }, + ); + } + + // Add all other workspace members. + for member_glob in workspace_definition.members.unwrap_or_default() { + let absolute_glob = workspace_root + .simplified() + .join(member_glob.as_str()) + .to_string_lossy() + .to_string(); + for member_root in glob(&absolute_glob) + .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))? + { + let member_root = member_root + .map_err(|err| WorkspaceError::Glob(absolute_glob.to_string(), err))?; + if !seen.insert(member_root.clone()) { + continue; + } + let member_root = absolutize_path(&member_root) + .map_err(WorkspaceError::Normalize)? + .to_path_buf(); + + trace!("Processing workspace member {}", member_root.user_display()); + + // Read the member `pyproject.toml`. + let pyproject_path = member_root.join("pyproject.toml"); + let contents = fs_err::tokio::read_to_string(&pyproject_path).await?; + let pyproject_toml: PyProjectToml = toml::from_str(&contents) + .map_err(|err| WorkspaceError::Toml(pyproject_path, Box::new(err)))?; + + // Extract the package name. + let Some(project) = pyproject_toml.project.clone() else { + return Err(WorkspaceError::MissingProject(member_root)); + }; + + let member = WorkspaceMember { + root: member_root.clone(), + pyproject_toml, + }; + + debug!( + "Adding discovered workspace member: {}", + member_root.simplified_display() + ); + workspace_members.insert(project.name, member); + } + } + let workspace_sources = workspace_pyproject_toml + .tool + .and_then(|tool| tool.uv) + .and_then(|uv| uv.sources) + .unwrap_or_default(); + + check_nested_workspaces(&workspace_root, stop_discovery_at); + + Ok(Workspace { + root: workspace_root, + packages: workspace_members, + sources: workspace_sources, + }) + } } /// A project in a workspace. @@ -183,11 +405,10 @@ impl ProjectWorkspace { /// `stop_discovery_at` must be either `None` or an ancestor of the current directory. If set, /// only directories between the current path and `stop_discovery_at` are considered. pub async fn discover( - path: impl AsRef, + path: &Path, stop_discovery_at: Option<&Path>, ) -> Result { let project_root = path - .as_ref() .ancestors() .take_while(|path| { // Only walk up the given directory, if any. @@ -329,17 +550,6 @@ impl ProjectWorkspace { }) .unwrap_or_default(); - let mut workspace_members = BTreeMap::new(); - // The current project is always a workspace member, especially in a single project - // workspace. - workspace_members.insert( - project_name.clone(), - WorkspaceMember { - root: project_path.clone(), - pyproject_toml: project.clone(), - }, - ); - // Check if the current project is also an explicit workspace root. let mut workspace = project .tool @@ -359,13 +569,20 @@ impl ProjectWorkspace { // The project isn't an explicit workspace root, but there's also no workspace root // above it, so the project is an implicit workspace root identical to the project root. debug!("No workspace root found, using project root"); + let current_project_as_members = BTreeMap::from_iter([( + project_name.clone(), + WorkspaceMember { + root: project_path.clone(), + pyproject_toml: project.clone(), + }, + )]); return Ok(Self { project_root: project_path.clone(), - project_name, + project_name: project_name.clone(), extras, workspace: Workspace { - root: project_path, - packages: workspace_members, + root: project_path.clone(), + packages: current_project_as_members, // There may be package sources, but we don't need to duplicate them into the // workspace sources. sources: BTreeMap::default(), @@ -377,79 +594,21 @@ impl ProjectWorkspace { "Found workspace root: `{}`", workspace_root.simplified_display() ); - if workspace_root != project_path { - let pyproject_path = workspace_root.join("pyproject.toml"); - let contents = fs_err::read_to_string(&pyproject_path)?; - let pyproject_toml = toml::from_str(&contents) - .map_err(|err| WorkspaceError::Toml(pyproject_path, Box::new(err)))?; - if let Some(project) = &workspace_pyproject_toml.project { - workspace_members.insert( - project.name.clone(), - WorkspaceMember { - root: workspace_root.clone(), - pyproject_toml, - }, - ); - }; - } - let mut seen = FxHashSet::default(); - for member_glob in workspace_definition.members.unwrap_or_default() { - let absolute_glob = workspace_root - .simplified() - .join(member_glob.as_str()) - .to_string_lossy() - .to_string(); - for member_root in glob(&absolute_glob) - .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))? - { - let member_root = member_root - .map_err(|err| WorkspaceError::Glob(absolute_glob.to_string(), err))?; - // Avoid reading the file more than once. - if !seen.insert(member_root.clone()) { - continue; - } - let member_root = absolutize_path(&member_root) - .map_err(WorkspaceError::Normalize)? - .to_path_buf(); - - trace!("Processing workspace member {}", member_root.user_display()); - // Read the member `pyproject.toml`. - let pyproject_path = member_root.join("pyproject.toml"); - let contents = fs_err::read_to_string(&pyproject_path)?; - let pyproject_toml: PyProjectToml = toml::from_str(&contents) - .map_err(|err| WorkspaceError::Toml(pyproject_path, Box::new(err)))?; - - // Extract the package name. - let Some(project) = pyproject_toml.project.clone() else { - return Err(WorkspaceError::MissingProject(member_root)); - }; - - let member = WorkspaceMember { - root: member_root.clone(), - pyproject_toml, - }; - workspace_members.insert(project.name, member); - } - } - let workspace_sources = workspace_pyproject_toml - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.sources.clone()) - .unwrap_or_default(); - - check_nested_workspaces(&workspace_root, stop_discovery_at); + let workspace = Workspace::collect_members( + workspace_root, + workspace_definition, + workspace_pyproject_toml, + Some((project_name.clone(), project_path.clone(), project.clone())), + stop_discovery_at, + ) + .await?; Ok(Self { - project_root: project_path.clone(), + project_root: project_path, project_name, extras, - workspace: Workspace { - root: workspace_root, - packages: workspace_members, - sources: workspace_sources, - }, + workspace, }) } @@ -669,13 +828,12 @@ fn is_excluded_from_workspace( #[cfg(unix)] // Avoid path escaping for the unit tests mod tests { use std::env; - use std::path::Path; use insta::assert_json_snapshot; use crate::workspace::ProjectWorkspace; - async fn workspace_test(folder: impl AsRef) -> (ProjectWorkspace, String) { + async fn workspace_test(folder: &str) -> (ProjectWorkspace, String) { let root_dir = env::current_dir() .unwrap() .parent() @@ -684,7 +842,7 @@ mod tests { .unwrap() .join("scripts") .join("workspaces"); - let project = ProjectWorkspace::discover(root_dir.join(folder), None) + let project = ProjectWorkspace::discover(&root_dir.join(folder), None) .await .unwrap(); let root_escaped = regex::escape(root_dir.to_string_lossy().as_ref()); diff --git a/crates/uv-installer/Cargo.toml b/crates/uv-installer/Cargo.toml index 6c1357edb..d313323de 100644 --- a/crates/uv-installer/Cargo.toml +++ b/crates/uv-installer/Cargo.toml @@ -38,6 +38,7 @@ fs-err = { workspace = true } futures = { workspace = true } rayon = { workspace = true } rustc-hash = { workspace = true } +same-file = { workspace = true } serde = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } diff --git a/crates/uv-installer/src/plan.rs b/crates/uv-installer/src/plan.rs index 6f7e515ec..0c9ce7773 100644 --- a/crates/uv-installer/src/plan.rs +++ b/crates/uv-installer/src/plan.rs @@ -113,16 +113,18 @@ impl<'a> Planner<'a> { NoBinary::Packages(packages) => packages.contains(&requirement.name), }; + let installed_dists = site_packages.remove_packages(&requirement.name); + if reinstall { - let installed_dists = site_packages.remove_packages(&requirement.name); reinstalls.extend(installed_dists); } else { - let installed_dists = site_packages.remove_packages(&requirement.name); match installed_dists.as_slice() { [] => {} [distribution] => { match RequirementSatisfaction::check(distribution, &requirement.source)? { - RequirementSatisfaction::Mismatch => {} + RequirementSatisfaction::Mismatch => { + debug!("Requirement installed, but mismatched: {distribution:?}"); + } RequirementSatisfaction::Satisfied => { debug!("Requirement already installed: {distribution}"); continue; diff --git a/crates/uv-installer/src/satisfies.rs b/crates/uv-installer/src/satisfies.rs index b77ae3a4a..c2395eba7 100644 --- a/crates/uv-installer/src/satisfies.rs +++ b/crates/uv-installer/src/satisfies.rs @@ -2,8 +2,10 @@ use std::fmt::Debug; use std::path::Path; use anyhow::Result; +use same_file::is_same_file; use serde::Deserialize; use tracing::{debug, trace}; +use url::Url; use cache_key::{CanonicalUrl, RepositoryUrl}; use distribution_types::{InstalledDirectUrlDist, InstalledDist}; @@ -147,8 +149,8 @@ impl RequirementSatisfaction { Ok(Self::Satisfied) } RequirementSource::Path { - path, - url: requested_url, + url: _, + path: requested_path, editable: requested_editable, } => { let InstalledDist::Url(InstalledDirectUrlDist { direct_url, .. }) = &distribution @@ -175,24 +177,34 @@ impl RequirementSatisfaction { return Ok(Self::Mismatch); } - if !CanonicalUrl::parse(installed_url) - .is_ok_and(|installed_url| installed_url == CanonicalUrl::new(requested_url)) + let Some(installed_path) = Url::parse(installed_url) + .ok() + .and_then(|url| url.to_file_path().ok()) + else { + return Ok(Self::Mismatch); + }; + + if !(*requested_path == installed_path + || is_same_file(requested_path, &installed_path).unwrap_or(false)) { trace!( - "URL mismatch: {:?} vs. {:?}", - CanonicalUrl::parse(installed_url), - CanonicalUrl::new(requested_url) + "Path mismatch: {:?} vs. {:?}", + requested_path, + installed_path ); - return Ok(Self::Mismatch); + return Ok(Self::Satisfied); } - if !ArchiveTimestamp::up_to_date_with(path, ArchiveTarget::Install(distribution))? { + if !ArchiveTimestamp::up_to_date_with( + requested_path, + ArchiveTarget::Install(distribution), + )? { trace!("Out of date"); return Ok(Self::OutOfDate); } // Does the package have dynamic metadata? - if is_dynamic(path) { + if is_dynamic(requested_path) { return Ok(Self::Dynamic); } diff --git a/crates/uv/src/cli.rs b/crates/uv/src/cli.rs index bd1bfcb07..c656352c6 100644 --- a/crates/uv/src/cli.rs +++ b/crates/uv/src/cli.rs @@ -1831,6 +1831,10 @@ pub(crate) struct RunArgs { /// format (e.g., `2006-12-02`). #[arg(long)] pub(crate) exclude_newer: Option, + + /// Run the command in a different package in the workspace. + #[arg(long, conflicts_with = "isolated")] + pub(crate) package: Option, } #[derive(Args)] diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 9c7eb9b84..c9980280f 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -36,7 +36,7 @@ pub(crate) async fn lock( } // Find the project requirements. - let project = ProjectWorkspace::discover(std::env::current_dir()?, None).await?; + let project = ProjectWorkspace::discover(&std::env::current_dir()?, None).await?; // Discover or create the virtual environment. let venv = project::init_environment(&project, preview, cache, printer)?; diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 8314074cb..36522c7a3 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -10,8 +10,9 @@ use tracing::debug; use uv_cache::Cache; use uv_client::Connectivity; use uv_configuration::{ExtrasSpecification, PreviewMode, Upgrade}; -use uv_distribution::ProjectWorkspace; +use uv_distribution::{ProjectWorkspace, Workspace}; use uv_interpreter::{PythonEnvironment, SystemPython}; +use uv_normalize::PackageName; use uv_requirements::RequirementsSource; use uv_resolver::ExcludeNewer; use uv_warnings::warn_user; @@ -29,6 +30,7 @@ pub(crate) async fn run( python: Option, upgrade: Upgrade, exclude_newer: Option, + package: Option, isolated: bool, preview: PreviewMode, connectivity: Connectivity, @@ -41,11 +43,21 @@ pub(crate) async fn run( // Discover and sync the project. let project_env = if isolated { + // package is `None`, isolated and package are marked as conflicting in clap. None } else { debug!("Syncing project environment."); - let project = ProjectWorkspace::discover(std::env::current_dir()?, None).await?; + 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. + Workspace::discover(&std::env::current_dir()?, None) + .await? + .with_current_project(package.clone()) + .with_context(|| format!("Package `{package}` not found in workspace"))? + } else { + ProjectWorkspace::discover(&std::env::current_dir()?, None).await? + }; let venv = project::init_environment(&project, preview, cache, printer)?; // Lock and sync the environment. diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 3a9c6ff2e..dcaa0167a 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -35,7 +35,7 @@ pub(crate) async fn sync( } // Find the project requirements. - let project = ProjectWorkspace::discover(std::env::current_dir()?, None).await?; + let project = ProjectWorkspace::discover(&std::env::current_dir()?, None).await?; // Discover or create the virtual environment. let venv = project::init_environment(&project, preview, cache, printer)?; diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index 2aa464cfc..a0125c98e 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -580,6 +580,7 @@ async fn run() -> Result { args.python, args.upgrade, args.exclude_newer, + args.package, globals.isolated, globals.preview, globals.connectivity, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index eabce4e2e..c17497ef7 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -105,6 +105,7 @@ pub(crate) struct RunSettings { pub(crate) refresh: Refresh, pub(crate) upgrade: Upgrade, pub(crate) exclude_newer: Option, + pub(crate) package: Option, } impl RunSettings { @@ -126,6 +127,7 @@ impl RunSettings { upgrade_package, python, exclude_newer, + package, } = args; Self { @@ -140,6 +142,7 @@ impl RunSettings { with, python, exclude_newer, + package, } } } diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index e2a670f7b..06dff0de5 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -276,6 +276,10 @@ impl TestContext { command } + pub fn interpreter(&self) -> PathBuf { + venv_to_interpreter(&self.venv) + } + /// Run the given python code and check whether it succeeds. pub fn assert_command(&self, command: &str) -> Assert { std::process::Command::new(venv_to_interpreter(&self.venv)) diff --git a/crates/uv/tests/workspace.rs b/crates/uv/tests/workspace.rs index bb2e44d47..e9b34b1cd 100644 --- a/crates/uv/tests/workspace.rs +++ b/crates/uv/tests/workspace.rs @@ -1,7 +1,10 @@ use std::env; use std::path::PathBuf; +use std::process::Command; -use crate::common::{get_bin, uv_snapshot, TestContext, EXCLUDE_NEWER}; +use anyhow::Result; + +use crate::common::{copy_dir_all, get_bin, uv_snapshot, TestContext, EXCLUDE_NEWER}; mod common; @@ -10,8 +13,8 @@ mod common; /// The goal of the workspace tests is to resolve local workspace packages correctly. We add some /// non-workspace dependencies to ensure that transitive non-workspace dependencies are also /// correctly resolved. -pub fn install_workspace(context: &TestContext) -> std::process::Command { - let mut command = std::process::Command::new(get_bin()); +fn install_workspace(context: &TestContext) -> Command { + let mut command = Command::new(get_bin()); command .arg("pip") .arg("install") @@ -34,6 +37,28 @@ pub fn install_workspace(context: &TestContext) -> std::process::Command { command } +/// A `uv run` command. +fn run_workspace(context: &TestContext) -> Command { + let mut command = Command::new(get_bin()); + command + .arg("run") + .arg("--preview") + .arg("--cache-dir") + .arg(context.cache_dir.path()) + .arg("--python") + .arg(context.interpreter()) + .arg("--exclude-newer") + .arg(EXCLUDE_NEWER) + .env("UV_NO_WRAP", "1"); + + if cfg!(all(windows, debug_assertions)) { + // TODO(konstin): Reduce stack usage in debug mode enough that the tests pass with the + // default windows stack of 1MB + command.env("UV_STACK_SIZE", (4 * 1024 * 1024).to_string()); + } + command +} + fn workspaces_dir() -> PathBuf { env::current_dir() .unwrap() @@ -341,3 +366,142 @@ fn test_albatross_virtual_workspace() { context.assert_file(current_dir.join("check_installed_bird_feeder.py")); } + +/// Check that `uv run --package` works in a virtual workspace. +#[test] +fn test_uv_run_with_package_virtual_workspace() -> Result<()> { + let context = TestContext::new("3.12"); + let work_dir = context.temp_dir.join("albatross-virtual-workspace"); + + copy_dir_all( + workspaces_dir().join("albatross-virtual-workspace"), + &work_dir, + )?; + + // TODO(konsti): `--python` is being ignored atm, so we need to create the correct venv + // ourselves and add the output filters. + let venv = work_dir.join(".venv"); + assert_cmd::Command::new(get_bin()) + .arg("venv") + .arg("-p") + .arg(context.interpreter()) + .arg(&venv) + .assert(); + + let mut filters = context.filters(); + filters.push(( + r"Using Python 3.12.\[X\] interpreter at: .*", + "Using Python 3.12.[X] interpreter at: [PYTHON]", + )); + + uv_snapshot!(filters, run_workspace(&context) + .arg("--package") + .arg("bird-feeder") + .arg("packages/bird-feeder/check_installed_bird_feeder.py") + .current_dir(&work_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Success + + ----- stderr ----- + Resolved 5 packages in [TIME] + Downloaded 5 packages in [TIME] + Installed 5 packages in [TIME] + + 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 + "### + ); + + uv_snapshot!(context.filters(), run_workspace(&context) + .arg("--package") + .arg("albatross") + .arg("packages/albatross/check_installed_albatross.py") + .current_dir(&work_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Success + + ----- stderr ----- + Resolved 7 packages in [TIME] + Downloaded 2 packages in [TIME] + Installed 2 packages in [TIME] + + albatross==0.1.0 (from file://[TEMP_DIR]/albatross-virtual-workspace/packages/albatross) + + 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<()> { + let context = TestContext::new("3.12"); + let work_dir = context.temp_dir.join("albatross-root-workspace"); + + copy_dir_all(workspaces_dir().join("albatross-root-workspace"), &work_dir)?; + + // TODO(konsti): `--python` is being ignored atm, so we need to create the correct venv + // ourselves and add the output filters. + let venv = work_dir.join(".venv"); + assert_cmd::Command::new(get_bin()) + .arg("venv") + .arg("-p") + .arg(context.interpreter()) + .arg(&venv) + .assert(); + + let mut filters = context.filters(); + filters.push(( + r"Using Python 3.12.\[X\] interpreter at: .*", + "Using Python 3.12.[X] interpreter at: [PYTHON]", + )); + + uv_snapshot!(filters, run_workspace(&context) + .arg("--package") + .arg("bird-feeder") + .arg("packages/bird-feeder/check_installed_bird_feeder.py") + .current_dir(&work_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Success + + ----- stderr ----- + Resolved 5 packages in [TIME] + Downloaded 5 packages in [TIME] + Installed 5 packages in [TIME] + + anyio==4.3.0 + + bird-feeder==1.0.0 (from file://[TEMP_DIR]/albatross-root-workspace/packages/bird-feeder) + + idna==3.6 + + seeds==1.0.0 (from file://[TEMP_DIR]/albatross-root-workspace/packages/seeds) + + sniffio==1.3.1 + "### + ); + + uv_snapshot!(context.filters(), run_workspace(&context) + .arg("--package") + .arg("albatross") + .arg("check_installed_albatross.py") + .current_dir(&work_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Success + + ----- stderr ----- + Resolved 7 packages in [TIME] + Downloaded 2 packages in [TIME] + Installed 2 packages in [TIME] + + albatross==0.1.0 (from file://[TEMP_DIR]/albatross-root-workspace) + + tqdm==4.66.2 + "### + ); + + Ok(()) +}