Add uv run --package (#3864)

Add a `--package` option that allows switching the current project in
the workspace. Wherever you are in a workspace, you should be able to
run with any other project as root. This is the uv equivalent of `cargo
run -p`.

I don't love the `--package` name, esp. since `-p` is already taken and
in general to many things start with p already.

Part of this change is moving the workspace discovery of
`ProjectWorkspace` to `Workspace` itself.

## Usage

In albatross-virtual-workspace:

```console
$ uv venv
$ uv run --preview --package bird-feeder python -c "import albatross"
   Built file:///home/konsti/projects/uv/scripts/workspaces/albatross-virtual-workspace/packages/bird-feeder
   Built file:///home/konsti/projects/uv/scripts/workspaces/albatross-virtual-workspace/packages/seeds
Built 2 editables in 167ms
Resolved 5 packages in 4ms
Installed 5 packages in 1ms
 + anyio==4.4.0
 + bird-feeder==1.0.0 (from file:///home/konsti/projects/uv/scripts/workspaces/albatross-virtual-workspace/packages/bird-feeder)
 + idna==3.6
 + seeds==1.0.0 (from file:///home/konsti/projects/uv/scripts/workspaces/albatross-virtual-workspace/packages/seeds)
 + sniffio==1.3.1
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'albatross'
$ uv venv
$ uv run --preview --package albatross python -c "import albatross"
   Built file:///home/konsti/projects/uv/scripts/workspaces/albatross-virtual-workspace/packages/albatross
   Built file:///home/konsti/projects/uv/scripts/workspaces/albatross-virtual-workspace/packages/bird-feeder
   Built file:///home/konsti/projects/uv/scripts/workspaces/albatross-virtual-workspace/packages/seeds
Built 3 editables in 173ms
Resolved 7 packages in 6ms
Installed 7 packages in 1ms
 + albatross==0.1.0 (from file:///home/konsti/projects/uv/scripts/workspaces/albatross-virtual-workspace/packages/albatross)
 + anyio==4.4.0
 + bird-feeder==1.0.0 (from file:///home/konsti/projects/uv/scripts/workspaces/albatross-virtual-workspace/packages/bird-feeder)
 + idna==3.6
 + seeds==1.0.0 (from file:///home/konsti/projects/uv/scripts/workspaces/albatross-virtual-workspace/packages/seeds)
 + sniffio==1.3.1
 + tqdm==4.66.4
```

In albatross-root-workspace:

```console
$ uv venv
$ uv run --preview --package bird-feeder python -c "import albatross"
  Using Python 3.12.3 interpreter at: /home/konsti/.local/bin/python3
  Creating virtualenv at: .venv
  Activate with: source .venv/bin/activate
      Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.10s
       Running `/home/konsti/projects/uv/target/debug/uv run --preview --package bird-feeder python -c 'import albatross'`
     Built file:///home/konsti/projects/uv/scripts/workspaces/albatross-root-workspace/packages/bird-feeder
     Built file:///home/konsti/projects/uv/scripts/workspaces/albatross-root-workspace/packages/seeds                                              Built 2 editables in 161ms
  Resolved 5 packages in 4ms
  Installed 5 packages in 1ms
   + anyio==4.4.0
   + bird-feeder==1.0.0 (from file:///home/konsti/projects/uv/scripts/workspaces/albatross-root-workspace/packages/bird-feeder)
   + idna==3.6
   + seeds==1.0.0 (from file:///home/konsti/projects/uv/scripts/workspaces/albatross-root-workspace/packages/seeds)
   + sniffio==1.3.1
  Traceback (most recent call last):
    File "<string>", line 1, in <module>
  ModuleNotFoundError: No module named 'albatross'
$ uv venv
$ cargo run run --preview --package albatross python -c "import albatross"
Using Python 3.12.3 interpreter at: /home/konsti/.local/bin/python3
Creating virtualenv at: .venv
Activate with: source .venv/bin/activate
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
     Running `/home/konsti/projects/uv/target/debug/uv run --preview --package albatross python -c 'import albatross'`
   Built file:///home/konsti/projects/uv/scripts/workspaces/albatross-root-workspace
   Built file:///home/konsti/projects/uv/scripts/workspaces/albatross-root-workspace/packages/bird-feeder
   Built file:///home/konsti/projects/uv/scripts/workspaces/albatross-root-workspace/packages/seeds
Built 3 editables in 168ms
Resolved 7 packages in 5ms
Installed 7 packages in 1ms
 + albatross==0.1.0 (from file:///home/konsti/projects/uv/scripts/workspaces/albatross-root-workspace)
 + anyio==4.4.0
 + bird-feeder==1.0.0 (from file:///home/konsti/projects/uv/scripts/workspaces/albatross-root-workspace/packages/bird-feeder)
 + idna==3.6
 + seeds==1.0.0 (from file:///home/konsti/projects/uv/scripts/workspaces/albatross-root-workspace/packages/seeds)
 + sniffio==1.3.1
 + tqdm==4.66.4
```
This commit is contained in:
konsti 2024-06-02 23:42:14 +02:00 committed by GitHub
parent 0d0308c531
commit 01d1a39c21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 472 additions and 110 deletions

1
Cargo.lock generated
View file

@ -4775,6 +4775,7 @@ dependencies = [
"pypi-types",
"rayon",
"rustc-hash",
"same-file",
"serde",
"tempfile",
"thiserror",

View file

@ -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<toml::de::Error>),
#[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<Workspace, WorkspaceError> {
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<ProjectWorkspace> {
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::<Vec<_>>();
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<PackageName, Source> {
&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<Workspace, WorkspaceError> {
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: &Path,
stop_discovery_at: Option<&Path>,
) -> Result<Self, WorkspaceError> {
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<Path>) -> (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());

View file

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

View file

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

View file

@ -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);
}

View file

@ -1831,6 +1831,10 @@ pub(crate) struct RunArgs {
/// format (e.g., `2006-12-02`).
#[arg(long)]
pub(crate) exclude_newer: Option<ExcludeNewer>,
/// Run the command in a different package in the workspace.
#[arg(long, conflicts_with = "isolated")]
pub(crate) package: Option<PackageName>,
}
#[derive(Args)]

View file

@ -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)?;

View file

@ -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<String>,
upgrade: Upgrade,
exclude_newer: Option<ExcludeNewer>,
package: Option<PackageName>,
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.

View file

@ -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)?;

View file

@ -580,6 +580,7 @@ async fn run() -> Result<ExitStatus> {
args.python,
args.upgrade,
args.exclude_newer,
args.package,
globals.isolated,
globals.preview,
globals.connectivity,

View file

@ -105,6 +105,7 @@ pub(crate) struct RunSettings {
pub(crate) refresh: Refresh,
pub(crate) upgrade: Upgrade,
pub(crate) exclude_newer: Option<ExcludeNewer>,
pub(crate) package: Option<PackageName>,
}
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,
}
}
}

View file

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

View file

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