Discover workspaces without using them in resolution (#3585)

Add minimal support for workspace discovery, only used for determining
paths in the bluejay commands.

We can now discover the workspace structure, namely that the
`pyproject.toml` of a package belongs to a workspace `pyproject.toml`
with members and exclusion. The globbing logic is inspired by cargo. We
don't resolve `workspace = true` metadata declarations yet.
This commit is contained in:
konsti 2024-05-21 19:17:26 +02:00 committed by GitHub
parent 5205165d42
commit 2ffd453003
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1007 additions and 151 deletions

1
Cargo.lock generated
View file

@ -4955,6 +4955,7 @@ dependencies = [
"pep440_rs",
"pep508_rs",
"pypi-types",
"regex",
"requirements-txt",
"rustc-hash",
"schemars",

View file

@ -50,7 +50,8 @@ schemars = ["dep:schemars"]
[dev-dependencies]
indoc = "2.0.5"
insta = "1.38.0"
insta = { version = "1.38.0", features = ["filters", "redactions", "json"] }
regex = { workspace = true }
[lints]
workspace = true

View file

@ -3,6 +3,7 @@ pub use crate::source_tree::*;
pub use crate::sources::*;
pub use crate::specification::*;
pub use crate::unnamed::*;
pub use crate::workspace::*;
mod confirm;
mod lookahead;
@ -12,3 +13,4 @@ mod sources;
mod specification;
mod unnamed;
pub mod upgrade;
mod workspace;

View file

@ -1,4 +1,4 @@
//! Reads the following fields from from `pyproject.toml`:
//! Reads the following fields from `pyproject.toml`:
//!
//! * `project.{dependencies,optional-dependencies}`
//! * `tool.uv.sources`
@ -6,7 +6,7 @@
//!
//! Then lowers them into a dependency specification.
use std::collections::HashMap;
use std::collections::BTreeMap;
use std::io;
use std::ops::Deref;
use std::path::{Path, PathBuf};
@ -110,7 +110,7 @@ pub struct Tool {
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ToolUv {
pub sources: Option<HashMap<PackageName, Source>>,
pub sources: Option<BTreeMap<PackageName, Source>>,
pub workspace: Option<ToolUvWorkspace>,
}
@ -238,8 +238,8 @@ impl Pep621Metadata {
extras: &ExtrasSpecification,
pyproject_path: &Path,
project_dir: &Path,
workspace_sources: &HashMap<PackageName, Source>,
workspace_packages: &HashMap<PackageName, String>,
workspace_sources: &BTreeMap<PackageName, Source>,
workspace_packages: &BTreeMap<PackageName, String>,
preview: PreviewMode,
) -> Result<Option<Self>, Pep621Error> {
let project_sources = pyproject
@ -323,9 +323,9 @@ pub(crate) fn lower_requirements(
pyproject_path: &Path,
project_name: &PackageName,
project_dir: &Path,
project_sources: &HashMap<PackageName, Source>,
workspace_sources: &HashMap<PackageName, Source>,
workspace_packages: &HashMap<PackageName, String>,
project_sources: &BTreeMap<PackageName, Source>,
workspace_sources: &BTreeMap<PackageName, Source>,
workspace_packages: &BTreeMap<PackageName, String>,
preview: PreviewMode,
) -> Result<Requirements, Pep621Error> {
let dependencies = dependencies
@ -386,9 +386,9 @@ pub(crate) fn lower_requirement(
requirement: pep508_rs::Requirement,
project_name: &PackageName,
project_dir: &Path,
project_sources: &HashMap<PackageName, Source>,
workspace_sources: &HashMap<PackageName, Source>,
workspace_packages: &HashMap<PackageName, String>,
project_sources: &BTreeMap<PackageName, Source>,
workspace_sources: &BTreeMap<PackageName, Source>,
workspace_packages: &BTreeMap<PackageName, String>,
preview: PreviewMode,
) -> Result<Requirement, LoweringError> {
let source = project_sources

View file

@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
@ -165,8 +165,8 @@ impl RequirementsSpecification {
.parent()
.context("`pyproject.toml` has no parent directory")?;
let workspace_sources = HashMap::default();
let workspace_packages = HashMap::default();
let workspace_sources = BTreeMap::default();
let workspace_packages = BTreeMap::default();
match Pep621Metadata::try_from(
pyproject,
extras,

View file

@ -0,0 +1,705 @@
//! Resolve the current [`ProjectWorkspace`].
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use glob::{glob, GlobError, PatternError};
use tracing::{debug, trace};
use uv_fs::Simplified;
use uv_normalize::PackageName;
use uv_warnings::warn_user;
use crate::pyproject::{PyProjectToml, Source, ToolUvWorkspace};
use crate::RequirementsSource;
#[derive(thiserror::Error, Debug)]
pub enum WorkspaceError {
#[error("No `pyproject.toml` found in current directory or any parent directory")]
MissingPyprojectToml,
#[error("Failed to find directories for glob: `{0}`")]
Pattern(String, #[source] PatternError),
#[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] toml::de::Error),
#[error("No `project` section found in: `{}`", _0.simplified_display())]
MissingProject(PathBuf),
}
/// A workspace, consisting of a root directory and members. See [`ProjectWorkspace`].
#[derive(Debug, Clone)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct Workspace {
/// 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.
root: PathBuf,
/// The members of the workspace.
packages: BTreeMap<PackageName, WorkspaceMember>,
/// The sources table from the workspace `pyproject.toml`. It is overridden by the project
/// sources.
sources: BTreeMap<PackageName, Source>,
}
impl Workspace {
/// 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 {
&self.root
}
/// The members of the workspace.
pub fn packages(&self) -> &BTreeMap<PackageName, WorkspaceMember> {
&self.packages
}
/// The sources table from the workspace `pyproject.toml`.
pub fn sources(&self) -> &BTreeMap<PackageName, Source> {
&self.sources
}
}
/// A project in a workspace.
#[derive(Debug, Clone)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct WorkspaceMember {
/// The path to the project root.
root: PathBuf,
/// The `pyproject.toml` of the project, found at `<root>/pyproject.toml`.
pyproject_toml: PyProjectToml,
}
impl WorkspaceMember {
/// The path to the project root.
pub fn root(&self) -> &PathBuf {
&self.root
}
/// The `pyproject.toml` of the project, found at `<root>/pyproject.toml`.
pub fn pyproject_toml(&self) -> &PyProjectToml {
&self.pyproject_toml
}
}
/// The current project and the workspace it is part of, with all of the workspace members.
///
/// # Structure
///
/// The workspace root is a directory with a `pyproject.toml`, all members need to be below that
/// directory. The workspace root defines members and exclusions. All packages below it must either
/// be a member or excluded. The workspace root can be a package itself or a virtual manifest.
///
/// For a simple single package project, the workspace root is implicitly the current project root
/// and the workspace has only this single member. Otherwise, a workspace root is declared through
/// a `tool.uv.workspace` section.
///
/// A workspace itself does not declare dependencies, instead one member is the current project used
/// as main requirement.
///
/// Each member is a directory with a `pyproject.toml` that contains a `[project]` section. Each
/// member is a Python package, with a name, a version and dependencies. Workspace members can
/// depend on other workspace members (`foo = { workspace = true }`). You can consider the
/// workspace another package source or index, similar to `--find-links`.
///
/// # Usage
///
/// There a two main usage patterns: A root package and helpers, and the flat workspace.
///
/// Root package and helpers:
///
/// ```text
/// albatross
/// ├── packages
/// │ ├── provider_a
/// │ │ ├── pyproject.toml
/// │ │ └── src
/// │ │ └── provider_a
/// │ │ ├── __init__.py
/// │ │ └── foo.py
/// │ └── provider_b
/// │ ├── pyproject.toml
/// │ └── src
/// │ └── provider_b
/// │ ├── __init__.py
/// │ └── bar.py
/// ├── pyproject.toml
/// ├── Readme.md
/// ├── uv.lock
/// └── src
/// └── albatross
/// ├── __init__.py
/// └── main.py
/// ```
///
/// Flat workspace:
///
/// ```text
/// albatross
/// ├── packages
/// │ ├── albatross
/// │ │ ├── pyproject.toml
/// │ │ └── src
/// │ │ └── albatross
/// │ │ ├── __init__.py
/// │ │ └── main.py
/// │ ├── provider_a
/// │ │ ├── pyproject.toml
/// │ │ └── src
/// │ │ └── provider_a
/// │ │ ├── __init__.py
/// │ │ └── foo.py
/// │ └── provider_b
/// │ ├── pyproject.toml
/// │ └── src
/// │ └── provider_b
/// │ ├── __init__.py
/// │ └── bar.py
/// ├── pyproject.toml
/// ├── Readme.md
/// └── uv.lock
/// ```
#[derive(Debug, Clone)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct ProjectWorkspace {
/// The path to the project root.
project_root: PathBuf,
/// The name of the package.
project_name: PackageName,
/// The workspace the project is part of.
workspace: Workspace,
}
impl ProjectWorkspace {
/// Find the current project and workspace.
pub fn discover(path: impl AsRef<Path>) -> Result<Self, WorkspaceError> {
let Some(project_root) = path
.as_ref()
.ancestors()
.find(|path| path.join("pyproject.toml").is_file())
else {
return Err(WorkspaceError::MissingPyprojectToml);
};
debug!(
"Found project root: `{}`",
project_root.simplified_display()
);
Self::from_project_root(project_root)
}
/// The directory containing the closest `pyproject.toml`, defining the current project.
pub fn project_root(&self) -> &Path {
&self.project_root
}
/// The name of the current project.
pub fn project_name(&self) -> &PackageName {
&self.project_name
}
/// The workspace definition.
pub fn workspace(&self) -> &Workspace {
&self.workspace
}
/// Return the requirements for the project.
pub fn requirements(&self) -> Vec<RequirementsSource> {
vec![
RequirementsSource::from_requirements_file(self.project_root.join("pyproject.toml")),
RequirementsSource::from_source_tree(self.project_root.clone()),
]
}
fn from_project_root(path: &Path) -> Result<Self, WorkspaceError> {
let pyproject_path = path.join("pyproject.toml");
// Read the `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.clone(), err))?;
// Extract the `[project]` metadata.
let Some(project) = pyproject_toml.project.clone() else {
return Err(WorkspaceError::MissingProject(pyproject_path));
};
Self::from_project(path.to_path_buf(), &pyproject_toml, project.name)
}
/// Find the workspace for a project.
fn from_project(
project_path: PathBuf,
project: &PyProjectToml,
project_name: PackageName,
) -> Result<Self, WorkspaceError> {
let mut workspace = project
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.workspace.as_ref())
.map(|workspace| (project_path.clone(), workspace.clone(), project.clone()));
if workspace.is_none() {
workspace = find_workspace(&project_path)?;
}
let mut workspace_members = BTreeMap::new();
workspace_members.insert(
project_name.clone(),
WorkspaceMember {
root: project_path.clone(),
pyproject_toml: project.clone(),
},
);
let Some((workspace_root, workspace_definition, project_in_workspace_root)) = workspace
else {
// The project and the workspace root are identical
debug!("No workspace root found, using project root");
return Ok(Self {
project_root: project_path.clone(),
project_name,
workspace: Workspace {
root: project_path,
packages: workspace_members,
// There may be package sources, but we don't need to duplicate them into the
// workspace sources.
sources: BTreeMap::default(),
},
});
};
debug!(
"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, err))?;
if let Some(project) = &project_in_workspace_root.project {
workspace_members.insert(
project.name.clone(),
WorkspaceMember {
root: workspace_root.clone(),
pyproject_toml,
},
);
};
}
for member_glob in workspace_definition.members.unwrap_or_default() {
let absolute_glob = workspace_root
.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))?
{
// TODO(konsti): Filter already seen.
let member_root = member_root
.map_err(|err| WorkspaceError::Glob(absolute_glob.to_string(), err))?;
// Read the `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, err))?;
// Extract the package name.
let Some(project) = pyproject_toml.project.clone() else {
return Err(WorkspaceError::MissingProject(member_root));
};
let pyproject_toml = workspace_root.join("pyproject.toml");
let contents = fs_err::read_to_string(&pyproject_toml)?;
let pyproject_toml = toml::from_str(&contents)
.map_err(|err| WorkspaceError::Toml(pyproject_toml, err))?;
let member = WorkspaceMember {
root: member_root.clone(),
pyproject_toml,
};
workspace_members.insert(project.name, member);
}
}
let workspace_sources = project_in_workspace_root
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.sources.clone())
.unwrap_or_default();
check_nested_workspaces(&workspace_root);
Ok(Self {
project_root: project_path.clone(),
project_name,
workspace: Workspace {
root: workspace_root,
packages: workspace_members,
sources: workspace_sources,
},
})
}
}
/// Find the workspace root above the current project, if any.
fn find_workspace(
project_root: &Path,
) -> Result<Option<(PathBuf, ToolUvWorkspace, PyProjectToml)>, WorkspaceError> {
// Skip 1 to ignore the current project itself.
for workspace_root in project_root.ancestors().skip(1) {
let pyproject_path = workspace_root.join("pyproject.toml");
if !pyproject_path.is_file() {
continue;
}
trace!(
"Found pyproject.toml: {}",
pyproject_path.simplified_display()
);
// Read the `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.clone(), err))?;
return if let Some(workspace) = pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.workspace.as_ref())
{
if is_excluded_from_workspace(project_root, workspace_root, workspace)? {
debug!(
"Found workspace root `{}`, but project is excluded.",
workspace_root.simplified_display()
);
return Ok(None);
}
// We found a workspace root.
Ok(Some((
workspace_root.to_path_buf(),
workspace.clone(),
pyproject_toml,
)))
} else if pyproject_toml.project.is_some() {
// We're in a directory of another project, e.g. tests or examples.
// Example:
// ```
// albatross
// ├── examples
// │ └── bird-feeder [CURRENT DIRECTORY]
// │ ├── pyproject.toml
// │ └── src
// │ └── bird_feeder
// │ └── __init__.py
// ├── pyproject.toml
// └── src
// └── albatross
// └── __init__.py
// ```
// The current project is the example (non-workspace) `bird-feeder` in `albatross`,
// we ignore all `albatross` is doing and any potential workspace it might be
// contained in.
debug!(
"Project is contained in non-workspace project: `{}`",
workspace_root.simplified_display()
);
Ok(None)
} else {
// We require that a `project.toml` file either declares a workspace or a project.
warn_user!(
"pyproject.toml does not contain `project` table: `{}`",
workspace_root.simplified_display()
);
Ok(None)
};
}
Ok(None)
}
/// Warn when the valid workspace is included in another workspace.
fn check_nested_workspaces(inner_workspace_root: &Path) {
for outer_workspace_root in inner_workspace_root.ancestors().skip(1) {
let pyproject_toml_path = outer_workspace_root.join("pyproject.toml");
if !pyproject_toml_path.is_file() {
continue;
}
let contents = match fs_err::read_to_string(&pyproject_toml_path) {
Ok(contents) => contents,
Err(err) => {
warn_user!(
"Unreadable pyproject.toml `{}`: {}",
pyproject_toml_path.user_display(),
err
);
return;
}
};
let pyproject_toml: PyProjectToml = match toml::from_str(&contents) {
Ok(contents) => contents,
Err(err) => {
warn_user!(
"Invalid pyproject.toml `{}`: {}",
pyproject_toml_path.user_display(),
err
);
return;
}
};
if let Some(workspace) = pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.workspace.as_ref())
{
let is_excluded = match is_excluded_from_workspace(
inner_workspace_root,
outer_workspace_root,
workspace,
) {
Ok(contents) => contents,
Err(err) => {
warn_user!(
"Invalid pyproject.toml `{}`: {}",
pyproject_toml_path.user_display(),
err
);
return;
}
};
if !is_excluded {
warn_user!(
"Outer workspace including existing workspace, nested workspaces are not supported: `{}`",
pyproject_toml_path.user_display(),
);
}
}
// We're in the examples or tests of another project (not a workspace), this is fine.
return;
}
}
/// Check if we're in the `tool.uv.workspace.excluded` of a workspace.
fn is_excluded_from_workspace(
project_path: &Path,
workspace_root: &Path,
workspace: &ToolUvWorkspace,
) -> Result<bool, WorkspaceError> {
for exclude_glob in workspace.exclude.iter().flatten() {
let absolute_glob = workspace_root
.join(exclude_glob.as_str())
.to_string_lossy()
.to_string();
for excluded_root in glob(&absolute_glob)
.map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?
{
let excluded_root = excluded_root
.map_err(|err| WorkspaceError::Glob(absolute_glob.to_string(), err))?;
if excluded_root == project_path {
return Ok(true);
}
}
}
Ok(false)
}
#[cfg(test)]
#[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;
fn workspace_test(folder: impl AsRef<Path>) -> (ProjectWorkspace, String) {
let root_dir = env::current_dir()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.join("scripts")
.join("workspaces");
let project = ProjectWorkspace::discover(root_dir.join(folder)).unwrap();
let root_escaped = regex::escape(root_dir.to_string_lossy().as_ref());
(project, root_escaped)
}
#[test]
fn albatross_in_example() {
let (project, root_escaped) = workspace_test("albatross-in-example/examples/bird-feeder");
let filters = vec![(root_escaped.as_str(), "[ROOT]")];
insta::with_settings!({filters => filters}, {
assert_json_snapshot!(
project,
{
".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
},
@r###"
{
"project_root": "[ROOT]/albatross-in-example/examples/bird-feeder",
"project_name": "bird-feeder",
"workspace": {
"root": "[ROOT]/albatross-in-example/examples/bird-feeder",
"packages": {
"bird-feeder": {
"root": "[ROOT]/albatross-in-example/examples/bird-feeder",
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"sources": {}
}
}
"###);
});
}
#[test]
fn albatross_project_in_excluded() {
let (project, root_escaped) =
workspace_test("albatross-project-in-excluded/excluded/bird-feeder");
let filters = vec![(root_escaped.as_str(), "[ROOT]")];
insta::with_settings!({filters => filters}, {
assert_json_snapshot!(
project,
{
".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
},
@r###"
{
"project_root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
"project_name": "bird-feeder",
"workspace": {
"root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
"packages": {
"bird-feeder": {
"root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"sources": {}
}
}
"###);
});
}
#[test]
fn albatross_root_workspace() {
let (project, root_escaped) = workspace_test("albatross-root-workspace");
let filters = vec![(root_escaped.as_str(), "[ROOT]")];
insta::with_settings!({filters => filters}, {
assert_json_snapshot!(
project,
{
".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
},
@r###"
{
"project_root": "[ROOT]/albatross-root-workspace",
"project_name": "albatross",
"workspace": {
"root": "[ROOT]/albatross-root-workspace",
"packages": {
"albatross": {
"root": "[ROOT]/albatross-root-workspace",
"pyproject_toml": "[PYPROJECT_TOML]"
},
"bird-feeder": {
"root": "[ROOT]/albatross-root-workspace/packages/bird-feeder",
"pyproject_toml": "[PYPROJECT_TOML]"
},
"seeds": {
"root": "[ROOT]/albatross-root-workspace/packages/seeds",
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"sources": {
"bird-feeder": {
"workspace": true,
"editable": null
}
}
}
}
"###);
});
}
#[test]
fn albatross_virtual_workspace() {
let (project, root_escaped) =
workspace_test("albatross-virtual-workspace/packages/albatross");
let filters = vec![(root_escaped.as_str(), "[ROOT]")];
insta::with_settings!({filters => filters}, {
assert_json_snapshot!(
project,
{
".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
},
@r###"
{
"project_root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
"project_name": "albatross",
"workspace": {
"root": "[ROOT]/albatross-virtual-workspace",
"packages": {
"albatross": {
"root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
"pyproject_toml": "[PYPROJECT_TOML]"
},
"bird-feeder": {
"root": "[ROOT]/albatross-virtual-workspace/packages/bird-feeder",
"pyproject_toml": "[PYPROJECT_TOML]"
},
"seeds": {
"root": "[ROOT]/albatross-virtual-workspace/packages/seeds",
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"sources": {}
}
}
"###);
});
}
#[test]
fn albatross_just_project() {
let (project, root_escaped) = workspace_test("albatross-just-project");
let filters = vec![(root_escaped.as_str(), "[ROOT]")];
insta::with_settings!({filters => filters}, {
assert_json_snapshot!(
project,
{
".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
},
@r###"
{
"project_root": "[ROOT]/albatross-just-project",
"project_name": "albatross",
"workspace": {
"root": "[ROOT]/albatross-just-project",
"packages": {
"albatross": {
"root": "[ROOT]/albatross-just-project",
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"sources": {}
}
}
"###);
});
}
}

View file

@ -1,99 +0,0 @@
use serde::Deserialize;
use std::path::{Path, PathBuf};
use tracing::debug;
use uv_fs::Simplified;
use uv_normalize::PackageName;
use uv_requirements::RequirementsSource;
#[derive(thiserror::Error, Debug)]
pub(crate) enum ProjectError {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Toml(#[from] toml::de::Error),
#[error("No `project` section found in: {}", _0.user_display())]
MissingProject(PathBuf),
#[error("No `name` found in `project` section in: {}", _0.user_display())]
MissingName(PathBuf),
}
#[derive(Debug, Clone)]
pub(crate) struct Project {
/// The name of the package.
name: PackageName,
/// The path to the `pyproject.toml` file.
path: PathBuf,
/// The path to the project root.
root: PathBuf,
}
impl Project {
/// Find the current project.
pub(crate) fn find(path: impl AsRef<Path>) -> Result<Option<Self>, ProjectError> {
for ancestor in path.as_ref().ancestors() {
let pyproject_path = ancestor.join("pyproject.toml");
if pyproject_path.exists() {
debug!(
"Loading requirements from: {}",
pyproject_path.user_display()
);
// Read the `pyproject.toml`.
let contents = fs_err::read_to_string(&pyproject_path)?;
let pyproject_toml: PyProjectToml = toml::from_str(&contents)?;
// Extract the package name.
let Some(project) = pyproject_toml.project else {
return Err(ProjectError::MissingProject(pyproject_path));
};
let Some(name) = project.name else {
return Err(ProjectError::MissingName(pyproject_path));
};
return Ok(Some(Self {
name,
path: pyproject_path,
root: ancestor.to_path_buf(),
}));
}
}
Ok(None)
}
/// Return the [`PackageName`] for the project.
pub(crate) fn name(&self) -> &PackageName {
&self.name
}
/// Return the root path for the project.
pub(crate) fn root(&self) -> &Path {
&self.root
}
/// Return the requirements for the project.
pub(crate) fn requirements(&self) -> Vec<RequirementsSource> {
vec![
RequirementsSource::from_requirements_file(self.path.clone()),
RequirementsSource::from_source_tree(self.root.clone()),
]
}
}
/// A pyproject.toml as specified in PEP 517.
#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
struct PyProjectToml {
project: Option<PyProjectProject>,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
struct PyProjectProject {
name: Option<PackageName>,
}

View file

@ -9,12 +9,11 @@ use uv_configuration::{
Concurrency, ConfigSettings, NoBinary, NoBuild, PreviewMode, Reinstall, SetupPyStrategy,
};
use uv_dispatch::BuildDispatch;
use uv_requirements::{ExtrasSpecification, RequirementsSpecification};
use uv_requirements::{ExtrasSpecification, ProjectWorkspace, RequirementsSpecification};
use uv_resolver::{FlatIndex, InMemoryIndex, OptionsBuilder};
use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy, InFlight};
use uv_warnings::warn_user;
use crate::commands::project::discovery::Project;
use crate::commands::project::Error;
use crate::commands::{project, ExitStatus};
use crate::editables::ResolvedEditables;
@ -32,11 +31,7 @@ pub(crate) async fn lock(
}
// Find the project requirements.
let Some(project) = Project::find(std::env::current_dir()?)? else {
return Err(anyhow::anyhow!(
"Unable to find `pyproject.toml` for project."
));
};
let project = ProjectWorkspace::discover(std::env::current_dir()?)?;
// Discover or create the virtual environment.
let venv = project::init(&project, cache, printer)?;
@ -111,7 +106,8 @@ pub(crate) async fn lock(
.build();
// Build all editable distributions. The editables are shared between resolution and
// installation, and should live for the duration of the command.
// installation, and should live for the duration of the command. If an editable is already
// installed in the environment, we'll still re-build it here.
let editables = ResolvedEditables::resolve(
spec.editables.clone(),
&EmptyInstalledPackages,
@ -159,7 +155,11 @@ pub(crate) async fn lock(
// Write the lockfile to disk.
let lock = resolution.lock()?;
let encoded = toml::to_string_pretty(&lock)?;
fs_err::tokio::write(project.root().join("uv.lock"), encoded.as_bytes()).await?;
fs_err::tokio::write(
project.workspace().root().join("uv.lock"),
encoded.as_bytes(),
)
.await?;
Ok(ExitStatus::Success)
}

View file

@ -22,8 +22,8 @@ use uv_fs::Simplified;
use uv_installer::{Downloader, Plan, Planner, SatisfiesResult, SitePackages};
use uv_interpreter::{find_default_python, Interpreter, PythonEnvironment};
use uv_requirements::{
ExtrasSpecification, LookaheadResolver, NamedRequirementsResolver, RequirementsSource,
RequirementsSpecification, SourceTreeResolver,
ExtrasSpecification, LookaheadResolver, NamedRequirementsResolver, ProjectWorkspace,
RequirementsSource, RequirementsSpecification, SourceTreeResolver,
};
use uv_resolver::{
Exclusions, FlatIndex, InMemoryIndex, Manifest, Options, OptionsBuilder, PythonRequirement,
@ -31,13 +31,11 @@ use uv_resolver::{
};
use uv_types::{BuildIsolation, HashStrategy, InFlight, InstalledPackagesProvider};
use crate::commands::project::discovery::Project;
use crate::commands::reporters::{DownloadReporter, InstallReporter, ResolverReporter};
use crate::commands::{elapsed, ChangeEvent, ChangeEventKind};
use crate::editables::ResolvedEditables;
use crate::printer::Printer;
mod discovery;
pub(crate) mod lock;
pub(crate) mod run;
pub(crate) mod sync;
@ -80,11 +78,11 @@ pub(crate) enum Error {
/// Initialize a virtual environment for the current project.
pub(crate) fn init(
project: &Project,
project: &ProjectWorkspace,
cache: &Cache,
printer: Printer,
) -> Result<PythonEnvironment, Error> {
let venv = project.root().join(".venv");
let venv = project.workspace().root().join(".venv");
// Discover or create the virtual environment.
// TODO(charlie): If the environment isn't compatible with `--python`, recreate it.
@ -165,7 +163,7 @@ pub(crate) async fn resolve<InstalledPackages: InstalledPackagesProvider>(
let requirements = {
// Convert from unnamed to named requirements.
let mut requirements = NamedRequirementsResolver::new(
requirements,
requirements.clone(),
hasher,
index,
DistributionDatabase::new(client, build_dispatch, concurrency.downloads),
@ -178,7 +176,7 @@ pub(crate) async fn resolve<InstalledPackages: InstalledPackagesProvider>(
if !source_trees.is_empty() {
requirements.extend(
SourceTreeResolver::new(
source_trees,
source_trees.clone(),
&ExtrasSpecification::None,
hasher,
index,
@ -214,7 +212,7 @@ pub(crate) async fn resolve<InstalledPackages: InstalledPackagesProvider>(
constraints,
overrides,
preferences,
project,
project.clone(),
editable_metadata,
exclusions,
lookaheads,

View file

@ -10,10 +10,9 @@ use tracing::debug;
use uv_cache::Cache;
use uv_configuration::PreviewMode;
use uv_interpreter::PythonEnvironment;
use uv_requirements::RequirementsSource;
use uv_requirements::{ProjectWorkspace, RequirementsSource};
use uv_warnings::warn_user;
use crate::commands::project::discovery::Project;
use crate::commands::{project, ExitStatus};
use crate::printer::Printer;
@ -55,11 +54,7 @@ pub(crate) async fn run(
} else {
debug!("Syncing project environment.");
let Some(project) = Project::find(std::env::current_dir()?)? else {
return Err(anyhow::anyhow!(
"Unable to find `pyproject.toml` for project."
));
};
let project = ProjectWorkspace::discover(std::env::current_dir()?)?;
let venv = project::init(&project, cache, printer)?;

View file

@ -5,15 +5,15 @@ use install_wheel_rs::linker::LinkMode;
use uv_cache::Cache;
use uv_client::RegistryClientBuilder;
use uv_configuration::{
Concurrency, ConfigSettings, NoBinary, NoBuild, PreviewMode, SetupPyStrategy,
Concurrency, ConfigSettings, NoBinary, NoBuild, PreviewMode, Reinstall, SetupPyStrategy,
};
use uv_dispatch::BuildDispatch;
use uv_installer::SitePackages;
use uv_requirements::ProjectWorkspace;
use uv_resolver::{FlatIndex, InMemoryIndex, Lock};
use uv_types::{BuildIsolation, HashStrategy, InFlight};
use uv_warnings::warn_user;
use crate::commands::project::discovery::Project;
use crate::commands::{project, ExitStatus};
use crate::editables::ResolvedEditables;
use crate::printer::Printer;
@ -30,11 +30,7 @@ pub(crate) async fn sync(
}
// Find the project requirements.
let Some(project) = Project::find(std::env::current_dir()?)? else {
return Err(anyhow::anyhow!(
"Unable to find `pyproject.toml` for project."
));
};
let project = ProjectWorkspace::discover(std::env::current_dir()?)?;
// Discover or create the virtual environment.
let venv = project::init(&project, cache, printer)?;
@ -43,9 +39,10 @@ pub(crate) async fn sync(
// Read the lockfile.
let resolution = {
let encoded = fs_err::tokio::read_to_string(project.root().join("uv.lock")).await?;
let encoded =
fs_err::tokio::read_to_string(project.workspace().root().join("uv.lock")).await?;
let lock: Lock = toml::from_str(&encoded)?;
lock.to_resolution(markers, tags, project.name())
lock.to_resolution(markers, tags, project.project_name())
};
// Initialize the registry client.
@ -55,6 +52,8 @@ pub(crate) async fn sync(
.platform(venv.interpreter().platform())
.build();
let site_packages = SitePackages::from_executable(&venv)?;
// TODO(charlie): Respect project configuration.
let build_isolation = BuildIsolation::default();
let config_settings = ConfigSettings::default();
@ -68,6 +67,7 @@ pub(crate) async fn sync(
let no_build = NoBuild::default();
let setup_py = SetupPyStrategy::default();
let concurrency = Concurrency::default();
let reinstall = Reinstall::None;
// Create a build dispatch.
let build_dispatch = BuildDispatch::new(
@ -87,8 +87,20 @@ pub(crate) async fn sync(
concurrency,
);
// TODO(konsti): Read editables from lockfile.
let editables = ResolvedEditables::default();
let editables = ResolvedEditables::resolve(
Vec::new(), // TODO(konsti): Read editables from lockfile
&site_packages,
&reinstall,
&hasher,
venv.interpreter(),
tags,
cache,
&client,
&build_dispatch,
concurrency,
printer,
)
.await?;
let site_packages = SitePackages::from_executable(&venv)?;

View file

@ -1,6 +1,7 @@
exclude = [
"crates/uv-virtualenv/src/activator/activate_this.py",
"crates/uv-virtualenv/src/_virtualenv.py"
"crates/uv-virtualenv/src/_virtualenv.py",
"scripts/workspaces"
]
[lint.per-file-ignores]
"__init__.py" = ["F403", "F405"]

View file

@ -0,0 +1,11 @@
from albatross import fly
try:
from bird_feeder import use
raise RuntimeError("bird-feeder installed")
except ModuleNotFoundError:
pass
fly()
print("Success")

View file

@ -0,0 +1,10 @@
from bird_feeder import use
try:
from albatross import fly
raise RuntimeError("albatross installed")
except ModuleNotFoundError:
pass
print("Success")

View file

@ -0,0 +1,8 @@
[project]
name = "bird-feeder"
version = "1.0.0"
dependencies = ["anyio>=4.3.0,<5"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

View file

@ -0,0 +1,5 @@
import anyio
def use():
print("squirrel")

View file

@ -0,0 +1,8 @@
[project]
name = "albatross"
version = "0.1.0"
dependencies = ["tqdm>=4,<5"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

View file

@ -0,0 +1,9 @@
import tqdm
def fly():
pass
if __name__ == '__main__':
print("Caw")

View file

@ -0,0 +1,4 @@
from albatross import fly
fly()
print("Success")

View file

@ -0,0 +1,8 @@
[project]
name = "albatross"
version = "0.1.0"
dependencies = ["tqdm>=4,<5"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

View file

@ -0,0 +1,2 @@
def fly():
pass

View file

@ -0,0 +1,10 @@
from bird_feeder import use
try:
from albatross import fly
raise RuntimeError("albatross installed")
except ModuleNotFoundError:
pass
print("Success")

View file

@ -0,0 +1,8 @@
[project]
name = "bird-feeder"
version = "1.0.0"
dependencies = ["anyio>=4.3.0,<5"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

View file

@ -0,0 +1,5 @@
import anyio
def use():
print("squirrel")

View file

@ -0,0 +1,12 @@
[project]
name = "albatross"
version = "0.1.0"
dependencies = ["tqdm>=4,<5"]
[tool.uv.workspace]
members = ["packages/*"]
exclude = ["excluded/*"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

View file

@ -0,0 +1,5 @@
import tqdm
from bird_feeder import use
print("Caw")
use()

View file

@ -0,0 +1,4 @@
from albatross import fly
fly()
print("Success")

View file

@ -0,0 +1,10 @@
from bird_feeder import use
try:
from albatross import fly
raise RuntimeError("albatross installed")
except ModuleNotFoundError:
pass
print("Success")

View file

@ -0,0 +1,11 @@
[project]
name = "bird-feeder"
version = "1.0.0"
dependencies = ["anyio>=4.3.0,<5", "seeds"]
[tool.uv.sources]
seeds = { workspace = true }
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

View file

@ -0,0 +1,5 @@
import anyio
def use():
print("squirrel")

View file

@ -0,0 +1,8 @@
[project]
name = "seeds"
version = "1.0.0"
dependencies = ["boltons==24.0.0"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

View file

@ -0,0 +1,5 @@
import boltons
def seeds():
print("sunflower")

View file

@ -0,0 +1,14 @@
[project]
name = "albatross"
version = "0.1.0"
dependencies = ["bird-feeder", "tqdm>=4,<5"]
[tool.uv.sources]
bird-feeder = { workspace = true }
[tool.uv.workspace]
members = ["packages/*"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

View file

@ -0,0 +1,11 @@
import tqdm
from bird_feeder import use
def fly():
pass
if __name__ == "__main__":
print("Caw")
use()

View file

@ -0,0 +1,4 @@
from albatross import fly
fly()
print("Success")

View file

@ -0,0 +1,11 @@
[project]
name = "albatross"
version = "0.1.0"
dependencies = ["bird-feeder", "tqdm>=4,<5"]
[tool.uv.sources]
bird-feeder = { workspace = true }
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

View file

@ -0,0 +1,11 @@
import tqdm
from bird_feeder import use
def fly():
pass
if __name__ == "__main__":
print("Caw")
use()

View file

@ -0,0 +1,10 @@
from bird_feeder import use
try:
from albatross import fly
raise RuntimeError("albatross installed")
except ModuleNotFoundError:
pass
print("Success")

View file

@ -0,0 +1,11 @@
[project]
name = "bird-feeder"
version = "1.0.0"
dependencies = ["anyio>=4.3.0,<5", "seeds"]
[tool.uv.sources]
seeds = { workspace = true }
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

View file

@ -0,0 +1,5 @@
import anyio
def use():
print("squirrel")

View file

@ -0,0 +1,8 @@
[project]
name = "seeds"
version = "1.0.0"
dependencies = ["boltons==24.0.0"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

View file

@ -0,0 +1,5 @@
import boltons
def seeds():
print("sunflower")

View file

@ -0,0 +1,2 @@
[tool.uv.workspace]
members = ["packages/*"]