mirror of
https://github.com/astral-sh/uv.git
synced 2025-08-04 19:08:04 +00:00
Add support for tool.uv
into distribution building (#3904)
With the change, we remove the special casing of workspace dependencies and resolve `tool.uv` for all git and directory distributions. This gives us support for non-editable workspace dependencies and path dependencies in other workspaces. It removes a lot of special casing around workspaces. These changes are the groundwork for supporting `tool.uv` with dynamic metadata. The basis for this change is moving `Requirement` from `distribution-types` to `pypi-types` and the lowering logic from `uv-requirements` to `uv-distribution`. This changes should be split out in separate PRs. I've included an example workspace `albatross-root-workspace2` where `bird-feeder` depends on `a` from another workspace `ab`. There's a bunch of failing tests and regressed error messages that still need fixing. It does fix the audited package count for the workspace tests.
This commit is contained in:
parent
09f55482a0
commit
081f20c53e
69 changed files with 1159 additions and 1680 deletions
|
@ -23,27 +23,39 @@ platform-tags = { workspace = true }
|
|||
pypi-types = { workspace = true }
|
||||
uv-cache = { workspace = true }
|
||||
uv-client = { workspace = true }
|
||||
uv-configuration = { workspace = true }
|
||||
uv-extract = { workspace = true }
|
||||
uv-fs = { workspace = true, features = ["tokio"] }
|
||||
uv-git = { workspace = true }
|
||||
uv-normalize = { workspace = true }
|
||||
uv-types = { workspace = true }
|
||||
uv-configuration = { workspace = true }
|
||||
uv-warnings = { workspace = true }
|
||||
|
||||
anyhow = { workspace = true }
|
||||
fs-err = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
glob = { workspace = true }
|
||||
nanoid = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
path-absolutize = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
reqwest-middleware = { workspace = true }
|
||||
rmp-serde = { workspace = true }
|
||||
rustc-hash = { workspace = true }
|
||||
schemars = { workspace = true, optional = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
tempfile = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true, features = ["compat"] }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
url = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
insta = { version = "1.39.0", features = ["filters", "json", "redactions"] }
|
||||
regex = { workspace = true }
|
||||
|
||||
[features]
|
||||
schemars = ["dep:schemars"]
|
||||
|
|
|
@ -25,7 +25,7 @@ use uv_cache::{ArchiveId, ArchiveTimestamp, CacheBucket, CacheEntry, Timestamp,
|
|||
use uv_client::{
|
||||
CacheControl, CachedClientError, Connectivity, DataWithCachePolicy, RegistryClient,
|
||||
};
|
||||
use uv_configuration::{NoBinary, NoBuild};
|
||||
use uv_configuration::{NoBinary, NoBuild, PreviewMode};
|
||||
use uv_extract::hash::Hasher;
|
||||
use uv_fs::write_atomic;
|
||||
use uv_types::BuildContext;
|
||||
|
@ -33,7 +33,7 @@ use uv_types::BuildContext;
|
|||
use crate::archive::Archive;
|
||||
use crate::locks::Locks;
|
||||
use crate::source::SourceDistributionBuilder;
|
||||
use crate::{ArchiveMetadata, Error, LocalWheel, Reporter};
|
||||
use crate::{ArchiveMetadata, Error, LocalWheel, Metadata, Reporter};
|
||||
|
||||
/// A cached high-level interface to convert distributions (a requirement resolved to a location)
|
||||
/// to a wheel or wheel metadata.
|
||||
|
@ -60,10 +60,11 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> {
|
|||
client: &'a RegistryClient,
|
||||
build_context: &'a Context,
|
||||
concurrent_downloads: usize,
|
||||
preview_mode: PreviewMode,
|
||||
) -> Self {
|
||||
Self {
|
||||
build_context,
|
||||
builder: SourceDistributionBuilder::new(build_context),
|
||||
builder: SourceDistributionBuilder::new(build_context, preview_mode),
|
||||
locks: Rc::new(Locks::default()),
|
||||
client: ManagedClient::new(client, concurrent_downloads),
|
||||
reporter: None,
|
||||
|
@ -364,7 +365,10 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> {
|
|||
let wheel = self.get_wheel(dist, hashes).await?;
|
||||
let metadata = wheel.metadata()?;
|
||||
let hashes = wheel.hashes;
|
||||
return Ok(ArchiveMetadata { metadata, hashes });
|
||||
return Ok(ArchiveMetadata {
|
||||
metadata: Metadata::from_metadata23(metadata),
|
||||
hashes,
|
||||
});
|
||||
}
|
||||
|
||||
let result = self
|
||||
|
@ -373,7 +377,7 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> {
|
|||
.await;
|
||||
|
||||
match result {
|
||||
Ok(metadata) => Ok(ArchiveMetadata::from(metadata)),
|
||||
Ok(metadata) => Ok(ArchiveMetadata::from_metadata23(metadata)),
|
||||
Err(err) if err.is_http_streaming_unsupported() => {
|
||||
warn!("Streaming unsupported when fetching metadata for {dist}; downloading wheel directly ({err})");
|
||||
|
||||
|
@ -382,7 +386,10 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> {
|
|||
let wheel = self.get_wheel(dist, hashes).await?;
|
||||
let metadata = wheel.metadata()?;
|
||||
let hashes = wheel.hashes;
|
||||
Ok(ArchiveMetadata { metadata, hashes })
|
||||
Ok(ArchiveMetadata {
|
||||
metadata: Metadata::from_metadata23(metadata),
|
||||
hashes,
|
||||
})
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ use std::path::PathBuf;
|
|||
use tokio::task::JoinError;
|
||||
use zip::result::ZipError;
|
||||
|
||||
use crate::MetadataLoweringError;
|
||||
use distribution_filename::WheelFilenameError;
|
||||
use pep440_rs::Version;
|
||||
use pypi_types::HashDigest;
|
||||
|
@ -77,6 +78,8 @@ pub enum Error {
|
|||
DynamicPyprojectToml(#[source] pypi_types::MetadataError),
|
||||
#[error("Unsupported scheme in URL: {0}")]
|
||||
UnsupportedScheme(String),
|
||||
#[error(transparent)]
|
||||
MetadataLowering(#[from] MetadataLoweringError),
|
||||
|
||||
/// A generic request middleware error happened while making a request.
|
||||
/// Refer to the error message for more details.
|
||||
|
|
|
@ -1,11 +1,21 @@
|
|||
pub use archive::Archive;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use archive::Archive;
|
||||
pub use distribution_database::{DistributionDatabase, HttpArchivePointer, LocalArchivePointer};
|
||||
pub use download::LocalWheel;
|
||||
pub use error::Error;
|
||||
pub use git::{git_url_to_precise, is_same_reference};
|
||||
pub use index::{BuiltWheelIndex, RegistryWheelIndex};
|
||||
use pep440_rs::{Version, VersionSpecifiers};
|
||||
use pypi_types::{HashDigest, Metadata23};
|
||||
pub use reporter::Reporter;
|
||||
use requirement_lowering::{lower_requirement, LoweringError};
|
||||
use uv_configuration::PreviewMode;
|
||||
use uv_normalize::{ExtraName, PackageName};
|
||||
pub use workspace::{ProjectWorkspace, Workspace, WorkspaceError, WorkspaceMember};
|
||||
|
||||
mod archive;
|
||||
mod distribution_database;
|
||||
|
@ -14,20 +24,120 @@ mod error;
|
|||
mod git;
|
||||
mod index;
|
||||
mod locks;
|
||||
pub mod pyproject;
|
||||
mod reporter;
|
||||
mod requirement_lowering;
|
||||
mod source;
|
||||
mod workspace;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MetadataLoweringError {
|
||||
#[error(transparent)]
|
||||
Workspace(#[from] WorkspaceError),
|
||||
#[error(transparent)]
|
||||
Lowering(#[from] LoweringError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Metadata {
|
||||
// Mandatory fields
|
||||
pub name: PackageName,
|
||||
pub version: Version,
|
||||
// Optional fields
|
||||
pub requires_dist: Vec<pypi_types::Requirement>,
|
||||
pub requires_python: Option<VersionSpecifiers>,
|
||||
pub provides_extras: Vec<ExtraName>,
|
||||
}
|
||||
|
||||
impl Metadata {
|
||||
/// Lower without considering `tool.uv` in `pyproject.toml`, used for index and other archive
|
||||
/// dependencies.
|
||||
pub fn from_metadata23(metadata: Metadata23) -> Self {
|
||||
Self {
|
||||
name: metadata.name,
|
||||
version: metadata.version,
|
||||
requires_dist: metadata
|
||||
.requires_dist
|
||||
.into_iter()
|
||||
.map(pypi_types::Requirement::from)
|
||||
.collect(),
|
||||
requires_python: metadata.requires_python,
|
||||
provides_extras: metadata.provides_extras,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lower by considering `tool.uv` in `pyproject.toml` if present, used for Git and directory
|
||||
/// dependencies.
|
||||
pub async fn from_workspace(
|
||||
metadata: Metadata23,
|
||||
project_root: &Path,
|
||||
preview_mode: PreviewMode,
|
||||
) -> Result<Self, MetadataLoweringError> {
|
||||
// TODO(konsti): Limit discovery for Git checkouts to Git root.
|
||||
// TODO(konsti): Cache workspace discovery.
|
||||
let Some(project_workspace) =
|
||||
ProjectWorkspace::from_maybe_project_root(project_root).await?
|
||||
else {
|
||||
return Ok(Self::from_metadata23(metadata));
|
||||
};
|
||||
|
||||
let empty = BTreeMap::default();
|
||||
let sources = project_workspace
|
||||
.current_project()
|
||||
.pyproject_toml()
|
||||
.tool
|
||||
.as_ref()
|
||||
.and_then(|tool| tool.uv.as_ref())
|
||||
.and_then(|uv| uv.sources.as_ref())
|
||||
.unwrap_or(&empty);
|
||||
|
||||
let requires_dist = metadata
|
||||
.requires_dist
|
||||
.into_iter()
|
||||
.map(|requirement| {
|
||||
lower_requirement(
|
||||
requirement,
|
||||
&metadata.name,
|
||||
project_workspace.project_root(),
|
||||
sources,
|
||||
project_workspace.workspace(),
|
||||
preview_mode,
|
||||
)
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
Ok(Self {
|
||||
name: metadata.name,
|
||||
version: metadata.version,
|
||||
requires_dist,
|
||||
requires_python: metadata.requires_python,
|
||||
provides_extras: metadata.provides_extras,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The metadata associated with an archive.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ArchiveMetadata {
|
||||
/// The [`Metadata23`] for the underlying distribution.
|
||||
pub metadata: Metadata23,
|
||||
/// The [`Metadata`] for the underlying distribution.
|
||||
pub metadata: Metadata,
|
||||
/// The hashes of the source or built archive.
|
||||
pub hashes: Vec<HashDigest>,
|
||||
}
|
||||
|
||||
impl From<Metadata23> for ArchiveMetadata {
|
||||
fn from(metadata: Metadata23) -> Self {
|
||||
impl ArchiveMetadata {
|
||||
/// Lower without considering `tool.uv` in `pyproject.toml`, used for index and other archive
|
||||
/// dependencies.
|
||||
pub fn from_metadata23(metadata: Metadata23) -> Self {
|
||||
Self {
|
||||
metadata: Metadata::from_metadata23(metadata),
|
||||
hashes: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Metadata> for ArchiveMetadata {
|
||||
fn from(metadata: Metadata) -> Self {
|
||||
Self {
|
||||
metadata,
|
||||
hashes: vec![],
|
||||
|
|
199
crates/uv-distribution/src/pyproject.rs
Normal file
199
crates/uv-distribution/src/pyproject.rs
Normal file
|
@ -0,0 +1,199 @@
|
|||
//! Reads the following fields from `pyproject.toml`:
|
||||
//!
|
||||
//! * `project.{dependencies,optional-dependencies}`
|
||||
//! * `tool.uv.sources`
|
||||
//! * `tool.uv.workspace`
|
||||
//!
|
||||
//! Then lowers them into a dependency specification.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::ops::Deref;
|
||||
|
||||
use glob::Pattern;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
|
||||
use pep508_rs::Pep508Error;
|
||||
use pypi_types::VerbatimParsedUrl;
|
||||
use uv_normalize::{ExtraName, PackageName};
|
||||
|
||||
use crate::LoweringError;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Pep621Error {
|
||||
#[error(transparent)]
|
||||
Pep508(#[from] Box<Pep508Error<VerbatimParsedUrl>>),
|
||||
#[error("Must specify a `[project]` section alongside `[tool.uv.sources]`")]
|
||||
MissingProjectSection,
|
||||
#[error("pyproject.toml section is declared as dynamic, but must be static: `{0}`")]
|
||||
DynamicNotAllowed(&'static str),
|
||||
#[error("Failed to parse entry for: `{0}`")]
|
||||
LoweringError(PackageName, #[source] LoweringError),
|
||||
}
|
||||
|
||||
impl From<Pep508Error<VerbatimParsedUrl>> for Pep621Error {
|
||||
fn from(error: Pep508Error<VerbatimParsedUrl>) -> Self {
|
||||
Self::Pep508(Box::new(error))
|
||||
}
|
||||
}
|
||||
|
||||
/// A `pyproject.toml` as specified in PEP 517.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct PyProjectToml {
|
||||
/// PEP 621-compliant project metadata.
|
||||
pub project: Option<Project>,
|
||||
/// Tool-specific metadata.
|
||||
pub tool: Option<Tool>,
|
||||
}
|
||||
|
||||
/// PEP 621 project metadata (`project`).
|
||||
///
|
||||
/// See <https://packaging.python.org/en/latest/specifications/pyproject-toml>.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct Project {
|
||||
/// The name of the project
|
||||
pub name: PackageName,
|
||||
/// The optional dependencies of the project.
|
||||
pub optional_dependencies: Option<BTreeMap<ExtraName, Vec<String>>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
pub struct Tool {
|
||||
pub uv: Option<ToolUv>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
pub struct ToolUv {
|
||||
pub sources: Option<BTreeMap<PackageName, Source>>,
|
||||
pub workspace: Option<ToolUvWorkspace>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
pub struct ToolUvWorkspace {
|
||||
pub members: Option<Vec<SerdePattern>>,
|
||||
pub exclude: Option<Vec<SerdePattern>>,
|
||||
}
|
||||
|
||||
/// (De)serialize globs as strings.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SerdePattern(#[serde(with = "serde_from_and_to_string")] pub Pattern);
|
||||
|
||||
#[cfg(feature = "schemars")]
|
||||
impl schemars::JsonSchema for SerdePattern {
|
||||
fn schema_name() -> String {
|
||||
<String as schemars::JsonSchema>::schema_name()
|
||||
}
|
||||
|
||||
fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
|
||||
<String as schemars::JsonSchema>::json_schema(gen)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for SerdePattern {
|
||||
type Target = Pattern;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A `tool.uv.sources` value.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[serde(untagged, deny_unknown_fields)]
|
||||
pub enum Source {
|
||||
/// A remote Git repository, available over HTTPS or SSH.
|
||||
///
|
||||
/// Example:
|
||||
/// ```toml
|
||||
/// flask = { git = "https://github.com/pallets/flask", tag = "3.0.0" }
|
||||
/// ```
|
||||
Git {
|
||||
/// The repository URL (without the `git+` prefix).
|
||||
git: Url,
|
||||
/// The path to the directory with the `pyproject.toml`, if it's not in the archive root.
|
||||
subdirectory: Option<String>,
|
||||
// Only one of the three may be used; we'll validate this later and emit a custom error.
|
||||
rev: Option<String>,
|
||||
tag: Option<String>,
|
||||
branch: Option<String>,
|
||||
},
|
||||
/// A remote `http://` or `https://` URL, either a wheel (`.whl`) or a source distribution
|
||||
/// (`.zip`, `.tar.gz`).
|
||||
///
|
||||
/// Example:
|
||||
/// ```toml
|
||||
/// flask = { url = "https://files.pythonhosted.org/packages/61/80/ffe1da13ad9300f87c93af113edd0638c75138c42a0994becfacac078c06/flask-3.0.3-py3-none-any.whl" }
|
||||
/// ```
|
||||
Url {
|
||||
url: Url,
|
||||
/// For source distributions, the path to the directory with the `pyproject.toml`, if it's
|
||||
/// not in the archive root.
|
||||
subdirectory: Option<String>,
|
||||
},
|
||||
/// The path to a dependency, either a wheel (a `.whl` file), source distribution (a `.zip` or
|
||||
/// `.tag.gz` file), or source tree (i.e., a directory containing a `pyproject.toml` or
|
||||
/// `setup.py` file in the root).
|
||||
Path {
|
||||
path: String,
|
||||
/// `false` by default.
|
||||
editable: Option<bool>,
|
||||
},
|
||||
/// A dependency pinned to a specific index, e.g., `torch` after setting `torch` to `https://download.pytorch.org/whl/cu118`.
|
||||
Registry {
|
||||
// TODO(konstin): The string is more-or-less a placeholder
|
||||
index: String,
|
||||
},
|
||||
/// A dependency on another package in the workspace.
|
||||
Workspace {
|
||||
/// When set to `false`, the package will be fetched from the remote index, rather than
|
||||
/// included as a workspace package.
|
||||
workspace: bool,
|
||||
/// `true` by default.
|
||||
editable: Option<bool>,
|
||||
},
|
||||
/// A catch-all variant used to emit precise error messages when deserializing.
|
||||
CatchAll {
|
||||
git: String,
|
||||
subdirectory: Option<String>,
|
||||
rev: Option<String>,
|
||||
tag: Option<String>,
|
||||
branch: Option<String>,
|
||||
url: String,
|
||||
patch: String,
|
||||
index: String,
|
||||
workspace: bool,
|
||||
},
|
||||
}
|
||||
/// <https://github.com/serde-rs/serde/issues/1316#issue-332908452>
|
||||
mod serde_from_and_to_string {
|
||||
use std::fmt::Display;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{de, Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub(super) fn serialize<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
T: Display,
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.collect_str(value)
|
||||
}
|
||||
|
||||
pub(super) fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
|
||||
where
|
||||
T: FromStr,
|
||||
T::Err: Display,
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
String::deserialize(deserializer)?
|
||||
.parse()
|
||||
.map_err(de::Error::custom)
|
||||
}
|
||||
}
|
241
crates/uv-distribution/src/requirement_lowering.rs
Normal file
241
crates/uv-distribution/src/requirement_lowering.rs
Normal file
|
@ -0,0 +1,241 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use path_absolutize::Absolutize;
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
|
||||
use pep440_rs::VersionSpecifiers;
|
||||
use pep508_rs::{VerbatimUrl, VersionOrUrl};
|
||||
use pypi_types::{Requirement, RequirementSource, VerbatimParsedUrl};
|
||||
use uv_configuration::PreviewMode;
|
||||
use uv_fs::Simplified;
|
||||
use uv_git::GitReference;
|
||||
use uv_normalize::PackageName;
|
||||
use uv_warnings::warn_user_once;
|
||||
|
||||
use crate::pyproject::Source;
|
||||
use crate::Workspace;
|
||||
|
||||
/// An error parsing and merging `tool.uv.sources` with
|
||||
/// `project.{dependencies,optional-dependencies}`.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LoweringError {
|
||||
#[error("Package is not included as workspace package in `tool.uv.workspace`")]
|
||||
UndeclaredWorkspacePackage,
|
||||
#[error("Can only specify one of: `rev`, `tag`, or `branch`")]
|
||||
MoreThanOneGitRef,
|
||||
#[error("Unable to combine options in `tool.uv.sources`")]
|
||||
InvalidEntry,
|
||||
#[error(transparent)]
|
||||
InvalidUrl(#[from] url::ParseError),
|
||||
#[error(transparent)]
|
||||
InvalidVerbatimUrl(#[from] pep508_rs::VerbatimUrlError),
|
||||
#[error("Can't combine URLs from both `project.dependencies` and `tool.uv.sources`")]
|
||||
ConflictingUrls,
|
||||
#[error("Could not normalize path: `{}`", _0.user_display())]
|
||||
Absolutize(PathBuf, #[source] io::Error),
|
||||
#[error("Fragments are not allowed in URLs: `{0}`")]
|
||||
ForbiddenFragment(Url),
|
||||
#[error("`workspace = false` is not yet supported")]
|
||||
WorkspaceFalse,
|
||||
#[error("`tool.uv.sources` is a preview feature; use `--preview` or set `UV_PREVIEW=1` to enable it")]
|
||||
MissingPreview,
|
||||
}
|
||||
|
||||
/// Combine `project.dependencies` or `project.optional-dependencies` with `tool.uv.sources`.
|
||||
pub(crate) fn lower_requirement(
|
||||
requirement: pep508_rs::Requirement<VerbatimParsedUrl>,
|
||||
project_name: &PackageName,
|
||||
project_dir: &Path,
|
||||
project_sources: &BTreeMap<PackageName, Source>,
|
||||
workspace: &Workspace,
|
||||
preview: PreviewMode,
|
||||
) -> Result<Requirement, LoweringError> {
|
||||
let source = project_sources
|
||||
.get(&requirement.name)
|
||||
.or(workspace.sources().get(&requirement.name))
|
||||
.cloned();
|
||||
|
||||
let workspace_package_declared =
|
||||
// We require that when you use a package that's part of the workspace, ...
|
||||
!workspace.packages().contains_key(&requirement.name)
|
||||
// ... it must be declared as a workspace dependency (`workspace = true`), ...
|
||||
|| matches!(
|
||||
source,
|
||||
Some(Source::Workspace {
|
||||
// By using toml, we technically support `workspace = false`.
|
||||
workspace: true,
|
||||
..
|
||||
})
|
||||
)
|
||||
// ... except for recursive self-inclusion (extras that activate other extras), e.g.
|
||||
// `framework[machine_learning]` depends on `framework[cuda]`.
|
||||
|| &requirement.name == project_name;
|
||||
if !workspace_package_declared {
|
||||
return Err(LoweringError::UndeclaredWorkspacePackage);
|
||||
}
|
||||
|
||||
let Some(source) = source else {
|
||||
let has_sources = !project_sources.is_empty() || !workspace.sources().is_empty();
|
||||
// Support recursive editable inclusions.
|
||||
if has_sources && requirement.version_or_url.is_none() && &requirement.name != project_name
|
||||
{
|
||||
warn_user_once!(
|
||||
"Missing version constraint (e.g., a lower bound) for `{}`",
|
||||
requirement.name
|
||||
);
|
||||
}
|
||||
return Ok(Requirement::from(requirement));
|
||||
};
|
||||
|
||||
if preview.is_disabled() {
|
||||
return Err(LoweringError::MissingPreview);
|
||||
}
|
||||
|
||||
let source = match source {
|
||||
Source::Git {
|
||||
git,
|
||||
subdirectory,
|
||||
rev,
|
||||
tag,
|
||||
branch,
|
||||
} => {
|
||||
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
|
||||
return Err(LoweringError::ConflictingUrls);
|
||||
}
|
||||
let reference = match (rev, tag, branch) {
|
||||
(None, None, None) => GitReference::DefaultBranch,
|
||||
(Some(rev), None, None) => {
|
||||
if rev.starts_with("refs/") {
|
||||
GitReference::NamedRef(rev.clone())
|
||||
} else if rev.len() == 40 {
|
||||
GitReference::FullCommit(rev.clone())
|
||||
} else {
|
||||
GitReference::ShortCommit(rev.clone())
|
||||
}
|
||||
}
|
||||
(None, Some(tag), None) => GitReference::Tag(tag),
|
||||
(None, None, Some(branch)) => GitReference::Branch(branch),
|
||||
_ => return Err(LoweringError::MoreThanOneGitRef),
|
||||
};
|
||||
|
||||
// Create a PEP 508-compatible URL.
|
||||
let mut url = Url::parse(&format!("git+{git}"))?;
|
||||
if let Some(rev) = reference.as_str() {
|
||||
url.set_path(&format!("{}@{}", url.path(), rev));
|
||||
}
|
||||
if let Some(subdirectory) = &subdirectory {
|
||||
url.set_fragment(Some(&format!("subdirectory={subdirectory}")));
|
||||
}
|
||||
let url = VerbatimUrl::from_url(url);
|
||||
|
||||
let repository = git.clone();
|
||||
|
||||
RequirementSource::Git {
|
||||
url,
|
||||
repository,
|
||||
reference,
|
||||
precise: None,
|
||||
subdirectory: subdirectory.map(PathBuf::from),
|
||||
}
|
||||
}
|
||||
Source::Url { url, subdirectory } => {
|
||||
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
|
||||
return Err(LoweringError::ConflictingUrls);
|
||||
}
|
||||
|
||||
let mut verbatim_url = url.clone();
|
||||
if verbatim_url.fragment().is_some() {
|
||||
return Err(LoweringError::ForbiddenFragment(url));
|
||||
}
|
||||
if let Some(subdirectory) = &subdirectory {
|
||||
verbatim_url.set_fragment(Some(subdirectory));
|
||||
}
|
||||
|
||||
let verbatim_url = VerbatimUrl::from_url(verbatim_url);
|
||||
RequirementSource::Url {
|
||||
location: url,
|
||||
subdirectory: subdirectory.map(PathBuf::from),
|
||||
url: verbatim_url,
|
||||
}
|
||||
}
|
||||
Source::Path { path, editable } => {
|
||||
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
|
||||
return Err(LoweringError::ConflictingUrls);
|
||||
}
|
||||
path_source(path, project_dir, editable.unwrap_or(false))?
|
||||
}
|
||||
Source::Registry { index } => match requirement.version_or_url {
|
||||
None => {
|
||||
warn_user_once!(
|
||||
"Missing version constraint (e.g., a lower bound) for `{}`",
|
||||
requirement.name
|
||||
);
|
||||
RequirementSource::Registry {
|
||||
specifier: VersionSpecifiers::empty(),
|
||||
index: Some(index),
|
||||
}
|
||||
}
|
||||
Some(VersionOrUrl::VersionSpecifier(version)) => RequirementSource::Registry {
|
||||
specifier: version,
|
||||
index: Some(index),
|
||||
},
|
||||
Some(VersionOrUrl::Url(_)) => return Err(LoweringError::ConflictingUrls),
|
||||
},
|
||||
Source::Workspace {
|
||||
workspace: is_workspace,
|
||||
editable,
|
||||
} => {
|
||||
if !is_workspace {
|
||||
return Err(LoweringError::WorkspaceFalse);
|
||||
}
|
||||
if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) {
|
||||
return Err(LoweringError::ConflictingUrls);
|
||||
}
|
||||
let path = workspace
|
||||
.packages()
|
||||
.get(&requirement.name)
|
||||
.ok_or(LoweringError::UndeclaredWorkspacePackage)?
|
||||
.clone();
|
||||
path_source(path.root(), workspace.root(), editable.unwrap_or(true))?
|
||||
}
|
||||
Source::CatchAll { .. } => {
|
||||
// Emit a dedicated error message, which is an improvement over Serde's default error.
|
||||
return Err(LoweringError::InvalidEntry);
|
||||
}
|
||||
};
|
||||
Ok(Requirement {
|
||||
name: requirement.name,
|
||||
extras: requirement.extras,
|
||||
marker: requirement.marker,
|
||||
source,
|
||||
origin: requirement.origin,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a path string to a path section.
|
||||
fn path_source(
|
||||
path: impl AsRef<Path>,
|
||||
project_dir: &Path,
|
||||
editable: bool,
|
||||
) -> Result<RequirementSource, LoweringError> {
|
||||
let url = VerbatimUrl::parse_path(path.as_ref(), project_dir)?
|
||||
.with_given(path.as_ref().to_string_lossy().to_string());
|
||||
let path_buf = path.as_ref().to_path_buf();
|
||||
let path_buf = path_buf
|
||||
.absolutize_from(project_dir)
|
||||
.map_err(|err| LoweringError::Absolutize(path.as_ref().to_path_buf(), err))?
|
||||
.to_path_buf();
|
||||
//if !editable {
|
||||
// // TODO(konsti): Support this. Currently we support `{ workspace = true }`, but we don't
|
||||
// // support `{ workspace = true, editable = false }` since we only collect editables.
|
||||
// return Err(LoweringError::NonEditableWorkspaceDependency);
|
||||
//}
|
||||
Ok(RequirementSource::Path {
|
||||
path: path_buf,
|
||||
url,
|
||||
editable,
|
||||
})
|
||||
}
|
|
@ -29,7 +29,7 @@ use uv_cache::{
|
|||
use uv_client::{
|
||||
CacheControl, CachedClientError, Connectivity, DataWithCachePolicy, RegistryClient,
|
||||
};
|
||||
use uv_configuration::{BuildKind, NoBuild};
|
||||
use uv_configuration::{BuildKind, NoBuild, PreviewMode};
|
||||
use uv_extract::hash::Hasher;
|
||||
use uv_fs::{write_atomic, LockedFile};
|
||||
use uv_types::{BuildContext, SourceBuildTrait};
|
||||
|
@ -39,7 +39,7 @@ use crate::error::Error;
|
|||
use crate::git::{fetch_git_archive, resolve_precise};
|
||||
use crate::source::built_wheel_metadata::BuiltWheelMetadata;
|
||||
use crate::source::revision::Revision;
|
||||
use crate::{ArchiveMetadata, Reporter};
|
||||
use crate::{ArchiveMetadata, Metadata, Reporter};
|
||||
|
||||
mod built_wheel_metadata;
|
||||
mod revision;
|
||||
|
@ -48,6 +48,7 @@ mod revision;
|
|||
pub(crate) struct SourceDistributionBuilder<'a, T: BuildContext> {
|
||||
build_context: &'a T,
|
||||
reporter: Option<Arc<dyn Reporter>>,
|
||||
preview_mode: PreviewMode,
|
||||
}
|
||||
|
||||
/// The name of the file that contains the revision ID for a remote distribution, encoded via `MsgPack`.
|
||||
|
@ -61,10 +62,11 @@ pub(crate) const METADATA: &str = "metadata.msgpack";
|
|||
|
||||
impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
||||
/// Initialize a [`SourceDistributionBuilder`] from a [`BuildContext`].
|
||||
pub(crate) fn new(build_context: &'a T) -> Self {
|
||||
pub(crate) fn new(build_context: &'a T, preview_mode: PreviewMode) -> Self {
|
||||
Self {
|
||||
build_context,
|
||||
reporter: None,
|
||||
preview_mode,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -492,7 +494,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
if let Some(metadata) = read_cached_metadata(&metadata_entry).await? {
|
||||
debug!("Using cached metadata for: {source}");
|
||||
return Ok(ArchiveMetadata {
|
||||
metadata,
|
||||
metadata: Metadata::from_metadata23(metadata),
|
||||
hashes: revision.into_hashes(),
|
||||
});
|
||||
}
|
||||
|
@ -515,7 +517,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
.map_err(Error::CacheWrite)?;
|
||||
|
||||
return Ok(ArchiveMetadata {
|
||||
metadata,
|
||||
metadata: Metadata::from_metadata23(metadata),
|
||||
hashes: revision.into_hashes(),
|
||||
});
|
||||
}
|
||||
|
@ -542,7 +544,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
}
|
||||
|
||||
Ok(ArchiveMetadata {
|
||||
metadata,
|
||||
metadata: Metadata::from_metadata23(metadata),
|
||||
hashes: revision.into_hashes(),
|
||||
})
|
||||
}
|
||||
|
@ -720,7 +722,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
if let Some(metadata) = read_cached_metadata(&metadata_entry).await? {
|
||||
debug!("Using cached metadata for: {source}");
|
||||
return Ok(ArchiveMetadata {
|
||||
metadata,
|
||||
metadata: Metadata::from_metadata23(metadata),
|
||||
hashes: revision.into_hashes(),
|
||||
});
|
||||
}
|
||||
|
@ -742,7 +744,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
.map_err(Error::CacheWrite)?;
|
||||
|
||||
return Ok(ArchiveMetadata {
|
||||
metadata,
|
||||
metadata: Metadata::from_metadata23(metadata),
|
||||
hashes: revision.into_hashes(),
|
||||
});
|
||||
}
|
||||
|
@ -769,7 +771,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
.map_err(Error::CacheWrite)?;
|
||||
|
||||
Ok(ArchiveMetadata {
|
||||
metadata,
|
||||
metadata: Metadata::from_metadata23(metadata),
|
||||
hashes: revision.into_hashes(),
|
||||
})
|
||||
}
|
||||
|
@ -929,7 +931,10 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
let metadata_entry = cache_shard.entry(METADATA);
|
||||
if let Some(metadata) = read_cached_metadata(&metadata_entry).await? {
|
||||
debug!("Using cached metadata for: {source}");
|
||||
return Ok(ArchiveMetadata::from(metadata));
|
||||
return Ok(ArchiveMetadata::from(
|
||||
Metadata::from_workspace(metadata, resource.path.as_ref(), self.preview_mode)
|
||||
.await?,
|
||||
));
|
||||
}
|
||||
|
||||
// If the backend supports `prepare_metadata_for_build_wheel`, use it.
|
||||
|
@ -946,7 +951,10 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
.await
|
||||
.map_err(Error::CacheWrite)?;
|
||||
|
||||
return Ok(ArchiveMetadata::from(metadata));
|
||||
return Ok(ArchiveMetadata::from(
|
||||
Metadata::from_workspace(metadata, resource.path.as_ref(), self.preview_mode)
|
||||
.await?,
|
||||
));
|
||||
}
|
||||
|
||||
// Otherwise, we need to build a wheel.
|
||||
|
@ -970,7 +978,9 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
.await
|
||||
.map_err(Error::CacheWrite)?;
|
||||
|
||||
Ok(ArchiveMetadata::from(metadata))
|
||||
Ok(ArchiveMetadata::from(
|
||||
Metadata::from_workspace(metadata, resource.path.as_ref(), self.preview_mode).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Return the [`Revision`] for a local source tree, refreshing it if necessary.
|
||||
|
@ -1137,7 +1147,9 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
{
|
||||
if let Some(metadata) = read_cached_metadata(&metadata_entry).await? {
|
||||
debug!("Using cached metadata for: {source}");
|
||||
return Ok(ArchiveMetadata::from(metadata));
|
||||
return Ok(ArchiveMetadata::from(
|
||||
Metadata::from_workspace(metadata, fetch.path(), self.preview_mode).await?,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1155,7 +1167,9 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
.await
|
||||
.map_err(Error::CacheWrite)?;
|
||||
|
||||
return Ok(ArchiveMetadata::from(metadata));
|
||||
return Ok(ArchiveMetadata::from(
|
||||
Metadata::from_workspace(metadata, fetch.path(), self.preview_mode).await?,
|
||||
));
|
||||
}
|
||||
|
||||
// Otherwise, we need to build a wheel.
|
||||
|
@ -1179,7 +1193,9 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
|
|||
.await
|
||||
.map_err(Error::CacheWrite)?;
|
||||
|
||||
Ok(ArchiveMetadata::from(metadata))
|
||||
Ok(ArchiveMetadata::from(
|
||||
Metadata::from_workspace(metadata, fetch.path(), self.preview_mode).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Download and unzip a source distribution into the cache from an HTTP response.
|
||||
|
@ -1592,7 +1608,7 @@ async fn read_pyproject_toml(
|
|||
/// Read an existing cached [`Metadata23`], if it exists.
|
||||
async fn read_cached_metadata(cache_entry: &CacheEntry) -> Result<Option<Metadata23>, Error> {
|
||||
match fs::read(&cache_entry.path()).await {
|
||||
Ok(cached) => Ok(Some(rmp_serde::from_slice::<Metadata23>(&cached)?)),
|
||||
Ok(cached) => Ok(Some(rmp_serde::from_slice(&cached)?)),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(err) => Err(Error::CacheRead(err)),
|
||||
}
|
||||
|
|
819
crates/uv-distribution/src/workspace.rs
Normal file
819
crates/uv-distribution/src/workspace.rs
Normal file
|
@ -0,0 +1,819 @@
|
|||
//! Resolve the current [`ProjectWorkspace`].
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use glob::{glob, GlobError, PatternError};
|
||||
use rustc_hash::FxHashSet;
|
||||
use tracing::{debug, trace};
|
||||
|
||||
use pep508_rs::VerbatimUrl;
|
||||
use pypi_types::{Requirement, RequirementSource};
|
||||
use uv_fs::{absolutize_path, Simplified};
|
||||
use uv_normalize::{ExtraName, PackageName};
|
||||
use uv_warnings::warn_user;
|
||||
|
||||
use crate::pyproject::{PyProjectToml, Source, ToolUvWorkspace};
|
||||
|
||||
#[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] 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),
|
||||
}
|
||||
|
||||
/// 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 extras available in the project.
|
||||
extras: Vec<ExtraName>,
|
||||
/// The workspace the project is part of.
|
||||
workspace: Workspace,
|
||||
}
|
||||
|
||||
impl ProjectWorkspace {
|
||||
/// Find the current project and workspace, given the current directory.
|
||||
pub async fn discover(path: impl AsRef<Path>) -> Result<Self, WorkspaceError> {
|
||||
let project_root = path
|
||||
.as_ref()
|
||||
.ancestors()
|
||||
.find(|path| path.join("pyproject.toml").is_file())
|
||||
.ok_or(WorkspaceError::MissingPyprojectToml)?;
|
||||
|
||||
debug!(
|
||||
"Found project root: `{}`",
|
||||
project_root.simplified_display()
|
||||
);
|
||||
|
||||
Self::from_project_root(project_root).await
|
||||
}
|
||||
|
||||
/// Discover the workspace starting from the directory containing the `pyproject.toml`.
|
||||
pub async fn from_project_root(project_root: &Path) -> Result<Self, WorkspaceError> {
|
||||
// Read the current `pyproject.toml`.
|
||||
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)))?;
|
||||
|
||||
// It must have a `[project]` table.
|
||||
let project = pyproject_toml
|
||||
.project
|
||||
.clone()
|
||||
.ok_or_else(|| WorkspaceError::MissingProject(pyproject_path.clone()))?;
|
||||
|
||||
Self::from_project(project_root, &pyproject_toml, project.name).await
|
||||
}
|
||||
|
||||
/// If the current directory contains a `pyproject.toml` with a `project` table, discover the
|
||||
/// workspace and return it, otherwise it is a dynamic path dependency and we return `Ok(None)`.
|
||||
pub async fn from_maybe_project_root(
|
||||
project_root: &Path,
|
||||
) -> Result<Option<Self>, WorkspaceError> {
|
||||
// Read the `pyproject.toml`.
|
||||
let pyproject_path = project_root.join("pyproject.toml");
|
||||
let Ok(contents) = fs_err::tokio::read_to_string(&pyproject_path).await else {
|
||||
// No `pyproject.toml`, but there may still be a `setup.py` or `setup.cfg`.
|
||||
return Ok(None);
|
||||
};
|
||||
let pyproject_toml: PyProjectToml = toml::from_str(&contents)
|
||||
.map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
|
||||
|
||||
// Extract the `[project]` metadata.
|
||||
let Some(project) = pyproject_toml.project.clone() else {
|
||||
// We have to build to get the metadata.
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(
|
||||
Self::from_project(project_root, &pyproject_toml, project.name).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Returns the directory containing the closest `pyproject.toml` that defines the current
|
||||
/// project.
|
||||
pub fn project_root(&self) -> &Path {
|
||||
&self.project_root
|
||||
}
|
||||
|
||||
/// Returns the [`PackageName`] of the current project.
|
||||
pub fn project_name(&self) -> &PackageName {
|
||||
&self.project_name
|
||||
}
|
||||
|
||||
/// Returns the extras available in the project.
|
||||
pub fn project_extras(&self) -> &[ExtraName] {
|
||||
&self.extras
|
||||
}
|
||||
|
||||
/// Returns the [`Workspace`] containing the current project.
|
||||
pub fn workspace(&self) -> &Workspace {
|
||||
&self.workspace
|
||||
}
|
||||
|
||||
/// Returns the current project as a [`WorkspaceMember`].
|
||||
pub fn current_project(&self) -> &WorkspaceMember {
|
||||
&self.workspace().packages[&self.project_name]
|
||||
}
|
||||
|
||||
/// Return the [`Requirement`] entries for the project, which is the current project as
|
||||
/// editable.
|
||||
pub fn requirements(&self) -> Vec<Requirement> {
|
||||
vec![Requirement {
|
||||
name: self.project_name.clone(),
|
||||
extras: self.extras.clone(),
|
||||
marker: None,
|
||||
source: RequirementSource::Path {
|
||||
path: self.project_root.clone(),
|
||||
editable: true,
|
||||
url: VerbatimUrl::from_path(&self.project_root).expect("path is valid URL"),
|
||||
},
|
||||
origin: None,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Find the workspace for a project.
|
||||
async fn from_project(
|
||||
project_path: &Path,
|
||||
project: &PyProjectToml,
|
||||
project_name: PackageName,
|
||||
) -> Result<Self, WorkspaceError> {
|
||||
let project_path = absolutize_path(project_path)
|
||||
.map_err(WorkspaceError::Normalize)?
|
||||
.to_path_buf();
|
||||
|
||||
// Extract the extras available in the project.
|
||||
let extras = project
|
||||
.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();
|
||||
|
||||
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
|
||||
.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() {
|
||||
// The project isn't an explicit workspace root, check if we're a regular workspace
|
||||
// member by looking for an explicit workspace root above.
|
||||
workspace = find_workspace(&project_path).await?;
|
||||
}
|
||||
|
||||
let Some((workspace_root, workspace_definition, workspace_pyproject_toml)) = workspace
|
||||
else {
|
||||
// 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");
|
||||
return Ok(Self {
|
||||
project_root: project_path.clone(),
|
||||
project_name,
|
||||
extras,
|
||||
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, 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);
|
||||
|
||||
Ok(Self {
|
||||
project_root: project_path.clone(),
|
||||
project_name,
|
||||
extras,
|
||||
workspace: Workspace {
|
||||
root: workspace_root,
|
||||
packages: workspace_members,
|
||||
sources: workspace_sources,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Used in tests.
|
||||
pub fn dummy(root: &Path, project_name: &PackageName) -> Self {
|
||||
// This doesn't necessarily match the exact test case, but we don't use the other fields
|
||||
// for the test cases atm.
|
||||
let root_member = WorkspaceMember {
|
||||
root: root.to_path_buf(),
|
||||
pyproject_toml: PyProjectToml {
|
||||
project: Some(crate::pyproject::Project {
|
||||
name: project_name.clone(),
|
||||
optional_dependencies: None,
|
||||
}),
|
||||
tool: None,
|
||||
},
|
||||
};
|
||||
Self {
|
||||
project_root: root.to_path_buf(),
|
||||
project_name: project_name.clone(),
|
||||
extras: Vec::new(),
|
||||
workspace: Workspace {
|
||||
root: root.to_path_buf(),
|
||||
packages: [(project_name.clone(), root_member)].into_iter().collect(),
|
||||
sources: BTreeMap::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the workspace root above the current project, if any.
|
||||
async 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::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)))?;
|
||||
|
||||
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
|
||||
.simplified()
|
||||
.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;
|
||||
|
||||
async 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))
|
||||
.await
|
||||
.unwrap();
|
||||
let root_escaped = regex::escape(root_dir.to_string_lossy().as_ref());
|
||||
(project, root_escaped)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn albatross_in_example() {
|
||||
let (project, root_escaped) =
|
||||
workspace_test("albatross-in-example/examples/bird-feeder").await;
|
||||
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",
|
||||
"extras": [],
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
"###);
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn albatross_project_in_excluded() {
|
||||
let (project, root_escaped) =
|
||||
workspace_test("albatross-project-in-excluded/excluded/bird-feeder").await;
|
||||
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",
|
||||
"extras": [],
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
"###);
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn albatross_root_workspace() {
|
||||
let (project, root_escaped) = workspace_test("albatross-root-workspace").await;
|
||||
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",
|
||||
"extras": [],
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"###);
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn albatross_virtual_workspace() {
|
||||
let (project, root_escaped) =
|
||||
workspace_test("albatross-virtual-workspace/packages/albatross").await;
|
||||
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",
|
||||
"extras": [],
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
"###);
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn albatross_just_project() {
|
||||
let (project, root_escaped) = workspace_test("albatross-just-project").await;
|
||||
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",
|
||||
"extras": [],
|
||||
"workspace": {
|
||||
"root": "[ROOT]/albatross-just-project",
|
||||
"packages": {
|
||||
"albatross": {
|
||||
"root": "[ROOT]/albatross-just-project",
|
||||
"pyproject_toml": "[PYPROJECT_TOML]"
|
||||
}
|
||||
},
|
||||
"sources": {}
|
||||
}
|
||||
}
|
||||
"###);
|
||||
});
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue