diff --git a/Cargo.lock b/Cargo.lock index 83ac5995fc..40d34b2e7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7154,6 +7154,7 @@ dependencies = [ "same-file", "schemars", "serde", + "serde_json", "smallvec", "textwrap", "thiserror 2.0.18", diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 1f46ed0fc4..f4f9ad477f 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -7884,7 +7884,7 @@ pub enum WorkspaceCommand { /// View metadata about the current workspace. /// /// The output of this command is not yet stable. - Metadata(MetadataArgs), + Metadata(Box), /// Display the path of a workspace member. /// /// By default, the path to the workspace root directory is displayed. @@ -7898,9 +7898,55 @@ pub enum WorkspaceCommand { #[command(hide = true)] List(WorkspaceListArgs), } +#[derive(Args)] +pub struct MetadataArgs { + /// Check if the lockfile is up-to-date [env: UV_LOCKED=] + /// + /// Asserts that the `uv.lock` would remain unchanged after a resolution. If the lockfile is + /// missing or needs to be updated, uv will exit with an error. + #[arg(long, conflicts_with_all = ["frozen", "upgrade"])] + pub locked: bool, -#[derive(Args, Debug)] -pub struct MetadataArgs; + /// Assert that a `uv.lock` exists without checking if it is up-to-date [env: UV_FROZEN=] + #[arg(long, conflicts_with_all = ["locked"])] + pub frozen: bool, + + /// Perform a dry run, without writing the lockfile. + /// + /// In dry-run mode, uv will resolve the project's dependencies and report on the resulting + /// changes, but will not write the lockfile to disk. + #[arg(long, conflicts_with = "frozen", conflicts_with = "locked")] + pub dry_run: bool, + + #[command(flatten)] + pub resolver: ResolverArgs, + + #[command(flatten)] + pub build: BuildOptionsArgs, + + #[command(flatten)] + pub refresh: RefreshArgs, + + /// The Python interpreter to use during resolution. + /// + /// A Python interpreter is required for building source distributions to determine package + /// metadata when there are not wheels. + /// + /// The interpreter is also used as the fallback value for the minimum Python version if + /// `requires-python` is not set. + /// + /// See `uv help python` for details on Python discovery and supported request formats. + #[arg( + long, + short, + env = EnvVars::UV_PYTHON, + verbatim_doc_comment, + help_heading = "Python options", + value_parser = parse_maybe_string, + value_hint = ValueHint::Other, + )] + pub python: Option>, +} #[derive(Args, Debug)] pub struct WorkspaceDirArgs { diff --git a/crates/uv-resolver/Cargo.toml b/crates/uv-resolver/Cargo.toml index 6f3277275a..c2810d6a94 100644 --- a/crates/uv-resolver/Cargo.toml +++ b/crates/uv-resolver/Cargo.toml @@ -66,6 +66,7 @@ rustc-hash = { workspace = true } same-file = { workspace = true } schemars = { workspace = true, optional = true } serde = { workspace = true } +serde_json = { workspace = true } smallvec = { workspace = true } textwrap = { workspace = true } thiserror = { workspace = true } diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index 756f2e57b7..27cb2512c9 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -9,7 +9,7 @@ pub use exclusions::Exclusions; pub use flat_index::{FlatDistributions, FlatIndex}; pub use fork_strategy::ForkStrategy; pub use lock::{ - Installable, Lock, LockError, LockVersion, Package, PackageMap, PylockToml, + Installable, Lock, LockError, LockVersion, Metadata, Package, PackageMap, PylockToml, PylockTomlErrorKind, RequirementsTxtExport, ResolverManifest, SatisfiesResult, TreeDisplay, VERSION, cyclonedx_json, }; diff --git a/crates/uv-resolver/src/lock/export/metadata.rs b/crates/uv-resolver/src/lock/export/metadata.rs new file mode 100644 index 0000000000..f618d9a6bc --- /dev/null +++ b/crates/uv-resolver/src/lock/export/metadata.rs @@ -0,0 +1,831 @@ +use std::collections::BTreeMap; +use std::fmt::Display; + +use uv_distribution_filename::WheelFilename; +use uv_distribution_types::{RequiresPython, UrlString}; +use uv_fs::PortablePathBuf; +use uv_normalize::{ExtraName, GroupName, PackageName}; +use uv_pep440::Version; +use uv_pypi_types::{ConflictItem, ConflictKind, ConflictSet, Conflicts}; +use uv_workspace::Workspace; + +use crate::Lock; +use crate::lock::{ + Dependency, DirectSource, PackageId, RegistrySource, Source, SourceDist, SourceDistMetadata, + Wheel, WheelWireSource, ZstdWheel, +}; + +#[derive(Debug, thiserror::Error)] +enum MetadataErrorKind { + #[error(transparent)] + Serialize(#[from] serde_json::error::Error), +} + +#[derive(Debug)] +pub struct MetadataError { + kind: Box, +} + +impl std::error::Error for MetadataError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.kind.source() + } +} + +impl std::fmt::Display for MetadataError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.kind)?; + Ok(()) + } +} + +impl From for MetadataError +where + MetadataErrorKind: From, +{ + fn from(err: E) -> Self { + Self { + kind: Box::new(MetadataErrorKind::from(err)), + } + } +} + +/// The full `uv workspace metadata` JSON object +#[derive(Debug, serde::Serialize)] +pub struct Metadata { + /// Format information + schema: SchemaReport, + /// Absolute path to the workspace root + /// + /// Ideally absolute paths to things that are found in subdirs of this should have exactly + /// this as a prefix so it can be stripped to get relative paths if one wants. + workspace_root: PortablePathBuf, + /// The version of python required by the workspace + /// + /// Every `marker` we emit implicitly assumes this constraint to keep things clean + requires_python: RequiresPython, + /// Info about conflicting packages + conflicts: MetadataConflicts, + /// An index of which nodes are workspace members + /// + /// These entries are often what you should use as the entry-points into the `resolve` graph. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + members: Vec, + /// The dependency graph + #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] + resolution: BTreeMap, +} + +/// The schema version for the metadata report. +#[derive(serde::Serialize, Debug, Default)] +#[serde(rename_all = "snake_case")] +enum SchemaVersion { + /// An unstable, experimental schema. + #[default] + Preview, +} + +/// The schema metadata for the metadata report. +#[derive(serde::Serialize, Debug, Default)] +struct SchemaReport { + /// The version of the schema. + version: SchemaVersion, +} + +/// Info for looking up workspace members, most information is stored in the node behind `id` +#[derive(Debug, serde::Serialize)] +struct MetadataWorkspaceMember { + /// Package name + name: PackageName, + /// Absolute path to the member + path: PortablePathBuf, + /// Key for the package's node in the `resolve` graph + id: MetadataNodeIdFlat, +} + +/// A node in the dependency graph +/// +/// There are 4 kinds of nodes: +/// +/// * packages: `mypackage==1.0.0@registry+https://pypi.org/simple` +/// * extras: `mypackage[myextra]==1.0.0@registry+https://pypi.org/simple` +/// * groups: `mypackage:mygroup==1.0.0@registry+https://pypi.org/simple` +/// * build: `mypackage(build)==1.0.0@registry+https://pypi.org/simple` +/// +/// ----------- +/// +/// A package like this: +/// +/// ```toml +/// [project] +/// name = "mypackage" +/// version = 1.0.0 +/// +/// dependencies = ["httpx"] +/// +/// [project.optional-dependencies] +/// cli = ["rich"] +/// +/// [dependency-groups] +/// dev = ["typing-extensions"] +/// +/// [build-system] +/// requires = ["hatchling"] +/// ``` +/// +/// will get 4 nodes with the following edges (Version and Source omitted here for brevity): +/// +/// * `mypackage` +/// * `httpx` +/// * `mypackage(build)` +/// * `hatchling` +/// * `mypackage[cli]` +/// * `mypackage` +/// * `rich` +/// * `mypackage:dev` +/// * `typing-extensions` +/// +/// Note that `mypackage[cli]` has a dependency edge on `mypackage` while `mypackage:dev` does not. +/// This is because `mypackage[cli]` is fundamentally an augmentation of `mypackage` while `mypackage:dev` +/// is just a list of packages that happens to be defined by `mypackage`'s pyproject.toml. +#[derive(Debug, Clone, serde::Serialize)] +struct MetadataNode { + /// A unique id for this node that will be used to refer to it + #[serde(flatten)] + id: MetadataNodeId, + /// Dependencies of this node (the edges of The Graph) + dependencies: Vec, + /// Extras + #[serde(skip_serializing_if = "Vec::is_empty", default)] + optional_dependencies: Vec, + /// Groups + #[serde(skip_serializing_if = "Vec::is_empty", default)] + dependency_groups: Vec, + /// Info about building the package + #[serde(skip_serializing_if = "Option::is_none", default)] + build_system: Option, + /// The source distribution found + #[serde(skip_serializing_if = "Option::is_none", default)] + sdist: Option, + /// Wheels we found + #[serde(skip_serializing_if = "Vec::is_empty", default)] + wheels: Vec, +} + +impl MetadataNode { + fn new(id: MetadataNodeId) -> Self { + Self { + id, + dependencies: Vec::new(), + dependency_groups: Vec::new(), + optional_dependencies: Vec::new(), + wheels: Vec::new(), + build_system: None, + sdist: None, + } + } + + fn from_package_id( + workspace_root: &PortablePathBuf, + id: &PackageId, + kind: MetadataNodeKind, + ) -> Self { + Self::new(MetadataNodeId::from_package_id(workspace_root, id, kind)) + } + + fn add_dependency(&mut self, workspace_root: &PortablePathBuf, dependency: &Dependency) { + let extras = dependency.extra(); + if extras.is_empty() { + let id = MetadataNodeId::from_package_id( + workspace_root, + &dependency.package_id, + MetadataNodeKind::Package, + ); + self.dependencies.push(MetadataDependency { + id: id.to_flat(), + marker: dependency.simplified_marker.try_to_string(), + }); + return; + } + for extra in extras { + let id = MetadataNodeId::from_package_id( + workspace_root, + &dependency.package_id, + MetadataNodeKind::Extra(extra.clone()), + ); + self.dependencies.push(MetadataDependency { + id: id.to_flat(), + marker: dependency.simplified_marker.try_to_string(), + }); + } + } +} + +/// The unique key for every node in the graph +/// +/// (It's not entirely clear to me that two nodes can differ only by `source` but it doesn't hurt.) +#[derive(Debug, Clone, serde::Serialize)] +struct MetadataNodeId { + /// The name of the package + name: PackageName, + /// The version of the package, if any could be found (source trees may have no version) + #[serde(skip_serializing_if = "Option::is_none", default)] + version: Option, + /// The source of the package (directory, registry, URL...) + source: MetadataSource, + /// What kind of node is this? + kind: MetadataNodeKind, +} + +/// This is intended to be an opaque unique id for referring to a node +/// +/// It's human readable for convenience but parsing it or relying on it is inadvisable. +/// As currently implemented this is just a concatenation of the 4 fields in `MetadataNodeId` +/// which every node includes, so parsing it is just making more work for yourself. +type MetadataNodeIdFlat = String; + +impl MetadataNodeId { + fn from_package_id( + workspace_root: &PortablePathBuf, + id: &PackageId, + kind: MetadataNodeKind, + ) -> Self { + let name = id.name.clone(); + let version = id.version.clone(); + let source = MetadataSource::from_source(workspace_root, id.source.clone()); + + Self { + name, + version, + source, + kind, + } + } + + fn to_flat(&self) -> MetadataNodeIdFlat { + self.to_string() + } +} + +impl Display for MetadataNodeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.version { + Some(version) => write!(f, "{}{}=={version}@{}", self.name, self.kind, self.source), + None => write!(f, "{}{}@{}", self.name, self.kind, self.source), + } + } +} + +#[derive(Debug, Clone, serde::Serialize)] +struct MetadataDependency { + id: MetadataNodeIdFlat, + #[serde(skip_serializing_if = "Option::is_none", default)] + marker: Option, +} + +type MetadataMarker = String; + +/// The kind a node can have in the dependency graph +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)] +#[serde(rename_all = "snake_case")] +enum MetadataNodeKind { + /// The node is the package itself + /// its edges are `project.dependencies` + Package, + /// The node is for building the package's sdist into a wheel + /// its edges are `build-system.requires` + #[expect(dead_code)] + Build, + /// The node is for an extra defined on the package + /// its edges are `project.optional-dependencies.myextra` + Extra(ExtraName), + /// The node is for a dependency-group defined on the package + /// its edges are `dependency-groups.mygroup` + Group(GroupName), +} + +impl Display for MetadataNodeKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + // Don't apply any special decoration, this is the default + Self::Package => Ok(()), + Self::Build => f.write_str("(build)"), + Self::Extra(extra_name) => write!(f, "[{extra_name}]"), + Self::Group(group_name) => write!(f, ":{group_name}"), + } + } +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(untagged, rename_all = "snake_case")] +enum MetadataSource { + Registry { + registry: MetadataRegistrySource, + }, + Git { + git: UrlString, + }, + Direct { + url: UrlString, + subdirectory: Option, + }, + Path { + path: PortablePathBuf, + }, + Directory { + directory: PortablePathBuf, + }, + Editable { + editable: PortablePathBuf, + }, + Virtual { + r#virtual: PortablePathBuf, + }, +} + +impl Display for MetadataSource { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Registry { + registry: MetadataRegistrySource::Url(url), + } + | Self::Git { git: url } + | Self::Direct { url, .. } => { + write!(f, "{}+{}", self.name(), url) + } + Self::Registry { + registry: MetadataRegistrySource::Path(path), + } + | Self::Path { path } + | Self::Directory { directory: path } + | Self::Editable { editable: path } + | Self::Virtual { r#virtual: path } => { + write!(f, "{}+{}", self.name(), path) + } + } + } +} + +impl MetadataSource { + fn name(&self) -> &str { + match self { + Self::Registry { .. } => "registry", + Self::Git { .. } => "git", + Self::Direct { .. } => "direct", + Self::Path { .. } => "path", + Self::Directory { .. } => "directory", + Self::Editable { .. } => "editable", + Self::Virtual { .. } => "virtual", + } + } +} + +impl MetadataSource { + fn from_source(workspace_root: &PortablePathBuf, source: Source) -> Self { + match source { + Source::Registry(source) => match source { + RegistrySource::Url(url) => Self::Registry { + registry: MetadataRegistrySource::Url(url), + }, + RegistrySource::Path(path) => Self::Registry { + registry: MetadataRegistrySource::Path(normalize_workspace_relative_path( + workspace_root, + &path, + )), + }, + }, + Source::Git(url, _) => Self::Git { git: url }, + Source::Direct(url, DirectSource { subdirectory }) => Self::Direct { + url, + subdirectory: subdirectory + .map(|path| normalize_workspace_relative_path(workspace_root, &path)), + }, + Source::Path(path) => Self::Path { + path: normalize_workspace_relative_path(workspace_root, &path), + }, + Source::Directory(path) => Self::Directory { + directory: normalize_workspace_relative_path(workspace_root, &path), + }, + Source::Editable(path) => Self::Editable { + editable: normalize_workspace_relative_path(workspace_root, &path), + }, + Source::Virtual(path) => Self::Virtual { + r#virtual: normalize_workspace_relative_path(workspace_root, &path), + }, + } + } +} + +fn normalize_workspace_relative_path( + workspace_root: &PortablePathBuf, + maybe_rel: &std::path::Path, +) -> PortablePathBuf { + if maybe_rel.is_absolute() { + PortablePathBuf::from(maybe_rel) + } else { + PortablePathBuf::from(workspace_root.as_ref().join(maybe_rel).as_path()) + } +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "snake_case")] +enum MetadataRegistrySource { + /// Ex) `https://pypi.org/simple` + Url(UrlString), + /// Ex) `/path/to/local/index` + Path(PortablePathBuf), +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(untagged, rename_all = "snake_case")] +enum MetadataSourceDist { + Url { + url: UrlString, + #[serde(flatten)] + metadata: MetadataSourceDistMetadata, + }, + Path { + path: PortablePathBuf, + #[serde(flatten)] + metadata: MetadataSourceDistMetadata, + }, + Metadata { + #[serde(flatten)] + metadata: MetadataSourceDistMetadata, + }, +} + +impl MetadataSourceDist { + fn from_sdist(workspace_root: &PortablePathBuf, sdist: &SourceDist) -> Self { + match sdist { + SourceDist::Url { url, metadata } => Self::Url { + url: url.clone(), + metadata: MetadataSourceDistMetadata::from_sdist(metadata), + }, + SourceDist::Path { path, metadata } => Self::Path { + path: normalize_workspace_relative_path(workspace_root, path), + metadata: MetadataSourceDistMetadata::from_sdist(metadata), + }, + SourceDist::Metadata { metadata } => Self::Metadata { + metadata: MetadataSourceDistMetadata::from_sdist(metadata), + }, + } + } +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "snake_case")] +struct MetadataSourceDistMetadata { + /// A hash of the source distribution. + #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] + hashes: BTreeMap, + /// The size of the source distribution in bytes. + /// + /// This is only present for source distributions that come from registries. + #[serde(skip_serializing_if = "Option::is_none", default)] + size: Option, + /// The upload time of the source distribution. + #[serde(skip_serializing_if = "Option::is_none", default)] + upload_time: Option, +} + +/// The name of a hash algorithm ("sha256", "blake2b", "md5", etc) +type HashAlgorithm = String; +/// A hex encoded digest of the file +type Hash = String; + +/// Oh you wanted a hash map? No this is the hashes map, a sorted map of hashes! +/// +/// We prefer matching PEP 691 (JSON-based Simple API for Python) here for future-proofing +/// and convenience of consumption. +fn hashes_map(hash: &crate::lock::Hash) -> BTreeMap { + Some((hash.0.algorithm.to_string(), hash.0.digest.to_string())) + .into_iter() + .collect() +} + +impl MetadataSourceDistMetadata { + fn from_sdist(sdist: &SourceDistMetadata) -> Self { + Self { + hashes: sdist.hash.as_ref().map(hashes_map).unwrap_or_default(), + size: sdist.size, + upload_time: sdist.upload_time, + } + } +} +#[derive(Clone, Debug, serde::Serialize)] +struct MetadataWheel { + /// A URL or file path (via `file://`) where the wheel that was locked + /// against was found. The location does not need to exist in the future, + /// so this should be treated as only a hint to where to look and/or + /// recording where the wheel file originally came from. + #[serde(flatten)] + source: Option, + /// A hash of the built distribution. + /// + /// This is only present for wheels that come from registries and direct + /// URLs. Wheels from git or path dependencies do not have hashes + /// associated with them. + #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] + hashes: BTreeMap, + /// The size of the built distribution in bytes. + /// + /// This is only present for wheels that come from registries. + #[serde(skip_serializing_if = "Option::is_none", default)] + size: Option, + /// The upload time of the built distribution. + /// + /// This is only present for wheels that come from registries. + #[serde(skip_serializing_if = "Option::is_none", default)] + upload_time: Option, + /// The filename of the wheel. + /// + /// This isn't part of the wire format since it's redundant with the + /// URL. But we do use it for various things, and thus compute it at + /// deserialization time. Not being able to extract a wheel filename from a + /// wheel URL is thus a deserialization error. + filename: WheelFilename, + /// The zstandard-compressed wheel metadata, if any. + #[serde(skip_serializing_if = "Option::is_none", default)] + zstd: Option, +} + +impl MetadataWheel { + fn from_wheel(workspace_root: &PortablePathBuf, wheel: &Wheel) -> Self { + Self { + source: MetadataWheelWireSource::from_wheel(workspace_root, &wheel.url), + hashes: wheel.hash.as_ref().map(hashes_map).unwrap_or_default(), + size: wheel.size, + upload_time: wheel.upload_time, + filename: wheel.filename.clone(), + zstd: wheel.zstd.as_ref().map(MetadataZstdWheel::from_wheel), + } + } +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(untagged, rename_all = "snake_case")] +enum MetadataWheelWireSource { + Url { url: UrlString }, + Path { path: PortablePathBuf }, +} + +impl MetadataWheelWireSource { + fn from_wheel(workspace_root: &PortablePathBuf, wheel: &WheelWireSource) -> Option { + match wheel { + WheelWireSource::Url { url } => Some(Self::Url { url: url.clone() }), + WheelWireSource::Path { path } => Some(Self::Path { + path: normalize_workspace_relative_path(workspace_root, path), + }), + // We guarantee this as a separate field so it's redundant + WheelWireSource::Filename { .. } => None, + } + } +} + +#[derive(Clone, Debug, serde::Serialize)] +struct MetadataZstdWheel { + #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] + hashes: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none", default)] + size: Option, +} + +impl MetadataZstdWheel { + fn from_wheel(wheel: &ZstdWheel) -> Self { + Self { + hashes: wheel.hash.as_ref().map(hashes_map).unwrap_or_default(), + size: wheel.size, + } + } +} + +#[derive(Clone, Debug, serde::Serialize)] +struct MetadataExtra { + name: ExtraName, + id: MetadataNodeIdFlat, +} + +#[derive(Clone, Debug, serde::Serialize)] +struct MetadataGroup { + name: GroupName, + id: MetadataNodeIdFlat, +} + +#[derive(Clone, Debug, serde::Serialize)] +struct MetadataBuildSystem { + /// The `build-backend` specified in the pyproject.toml + build_backend: String, + id: MetadataNodeIdFlat, +} + +/// Conflicts +#[derive(Clone, Debug, serde::Serialize)] +struct MetadataConflicts { + sets: Vec, +} + +impl MetadataConflicts { + fn from_conflicts( + members: &[MetadataWorkspaceMember], + resolve: &BTreeMap, + conflicts: &Conflicts, + ) -> Self { + Self { + sets: conflicts + .iter() + .map(|set| MetadataConflictSet::from_conflicts(members, resolve, set)) + .collect(), + } + } +} + +#[derive(Clone, Debug, serde::Serialize)] +struct MetadataConflictSet { + items: Vec, +} + +impl MetadataConflictSet { + fn from_conflicts( + members: &[MetadataWorkspaceMember], + resolve: &BTreeMap, + set: &ConflictSet, + ) -> Self { + Self { + items: set + .iter() + .map(|item| MetadataConflictItem::from_conflicts(members, resolve, item)) + .collect(), + } + } +} + +#[derive(Clone, Debug, serde::Serialize)] +struct MetadataConflictItem { + /// These should always be names of packages referred to in [`Metadata::members`] + package: PackageName, + kind: MetadataConflictKind, + /// This should never be None (should be a validation error way earlier in uv) + /// ...but I'd rather not error if wrong. + id: Option, +} + +impl MetadataConflictItem { + fn from_conflicts( + members: &[MetadataWorkspaceMember], + resolve: &BTreeMap, + item: &ConflictItem, + ) -> Self { + let kind = MetadataConflictKind::from_conflicts(item.kind()); + let id = members + .iter() + .find(|member| &member.name == item.package()) + .and_then(|member| { + let package_node = resolve.get(&member.id)?; + let id = MetadataNodeId { + kind: kind.to_node_kind(), + ..package_node.id.clone() + }; + Some(id.to_flat()) + }); + Self { + package: item.package().clone(), + kind, + id, + } + } +} + +#[derive(Clone, Debug, serde::Serialize)] +enum MetadataConflictKind { + Group(GroupName), + Extra(ExtraName), + Project, +} + +impl MetadataConflictKind { + fn from_conflicts(item: &ConflictKind) -> Self { + match item { + ConflictKind::Extra(name) => Self::Extra(name.clone()), + ConflictKind::Group(name) => Self::Group(name.clone()), + ConflictKind::Project => Self::Project, + } + } + + fn to_node_kind(&self) -> MetadataNodeKind { + match self { + Self::Group(name) => MetadataNodeKind::Group(name.clone()), + Self::Extra(name) => MetadataNodeKind::Extra(name.clone()), + Self::Project => MetadataNodeKind::Package, + } + } +} + +impl Metadata { + /// Construct a [`PylockToml`] from a uv lockfile. + pub fn from_lock(workspace: &Workspace, lock: &Lock) -> Result { + let mut resolve = BTreeMap::new(); + let mut members = Vec::new(); + let workspace_root = PortablePathBuf::from(workspace.install_path().as_path()); + + for lock_package in lock.packages() { + let mut meta_package = MetadataNode::from_package_id( + &workspace_root, + &lock_package.id, + MetadataNodeKind::Package, + ); + + // Direct dependencies go on the package node + for dependency in &lock_package.dependencies { + meta_package.add_dependency(&workspace_root, dependency); + } + + // Extras get their own nodes + for (extra, dependencies) in &lock_package.optional_dependencies { + let mut meta_extra = MetadataNode::from_package_id( + &workspace_root, + &lock_package.id, + MetadataNodeKind::Extra(extra.clone()), + ); + // Extras always depend on the base package + meta_extra.dependencies.push(MetadataDependency { + id: meta_package.id.to_flat(), + marker: None, + }); + for dependency in dependencies { + meta_extra.add_dependency(&workspace_root, dependency); + } + + meta_package.optional_dependencies.push(MetadataExtra { + name: extra.clone(), + id: meta_extra.id.to_flat(), + }); + + resolve.insert(meta_extra.id.to_flat(), meta_extra); + } + + // Groups get their own nodes + for (group, dependencies) in &lock_package.dependency_groups { + let mut meta_group = MetadataNode::from_package_id( + &workspace_root, + &lock_package.id, + MetadataNodeKind::Group(group.clone()), + ); + // Groups *do not* depend on the base package, so don't add that + for dependency in dependencies { + meta_group.add_dependency(&workspace_root, dependency); + } + + meta_package.dependency_groups.push(MetadataGroup { + name: group.clone(), + id: meta_group.id.to_flat(), + }); + + resolve.insert(meta_group.id.to_flat(), meta_group); + } + + // Register this package if it appears to be a workspace member + if let Some(workspace_package) = workspace.packages().get(lock_package.name()) { + let member = MetadataWorkspaceMember { + name: meta_package.id.name.clone(), + path: normalize_workspace_relative_path( + &workspace_root, + workspace_package.root().as_path(), + ), + id: meta_package.id.to_flat(), + }; + members.push(member); + } + + // Record sdist/wheel information + if let Some(sdist) = &lock_package.sdist { + meta_package.sdist = Some(MetadataSourceDist::from_sdist(&workspace_root, sdist)); + } + + for wheel in &lock_package.wheels { + meta_package + .wheels + .push(MetadataWheel::from_wheel(&workspace_root, wheel)); + } + + resolve.insert(meta_package.id.to_flat(), meta_package); + } + + let conflicts = MetadataConflicts::from_conflicts(&members, &resolve, &lock.conflicts); + + Ok(Self { + schema: SchemaReport { + version: SchemaVersion::Preview, + }, + conflicts, + workspace_root, + requires_python: lock.requires_python.clone(), + members, + resolution: resolve, + }) + } + + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string_pretty(self)?) + } +} diff --git a/crates/uv-resolver/src/lock/export/mod.rs b/crates/uv-resolver/src/lock/export/mod.rs index 23f48da9de..25cafd363b 100644 --- a/crates/uv-resolver/src/lock/export/mod.rs +++ b/crates/uv-resolver/src/lock/export/mod.rs @@ -17,6 +17,7 @@ use uv_pypi_types::ConflictItem; use crate::graph_ops::{Reachable, marker_reachability}; use crate::lock::LockErrorKind; +pub use crate::lock::export::metadata::Metadata; pub(crate) use crate::lock::export::pylock_toml::PylockTomlPackage; pub use crate::lock::export::pylock_toml::{PylockToml, PylockTomlErrorKind}; pub use crate::lock::export::requirements_txt::RequirementsTxtExport; @@ -24,6 +25,7 @@ use crate::universal_marker::resolve_conflicts; use crate::{Installable, LockError, Package}; pub mod cyclonedx_json; +mod metadata; mod pylock_toml; mod requirements_txt; diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index aa81194a25..4667530baf 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -58,7 +58,7 @@ use crate::exclude_newer::ExcludeNewerSpan; use crate::fork_strategy::ForkStrategy; pub(crate) use crate::lock::export::PylockTomlPackage; pub use crate::lock::export::RequirementsTxtExport; -pub use crate::lock::export::{PylockToml, PylockTomlErrorKind, cyclonedx_json}; +pub use crate::lock::export::{Metadata, PylockToml, PylockTomlErrorKind, cyclonedx_json}; pub use crate::lock::installable::Installable; pub use crate::lock::map::PackageMap; pub use crate::lock::tree::TreeDisplay; diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 8971a228be..0067b7322f 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -269,7 +269,7 @@ pub(crate) async fn lock( } #[derive(Debug, Clone, Copy)] -pub(super) enum LockMode<'env> { +pub(crate) enum LockMode<'env> { /// Write the lockfile to disk. Write(&'env Interpreter), /// Perform a resolution, but don't write the lockfile to disk. @@ -281,7 +281,7 @@ pub(super) enum LockMode<'env> { } /// A lock operation. -pub(super) struct LockOperation<'env> { +pub(crate) struct LockOperation<'env> { mode: LockMode<'env>, constraints: Vec, refresh: Option<&'env Refresh>, @@ -298,7 +298,7 @@ pub(super) struct LockOperation<'env> { impl<'env> LockOperation<'env> { /// Initialize a [`LockOperation`]. - pub(super) fn new( + pub(crate) fn new( mode: LockMode<'env>, settings: &'env ResolverSettings, client_builder: &'env BaseClientBuilder<'env>, @@ -328,7 +328,7 @@ impl<'env> LockOperation<'env> { /// Set the external constraints for the [`LockOperation`]. #[must_use] - pub(super) fn with_constraints( + pub(crate) fn with_constraints( mut self, constraints: Vec, ) -> Self { @@ -338,13 +338,13 @@ impl<'env> LockOperation<'env> { /// Set the refresh strategy for the [`LockOperation`]. #[must_use] - pub(super) fn with_refresh(mut self, refresh: &'env Refresh) -> Self { + pub(crate) fn with_refresh(mut self, refresh: &'env Refresh) -> Self { self.refresh = Some(refresh); self } /// Perform a [`LockOperation`]. - pub(super) async fn execute(self, target: LockTarget<'_>) -> Result { + pub(crate) async fn execute(self, target: LockTarget<'_>) -> Result { match self.mode { LockMode::Frozen(source) => { // Read the existing lockfile, but don't attempt to lock the project. diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index e1c389d768..5cc7331545 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -70,7 +70,7 @@ pub(crate) mod format; pub(crate) mod init; mod install_target; pub(crate) mod lock; -mod lock_target; +pub(crate) mod lock_target; pub(crate) mod remove; pub(crate) mod run; pub(crate) mod sync; diff --git a/crates/uv/src/commands/workspace/metadata.rs b/crates/uv/src/commands/workspace/metadata.rs index 67dd15c337..f82bd409db 100644 --- a/crates/uv/src/commands/workspace/metadata.rs +++ b/crates/uv/src/commands/workspace/metadata.rs @@ -2,59 +2,45 @@ use std::fmt::Write; use std::path::Path; use anyhow::Result; -use serde::Serialize; +use owo_colors::OwoColorize; -use uv_fs::PortablePathBuf; -use uv_normalize::PackageName; +use uv_cache::{Cache, Refresh}; +use uv_client::BaseClientBuilder; +use uv_configuration::{Concurrency, DependencyGroupsWithDefaults, DryRun}; use uv_preview::{Preview, PreviewFeature}; +use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; +use uv_resolver::{Lock, Metadata}; +use uv_settings::PythonInstallMirrors; use uv_warnings::warn_user; -use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache}; +use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceCache}; -use crate::commands::ExitStatus; +use crate::commands::pip::loggers::DefaultResolveLogger; +use crate::commands::project::lock::{LockMode, LockOperation}; +use crate::commands::project::lock_target::LockTarget; +use crate::commands::project::{ProjectError, ProjectInterpreter, UniversalState}; +use crate::commands::{ExitStatus, diagnostics}; use crate::printer::Printer; - -/// The schema version for the metadata report. -#[derive(Serialize, Debug, Default)] -#[serde(rename_all = "snake_case")] -enum SchemaVersion { - /// An unstable, experimental schema. - #[default] - Preview, -} - -/// The schema metadata for the metadata report. -#[derive(Serialize, Debug, Default)] -struct SchemaReport { - /// The version of the schema. - version: SchemaVersion, -} - -/// Report for a single workspace member. -#[derive(Serialize, Debug)] -struct WorkspaceMemberReport { - /// The name of the workspace member. - name: PackageName, - /// The path to the workspace member's root directory. - path: PortablePathBuf, -} - -/// The report for a metadata operation. -#[derive(Serialize, Debug)] -struct MetadataReport { - /// The schema of this report. - schema: SchemaReport, - /// The workspace root directory. - workspace_root: PortablePathBuf, - /// The workspace members. - members: Vec, -} +use crate::settings::{FrozenSource, LockCheck, ResolverSettings}; /// Display metadata about the workspace. pub(crate) async fn metadata( project_dir: &Path, - preview: Preview, + lock_check: LockCheck, + frozen: Option, + dry_run: DryRun, + refresh: Refresh, + python: Option, + install_mirrors: PythonInstallMirrors, + settings: ResolverSettings, + client_builder: BaseClientBuilder<'_>, + python_preference: PythonPreference, + python_downloads: PythonDownloads, + concurrency: Concurrency, + no_config: bool, + cache: &Cache, workspace_cache: &WorkspaceCache, printer: Printer, + preview: Preview, ) -> Result { if !preview.is_enabled(PreviewFeature::WorkspaceMetadata) { warn_user!( @@ -63,29 +49,89 @@ pub(crate) async fn metadata( ); } - let workspace = - Workspace::discover(project_dir, &DiscoveryOptions::default(), workspace_cache).await?; + let virtual_project = + VirtualProject::discover(project_dir, &DiscoveryOptions::default(), workspace_cache) + .await?; + let target = LockTarget::Workspace(virtual_project.workspace()); - let members = workspace - .packages() - .values() - .map(|package| WorkspaceMemberReport { - name: package.project().name.clone(), - path: PortablePathBuf::from(package.root().as_path()), - }) - .collect(); + // Determine the lock mode. + let interpreter; + let mode = if let Some(frozen_source) = frozen { + LockMode::Frozen(frozen_source.into()) + } else { + interpreter = ProjectInterpreter::discover( + virtual_project.workspace(), + project_dir, + // Don't enable any groups' requires-python for interpreter discovery + &DependencyGroupsWithDefaults::none(), + python.as_deref().map(PythonRequest::parse), + &client_builder, + python_preference, + python_downloads, + &install_mirrors, + false, + no_config, + Some(false), + cache, + printer, + preview, + ) + .await? + .into_interpreter(); - let report = MetadataReport { - schema: SchemaReport::default(), - workspace_root: PortablePathBuf::from(workspace.install_path().as_path()), - members, + if let LockCheck::Enabled(lock_check) = lock_check { + LockMode::Locked(&interpreter, lock_check) + } else if dry_run.enabled() { + LockMode::DryRun(&interpreter) + } else { + LockMode::Write(&interpreter) + } }; - writeln!( - printer.stdout(), - "{}", - serde_json::to_string_pretty(&report)? - )?; + // Initialize any shared state. + let state = UniversalState::default(); + + // Perform the lock operation. + match Box::pin( + LockOperation::new( + mode, + &settings, + &client_builder, + &state, + Box::new(DefaultResolveLogger), + &concurrency, + cache, + workspace_cache, + printer, + preview, + ) + .with_refresh(&refresh) + .execute(target), + ) + .await + { + Ok(lock) => print_lock_as_metadata(virtual_project.workspace(), &lock.into_lock(), printer), + Err(err @ ProjectError::LockMismatch(..)) => { + writeln!(printer.stderr(), "{}", err.to_string().bold())?; + Ok(ExitStatus::Failure) + } + Err(ProjectError::Operation(err)) => { + diagnostics::OperationDiagnostic::with_system_certs(client_builder.system_certs()) + .report(err) + .map_or(Ok(ExitStatus::Failure), |err| Err(err.into())) + } + Err(err) => Err(err.into()), + } +} + +fn print_lock_as_metadata( + workspace: &Workspace, + lock: &Lock, + printer: Printer, +) -> Result { + let export = Metadata::from_lock(workspace, lock)?; + + writeln!(printer.stdout(), "{}", export.to_json()?)?; Ok(ExitStatus::Success) } diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index b0f7870d89..bc347fb68b 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1932,8 +1932,43 @@ async fn run(cli: Cli) -> Result { .await } Commands::Workspace(WorkspaceNamespace { command }) => match command { - WorkspaceCommand::Metadata(_args) => { - commands::metadata(&project_dir, globals.preview, &workspace_cache, printer).await + WorkspaceCommand::Metadata(args) => { + // Resolve the settings from the command-line arguments and workspace configuration. + let args = settings::MetadataSettings::resolve(args, filesystem, environment); + show_settings!(args); + + // Check for conflicts between offline and refresh. + globals + .network_settings + .check_refresh_conflict(&args.refresh); + + // Initialize the cache. + let cache = cache.init().await?.with_refresh( + args.refresh + .clone() + .combine(Refresh::from(args.settings.upgrade.clone())), + ); + + Box::pin(commands::metadata( + &project_dir, + args.lock_check, + args.frozen, + args.dry_run, + args.refresh, + args.python, + args.install_mirrors, + args.settings, + client_builder.subcommand(vec!["workspace metadata".to_owned()]), + globals.python_preference, + globals.python_downloads, + globals.concurrency, + cli.top_level.no_config, + &cache, + &workspace_cache, + printer, + globals.preview, + )) + .await } WorkspaceCommand::Dir(args) => { commands::dir(args.package, &project_dir, &workspace_cache, printer).await diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index fd57b2e7c1..a0bf551a38 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -14,12 +14,12 @@ use uv_cache::{CacheArgs, Refresh}; use uv_cli::comma::CommaSeparatedRequirements; use uv_cli::{ AddArgs, AuditArgs, AuthLoginArgs, AuthLogoutArgs, AuthTokenArgs, ColorChoice, ExternalCommand, - GlobalArgs, InitArgs, ListFormat, LockArgs, Maybe, PipCheckArgs, PipCompileArgs, PipFreezeArgs, - PipInstallArgs, PipListArgs, PipShowArgs, PipSyncArgs, PipTreeArgs, PipUninstallArgs, - PythonFindArgs, PythonInstallArgs, PythonListArgs, PythonListFormat, PythonPinArgs, - PythonUninstallArgs, PythonUpgradeArgs, RemoveArgs, RunArgs, SyncArgs, SyncFormat, ToolDirArgs, - ToolInstallArgs, ToolListArgs, ToolRunArgs, ToolUninstallArgs, TreeArgs, VenvArgs, VersionArgs, - VersionBumpSpec, VersionFormat, + GlobalArgs, InitArgs, ListFormat, LockArgs, Maybe, MetadataArgs, PipCheckArgs, PipCompileArgs, + PipFreezeArgs, PipInstallArgs, PipListArgs, PipShowArgs, PipSyncArgs, PipTreeArgs, + PipUninstallArgs, PythonFindArgs, PythonInstallArgs, PythonListArgs, PythonListFormat, + PythonPinArgs, PythonUninstallArgs, PythonUpgradeArgs, RemoveArgs, RunArgs, SyncArgs, + SyncFormat, ToolDirArgs, ToolInstallArgs, ToolListArgs, ToolRunArgs, ToolUninstallArgs, + TreeArgs, VenvArgs, VersionArgs, VersionBumpSpec, VersionFormat, }; use uv_cli::{ AuthorFrom, BuildArgs, ExportArgs, FormatArgs, PublishArgs, PythonDirArgs, @@ -1796,6 +1796,60 @@ impl LockSettings { } } } +/// The resolved settings to use for a `lock` invocation. +#[derive(Debug, Clone)] +pub(crate) struct MetadataSettings { + pub(crate) lock_check: LockCheck, + pub(crate) frozen: Option, + pub(crate) dry_run: DryRun, + pub(crate) python: Option, + pub(crate) install_mirrors: PythonInstallMirrors, + pub(crate) refresh: Refresh, + pub(crate) settings: ResolverSettings, +} + +impl MetadataSettings { + /// Resolve the [`LockSettings`] from the CLI and filesystem configuration. + pub(crate) fn resolve( + args: Box, + filesystem: Option, + environment: EnvironmentOptions, + ) -> Self { + let MetadataArgs { + locked, + frozen, + dry_run, + resolver, + build, + refresh, + python, + } = *args; + + let filesystem_install_mirrors = filesystem + .clone() + .map(|fs| fs.install_mirrors.clone()) + .unwrap_or_default(); + + // Resolve flags from CLI and environment variables. + let locked = resolve_flag(locked, "locked", environment.locked); + let frozen = resolve_flag(frozen, "frozen", environment.frozen); + + // Check for conflicts between locked and frozen. + check_conflicts(locked, frozen); + + Self { + lock_check: resolve_lock_check(locked), + frozen: resolve_frozen(frozen), + dry_run: DryRun::from_args(dry_run), + python: python.and_then(Maybe::into_option), + refresh: Refresh::from(refresh), + settings: ResolverSettings::combine(resolver_options(resolver, build), filesystem), + install_mirrors: environment + .install_mirrors + .combine(filesystem_install_mirrors), + } + } +} /// The resolved settings to use for a `add` invocation. #[expect(clippy::struct_excessive_bools)] diff --git a/crates/uv/tests/it/workspace_metadata.rs b/crates/uv/tests/it/workspace_metadata.rs index db1d79d7c1..0cad21b29d 100644 --- a/crates/uv/tests/it/workspace_metadata.rs +++ b/crates/uv/tests/it/workspace_metadata.rs @@ -23,16 +23,34 @@ fn workspace_metadata_simple() { "version": "preview" }, "workspace_root": "[TEMP_DIR]/foo", + "requires_python": ">=3.12", + "conflicts": { + "sets": [] + }, "members": [ { "name": "foo", - "path": "[TEMP_DIR]/foo" + "path": "[TEMP_DIR]/foo", + "id": "foo==0.1.0@virtual+[TEMP_DIR]/foo/" } - ] + ], + "resolution": { + "foo==0.1.0@virtual+[TEMP_DIR]/foo/": { + "name": "foo", + "version": "0.1.0", + "source": { + "virtual": "[TEMP_DIR]/foo/" + }, + "kind": "package", + "dependencies": [] + } + } } ----- stderr ----- warning: The `uv workspace metadata` command is experimental and may change without warning. Pass `--preview-features workspace-metadata` to disable this warning. + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 1 package in [TIME] "# ); } @@ -59,24 +77,140 @@ fn workspace_metadata_root_workspace() -> Result<()> { "version": "preview" }, "workspace_root": "[TEMP_DIR]/workspace", + "requires_python": ">=3.12", + "conflicts": { + "sets": [] + }, "members": [ { "name": "albatross", - "path": "[TEMP_DIR]/workspace" + "path": "[TEMP_DIR]/workspace", + "id": "albatross==0.1.0@editable+[TEMP_DIR]/workspace/" }, { "name": "bird-feeder", - "path": "[TEMP_DIR]/workspace/packages/bird-feeder" + "path": "[TEMP_DIR]/workspace/packages/bird-feeder", + "id": "bird-feeder==1.0.0@editable+[TEMP_DIR]/workspace/packages/bird-feeder" }, { "name": "seeds", - "path": "[TEMP_DIR]/workspace/packages/seeds" + "path": "[TEMP_DIR]/workspace/packages/seeds", + "id": "seeds==1.0.0@editable+[TEMP_DIR]/workspace/packages/seeds" } - ] + ], + "resolution": { + "albatross==0.1.0@editable+[TEMP_DIR]/workspace/": { + "name": "albatross", + "version": "0.1.0", + "source": { + "editable": "[TEMP_DIR]/workspace/" + }, + "kind": "package", + "dependencies": [ + { + "id": "bird-feeder==1.0.0@editable+[TEMP_DIR]/workspace/packages/bird-feeder" + }, + { + "id": "iniconfig==2.0.0@registry+https://pypi.org/simple" + } + ] + }, + "bird-feeder==1.0.0@editable+[TEMP_DIR]/workspace/packages/bird-feeder": { + "name": "bird-feeder", + "version": "1.0.0", + "source": { + "editable": "[TEMP_DIR]/workspace/packages/bird-feeder" + }, + "kind": "package", + "dependencies": [ + { + "id": "iniconfig==2.0.0@registry+https://pypi.org/simple" + }, + { + "id": "seeds==1.0.0@editable+[TEMP_DIR]/workspace/packages/seeds" + } + ] + }, + "idna==3.6@registry+https://pypi.org/simple": { + "name": "idna", + "version": "3.6", + "source": { + "registry": { + "url": "https://pypi.org/simple" + } + }, + "kind": "package", + "dependencies": [], + "sdist": { + "url": "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", + "hashes": { + "sha256": "9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca" + }, + "size": 175426, + "upload_time": "2023-11-25T15:40:54.902Z" + }, + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", + "hashes": { + "sha256": "c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" + }, + "size": 61567, + "upload_time": "2023-11-25T15:40:52.604Z", + "filename": "idna-3.6-py3-none-any.whl" + } + ] + }, + "iniconfig==2.0.0@registry+https://pypi.org/simple": { + "name": "iniconfig", + "version": "2.0.0", + "source": { + "registry": { + "url": "https://pypi.org/simple" + } + }, + "kind": "package", + "dependencies": [], + "sdist": { + "url": "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", + "hashes": { + "sha256": "2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3" + }, + "size": 4646, + "upload_time": "2023-01-07T11:08:11.254Z" + }, + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", + "hashes": { + "sha256": "b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + }, + "size": 5892, + "upload_time": "2023-01-07T11:08:09.864Z", + "filename": "iniconfig-2.0.0-py3-none-any.whl" + } + ] + }, + "seeds==1.0.0@editable+[TEMP_DIR]/workspace/packages/seeds": { + "name": "seeds", + "version": "1.0.0", + "source": { + "editable": "[TEMP_DIR]/workspace/packages/seeds" + }, + "kind": "package", + "dependencies": [ + { + "id": "idna==3.6@registry+https://pypi.org/simple" + } + ] + } + } } ----- stderr ----- warning: The `uv workspace metadata` command is experimental and may change without warning. Pass `--preview-features workspace-metadata` to disable this warning. + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 5 packages in [TIME] "# ); @@ -105,24 +239,207 @@ fn workspace_metadata_virtual_workspace() -> Result<()> { "version": "preview" }, "workspace_root": "[TEMP_DIR]/workspace", + "requires_python": ">=3.12", + "conflicts": { + "sets": [] + }, "members": [ { "name": "albatross", - "path": "[TEMP_DIR]/workspace/packages/albatross" + "path": "[TEMP_DIR]/workspace/packages/albatross", + "id": "albatross==0.1.0@editable+[TEMP_DIR]/workspace/packages/albatross" }, { "name": "bird-feeder", - "path": "[TEMP_DIR]/workspace/packages/bird-feeder" + "path": "[TEMP_DIR]/workspace/packages/bird-feeder", + "id": "bird-feeder==1.0.0@editable+[TEMP_DIR]/workspace/packages/bird-feeder" }, { "name": "seeds", - "path": "[TEMP_DIR]/workspace/packages/seeds" + "path": "[TEMP_DIR]/workspace/packages/seeds", + "id": "seeds==1.0.0@editable+[TEMP_DIR]/workspace/packages/seeds" } - ] + ], + "resolution": { + "albatross==0.1.0@editable+[TEMP_DIR]/workspace/packages/albatross": { + "name": "albatross", + "version": "0.1.0", + "source": { + "editable": "[TEMP_DIR]/workspace/packages/albatross" + }, + "kind": "package", + "dependencies": [ + { + "id": "bird-feeder==1.0.0@editable+[TEMP_DIR]/workspace/packages/bird-feeder" + }, + { + "id": "iniconfig==2.0.0@registry+https://pypi.org/simple" + } + ] + }, + "anyio==4.3.0@registry+https://pypi.org/simple": { + "name": "anyio", + "version": "4.3.0", + "source": { + "registry": { + "url": "https://pypi.org/simple" + } + }, + "kind": "package", + "dependencies": [ + { + "id": "idna==3.6@registry+https://pypi.org/simple" + }, + { + "id": "sniffio==1.3.1@registry+https://pypi.org/simple" + } + ], + "sdist": { + "url": "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", + "hashes": { + "sha256": "f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6" + }, + "size": 159642, + "upload_time": "2024-02-19T08:36:28.641Z" + }, + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", + "hashes": { + "sha256": "048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8" + }, + "size": 85584, + "upload_time": "2024-02-19T08:36:26.842Z", + "filename": "anyio-4.3.0-py3-none-any.whl" + } + ] + }, + "bird-feeder==1.0.0@editable+[TEMP_DIR]/workspace/packages/bird-feeder": { + "name": "bird-feeder", + "version": "1.0.0", + "source": { + "editable": "[TEMP_DIR]/workspace/packages/bird-feeder" + }, + "kind": "package", + "dependencies": [ + { + "id": "anyio==4.3.0@registry+https://pypi.org/simple" + }, + { + "id": "seeds==1.0.0@editable+[TEMP_DIR]/workspace/packages/seeds" + } + ] + }, + "idna==3.6@registry+https://pypi.org/simple": { + "name": "idna", + "version": "3.6", + "source": { + "registry": { + "url": "https://pypi.org/simple" + } + }, + "kind": "package", + "dependencies": [], + "sdist": { + "url": "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", + "hashes": { + "sha256": "9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca" + }, + "size": 175426, + "upload_time": "2023-11-25T15:40:54.902Z" + }, + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", + "hashes": { + "sha256": "c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" + }, + "size": 61567, + "upload_time": "2023-11-25T15:40:52.604Z", + "filename": "idna-3.6-py3-none-any.whl" + } + ] + }, + "iniconfig==2.0.0@registry+https://pypi.org/simple": { + "name": "iniconfig", + "version": "2.0.0", + "source": { + "registry": { + "url": "https://pypi.org/simple" + } + }, + "kind": "package", + "dependencies": [], + "sdist": { + "url": "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", + "hashes": { + "sha256": "2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3" + }, + "size": 4646, + "upload_time": "2023-01-07T11:08:11.254Z" + }, + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", + "hashes": { + "sha256": "b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + }, + "size": 5892, + "upload_time": "2023-01-07T11:08:09.864Z", + "filename": "iniconfig-2.0.0-py3-none-any.whl" + } + ] + }, + "seeds==1.0.0@editable+[TEMP_DIR]/workspace/packages/seeds": { + "name": "seeds", + "version": "1.0.0", + "source": { + "editable": "[TEMP_DIR]/workspace/packages/seeds" + }, + "kind": "package", + "dependencies": [ + { + "id": "idna==3.6@registry+https://pypi.org/simple" + } + ] + }, + "sniffio==1.3.1@registry+https://pypi.org/simple": { + "name": "sniffio", + "version": "1.3.1", + "source": { + "registry": { + "url": "https://pypi.org/simple" + } + }, + "kind": "package", + "dependencies": [], + "sdist": { + "url": "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", + "hashes": { + "sha256": "f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" + }, + "size": 20372, + "upload_time": "2024-02-25T23:20:04.057Z" + }, + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", + "hashes": { + "sha256": "2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" + }, + "size": 10235, + "upload_time": "2024-02-25T23:20:01.196Z", + "filename": "sniffio-1.3.1-py3-none-any.whl" + } + ] + } + } } ----- stderr ----- warning: The `uv workspace metadata` command is experimental and may change without warning. Pass `--preview-features workspace-metadata` to disable this warning. + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 7 packages in [TIME] "# ); @@ -153,24 +470,140 @@ fn workspace_metadata_from_member() -> Result<()> { "version": "preview" }, "workspace_root": "[TEMP_DIR]/workspace", + "requires_python": ">=3.12", + "conflicts": { + "sets": [] + }, "members": [ { "name": "albatross", - "path": "[TEMP_DIR]/workspace" + "path": "[TEMP_DIR]/workspace", + "id": "albatross==0.1.0@editable+[TEMP_DIR]/workspace/" }, { "name": "bird-feeder", - "path": "[TEMP_DIR]/workspace/packages/bird-feeder" + "path": "[TEMP_DIR]/workspace/packages/bird-feeder", + "id": "bird-feeder==1.0.0@editable+[TEMP_DIR]/workspace/packages/bird-feeder" }, { "name": "seeds", - "path": "[TEMP_DIR]/workspace/packages/seeds" + "path": "[TEMP_DIR]/workspace/packages/seeds", + "id": "seeds==1.0.0@editable+[TEMP_DIR]/workspace/packages/seeds" } - ] + ], + "resolution": { + "albatross==0.1.0@editable+[TEMP_DIR]/workspace/": { + "name": "albatross", + "version": "0.1.0", + "source": { + "editable": "[TEMP_DIR]/workspace/" + }, + "kind": "package", + "dependencies": [ + { + "id": "bird-feeder==1.0.0@editable+[TEMP_DIR]/workspace/packages/bird-feeder" + }, + { + "id": "iniconfig==2.0.0@registry+https://pypi.org/simple" + } + ] + }, + "bird-feeder==1.0.0@editable+[TEMP_DIR]/workspace/packages/bird-feeder": { + "name": "bird-feeder", + "version": "1.0.0", + "source": { + "editable": "[TEMP_DIR]/workspace/packages/bird-feeder" + }, + "kind": "package", + "dependencies": [ + { + "id": "iniconfig==2.0.0@registry+https://pypi.org/simple" + }, + { + "id": "seeds==1.0.0@editable+[TEMP_DIR]/workspace/packages/seeds" + } + ] + }, + "idna==3.6@registry+https://pypi.org/simple": { + "name": "idna", + "version": "3.6", + "source": { + "registry": { + "url": "https://pypi.org/simple" + } + }, + "kind": "package", + "dependencies": [], + "sdist": { + "url": "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", + "hashes": { + "sha256": "9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca" + }, + "size": 175426, + "upload_time": "2023-11-25T15:40:54.902Z" + }, + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", + "hashes": { + "sha256": "c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" + }, + "size": 61567, + "upload_time": "2023-11-25T15:40:52.604Z", + "filename": "idna-3.6-py3-none-any.whl" + } + ] + }, + "iniconfig==2.0.0@registry+https://pypi.org/simple": { + "name": "iniconfig", + "version": "2.0.0", + "source": { + "registry": { + "url": "https://pypi.org/simple" + } + }, + "kind": "package", + "dependencies": [], + "sdist": { + "url": "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", + "hashes": { + "sha256": "2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3" + }, + "size": 4646, + "upload_time": "2023-01-07T11:08:11.254Z" + }, + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", + "hashes": { + "sha256": "b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + }, + "size": 5892, + "upload_time": "2023-01-07T11:08:09.864Z", + "filename": "iniconfig-2.0.0-py3-none-any.whl" + } + ] + }, + "seeds==1.0.0@editable+[TEMP_DIR]/workspace/packages/seeds": { + "name": "seeds", + "version": "1.0.0", + "source": { + "editable": "[TEMP_DIR]/workspace/packages/seeds" + }, + "kind": "package", + "dependencies": [ + { + "id": "idna==3.6@registry+https://pypi.org/simple" + } + ] + } + } } ----- stderr ----- warning: The `uv workspace metadata` command is experimental and may change without warning. Pass `--preview-features workspace-metadata` to disable this warning. + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 5 packages in [TIME] "# ); @@ -211,24 +644,62 @@ fn workspace_metadata_multiple_members() { "version": "preview" }, "workspace_root": "[TEMP_DIR]/pkg-a", + "requires_python": ">=3.12", + "conflicts": { + "sets": [] + }, "members": [ { "name": "pkg-a", - "path": "[TEMP_DIR]/pkg-a" + "path": "[TEMP_DIR]/pkg-a", + "id": "pkg-a==0.1.0@virtual+[TEMP_DIR]/pkg-a/" }, { "name": "pkg-b", - "path": "[TEMP_DIR]/pkg-a/pkg-b" + "path": "[TEMP_DIR]/pkg-a/pkg-b", + "id": "pkg-b==0.1.0@virtual+[TEMP_DIR]/pkg-a/pkg-b" }, { "name": "pkg-c", - "path": "[TEMP_DIR]/pkg-a/pkg-c" + "path": "[TEMP_DIR]/pkg-a/pkg-c", + "id": "pkg-c==0.1.0@virtual+[TEMP_DIR]/pkg-a/pkg-c" } - ] + ], + "resolution": { + "pkg-a==0.1.0@virtual+[TEMP_DIR]/pkg-a/": { + "name": "pkg-a", + "version": "0.1.0", + "source": { + "virtual": "[TEMP_DIR]/pkg-a/" + }, + "kind": "package", + "dependencies": [] + }, + "pkg-b==0.1.0@virtual+[TEMP_DIR]/pkg-a/pkg-b": { + "name": "pkg-b", + "version": "0.1.0", + "source": { + "virtual": "[TEMP_DIR]/pkg-a/pkg-b" + }, + "kind": "package", + "dependencies": [] + }, + "pkg-c==0.1.0@virtual+[TEMP_DIR]/pkg-a/pkg-c": { + "name": "pkg-c", + "version": "0.1.0", + "source": { + "virtual": "[TEMP_DIR]/pkg-a/pkg-c" + }, + "kind": "package", + "dependencies": [] + } + } } ----- stderr ----- warning: The `uv workspace metadata` command is experimental and may change without warning. Pass `--preview-features workspace-metadata` to disable this warning. + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 3 packages in [TIME] "# ); } @@ -251,16 +722,34 @@ fn workspace_metadata_single_project() { "version": "preview" }, "workspace_root": "[TEMP_DIR]/my-project", + "requires_python": ">=3.12", + "conflicts": { + "sets": [] + }, "members": [ { "name": "my-project", - "path": "[TEMP_DIR]/my-project" + "path": "[TEMP_DIR]/my-project", + "id": "my-project==0.1.0@virtual+[TEMP_DIR]/my-project/" } - ] + ], + "resolution": { + "my-project==0.1.0@virtual+[TEMP_DIR]/my-project/": { + "name": "my-project", + "version": "0.1.0", + "source": { + "virtual": "[TEMP_DIR]/my-project/" + }, + "kind": "package", + "dependencies": [] + } + } } ----- stderr ----- warning: The `uv workspace metadata` command is experimental and may change without warning. Pass `--preview-features workspace-metadata` to disable this warning. + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 1 package in [TIME] "# ); } @@ -287,16 +776,139 @@ fn workspace_metadata_with_excluded() -> Result<()> { "version": "preview" }, "workspace_root": "[TEMP_DIR]/workspace", + "requires_python": ">=3.12", + "conflicts": { + "sets": [] + }, "members": [ { "name": "albatross", - "path": "[TEMP_DIR]/workspace" + "path": "[TEMP_DIR]/workspace", + "id": "albatross==0.1.0@editable+[TEMP_DIR]/workspace/" } - ] + ], + "resolution": { + "albatross==0.1.0@editable+[TEMP_DIR]/workspace/": { + "name": "albatross", + "version": "0.1.0", + "source": { + "editable": "[TEMP_DIR]/workspace/" + }, + "kind": "package", + "dependencies": [ + { + "id": "iniconfig==2.0.0@registry+https://pypi.org/simple" + } + ] + }, + "iniconfig==2.0.0@registry+https://pypi.org/simple": { + "name": "iniconfig", + "version": "2.0.0", + "source": { + "registry": { + "url": "https://pypi.org/simple" + } + }, + "kind": "package", + "dependencies": [], + "sdist": { + "url": "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", + "hashes": { + "sha256": "2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3" + }, + "size": 4646, + "upload_time": "2023-01-07T11:08:11.254Z" + }, + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", + "hashes": { + "sha256": "b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + }, + "size": 5892, + "upload_time": "2023-01-07T11:08:09.864Z", + "filename": "iniconfig-2.0.0-py3-none-any.whl" + } + ] + } + } } ----- stderr ----- warning: The `uv workspace metadata` command is experimental and may change without warning. Pass `--preview-features workspace-metadata` to disable this warning. + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 2 packages in [TIME] + "# + ); + + Ok(()) +} + +/// Test metadata with excluded packages. +#[test] +fn workspace_metadata_group_only() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let workspace = context.temp_dir.child("workspace"); + + copy_dir_ignore( + context + .workspace_root + .join("test/workspaces/albatross-groups-only"), + &workspace, + )?; + + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "workspace_root": "[TEMP_DIR]/workspace", + "requires_python": ">=3.12", + "conflicts": { + "sets": [] + }, + "resolution": { + "iniconfig==2.0.0@registry+https://pypi.org/simple": { + "name": "iniconfig", + "version": "2.0.0", + "source": { + "registry": { + "url": "https://pypi.org/simple" + } + }, + "kind": "package", + "dependencies": [], + "sdist": { + "url": "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", + "hashes": { + "sha256": "2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3" + }, + "size": 4646, + "upload_time": "2023-01-07T11:08:11.254Z" + }, + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", + "hashes": { + "sha256": "b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + }, + "size": 5892, + "upload_time": "2023-01-07T11:08:09.864Z", + "filename": "iniconfig-2.0.0-py3-none-any.whl" + } + ] + } + } + } + + ----- stderr ----- + warning: The `uv workspace metadata` command is experimental and may change without warning. Pass `--preview-features workspace-metadata` to disable this warning. + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. + Resolved 1 package in [TIME] "# ); @@ -319,3 +931,235 @@ fn workspace_metadata_no_project() { " ); } + +/// Test optional-dependencies, dependency-groups, and build-system +#[test] +fn workspace_metadata_various_dependency_rainbow() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let workspace = context.temp_dir.child("workspace"); + + copy_dir_ignore( + context + .workspace_root + .join("test/workspaces/albatross-dependency-rainbow"), + &workspace, + )?; + + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "workspace_root": "[TEMP_DIR]/workspace", + "requires_python": ">=3.12", + "conflicts": { + "sets": [] + }, + "members": [ + { + "name": "albatross", + "path": "[TEMP_DIR]/workspace", + "id": "albatross==0.1.0@editable+[TEMP_DIR]/workspace/" + } + ], + "resolution": { + "albatross:dev==0.1.0@editable+[TEMP_DIR]/workspace/": { + "name": "albatross", + "version": "0.1.0", + "source": { + "editable": "[TEMP_DIR]/workspace/" + }, + "kind": { + "group": "dev" + }, + "dependencies": [ + { + "id": "idna==3.6@registry+https://pypi.org/simple" + } + ] + }, + "albatross==0.1.0@editable+[TEMP_DIR]/workspace/": { + "name": "albatross", + "version": "0.1.0", + "source": { + "editable": "[TEMP_DIR]/workspace/" + }, + "kind": "package", + "dependencies": [ + { + "id": "iniconfig==2.0.0@registry+https://pypi.org/simple" + } + ], + "optional_dependencies": [ + { + "name": "io", + "id": "albatross[io]==0.1.0@editable+[TEMP_DIR]/workspace/" + } + ], + "dependency_groups": [ + { + "name": "dev", + "id": "albatross:dev==0.1.0@editable+[TEMP_DIR]/workspace/" + } + ] + }, + "albatross[io]==0.1.0@editable+[TEMP_DIR]/workspace/": { + "name": "albatross", + "version": "0.1.0", + "source": { + "editable": "[TEMP_DIR]/workspace/" + }, + "kind": { + "extra": "io" + }, + "dependencies": [ + { + "id": "albatross==0.1.0@editable+[TEMP_DIR]/workspace/" + }, + { + "id": "anyio==4.3.0@registry+https://pypi.org/simple" + } + ] + }, + "anyio==4.3.0@registry+https://pypi.org/simple": { + "name": "anyio", + "version": "4.3.0", + "source": { + "registry": { + "url": "https://pypi.org/simple" + } + }, + "kind": "package", + "dependencies": [ + { + "id": "idna==3.6@registry+https://pypi.org/simple" + }, + { + "id": "sniffio==1.3.1@registry+https://pypi.org/simple" + } + ], + "sdist": { + "url": "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", + "hashes": { + "sha256": "f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6" + }, + "size": 159642, + "upload_time": "2024-02-19T08:36:28.641Z" + }, + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", + "hashes": { + "sha256": "048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8" + }, + "size": 85584, + "upload_time": "2024-02-19T08:36:26.842Z", + "filename": "anyio-4.3.0-py3-none-any.whl" + } + ] + }, + "idna==3.6@registry+https://pypi.org/simple": { + "name": "idna", + "version": "3.6", + "source": { + "registry": { + "url": "https://pypi.org/simple" + } + }, + "kind": "package", + "dependencies": [], + "sdist": { + "url": "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", + "hashes": { + "sha256": "9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca" + }, + "size": 175426, + "upload_time": "2023-11-25T15:40:54.902Z" + }, + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", + "hashes": { + "sha256": "c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" + }, + "size": 61567, + "upload_time": "2023-11-25T15:40:52.604Z", + "filename": "idna-3.6-py3-none-any.whl" + } + ] + }, + "iniconfig==2.0.0@registry+https://pypi.org/simple": { + "name": "iniconfig", + "version": "2.0.0", + "source": { + "registry": { + "url": "https://pypi.org/simple" + } + }, + "kind": "package", + "dependencies": [], + "sdist": { + "url": "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", + "hashes": { + "sha256": "2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3" + }, + "size": 4646, + "upload_time": "2023-01-07T11:08:11.254Z" + }, + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", + "hashes": { + "sha256": "b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + }, + "size": 5892, + "upload_time": "2023-01-07T11:08:09.864Z", + "filename": "iniconfig-2.0.0-py3-none-any.whl" + } + ] + }, + "sniffio==1.3.1@registry+https://pypi.org/simple": { + "name": "sniffio", + "version": "1.3.1", + "source": { + "registry": { + "url": "https://pypi.org/simple" + } + }, + "kind": "package", + "dependencies": [], + "sdist": { + "url": "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", + "hashes": { + "sha256": "f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" + }, + "size": 20372, + "upload_time": "2024-02-25T23:20:04.057Z" + }, + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", + "hashes": { + "sha256": "2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" + }, + "size": 10235, + "upload_time": "2024-02-25T23:20:01.196Z", + "filename": "sniffio-1.3.1-py3-none-any.whl" + } + ] + } + } + } + + ----- stderr ----- + warning: The `uv workspace metadata` command is experimental and may change without warning. Pass `--preview-features workspace-metadata` to disable this warning. + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 5 packages in [TIME] + "# + ); + + Ok(()) +} diff --git a/docs/reference/internals/index.md b/docs/reference/internals/index.md index adbfa2343e..58206df448 100644 --- a/docs/reference/internals/index.md +++ b/docs/reference/internals/index.md @@ -3,3 +3,4 @@ The internals section provides details about uv's internal components and implementation details. - [Resolver](./resolver.md) +- [Workspace Metadata](./metadata.md) diff --git a/docs/reference/internals/metadata.md b/docs/reference/internals/metadata.md new file mode 100644 index 0000000000..341e9edf34 --- /dev/null +++ b/docs/reference/internals/metadata.md @@ -0,0 +1,326 @@ +# Workspace metadata + +`uv workspace metadata` exports the information uv has about your workspace as JSON so other tools +can use it. In particular, if you want access to the information in a `uv.lock`, you should prefer +this command's output, as `uv.lock` is not a stable format we guarantee anything about. + +The primary structure is the "resolution" field which contains the dependency graph with exact +package versions that a `uv.lock` encodes. + +The edges of the graph are the `dependencies` every node defines. These are the things that must +also be installed for it to be installed (and their `dependencies` recursively, keeping in mind that +cycles are perfectly normal to encounter in this graph). Each dependency entry will include an `id` +for the node it refers to, and an optional `marker` that +[specifies on what platforms the dependency is required](https://packaging.python.org/en/latest/specifications/dependency-specifiers/#dependency-specifiers) +(if there is no marker the dependency is always required). + +Nodes in the graph are uniquely identified by package `name`, `version`, `source`, and `kind`. + +There are 3 kinds of node in the graph: + +- `"package"` -- the package itself +- `{ "extra": "extraname" }` -- an extra the package defines +- `{ "group": "groupname" }` -- a dependency group the package defines + +(In the future we will add "build" nodes for the dependencies of +[build environments](https://docs.astral.sh/uv/concepts/projects/config/#build-isolation).) + +If you want to install `mypackage`, find its `"kind": "package"` node. This node will also include +information on its sdist, its wheels, its extras (`optional_dependencies`), and dependency groups +(`dependency_groups`). + +If you want to install `mypackage[myextra]` then find the node with `"kind": { "extra": "myextra" }` +for `mypackage` (this node will always depend on `mypackage`). If you want to install +`mypackage[extra1, extra2]`, find the two nodes for `mypackage[extra1]` and `mypackage[extra2]`. + +If you want to install the dependency group `mypackage:mygroup` then find the node with +`"kind": { "group": "mygroup" }` for `mypackage` (this node will _not_ depend on `mypackage`, as +dependency groups are just lists of things you might want when working on the package itself). + +## Handling multiple versions of a package + +Two versions of a package cannot be installed into a python environment, but the dependency graph +may still include multiple versions of a package. This can happen for two different reasons. + +The first way is for +[different platforms](https://packaging.python.org/en/latest/specifications/dependency-specifiers/#dependency-specifiers) +to have conflicting requirements that force different versions of a package to be used. + +The second way is when a workspace has +[conflicts](https://docs.astral.sh/uv/concepts/resolution/#conflicting-dependencies), implying some +workspace members or their extras are mutually exclusive, and only one of them can be installed at a +time. Information about conflicts can be found in the top-level `conflicts` field. + +The specific guarantee we provide is that **for any concrete choice of +[markers](https://packaging.python.org/en/latest/specifications/dependency-specifiers/#dependency-specifiers), +if you select a set of packages to install that has no +[conflicts](https://docs.astral.sh/uv/concepts/resolution/#conflicting-dependencies), then the +resulting set of packages to install will not have multiple versions of a package**. + +If you just want to get "every version of pydantic this workspace uses" you're free to iterate +through the list of nodes and collect up every instance. If however you want to specifically analyze +the graph and get actual resolutions you will likely need to consult `conflicts` and need to +understand how to resolve `markers` for a specific platform. + +The best way to avoid mistakes when working with multiple versions of a package is to keep your +queries into the dependency graph rooted in operations on workspace members, as those are the +natural entry-points to the graph that uv wants to work on, and can give coherent responses for: +"install `member1` and `member2[extra]`". + +Another way to put this is that when possible _you should avoid iterating over the `resolution` +object to find a node_. Only access `resolution` like a map using ids that were provided by another +part of the metadata. The only ids this initially gives you access to are the ones listed in the +`members` array, which lists all the workspace members. From there you may find the ids of that +package's dependencies, extras, and dependency groups and recursively discover other packages. + +So rather than trying to find a node for anyio in the dependency graph directly, you should decide +what workspace member(s) you're interested in analyzing as if they were going to be installed. While +traversing the `dependencies` of the things you want to install, you may visit an instance of anyio, +which is the one you should use. If you visit multiple instances of anyio then that means you've +selected a conflicting set of things to install which uv would never select. + +So if you wanted to analyze say, installing the `dev` dependency group of the workspace member +`mypackage` it would look something like: + +```python +member = find_by_name(metadata.members, "mypackage") +member_node = metadata.resolution[member.id] +group = find_by_name(member_node.dependency_groups, "dev") +group_node = metadata.resolution[group.id] +visit(metadata, [group_node]) +``` + +If you wanted to analyze two particular workspace members installed together, it would look +something like: + +```python +to_analyze = [] +for member_name in ["package1", "package2"]: + member = find_by_name(metadata.members, member_name) + member_node = metadata.resolution[member.id] + to_analyze.append(member_node) +visit(metadata, to_analyze) +``` + +Where `visit` is your favourite graph traversal algorithm like depth-first-search: + +```python +def visit(metadata: UvMetadata, to_analyze: list[Node]): + visited = set() + while len(to_analyze) > 0: + node = to_analyze.pop() + + # Handle cycles by avoiding revisiting nodes + if node.id in visited: + continue + visited.add(node.id) + + # We also need to analyze its dependencies + for dependency in node.dependencies: + # Only follow edges if they satisfy the desired platform's markers + if dependency.marker and not satisfies(platform, dependency.marker): + continue + to_analyze.append(metadata.resolution[dependency.id]) + + # Analyze any package node we encounter + if node.kind == "package": + print(node.name, node.version, node.source) +``` + +## Schema + +A full JSON schema for the format will be provided when the format is finalized. + +Here is a human-readable annotated example: + +```js +{ + // Information about the schema of this output + "schema": { + // The version of this output, currently "preview" + "version": "preview" + }, + // The directory the uv.lock can be found in + "workspace_root": "/workspace", + // Any requirements on the python version this workspace has + // + // `marker` fields all have this as an implicit constraint that is omitted for cleanliness + "requires_python": ">=3.12", + // A list of workspace members + "members": [ + { + // The name of the package + "name": "mypackage", + // The directory that contains its pyproject.toml + "path": "/workspace/packages/mypackage", + // The id of this package's info in the `resolution` map below + "id": "mypackage==0.1.0@editable+/workspace/packages/mypackage" + }, + ], + // A list-of-sets of workspace items that are mutually-exclusive to install, + // presumably because they need to install different versions of the same package. + // + // Any attempt to install two things that belong to the same set must be rejected. + // + // There are 3 kinds of item: + // + // * Project -- "kind": "project" + // * Extra -- "kind": { "extra": "extraname" } + // * Group -- "kind": { "group": "groupname" } + "conflicts": { + "sets": [ + { + "items": [ + { + "package": "mypackage", + "kind": { "extra": "myextra" } + "id": "mypackage[myextra]==0.1.0@editable+/workspace/packages/mypackage", + } + { + "package": "mypackage", + "kind": { "group": "mygroup" } + "id": "mypackage:mygroup==0.1.0@editable+/workspace/packages/mypackage", + } + ] + } + ] + } + // Resolved information about packages and dependencies. + // + // Each entry in this map is a node in the dependency graph. There are currently + // 3 kinds of node in the dependency graph, although more are planned in the future. + // + // * Packages -- "kind": "package" + // * Extras -- "kind": { "extra": "extraname" } + // * Groups -- "kind": { "group": "groupname" } + // + // Package nodes contain most of the metadata, while other nodes are mostly just a list + // of dependencies. The different kinds of node are included like this to encourage correct + // analysis of the graph. For instance, a node for `mypackage[someextra]` always depends on + // `mypackage`, while `mypackage:somegroup` does not (because dependency-groups are just a + // list of packages you might want to install while working on `mypackage`). Sugars like + // `mypackage[extra1, extra2]` are decomposed into separate dependencies on `mypackage[extra1]` + // and `mypackage[extra2]`. + // + // The ids used here are human-readable but should be handled as opaque (the nodes contain + // the same information in a more convenient form). + "resolution": { + + // This node is a workspace member + "mypackage==0.1.0@editable+/workspace/packages/mypackage": { + // The name of the package + "name": "mypackage", + // The version of the package (this may be missing, as source trees do not need versions) + "version": "0.1.0", + // The source of the package, in this case it's an editable whose path relative to the + // `workspace_root` is `./packages/mypackage` + "source": { + "editable": "/workspace/packages/mypackage" + }, + // The kind of the node, in this case "package" (see the docs on `resolution` above for details) + "kind": "package", + // The dependencies that must be installed to also install this node into an environment + "dependencies": [ + { + // The id of the node to lookup for details + "id": "iniconfig==2.0.0@registry+https://pypi.org/simple" + "marker": "marker": "sys_platform == 'linux'" + } + ], + // The extras that this package defines + "optional_dependencies": [ + { + "name": "myextra", + "id": "mypackage[myextra]==0.1.0@editable+/workspace/packages/mypackage" + } + ], + // The dependency groups this package defines + "dependency_groups": [ + { + "name": "mygroup", + "id": "mypackage:mygroup==0.1.0@editable+/workspace/packages/mypackage" + } + ] + }, + + // This node is an extra on a workspace member + "mypackage[myextra]==0.1.0@editable+/workspace/packages/mypackage": { + // These fields will match the package node above + "name": "mypackage", + "version": "0.1.0", + "source": { + "editable": "/workspace/packages/mypackage" + }, + // But these two will differ from the package node above + "kind": { "extra": "myextra" }, + "dependencies": [ + { + "id": "mypackage==0.1.0@editable+/workspace/packages/mypackage" + } + { + "id": "anyio==2.0.0@registry+https://pypi.org/simple" + } + ] + }, + + // This node is a dependency-group on a workspace member + "mypackage:mygroup==0.1.0@editable+/workspace/packages/mypackage": { + // These fields will match the package node above + "name": "mypackage", + "version": "0.1.0", + "source": { + "editable": "/workspace/packages/mypackage" + }, + // But these two will differ from the package node above + "kind": { "extra": "myextra" }, + "dependencies": [ + { + "id": "anyio==1.0.0@registry+https://pypi.org/simple" + } + ] + }, + + // This node is a package on pypi + "iniconfig==2.0.0@registry+https://pypi.org/simple": { + "name": "iniconfig", + "version": "2.0.0", + // registry sources look like this + "source": { + "registry": { + "url": "https://pypi.org/simple" + } + }, + "kind": "package", + "dependencies": [], + // Details on the package's source distribution + "sdist": { + // May alternatively be `path` + "url": "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", + "hashes": { + "sha256": "2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3" + }, + "size": 4646, + "upload_time": "2023-01-07T11:08:11.254Z" + }, + // The wheels we found for this package + "wheels": [ + { + // May alternatively be `path` + "url": "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", + "hashes": { + "sha256": "b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + }, + "size": 5892, + "upload_time": "2023-01-07T11:08:09.864Z", + // Parsing this name is how you know what platform a wheel supports + "filename": "iniconfig-2.0.0-py3-none-any.whl" + } + ] + } + + // ...and so on + "anyio==1.0.0@registry+https://pypi.org/simple": { ... } + "anyio==2.0.0@registry+https://pypi.org/simple": { ... } + } +} +``` diff --git a/mkdocs.yml b/mkdocs.yml index 58a584ed3c..56dcd53991 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -263,6 +263,7 @@ nav: - Internals: - reference/internals/index.md - Resolver: reference/internals/resolver.md + - Workspace Metadata: reference/internals/metadata.md - Benchmarks: reference/benchmarks.md - Policies: - reference/policies/index.md diff --git a/test/workspaces/albatross-dependency-rainbow/check_installed_albatross.py b/test/workspaces/albatross-dependency-rainbow/check_installed_albatross.py new file mode 100644 index 0000000000..e2e52c015f --- /dev/null +++ b/test/workspaces/albatross-dependency-rainbow/check_installed_albatross.py @@ -0,0 +1,4 @@ +from albatross import fly + +fly() +print("Success") diff --git a/test/workspaces/albatross-dependency-rainbow/pyproject.toml b/test/workspaces/albatross-dependency-rainbow/pyproject.toml new file mode 100644 index 0000000000..2c96dafd1e --- /dev/null +++ b/test/workspaces/albatross-dependency-rainbow/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "albatross" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = ["iniconfig>=2,<3"] + +[project.optional-dependencies] +io = ["anyio"] + +[dependency-groups] +dev = ["idna>=3"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/test/workspaces/albatross-dependency-rainbow/src/albatross/__init__.py b/test/workspaces/albatross-dependency-rainbow/src/albatross/__init__.py new file mode 100644 index 0000000000..c36c6606c8 --- /dev/null +++ b/test/workspaces/albatross-dependency-rainbow/src/albatross/__init__.py @@ -0,0 +1,2 @@ +def fly(): + pass diff --git a/test/workspaces/albatross-groups-only/pyproject.toml b/test/workspaces/albatross-groups-only/pyproject.toml new file mode 100644 index 0000000000..363228b7df --- /dev/null +++ b/test/workspaces/albatross-groups-only/pyproject.toml @@ -0,0 +1,2 @@ +[dependency-groups] +dev = ["iniconfig>=2,<3"]