mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 21:35:00 +00:00
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:
parent
0d0308c531
commit
01d1a39c21
13 changed files with 472 additions and 110 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -4775,6 +4775,7 @@ dependencies = [
|
|||
"pypi-types",
|
||||
"rayon",
|
||||
"rustc-hash",
|
||||
"same-file",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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)?;
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)?;
|
||||
|
|
|
@ -580,6 +580,7 @@ async fn run() -> Result<ExitStatus> {
|
|||
args.python,
|
||||
args.upgrade,
|
||||
args.exclude_newer,
|
||||
args.package,
|
||||
globals.isolated,
|
||||
globals.preview,
|
||||
globals.connectivity,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue