diff --git a/Cargo.lock b/Cargo.lock index 0345601d7c841..a1288fa0dc333 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1042,6 +1042,41 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "cyclonedx-bom" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2ec98a191e17f63b92b132f6852462de9eaee03ca8dbf2df401b9fd809bcac" +dependencies = [ + "base64 0.21.7", + "cyclonedx-bom-macros", + "fluent-uri", + "indexmap", + "once_cell", + "ordered-float", + "purl", + "regex", + "serde", + "serde_json", + "spdx 0.10.9", + "strum", + "thiserror 1.0.69", + "time", + "uuid", + "xml-rs", +] + +[[package]] +name = "cyclonedx-bom-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c50341f21df64b412b4f917e34b7aa786c092d64f3f905f478cb76950c7e980c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -1409,6 +1444,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2771,6 +2815,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -3161,6 +3214,17 @@ dependencies = [ "version-ranges", ] +[[package]] +name = "purl" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60ebe4262ae91ddd28c8721111a0a6e9e58860e211fc92116c4bb85c98fd96ad" +dependencies = [ + "hex", + "percent-encoding", + "thiserror 2.0.17", +] + [[package]] name = "quick-xml" version = "0.38.3" @@ -4273,6 +4337,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "spdx" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" +dependencies = [ + "smallvec", +] + [[package]] name = "spdx" version = "0.12.0" @@ -4319,6 +4392,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -4646,10 +4741,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde", "time-core", + "time-macros", ] [[package]] @@ -4658,6 +4755,16 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -5493,7 +5600,7 @@ dependencies = [ "schemars", "serde", "sha2", - "spdx", + "spdx 0.12.0", "tar", "tempfile", "thiserror 2.0.17", @@ -6513,6 +6620,7 @@ version = "0.0.1" dependencies = [ "arcstr", "clap", + "cyclonedx-bom", "dashmap", "either", "fs-err", @@ -6523,6 +6631,7 @@ dependencies = [ "itertools 0.14.0", "jiff", "owo-colors", + "percent-encoding", "petgraph", "pubgrub", "rkyv", @@ -6556,6 +6665,7 @@ dependencies = [ "uv-pep440", "uv-pep508", "uv-platform-tags", + "uv-preview", "uv-pypi-types", "uv-python", "uv-redacted", @@ -6564,6 +6674,7 @@ dependencies = [ "uv-static", "uv-torch", "uv-types", + "uv-version", "uv-warnings", "uv-workspace", ] @@ -7505,6 +7616,12 @@ dependencies = [ "rustix 1.0.8", ] +[[package]] +name = "xml-rs" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7" + [[package]] name = "xmlparser" version = "0.13.6" diff --git a/Cargo.toml b/Cargo.toml index a9aad6bea722b..81f1a6ff07b1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,6 +103,7 @@ configparser = { version = "3.1.0" } console = { version = "0.16.0", default-features = false, features = ["std"] } csv = { version = "1.3.0" } ctrlc = { version = "3.4.5" } +cyclonedx-bom = { version = "0.8.0" } dashmap = { version = "6.1.0" } data-encoding = { version = "2.6.0" } dotenvy = { version = "0.15.7" } diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 5a3946b936115..ef03af373c671 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -11,8 +11,8 @@ use clap::{Args, Parser, Subcommand}; use uv_auth::Service; use uv_cache::CacheArgs; use uv_configuration::{ - ExportFormat, IndexStrategy, KeyringProviderType, PackageNameSpecifier, ProjectBuildBackend, - TargetTriple, TrustedHost, TrustedPublishing, VersionControlSystem, + ExportFormat, IndexStrategy, KeyringProviderType, PackageNameSpecifier, PipCompileFormat, + ProjectBuildBackend, TargetTriple, TrustedHost, TrustedPublishing, VersionControlSystem, }; use uv_distribution_types::{ ConfigSettingEntry, ConfigSettingPackageEntry, Index, IndexUrl, Origin, PipExtraIndex, @@ -1328,7 +1328,7 @@ pub struct PipCompileArgs { /// uv will infer the output format from the file extension of the output file, if /// provided. Otherwise, defaults to `requirements.txt`. #[arg(long, value_enum)] - pub format: Option, + pub format: Option, /// Include extras in the output file. /// @@ -4303,7 +4303,7 @@ pub struct TreeArgs { pub struct ExportArgs { /// The format to which `uv.lock` should be exported. /// - /// Supports both `requirements.txt` and `pylock.toml` (PEP 751) output formats. + /// Supports `requirements.txt`, `pylock.toml` (PEP 751) and `CycloneDX` v1.5 JSON output formats. /// /// uv will infer the output format from the file extension of the output file, if /// provided. Otherwise, defaults to `requirements.txt`. diff --git a/crates/uv-configuration/src/export_format.rs b/crates/uv-configuration/src/export_format.rs index c38218dc4422e..c1e5c57fb9c17 100644 --- a/crates/uv-configuration/src/export_format.rs +++ b/crates/uv-configuration/src/export_format.rs @@ -15,4 +15,30 @@ pub enum ExportFormat { #[serde(rename = "pylock.toml", alias = "pylock-toml")] #[cfg_attr(feature = "clap", clap(name = "pylock.toml", alias = "pylock-toml"))] PylockToml, + /// Export in `CycloneDX` v1.5 JSON format. + #[serde(rename = "cyclonedx1.5")] + #[cfg_attr( + feature = "clap", + clap(name = "cyclonedx1.5", alias = "cyclonedx1.5+json") + )] + CycloneDX1_5, +} + +/// The output format to use in `uv pip compile`. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +pub enum PipCompileFormat { + /// Export in `requirements.txt` format. + #[default] + #[serde(rename = "requirements.txt", alias = "requirements-txt")] + #[cfg_attr( + feature = "clap", + clap(name = "requirements.txt", alias = "requirements-txt") + )] + RequirementsTxt, + /// Export in `pylock.toml` format. + #[serde(rename = "pylock.toml", alias = "pylock-toml")] + #[cfg_attr(feature = "clap", clap(name = "pylock.toml", alias = "pylock-toml"))] + PylockToml, } diff --git a/crates/uv-preview/src/lib.rs b/crates/uv-preview/src/lib.rs index a2453e4d80c33..99fabef3aa640 100644 --- a/crates/uv-preview/src/lib.rs +++ b/crates/uv-preview/src/lib.rs @@ -21,6 +21,7 @@ bitflags::bitflags! { const NATIVE_AUTH = 1 << 9; const S3_ENDPOINT = 1 << 10; const CACHE_SIZE = 1 << 11; + const SBOM_EXPORT = 1 << 12; } } @@ -42,6 +43,7 @@ impl PreviewFeatures { Self::NATIVE_AUTH => "native-auth", Self::S3_ENDPOINT => "s3-endpoint", Self::CACHE_SIZE => "cache-size", + Self::SBOM_EXPORT => "sbom-export", _ => panic!("`flag_as_str` can only be used for exactly one feature flag"), } } @@ -91,6 +93,7 @@ impl FromStr for PreviewFeatures { "native-auth" => Self::NATIVE_AUTH, "s3-endpoint" => Self::S3_ENDPOINT, "cache-size" => Self::CACHE_SIZE, + "sbom-export" => Self::SBOM_EXPORT, _ => { warn_user_once!("Unknown preview feature: `{part}`"); continue; @@ -267,6 +270,7 @@ mod tests { ); assert_eq!(PreviewFeatures::FORMAT.flag_as_str(), "format"); assert_eq!(PreviewFeatures::S3_ENDPOINT.flag_as_str(), "s3-endpoint"); + assert_eq!(PreviewFeatures::SBOM_EXPORT.flag_as_str(), "sbom-export"); } #[test] diff --git a/crates/uv-resolver/Cargo.toml b/crates/uv-resolver/Cargo.toml index 158e2967ddd51..9e204df0eff09 100644 --- a/crates/uv-resolver/Cargo.toml +++ b/crates/uv-resolver/Cargo.toml @@ -33,6 +33,7 @@ uv-once-map = { workspace = true } uv-pep440 = { workspace = true } uv-pep508 = { workspace = true } uv-platform-tags = { workspace = true } +uv-preview = { workspace = true } uv-pypi-types = { workspace = true } uv-python = { workspace = true } uv-redacted = { workspace = true } @@ -41,11 +42,13 @@ uv-small-str = { workspace = true } uv-static = { workspace = true } uv-torch = { workspace = true } uv-types = { workspace = true } +uv-version = { workspace = true } uv-warnings = { workspace = true } uv-workspace = { workspace = true } arcstr = { workspace = true } clap = { workspace = true, features = ["derive"], optional = true } +cyclonedx-bom = { workspace = true } dashmap = { workspace = true } either = { workspace = true } fs-err = { workspace = true, features = ["tokio"] } @@ -55,6 +58,7 @@ indexmap = { workspace = true } itertools = { workspace = true } jiff = { workspace = true, features = ["serde"] } owo-colors = { workspace = true } +percent-encoding = { workspace = true } petgraph = { workspace = true } pubgrub = { workspace = true } rkyv = { workspace = true } diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index 00cb9732e5ae4..901f8366a8ae1 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -9,7 +9,7 @@ pub use fork_strategy::ForkStrategy; pub use lock::{ Installable, Lock, LockError, LockVersion, Package, PackageMap, PylockToml, PylockTomlErrorKind, RequirementsTxtExport, ResolverManifest, SatisfiesResult, TreeDisplay, - VERSION, + VERSION, cyclonedx_json, }; pub use manifest::Manifest; pub use options::{Flexibility, Options, OptionsBuilder}; diff --git a/crates/uv-resolver/src/lock/export/cyclonedx_json.rs b/crates/uv-resolver/src/lock/export/cyclonedx_json.rs new file mode 100644 index 0000000000000..b795d317c4795 --- /dev/null +++ b/crates/uv-resolver/src/lock/export/cyclonedx_json.rs @@ -0,0 +1,417 @@ +use std::collections::HashMap; +use std::path::Path; + +use cyclonedx_bom::{ + models::{ + component::Classification, + dependency::{Dependencies, Dependency}, + metadata::Metadata, + property::{Properties, Property}, + tool::{Tool, Tools}, + }, + prelude::{Bom, Component, Components, NormalizedString}, +}; +use itertools::Itertools; +use percent_encoding::{AsciiSet, CONTROLS, percent_encode}; +use rustc_hash::FxHashSet; + +use uv_configuration::{ + DependencyGroupsWithDefaults, ExtrasSpecificationWithDefaults, InstallOptions, +}; +use uv_fs::PortablePath; +use uv_normalize::PackageName; +use uv_pep508::MarkerTree; +use uv_preview::{Preview, PreviewFeatures}; +use uv_warnings::warn_user; + +use crate::lock::export::{ExportableRequirement, ExportableRequirements}; +use crate::lock::{LockErrorKind, Package, PackageId, Source}; +use crate::{Installable, LockError}; + +/// Character set for percent-encoding PURL components, copied from packageurl.rs (). +const PURL_ENCODE_SET: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'%') + .add(b'<') + .add(b'>') + .add(b'`') + .add(b'?') + .add(b'{') + .add(b'}') + .add(b';') + .add(b'=') + .add(b'+') + .add(b'@') + .add(b'\\') + .add(b'[') + .add(b']') + .add(b'^') + .add(b'|'); + +/// Creates `CycloneDX` components, registering them in a `HashMap` so that they can be retrieved by `PackageId`. +/// Also ensures uniqueness when generating bom-refs by using a numeric prefix which is incremented for each component. +#[derive(Default)] +struct ComponentBuilder<'a> { + id_counter: usize, // Used as prefix in bom-ref generation, to ensure uniqueness + package_to_component_map: HashMap<&'a PackageId, Component>, +} + +impl<'a> ComponentBuilder<'a> { + /// Creates a bom-ref string in the format "{id}-{package_name}@{version}" or "{id}-{package_name}" if no version is provided. + fn create_bom_ref(&mut self, name: &str, version: Option<&str>) -> String { + self.id_counter += 1; + let id = self.id_counter; + if let Some(version) = version { + format!("{name}-{id}@{version}") + } else { + format!("{name}-{id}") + } + } + + /// Extract version string from a package. + fn get_version_string(package: &Package) -> Option { + package + .id + .version + .as_ref() + .map(std::string::ToString::to_string) + } + + /// Extract package name string from a package. + fn get_package_name(package: &Package) -> &str { + package.id.name.as_str() + } + + /// Generate a Package URL (purl) from a package. Returns `None` for local sources. + fn create_purl(package: &Package) -> Option { + let name = percent_encode(Self::get_package_name(package).as_bytes(), PURL_ENCODE_SET); + + let version = Self::get_version_string(package).map_or_else(String::new, |v| { + format!("@{}", percent_encode(v.as_bytes(), PURL_ENCODE_SET)) + }); + + let (purl_type, qualifiers) = match &package.id.source { + Source::Registry(_) => ("pypi", vec![]), + Source::Git(url, _) => ("pypi", vec![("vcs_url", url.as_ref())]), + Source::Direct(url, _) => ("pypi", vec![("download_url", url.as_ref())]), + // No purl for local sources + Source::Path(_) | Source::Directory(_) | Source::Editable(_) | Source::Virtual(_) => { + return None; + } + }; + + let qualifiers = if qualifiers.is_empty() { + String::new() + } else { + Self::format_qualifiers(&qualifiers) + }; + + Some(format!("pkg:{purl_type}/{name}{version}{qualifiers}")) + } + + fn format_qualifiers(qualifiers: &[(&str, &str)]) -> String { + let joined_qualifiers = qualifiers + .iter() + .map(|(key, value)| { + format!( + "{key}={}", + percent_encode(value.as_bytes(), PURL_ENCODE_SET) + ) + }) + .join("&"); + format!("?{joined_qualifiers}") + } + + fn create_component( + &mut self, + package: &'a Package, + package_type: PackageType, + marker: Option<&MarkerTree>, + ) -> Component { + let component = self.create_component_from_package(package, package_type, marker); + self.package_to_component_map + .insert(&package.id, component.clone()); + component + } + + fn create_synthetic_root_component(&mut self, root: Option<&Package>) -> Component { + let name = root.map(Self::get_package_name).unwrap_or("uv-workspace"); + let bom_ref = self.create_bom_ref(name, None); + + // No need to register as we manually add dependencies in `if all_packages` check in `from_lock` + Component { + component_type: Classification::Library, + name: NormalizedString::new(name), + version: None, + bom_ref: Some(bom_ref), + purl: None, + mime_type: None, + supplier: None, + author: None, + publisher: None, + group: None, + description: None, + scope: None, + hashes: None, + licenses: None, + copyright: None, + cpe: None, + swid: None, + modified: None, + pedigree: None, + external_references: None, + properties: None, + components: None, + evidence: None, + signature: None, + model_card: None, + data: None, + } + } + + fn create_component_from_package( + &mut self, + package: &Package, + package_type: PackageType, + marker: Option<&MarkerTree>, + ) -> Component { + let name = Self::get_package_name(package); + let version = Self::get_version_string(package); + let bom_ref = self.create_bom_ref(name, version.as_deref()); + let purl = Self::create_purl(package).and_then(|purl_string| purl_string.parse().ok()); + let mut properties = vec![]; + + match package_type { + PackageType::Workspace(path) => { + properties.push(Property::new( + "uv:workspace:path", + &PortablePath::from(path).to_string(), + )); + } + PackageType::Root | PackageType::Dependency => {} + } + + if let Some(marker_contents) = marker.and_then(|marker| marker.contents()) { + properties.push(Property::new( + "uv:package:marker", + &marker_contents.to_string(), + )); + } + + Component { + component_type: Classification::Library, + name: NormalizedString::new(name), + version: version.as_deref().map(NormalizedString::new), + bom_ref: Some(bom_ref), + purl, + mime_type: None, + supplier: None, + author: None, + publisher: None, + group: None, + description: None, + scope: None, + hashes: None, + licenses: None, + copyright: None, + cpe: None, + swid: None, + modified: None, + pedigree: None, + external_references: None, + properties: if !properties.is_empty() { + Some(Properties(properties)) + } else { + None + }, + components: None, + evidence: None, + signature: None, + model_card: None, + data: None, + } + } + + fn get_component(&self, id: &PackageId) -> Option<&Component> { + self.package_to_component_map.get(id) + } +} + +pub fn from_lock<'lock>( + target: &impl Installable<'lock>, + prune: &[PackageName], + extras: &ExtrasSpecificationWithDefaults, + groups: &DependencyGroupsWithDefaults, + annotate: bool, + install_options: &'lock InstallOptions, + preview: Preview, + all_packages: bool, +) -> Result { + if !preview.is_enabled(PreviewFeatures::SBOM_EXPORT) { + warn_user!( + "`uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::SBOM_EXPORT + ); + } + + // Extract the packages from the lock file. + let ExportableRequirements(mut nodes) = ExportableRequirements::from_lock( + target, + prune, + extras, + groups, + annotate, + install_options, + )?; + + nodes.sort_unstable_by_key(|node| &node.package.id); + + // CycloneDX requires exactly one root component in `metadata.component`. + let root = match target.roots().collect::>().as_slice() { + // Single root: use it directly + [single_root] => nodes + .iter() + .find(|node| &node.package.id.name == *single_root) + .map(|node| node.package), + // Multiple roots or no roots: use fallback + _ => None, + } + .or_else(|| target.lock().root()); // Fallback to project root + + let mut component_builder = ComponentBuilder::default(); + + let mut metadata = Metadata { + component: root + .map(|package| component_builder.create_component(package, PackageType::Root, None)), + timestamp: cyclonedx_bom::prelude::DateTime::now().ok(), + tools: Some(Tools::List(vec![Tool { + vendor: Some(NormalizedString::new("Astral Software Inc.")), + name: Some(NormalizedString::new("uv")), + version: Some(NormalizedString::new(uv_version::version())), + hashes: None, + external_references: None, + }])), + ..Metadata::default() + }; + + let workspace_member_ids = nodes + .iter() + .filter_map(|node| { + if target.lock().members().contains(&node.package.id.name) + && node.package.id.source.is_local() + { + Some(&node.package.id) + } else { + None + } + }) + .collect::>(); + + let mut components = nodes + .iter() + .filter(|node| root.is_none_or(|root_pkg| root_pkg.id != node.package.id)) // Filter out root package as this is included in `metadata` + .map(|node| { + let package_type = if workspace_member_ids.contains(&node.package.id) { + let path = match &node.package.id.source { + Source::Path(path) + | Source::Directory(path) + | Source::Editable(path) + | Source::Virtual(path) => path, + Source::Registry(_) | Source::Git(_, _) | Source::Direct(_, _) => { + // Workspace packages should always be local dependencies + return Err(LockErrorKind::NonLocalWorkspaceMember { + id: node.package.id.clone(), + } + .into()); + } + }; + PackageType::Workspace(path) + } else { + PackageType::Dependency + }; + Ok(component_builder.create_component(node.package, package_type, Some(&node.marker))) + }) + .collect::, LockError>>()?; + + let mut dependencies = create_dependencies(&nodes, &component_builder); + + // With `--all-packages`, use synthetic root which depends on workspace root and all workspace members. + // This ensures that we don't have any dangling components resulting from workspace packages not depended on by the workspace root. + if all_packages { + let synthetic_root = component_builder.create_synthetic_root_component(root); + let synthetic_root_bom_ref = synthetic_root + .bom_ref + .clone() + .expect("bom-ref should always exist"); + let workspace_root = metadata.component.replace(synthetic_root); + + if let Some(workspace_root) = workspace_root { + components.push(workspace_root); + } + + dependencies.push(Dependency { + dependency_ref: synthetic_root_bom_ref, + dependencies: workspace_member_ids + .iter() + .filter_map(|c| component_builder.get_component(c)) + .map(|c| c.bom_ref.clone().expect("bom-ref should always exist")) + .collect(), + }); + } + + let bom = Bom { + metadata: Some(metadata), + components: Some(Components(components)), + dependencies: Some(Dependencies(dependencies)), + ..Bom::default() + }; + + Ok(bom) +} + +fn create_dependencies( + nodes: &[ExportableRequirement<'_>], + component_builder: &ComponentBuilder, +) -> Vec { + nodes + .iter() + .map(|node| { + let component = component_builder + .get_component(&node.package.id) + .expect("All nodes should have been added to map"); + + let immediate_deps = &node.package.dependencies; + let optional_deps = node.package.optional_dependencies.values().flatten(); + let dep_groups = node.package.dependency_groups.values().flatten(); + + let package_deps = immediate_deps + .iter() + .chain(optional_deps) + .chain(dep_groups) + .filter_map(|dep| component_builder.get_component(&dep.package_id)); + + let bom_refs = package_deps + .map(|p| p.bom_ref.clone().expect("bom-ref should always exist")) + .sorted_unstable() + .unique() + .collect(); + + Dependency { + dependency_ref: component + .bom_ref + .clone() + .expect("bom-ref should always exist"), + dependencies: bom_refs, + } + }) + .collect() +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum PackageType<'a> { + Root, + Workspace(&'a Path), + Dependency, +} diff --git a/crates/uv-resolver/src/lock/export/mod.rs b/crates/uv-resolver/src/lock/export/mod.rs index f1fd1610649e9..08779add29244 100644 --- a/crates/uv-resolver/src/lock/export/mod.rs +++ b/crates/uv-resolver/src/lock/export/mod.rs @@ -23,6 +23,7 @@ pub use crate::lock::export::requirements_txt::RequirementsTxtExport; use crate::universal_marker::resolve_conflicts; use crate::{Installable, LockError, Package}; +pub mod cyclonedx_json; 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 36124917937b3..c723335fed532 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -52,7 +52,7 @@ use uv_workspace::{Editability, WorkspaceMember}; 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}; +pub use crate::lock::export::{PylockToml, PylockTomlErrorKind, cyclonedx_json}; pub use crate::lock::installable::Installable; pub use crate::lock::map::PackageMap; pub use crate::lock::tree::TreeDisplay; @@ -5963,6 +5963,12 @@ enum LockErrorKind { #[source] err: toml::de::Error, }, + /// An error that occurs when a workspace member has a non-local source. + #[error("Workspace member `{id}` has non-local source", id = id.cyan())] + NonLocalWorkspaceMember { + /// The ID of the workspace member with an invalid source. + id: PackageId, + }, } /// An error that occurs when a source string could not be parsed. diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index fc371530f24dd..83cf77352bf7a 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -223,23 +223,6 @@ impl<'a> OutputWriter<'a> { } } - /// Write the given arguments to both standard output and the output buffer, if present. - fn write_fmt(&mut self, args: std::fmt::Arguments<'_>) -> std::io::Result<()> { - use std::io::Write; - - // Write to the buffer. - if self.output_file.is_some() { - self.buffer.write_fmt(args)?; - } - - // Write to standard output. - if let Some(stdout) = &mut self.stdout { - write!(stdout, "{args}")?; - } - - Ok(()) - } - /// Commit the buffer to the output file. async fn commit(self) -> std::io::Result<()> { if let Some(output_file) = self.output_file { @@ -258,6 +241,30 @@ impl<'a> OutputWriter<'a> { } } +impl std::io::Write for OutputWriter<'_> { + /// Write to both standard output and the output buffer, if present. + fn write(&mut self, buf: &[u8]) -> std::io::Result { + // Write to the buffer. + if self.output_file.is_some() { + self.buffer.write_all(buf)?; + } + + // Write to standard output. + if let Some(stdout) = &mut self.stdout { + stdout.write_all(buf)?; + } + + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + if let Some(stdout) = &mut self.stdout { + stdout.flush()?; + } + Ok(()) + } +} + /// Given a list of names, return a conjunction of the names (e.g., "Alice, Bob, and Charlie"). pub(super) fn conjunction(names: Vec) -> String { let mut names = names.into_iter(); diff --git a/crates/uv/src/commands/pip/compile.rs b/crates/uv/src/commands/pip/compile.rs index 351f9228a9e87..65732ba791dc5 100644 --- a/crates/uv/src/commands/pip/compile.rs +++ b/crates/uv/src/commands/pip/compile.rs @@ -1,6 +1,7 @@ use std::collections::BTreeSet; use std::env; use std::ffi::OsStr; +use std::io::Write; use std::path::Path; use std::str::FromStr; @@ -13,8 +14,8 @@ use tracing::debug; use uv_cache::Cache; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ - BuildIsolation, BuildOptions, Concurrency, Constraints, ExportFormat, ExtrasSpecification, - IndexStrategy, NoBinary, NoBuild, Reinstall, SourceStrategy, Upgrade, + BuildIsolation, BuildOptions, Concurrency, Constraints, ExtrasSpecification, IndexStrategy, + NoBinary, NoBuild, PipCompileFormat, Reinstall, SourceStrategy, Upgrade, }; use uv_configuration::{KeyringProviderType, TargetTriple}; use uv_dispatch::{BuildDispatch, SharedState}; @@ -72,7 +73,7 @@ pub(crate) async fn pip_compile( extras: ExtrasSpecification, groups: GroupsSpecification, output_file: Option<&Path>, - format: Option, + format: Option, resolution_mode: ResolutionMode, prerelease_mode: PrereleaseMode, fork_strategy: ForkStrategy, @@ -143,16 +144,16 @@ pub(crate) async fn pip_compile( let format = format.unwrap_or_else(|| { let extension = output_file.and_then(Path::extension); if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("txt")) { - ExportFormat::RequirementsTxt + PipCompileFormat::RequirementsTxt } else if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("toml")) { - ExportFormat::PylockToml + PipCompileFormat::PylockToml } else { - ExportFormat::RequirementsTxt + PipCompileFormat::RequirementsTxt } }); // If the user is exporting to PEP 751, ensure the filename matches the specification. - if matches!(format, ExportFormat::PylockToml) { + if matches!(format, PipCompileFormat::PylockToml) { if let Some(file_name) = output_file .and_then(Path::file_name) .and_then(OsStr::to_str) @@ -386,7 +387,7 @@ pub(crate) async fn pip_compile( // Generate, but don't enforce hashes for the requirements. PEP 751 _requires_ a hash to be // present, but otherwise, we omit them by default. - let hasher = if generate_hashes || matches!(format, ExportFormat::PylockToml) { + let hasher = if generate_hashes || matches!(format, PipCompileFormat::PylockToml) { HashStrategy::Generate(HashGeneration::All) } else { HashStrategy::None @@ -443,10 +444,10 @@ pub(crate) async fn pip_compile( let LockedRequirements { preferences, git } = if let Some(output_file) = output_file.filter(|output_file| output_file.exists()) { match format { - ExportFormat::RequirementsTxt => LockedRequirements::from_preferences( + PipCompileFormat::RequirementsTxt => LockedRequirements::from_preferences( read_requirements_txt(output_file, &upgrade).await?, ), - ExportFormat::PylockToml => { + PipCompileFormat::PylockToml => { read_pylock_toml_requirements(output_file, &upgrade).await? } } @@ -601,7 +602,7 @@ pub(crate) async fn pip_compile( } match format { - ExportFormat::RequirementsTxt => { + PipCompileFormat::RequirementsTxt => { if include_marker_expression { if let Some(marker_env) = resolver_env.marker_environment() { let relevant_markers = resolution.marker_tree(&top_level_index, marker_env)?; @@ -692,7 +693,7 @@ pub(crate) async fn pip_compile( ) )?; } - ExportFormat::PylockToml => { + PipCompileFormat::PylockToml => { if include_marker_expression { warn_user!( "The `--emit-marker-expression` option is not supported for `pylock.toml` output" diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index 1028feb85a0a3..32d68bb59dbbd 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -1,5 +1,6 @@ use std::env; use std::ffi::OsStr; +use std::io::Write; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow}; @@ -15,7 +16,7 @@ use uv_normalize::{DefaultExtras, DefaultGroups, PackageName}; use uv_preview::Preview; use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; use uv_requirements::is_pylock_toml; -use uv_resolver::{PylockToml, RequirementsTxtExport}; +use uv_resolver::{PylockToml, RequirementsTxtExport, cyclonedx_json}; use uv_scripts::Pep723Script; use uv_settings::PythonInstallMirrors; use uv_workspace::{DiscoveryOptions, MemberDiscovery, VirtualProject, Workspace, WorkspaceCache}; @@ -286,9 +287,6 @@ pub(crate) async fn export( }, }; - // Validate that the set of requested extras and development groups are compatible. - detect_conflicts(&target, &extras, &groups)?; - // Validate that the set of requested extras and development groups are defined in the lockfile. target.validate_extras(&extras)?; target.validate_groups(&groups)?; @@ -316,6 +314,11 @@ pub(crate) async fn export( } }); + // Skip conflict detection for CycloneDX exports, as SBOMs are meant to document all dependencies including conflicts. + if !matches!(format, ExportFormat::CycloneDX1_5) { + detect_conflicts(&target, &extras, &groups)?; + } + // If the user is exporting to PEP 751, ensure the filename matches the specification. if matches!(format, ExportFormat::PylockToml) { if let Some(file_name) = output_file @@ -376,6 +379,20 @@ pub(crate) async fn export( } write!(writer, "{}", export.to_toml()?)?; } + ExportFormat::CycloneDX1_5 => { + let export = cyclonedx_json::from_lock( + &target, + &prune, + &extras, + &groups, + include_annotations, + &install_options, + preview, + all_packages, + )?; + + export.output_as_json_v1_5(&mut writer)?; + } } writer.commit().await?; diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 548c5c2383751..733a5aba766a2 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -26,8 +26,9 @@ use uv_client::Connectivity; use uv_configuration::{ BuildIsolation, BuildOptions, Concurrency, DependencyGroups, DryRun, EditableMode, EnvFile, ExportFormat, ExtrasSpecification, HashCheckingMode, IndexStrategy, InstallOptions, - KeyringProviderType, NoBinary, NoBuild, ProjectBuildBackend, Reinstall, RequiredVersion, - SourceStrategy, TargetTriple, TrustedHost, TrustedPublishing, Upgrade, VersionControlSystem, + KeyringProviderType, NoBinary, NoBuild, PipCompileFormat, ProjectBuildBackend, Reinstall, + RequiredVersion, SourceStrategy, TargetTriple, TrustedHost, TrustedPublishing, Upgrade, + VersionControlSystem, }; use uv_distribution_types::{ ConfigSettings, DependencyMetadata, ExtraBuildVariables, Index, IndexLocations, IndexUrl, @@ -2121,7 +2122,7 @@ impl FormatSettings { /// The resolved settings to use for a `pip compile` invocation. #[derive(Debug, Clone)] pub(crate) struct PipCompileSettings { - pub(crate) format: Option, + pub(crate) format: Option, pub(crate) src_file: Vec, pub(crate) constraints: Vec, pub(crate) overrides: Vec, diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index e3145a7e94975..6cba11a66321c 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -477,6 +477,27 @@ impl TestContext { self } + /// Adds filters for non-deterministic `CycloneDX` data + pub fn with_cyclonedx_filters(mut self) -> Self { + self.filters.push(( + r"urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}".to_string(), + "[SERIAL_NUMBER]".to_string(), + )); + self.filters.push(( + r#""timestamp": "[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]+Z""# + .to_string(), + r#""timestamp": "[TIMESTAMP]""#.to_string(), + )); + self.filters.push(( + r#""name": "uv",\s*"version": "\d+\.\d+\.\d+(-(alpha|beta|rc)\.\d+)?(\+\d+)?""# + .to_string(), + r#""name": "uv", + "version": "[VERSION]""# + .to_string(), + )); + self + } + /// Add a filter that collapses duplicate whitespace. #[must_use] pub fn with_collapsed_whitespace(mut self) -> Self { diff --git a/crates/uv/tests/it/export.rs b/crates/uv/tests/it/export.rs index 186e66850c78d..e3e4af3b22583 100644 --- a/crates/uv/tests/it/export.rs +++ b/crates/uv/tests/it/export.rs @@ -4804,3 +4804,3617 @@ fn multiple_packages() -> Result<()> { Ok(()) } + +#[test] +fn cyclonedx_export_basic() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["urllib3==2.2.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "urllib3-2@2.2.0", + "name": "urllib3", + "version": "2.2.0", + "purl": "pkg:pypi/urllib3@2.2.0" + } + ], + "dependencies": [ + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "urllib3-2@2.2.0" + ] + }, + { + "ref": "urllib3-2@2.2.0", + "dependsOn": [] + } + ] + } + ----- stderr ----- + Resolved 2 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_direct_url() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["idna @ https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "idna-2@3.6", + "name": "idna", + "version": "3.6", + "purl": "pkg:pypi/idna@3.6?download_url=https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl" + } + ], + "dependencies": [ + { + "ref": "idna-2@3.6", + "dependsOn": [] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "idna-2@3.6" + ] + } + ] + } + ----- stderr ----- + Resolved 2 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[cfg(feature = "git")] +#[test] +fn cyclonedx_export_git_dependency() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["flask @ git+https://github.com/pallets/flask.git@2.3.3"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "blinker-2@1.7.0", + "name": "blinker", + "version": "1.7.0", + "purl": "pkg:pypi/blinker@1.7.0" + }, + { + "type": "library", + "bom-ref": "click-3@8.1.7", + "name": "click", + "version": "8.1.7", + "purl": "pkg:pypi/click@8.1.7" + }, + { + "type": "library", + "bom-ref": "colorama-4@0.4.6", + "name": "colorama", + "version": "0.4.6", + "purl": "pkg:pypi/colorama@0.4.6", + "properties": [ + { + "name": "uv:package:marker", + "value": "sys_platform == 'win32'" + } + ] + }, + { + "type": "library", + "bom-ref": "flask-5@2.3.3", + "name": "flask", + "version": "2.3.3", + "purl": "pkg:pypi/flask@2.3.3?vcs_url=https://github.com/pallets/flask.git%3Frev%3D2.3.3%233205b53c7cf69d17fee49cac6b84978175b7dd73" + }, + { + "type": "library", + "bom-ref": "itsdangerous-6@2.1.2", + "name": "itsdangerous", + "version": "2.1.2", + "purl": "pkg:pypi/itsdangerous@2.1.2" + }, + { + "type": "library", + "bom-ref": "jinja2-7@3.1.3", + "name": "jinja2", + "version": "3.1.3", + "purl": "pkg:pypi/jinja2@3.1.3" + }, + { + "type": "library", + "bom-ref": "markupsafe-8@2.1.5", + "name": "markupsafe", + "version": "2.1.5", + "purl": "pkg:pypi/markupsafe@2.1.5" + }, + { + "type": "library", + "bom-ref": "werkzeug-9@3.0.1", + "name": "werkzeug", + "version": "3.0.1", + "purl": "pkg:pypi/werkzeug@3.0.1" + } + ], + "dependencies": [ + { + "ref": "blinker-2@1.7.0", + "dependsOn": [] + }, + { + "ref": "click-3@8.1.7", + "dependsOn": [ + "colorama-4@0.4.6" + ] + }, + { + "ref": "colorama-4@0.4.6", + "dependsOn": [] + }, + { + "ref": "flask-5@2.3.3", + "dependsOn": [ + "blinker-2@1.7.0", + "click-3@8.1.7", + "itsdangerous-6@2.1.2", + "jinja2-7@3.1.3", + "werkzeug-9@3.0.1" + ] + }, + { + "ref": "itsdangerous-6@2.1.2", + "dependsOn": [] + }, + { + "ref": "jinja2-7@3.1.3", + "dependsOn": [ + "markupsafe-8@2.1.5" + ] + }, + { + "ref": "markupsafe-8@2.1.5", + "dependsOn": [] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "flask-5@2.3.3" + ] + }, + { + "ref": "werkzeug-9@3.0.1", + "dependsOn": [ + "markupsafe-8@2.1.5" + ] + } + ] + } + ----- stderr ----- + Resolved 9 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_no_dependencies() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "standalone-project" + version = "1.0.0" + requires-python = ">=3.12" + dependencies = [] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "standalone-project-1@1.0.0", + "name": "standalone-project", + "version": "1.0.0" + } + }, + "components": [], + "dependencies": [ + { + "ref": "standalone-project-1@1.0.0", + "dependsOn": [] + } + ] + } + ----- stderr ----- + Resolved 1 package in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[cfg(feature = "git")] +#[test] +fn cyclonedx_export_mixed_source_types() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "mixed-project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "requests==2.31.0", # PyPI registry package + "flask @ git+https://github.com/pallets/flask.git@2.3.3", # Git package + "iniconfig @ https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" # Direct URL package + ] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "mixed-project-1@0.1.0", + "name": "mixed-project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "blinker-2@1.7.0", + "name": "blinker", + "version": "1.7.0", + "purl": "pkg:pypi/blinker@1.7.0" + }, + { + "type": "library", + "bom-ref": "certifi-3@2024.2.2", + "name": "certifi", + "version": "2024.2.2", + "purl": "pkg:pypi/certifi@2024.2.2" + }, + { + "type": "library", + "bom-ref": "charset-normalizer-4@3.3.2", + "name": "charset-normalizer", + "version": "3.3.2", + "purl": "pkg:pypi/charset-normalizer@3.3.2" + }, + { + "type": "library", + "bom-ref": "click-5@8.1.7", + "name": "click", + "version": "8.1.7", + "purl": "pkg:pypi/click@8.1.7" + }, + { + "type": "library", + "bom-ref": "colorama-6@0.4.6", + "name": "colorama", + "version": "0.4.6", + "purl": "pkg:pypi/colorama@0.4.6", + "properties": [ + { + "name": "uv:package:marker", + "value": "sys_platform == 'win32'" + } + ] + }, + { + "type": "library", + "bom-ref": "flask-7@2.3.3", + "name": "flask", + "version": "2.3.3", + "purl": "pkg:pypi/flask@2.3.3?vcs_url=https://github.com/pallets/flask.git%3Frev%3D2.3.3%233205b53c7cf69d17fee49cac6b84978175b7dd73" + }, + { + "type": "library", + "bom-ref": "idna-8@3.6", + "name": "idna", + "version": "3.6", + "purl": "pkg:pypi/idna@3.6" + }, + { + "type": "library", + "bom-ref": "iniconfig-9@2.0.0", + "name": "iniconfig", + "version": "2.0.0", + "purl": "pkg:pypi/iniconfig@2.0.0?download_url=https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl" + }, + { + "type": "library", + "bom-ref": "itsdangerous-10@2.1.2", + "name": "itsdangerous", + "version": "2.1.2", + "purl": "pkg:pypi/itsdangerous@2.1.2" + }, + { + "type": "library", + "bom-ref": "jinja2-11@3.1.3", + "name": "jinja2", + "version": "3.1.3", + "purl": "pkg:pypi/jinja2@3.1.3" + }, + { + "type": "library", + "bom-ref": "markupsafe-12@2.1.5", + "name": "markupsafe", + "version": "2.1.5", + "purl": "pkg:pypi/markupsafe@2.1.5" + }, + { + "type": "library", + "bom-ref": "requests-13@2.31.0", + "name": "requests", + "version": "2.31.0", + "purl": "pkg:pypi/requests@2.31.0" + }, + { + "type": "library", + "bom-ref": "urllib3-14@2.2.1", + "name": "urllib3", + "version": "2.2.1", + "purl": "pkg:pypi/urllib3@2.2.1" + }, + { + "type": "library", + "bom-ref": "werkzeug-15@3.0.1", + "name": "werkzeug", + "version": "3.0.1", + "purl": "pkg:pypi/werkzeug@3.0.1" + } + ], + "dependencies": [ + { + "ref": "blinker-2@1.7.0", + "dependsOn": [] + }, + { + "ref": "certifi-3@2024.2.2", + "dependsOn": [] + }, + { + "ref": "charset-normalizer-4@3.3.2", + "dependsOn": [] + }, + { + "ref": "click-5@8.1.7", + "dependsOn": [ + "colorama-6@0.4.6" + ] + }, + { + "ref": "colorama-6@0.4.6", + "dependsOn": [] + }, + { + "ref": "flask-7@2.3.3", + "dependsOn": [ + "blinker-2@1.7.0", + "click-5@8.1.7", + "itsdangerous-10@2.1.2", + "jinja2-11@3.1.3", + "werkzeug-15@3.0.1" + ] + }, + { + "ref": "idna-8@3.6", + "dependsOn": [] + }, + { + "ref": "iniconfig-9@2.0.0", + "dependsOn": [] + }, + { + "ref": "itsdangerous-10@2.1.2", + "dependsOn": [] + }, + { + "ref": "jinja2-11@3.1.3", + "dependsOn": [ + "markupsafe-12@2.1.5" + ] + }, + { + "ref": "markupsafe-12@2.1.5", + "dependsOn": [] + }, + { + "ref": "mixed-project-1@0.1.0", + "dependsOn": [ + "flask-7@2.3.3", + "iniconfig-9@2.0.0", + "requests-13@2.31.0" + ] + }, + { + "ref": "requests-13@2.31.0", + "dependsOn": [ + "certifi-3@2024.2.2", + "charset-normalizer-4@3.3.2", + "idna-8@3.6", + "urllib3-14@2.2.1" + ] + }, + { + "ref": "urllib3-14@2.2.1", + "dependsOn": [] + }, + { + "ref": "werkzeug-15@3.0.1", + "dependsOn": [ + "markupsafe-12@2.1.5" + ] + } + ] + } + ----- stderr ----- + Resolved 15 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_project_extra() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + + [project.optional-dependencies] + url = ["urllib3==2.2.0"] + pytest = ["iniconfig"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "typing-extensions-2@4.10.0", + "name": "typing-extensions", + "version": "4.10.0", + "purl": "pkg:pypi/typing-extensions@4.10.0" + } + ], + "dependencies": [ + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "typing-extensions-2@4.10.0" + ] + }, + { + "ref": "typing-extensions-2@4.10.0", + "dependsOn": [] + } + ] + } + ----- stderr ----- + Resolved 4 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_project_extra_with_optional_flag() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + + [project.optional-dependencies] + url = ["urllib3==2.2.0"] + pytest = ["iniconfig"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5").arg("--all-extras"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "iniconfig-2@2.0.0", + "name": "iniconfig", + "version": "2.0.0", + "purl": "pkg:pypi/iniconfig@2.0.0" + }, + { + "type": "library", + "bom-ref": "typing-extensions-3@4.10.0", + "name": "typing-extensions", + "version": "4.10.0", + "purl": "pkg:pypi/typing-extensions@4.10.0" + }, + { + "type": "library", + "bom-ref": "urllib3-4@2.2.0", + "name": "urllib3", + "version": "2.2.0", + "purl": "pkg:pypi/urllib3@2.2.0" + } + ], + "dependencies": [ + { + "ref": "iniconfig-2@2.0.0", + "dependsOn": [] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "iniconfig-2@2.0.0", + "typing-extensions-3@4.10.0", + "urllib3-4@2.2.0" + ] + }, + { + "ref": "typing-extensions-3@4.10.0", + "dependsOn": [] + }, + { + "ref": "urllib3-4@2.2.0", + "dependsOn": [] + } + ] + } + ----- stderr ----- + Resolved 4 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_with_workspace_member() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["urllib3==2.2.0", "child1", "child2"] + + [tool.uv.workspace] + members = ["child1", "packages/*"] + + [tool.uv.sources] + child1 = { workspace = true } + child2 = { workspace = true } + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + let child1 = context.temp_dir.child("child1"); + child1.child("pyproject.toml").write_str( + r#" + [project] + name = "child1" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig>=2"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + let child2 = context.temp_dir.child("packages").child("child2"); + child2.child("pyproject.toml").write_str( + r#" + [project] + name = "child2" + version = "0.2.9" + requires-python = ">=3.11" + dependencies = [] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5").arg("--all-extras"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "child1-2@0.1.0", + "name": "child1", + "version": "0.1.0", + "properties": [ + { + "name": "uv:workspace:path", + "value": "child1" + } + ] + }, + { + "type": "library", + "bom-ref": "child2-3@0.2.9", + "name": "child2", + "version": "0.2.9", + "properties": [ + { + "name": "uv:workspace:path", + "value": "packages/child2" + } + ] + }, + { + "type": "library", + "bom-ref": "iniconfig-4@2.0.0", + "name": "iniconfig", + "version": "2.0.0", + "purl": "pkg:pypi/iniconfig@2.0.0" + }, + { + "type": "library", + "bom-ref": "urllib3-5@2.2.0", + "name": "urllib3", + "version": "2.2.0", + "purl": "pkg:pypi/urllib3@2.2.0" + } + ], + "dependencies": [ + { + "ref": "child1-2@0.1.0", + "dependsOn": [ + "iniconfig-4@2.0.0" + ] + }, + { + "ref": "child2-3@0.2.9", + "dependsOn": [] + }, + { + "ref": "iniconfig-4@2.0.0", + "dependsOn": [] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "child1-2@0.1.0", + "child2-3@0.2.9", + "urllib3-5@2.2.0" + ] + }, + { + "ref": "urllib3-5@2.2.0", + "dependsOn": [] + } + ] + } + ----- stderr ----- + Resolved 5 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_workspace_non_root() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["urllib3==2.2.0", "child"] + + [tool.uv.workspace] + members = ["child"] + + [tool.uv.sources] + child = { workspace = true } + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + let child = context.temp_dir.child("child"); + child.child("pyproject.toml").write_str( + r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig>=2"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5").arg("--package").arg("child"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "child-1@0.1.0", + "name": "child", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "iniconfig-2@2.0.0", + "name": "iniconfig", + "version": "2.0.0", + "purl": "pkg:pypi/iniconfig@2.0.0" + } + ], + "dependencies": [ + { + "ref": "child-1@0.1.0", + "dependsOn": [ + "iniconfig-2@2.0.0" + ] + }, + { + "ref": "iniconfig-2@2.0.0", + "dependsOn": [] + } + ] + } + ----- stderr ----- + Resolved 4 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_workspace_with_extras() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["child"] + + [tool.uv.workspace] + members = ["child"] + + [tool.uv.sources] + child = { workspace = true } + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + let child = context.temp_dir.child("child"); + child.child("pyproject.toml").write_str( + r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + + [project.optional-dependencies] + url = ["urllib3==2.2.0"] + test = ["iniconfig"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "child-2@0.1.0", + "name": "child", + "version": "0.1.0", + "properties": [ + { + "name": "uv:workspace:path", + "value": "child" + } + ] + }, + { + "type": "library", + "bom-ref": "typing-extensions-3@4.10.0", + "name": "typing-extensions", + "version": "4.10.0", + "purl": "pkg:pypi/typing-extensions@4.10.0" + } + ], + "dependencies": [ + { + "ref": "child-2@0.1.0", + "dependsOn": [ + "typing-extensions-3@4.10.0" + ] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "child-2@0.1.0" + ] + }, + { + "ref": "typing-extensions-3@4.10.0", + "dependsOn": [] + } + ] + } + ----- stderr ----- + Resolved 5 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5").arg("--all-extras"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "child-2@0.1.0", + "name": "child", + "version": "0.1.0", + "properties": [ + { + "name": "uv:workspace:path", + "value": "child" + } + ] + }, + { + "type": "library", + "bom-ref": "typing-extensions-3@4.10.0", + "name": "typing-extensions", + "version": "4.10.0", + "purl": "pkg:pypi/typing-extensions@4.10.0" + } + ], + "dependencies": [ + { + "ref": "child-2@0.1.0", + "dependsOn": [ + "typing-extensions-3@4.10.0" + ] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "child-2@0.1.0" + ] + }, + { + "ref": "typing-extensions-3@4.10.0", + "dependsOn": [] + } + ] + } + ----- stderr ----- + Resolved 5 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_workspace_frozen() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["urllib3==2.2.0", "child"] + + [tool.uv.workspace] + members = ["child"] + + [tool.uv.sources] + child = { workspace = true } + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + let child = context.temp_dir.child("child"); + child.child("pyproject.toml").write_str( + r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig>=2"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + // Remove the child `pyproject.toml`. + fs_err::remove_dir_all(child.path())?; + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5").arg("--all-packages"), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to build `project @ file://[TEMP_DIR]/` + ├─▶ Failed to parse entry: `child` + ╰─▶ `child` references a workspace in `tool.uv.sources` (e.g., `child = { workspace = true }`), but is not a workspace member + "###); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5").arg("--all-packages").arg("--frozen"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-5", + "name": "project" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "child-2@0.1.0", + "name": "child", + "version": "0.1.0", + "properties": [ + { + "name": "uv:workspace:path", + "value": "child" + } + ] + }, + { + "type": "library", + "bom-ref": "iniconfig-3@2.0.0", + "name": "iniconfig", + "version": "2.0.0", + "purl": "pkg:pypi/iniconfig@2.0.0" + }, + { + "type": "library", + "bom-ref": "urllib3-4@2.2.0", + "name": "urllib3", + "version": "2.2.0", + "purl": "pkg:pypi/urllib3@2.2.0" + }, + { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + ], + "dependencies": [ + { + "ref": "child-2@0.1.0", + "dependsOn": [ + "iniconfig-3@2.0.0" + ] + }, + { + "ref": "iniconfig-3@2.0.0", + "dependsOn": [] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "child-2@0.1.0", + "urllib3-4@2.2.0" + ] + }, + { + "ref": "urllib3-4@2.2.0", + "dependsOn": [] + }, + { + "ref": "project-5", + "dependsOn": [ + "child-2@0.1.0", + "project-1@0.1.0" + ] + } + ] + } + ----- stderr ----- + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_workspace_all_packages() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["urllib3==2.2.0"] + + [tool.uv.workspace] + members = ["child1", "child2"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + let child1 = context.temp_dir.child("child1"); + child1.child("pyproject.toml").write_str( + r#" + [project] + name = "child1" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig>=2"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + let child2 = context.temp_dir.child("child2"); + child2.child("pyproject.toml").write_str( + r#" + [project] + name = "child2" + version = "0.2.0" + requires-python = ">=3.12" + dependencies = ["sniffio"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5").arg("--all-packages"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-7", + "name": "project" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "child1-2@0.1.0", + "name": "child1", + "version": "0.1.0", + "properties": [ + { + "name": "uv:workspace:path", + "value": "child1" + } + ] + }, + { + "type": "library", + "bom-ref": "child2-3@0.2.0", + "name": "child2", + "version": "0.2.0", + "properties": [ + { + "name": "uv:workspace:path", + "value": "child2" + } + ] + }, + { + "type": "library", + "bom-ref": "iniconfig-4@2.0.0", + "name": "iniconfig", + "version": "2.0.0", + "purl": "pkg:pypi/iniconfig@2.0.0" + }, + { + "type": "library", + "bom-ref": "sniffio-5@1.3.1", + "name": "sniffio", + "version": "1.3.1", + "purl": "pkg:pypi/sniffio@1.3.1" + }, + { + "type": "library", + "bom-ref": "urllib3-6@2.2.0", + "name": "urllib3", + "version": "2.2.0", + "purl": "pkg:pypi/urllib3@2.2.0" + }, + { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + ], + "dependencies": [ + { + "ref": "child1-2@0.1.0", + "dependsOn": [ + "iniconfig-4@2.0.0" + ] + }, + { + "ref": "child2-3@0.2.0", + "dependsOn": [ + "sniffio-5@1.3.1" + ] + }, + { + "ref": "iniconfig-4@2.0.0", + "dependsOn": [] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "urllib3-6@2.2.0" + ] + }, + { + "ref": "sniffio-5@1.3.1", + "dependsOn": [] + }, + { + "ref": "urllib3-6@2.2.0", + "dependsOn": [] + }, + { + "ref": "project-7", + "dependsOn": [ + "child1-2@0.1.0", + "child2-3@0.2.0", + "project-1@0.1.0" + ] + } + ] + } + ----- stderr ----- + Resolved 6 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +// Contains a combination of combination of workspace and registry deps, with another workspace dep not depended on by the root +#[test] +fn cyclonedx_export_workspace_mixed_dependencies() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["child1", "urllib3==2.2.0"] + + [tool.uv.workspace] + members = ["child1", "child2"] + + [tool.uv.sources] + child1 = { workspace = true } + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + let child1 = context.temp_dir.child("child1"); + child1.child("pyproject.toml").write_str( + r#" + [project] + name = "child1" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["child2", "iniconfig>=2"] + + [tool.uv.sources] + child2 = { workspace = true } + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + let child2 = context.temp_dir.child("child2"); + child2.child("pyproject.toml").write_str( + r#" + [project] + name = "child2" + version = "0.2.0" + requires-python = ">=3.12" + dependencies = ["sniffio"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "child1-2@0.1.0", + "name": "child1", + "version": "0.1.0", + "properties": [ + { + "name": "uv:workspace:path", + "value": "child1" + } + ] + }, + { + "type": "library", + "bom-ref": "child2-3@0.2.0", + "name": "child2", + "version": "0.2.0", + "properties": [ + { + "name": "uv:workspace:path", + "value": "child2" + } + ] + }, + { + "type": "library", + "bom-ref": "iniconfig-4@2.0.0", + "name": "iniconfig", + "version": "2.0.0", + "purl": "pkg:pypi/iniconfig@2.0.0" + }, + { + "type": "library", + "bom-ref": "sniffio-5@1.3.1", + "name": "sniffio", + "version": "1.3.1", + "purl": "pkg:pypi/sniffio@1.3.1" + }, + { + "type": "library", + "bom-ref": "urllib3-6@2.2.0", + "name": "urllib3", + "version": "2.2.0", + "purl": "pkg:pypi/urllib3@2.2.0" + } + ], + "dependencies": [ + { + "ref": "child1-2@0.1.0", + "dependsOn": [ + "child2-3@0.2.0", + "iniconfig-4@2.0.0" + ] + }, + { + "ref": "child2-3@0.2.0", + "dependsOn": [ + "sniffio-5@1.3.1" + ] + }, + { + "ref": "iniconfig-4@2.0.0", + "dependsOn": [] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "child1-2@0.1.0", + "urllib3-6@2.2.0" + ] + }, + { + "ref": "sniffio-5@1.3.1", + "dependsOn": [] + }, + { + "ref": "urllib3-6@2.2.0", + "dependsOn": [] + } + ] + } + ----- stderr ----- + Resolved 6 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_dependency_marker() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["urllib3 ; sys_platform == 'darwin'", "iniconfig"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "iniconfig-2@2.0.0", + "name": "iniconfig", + "version": "2.0.0", + "purl": "pkg:pypi/iniconfig@2.0.0" + }, + { + "type": "library", + "bom-ref": "urllib3-3@2.2.1", + "name": "urllib3", + "version": "2.2.1", + "purl": "pkg:pypi/urllib3@2.2.1", + "properties": [ + { + "name": "uv:package:marker", + "value": "sys_platform == 'darwin'" + } + ] + } + ], + "dependencies": [ + { + "ref": "iniconfig-2@2.0.0", + "dependsOn": [] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "iniconfig-2@2.0.0", + "urllib3-3@2.2.1" + ] + }, + { + "ref": "urllib3-3@2.2.1", + "dependsOn": [] + } + ] + } + ----- stderr ----- + Resolved 3 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_multiple_dependency_markers() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.10" + dependencies = [ + "trio ; python_version > '3.11'", + "trio ; sys_platform == 'win32'", + ] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "attrs-2@23.2.0", + "name": "attrs", + "version": "23.2.0", + "purl": "pkg:pypi/attrs@23.2.0", + "properties": [ + { + "name": "uv:package:marker", + "value": "python_full_version >= '3.12' or sys_platform == 'win32'" + } + ] + }, + { + "type": "library", + "bom-ref": "cffi-3@1.16.0", + "name": "cffi", + "version": "1.16.0", + "purl": "pkg:pypi/cffi@1.16.0", + "properties": [ + { + "name": "uv:package:marker", + "value": "(python_full_version >= '3.12' and implementation_name != 'pypy' and os_name == 'nt') or (implementation_name != 'pypy' and os_name == 'nt' and sys_platform == 'win32')" + } + ] + }, + { + "type": "library", + "bom-ref": "exceptiongroup-4@1.2.0", + "name": "exceptiongroup", + "version": "1.2.0", + "purl": "pkg:pypi/exceptiongroup@1.2.0", + "properties": [ + { + "name": "uv:package:marker", + "value": "python_full_version < '3.11' and sys_platform == 'win32'" + } + ] + }, + { + "type": "library", + "bom-ref": "idna-5@3.6", + "name": "idna", + "version": "3.6", + "purl": "pkg:pypi/idna@3.6", + "properties": [ + { + "name": "uv:package:marker", + "value": "python_full_version >= '3.12' or sys_platform == 'win32'" + } + ] + }, + { + "type": "library", + "bom-ref": "outcome-6@1.3.0.post0", + "name": "outcome", + "version": "1.3.0.post0", + "purl": "pkg:pypi/outcome@1.3.0.post0", + "properties": [ + { + "name": "uv:package:marker", + "value": "python_full_version >= '3.12' or sys_platform == 'win32'" + } + ] + }, + { + "type": "library", + "bom-ref": "pycparser-7@2.21", + "name": "pycparser", + "version": "2.21", + "purl": "pkg:pypi/pycparser@2.21", + "properties": [ + { + "name": "uv:package:marker", + "value": "(python_full_version >= '3.12' and implementation_name != 'pypy' and os_name == 'nt') or (implementation_name != 'pypy' and os_name == 'nt' and sys_platform == 'win32')" + } + ] + }, + { + "type": "library", + "bom-ref": "sniffio-8@1.3.1", + "name": "sniffio", + "version": "1.3.1", + "purl": "pkg:pypi/sniffio@1.3.1", + "properties": [ + { + "name": "uv:package:marker", + "value": "python_full_version >= '3.12' or sys_platform == 'win32'" + } + ] + }, + { + "type": "library", + "bom-ref": "sortedcontainers-9@2.4.0", + "name": "sortedcontainers", + "version": "2.4.0", + "purl": "pkg:pypi/sortedcontainers@2.4.0", + "properties": [ + { + "name": "uv:package:marker", + "value": "python_full_version >= '3.12' or sys_platform == 'win32'" + } + ] + }, + { + "type": "library", + "bom-ref": "trio-10@0.25.0", + "name": "trio", + "version": "0.25.0", + "purl": "pkg:pypi/trio@0.25.0", + "properties": [ + { + "name": "uv:package:marker", + "value": "python_full_version >= '3.12' or sys_platform == 'win32'" + } + ] + } + ], + "dependencies": [ + { + "ref": "attrs-2@23.2.0", + "dependsOn": [] + }, + { + "ref": "cffi-3@1.16.0", + "dependsOn": [ + "pycparser-7@2.21" + ] + }, + { + "ref": "exceptiongroup-4@1.2.0", + "dependsOn": [] + }, + { + "ref": "idna-5@3.6", + "dependsOn": [] + }, + { + "ref": "outcome-6@1.3.0.post0", + "dependsOn": [ + "attrs-2@23.2.0" + ] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "trio-10@0.25.0" + ] + }, + { + "ref": "pycparser-7@2.21", + "dependsOn": [] + }, + { + "ref": "sniffio-8@1.3.1", + "dependsOn": [] + }, + { + "ref": "sortedcontainers-9@2.4.0", + "dependsOn": [] + }, + { + "ref": "trio-10@0.25.0", + "dependsOn": [ + "attrs-2@23.2.0", + "cffi-3@1.16.0", + "exceptiongroup-4@1.2.0", + "idna-5@3.6", + "outcome-6@1.3.0.post0", + "sniffio-8@1.3.1", + "sortedcontainers-9@2.4.0" + ] + } + ] + } + ----- stderr ----- + Resolved 10 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_dependency_extra() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["flask[dotenv]"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "blinker-2@1.7.0", + "name": "blinker", + "version": "1.7.0", + "purl": "pkg:pypi/blinker@1.7.0" + }, + { + "type": "library", + "bom-ref": "click-3@8.1.7", + "name": "click", + "version": "8.1.7", + "purl": "pkg:pypi/click@8.1.7" + }, + { + "type": "library", + "bom-ref": "colorama-4@0.4.6", + "name": "colorama", + "version": "0.4.6", + "purl": "pkg:pypi/colorama@0.4.6", + "properties": [ + { + "name": "uv:package:marker", + "value": "sys_platform == 'win32'" + } + ] + }, + { + "type": "library", + "bom-ref": "flask-5@3.0.2", + "name": "flask", + "version": "3.0.2", + "purl": "pkg:pypi/flask@3.0.2" + }, + { + "type": "library", + "bom-ref": "itsdangerous-6@2.1.2", + "name": "itsdangerous", + "version": "2.1.2", + "purl": "pkg:pypi/itsdangerous@2.1.2" + }, + { + "type": "library", + "bom-ref": "jinja2-7@3.1.3", + "name": "jinja2", + "version": "3.1.3", + "purl": "pkg:pypi/jinja2@3.1.3" + }, + { + "type": "library", + "bom-ref": "markupsafe-8@2.1.5", + "name": "markupsafe", + "version": "2.1.5", + "purl": "pkg:pypi/markupsafe@2.1.5" + }, + { + "type": "library", + "bom-ref": "python-dotenv-9@1.0.1", + "name": "python-dotenv", + "version": "1.0.1", + "purl": "pkg:pypi/python-dotenv@1.0.1" + }, + { + "type": "library", + "bom-ref": "werkzeug-10@3.0.1", + "name": "werkzeug", + "version": "3.0.1", + "purl": "pkg:pypi/werkzeug@3.0.1" + } + ], + "dependencies": [ + { + "ref": "blinker-2@1.7.0", + "dependsOn": [] + }, + { + "ref": "click-3@8.1.7", + "dependsOn": [ + "colorama-4@0.4.6" + ] + }, + { + "ref": "colorama-4@0.4.6", + "dependsOn": [] + }, + { + "ref": "flask-5@3.0.2", + "dependsOn": [ + "blinker-2@1.7.0", + "click-3@8.1.7", + "itsdangerous-6@2.1.2", + "jinja2-7@3.1.3", + "python-dotenv-9@1.0.1", + "werkzeug-10@3.0.1" + ] + }, + { + "ref": "itsdangerous-6@2.1.2", + "dependsOn": [] + }, + { + "ref": "jinja2-7@3.1.3", + "dependsOn": [ + "markupsafe-8@2.1.5" + ] + }, + { + "ref": "markupsafe-8@2.1.5", + "dependsOn": [] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "flask-5@3.0.2" + ] + }, + { + "ref": "python-dotenv-9@1.0.1", + "dependsOn": [] + }, + { + "ref": "werkzeug-10@3.0.1", + "dependsOn": [ + "markupsafe-8@2.1.5" + ] + } + ] + } + ----- stderr ----- + Resolved 10 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_prune() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "jupyter-client" + ] + "#, + )?; + + context.lock().assert().success(); + + // project v0.1.0 + // └── jupyter-client v8.6.1 + // ├── jupyter-core v5.7.2 + // │ ├── platformdirs v4.2.0 + // │ └── traitlets v5.14.2 + // ├── python-dateutil v2.9.0.post0 + // │ └── six v1.16.0 + // ├── pyzmq v25.1.2 + // ├── tornado v6.4 + // └── traitlets v5.14.2 + + uv_snapshot!( + context.filters(), + context.export() + .arg("--format") + .arg("cyclonedx1.5") + .arg("--prune") + .arg("jupyter-core"), + @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "cffi-2@1.16.0", + "name": "cffi", + "version": "1.16.0", + "purl": "pkg:pypi/cffi@1.16.0", + "properties": [ + { + "name": "uv:package:marker", + "value": "implementation_name == 'pypy'" + } + ] + }, + { + "type": "library", + "bom-ref": "jupyter-client-3@8.6.1", + "name": "jupyter-client", + "version": "8.6.1", + "purl": "pkg:pypi/jupyter-client@8.6.1" + }, + { + "type": "library", + "bom-ref": "pycparser-4@2.21", + "name": "pycparser", + "version": "2.21", + "purl": "pkg:pypi/pycparser@2.21", + "properties": [ + { + "name": "uv:package:marker", + "value": "implementation_name == 'pypy'" + } + ] + }, + { + "type": "library", + "bom-ref": "python-dateutil-5@2.9.0.post0", + "name": "python-dateutil", + "version": "2.9.0.post0", + "purl": "pkg:pypi/python-dateutil@2.9.0.post0" + }, + { + "type": "library", + "bom-ref": "pyzmq-6@25.1.2", + "name": "pyzmq", + "version": "25.1.2", + "purl": "pkg:pypi/pyzmq@25.1.2" + }, + { + "type": "library", + "bom-ref": "six-7@1.16.0", + "name": "six", + "version": "1.16.0", + "purl": "pkg:pypi/six@1.16.0" + }, + { + "type": "library", + "bom-ref": "tornado-8@6.4", + "name": "tornado", + "version": "6.4", + "purl": "pkg:pypi/tornado@6.4" + }, + { + "type": "library", + "bom-ref": "traitlets-9@5.14.2", + "name": "traitlets", + "version": "5.14.2", + "purl": "pkg:pypi/traitlets@5.14.2" + } + ], + "dependencies": [ + { + "ref": "cffi-2@1.16.0", + "dependsOn": [ + "pycparser-4@2.21" + ] + }, + { + "ref": "jupyter-client-3@8.6.1", + "dependsOn": [ + "python-dateutil-5@2.9.0.post0", + "pyzmq-6@25.1.2", + "tornado-8@6.4", + "traitlets-9@5.14.2" + ] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "jupyter-client-3@8.6.1" + ] + }, + { + "ref": "pycparser-4@2.21", + "dependsOn": [] + }, + { + "ref": "python-dateutil-5@2.9.0.post0", + "dependsOn": [ + "six-7@1.16.0" + ] + }, + { + "ref": "pyzmq-6@25.1.2", + "dependsOn": [ + "cffi-2@1.16.0" + ] + }, + { + "ref": "six-7@1.16.0", + "dependsOn": [] + }, + { + "ref": "tornado-8@6.4", + "dependsOn": [] + }, + { + "ref": "traitlets-9@5.14.2", + "dependsOn": [] + } + ] + } + ----- stderr ----- + Resolved 12 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "# + ); + + Ok(()) +} + +#[test] +fn cyclonedx_export_group() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + + [dependency-groups] + foo = ["urllib3 ; sys_platform == 'darwin'"] + bar = ["iniconfig"] + dev = ["sniffio"] + "#, + )?; + + context.lock().assert().success(); + + // Default exports include dev group + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "sniffio-2@1.3.1", + "name": "sniffio", + "version": "1.3.1", + "purl": "pkg:pypi/sniffio@1.3.1" + }, + { + "type": "library", + "bom-ref": "typing-extensions-3@4.10.0", + "name": "typing-extensions", + "version": "4.10.0", + "purl": "pkg:pypi/typing-extensions@4.10.0" + } + ], + "dependencies": [ + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "sniffio-2@1.3.1", + "typing-extensions-3@4.10.0" + ] + }, + { + "ref": "sniffio-2@1.3.1", + "dependsOn": [] + }, + { + "ref": "typing-extensions-3@4.10.0", + "dependsOn": [] + } + ] + } + ----- stderr ----- + Resolved 5 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + // Export only specific group + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5").arg("--only-group").arg("bar"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "iniconfig-2@2.0.0", + "name": "iniconfig", + "version": "2.0.0", + "purl": "pkg:pypi/iniconfig@2.0.0" + } + ], + "dependencies": [ + { + "ref": "iniconfig-2@2.0.0", + "dependsOn": [] + } + ] + } + ----- stderr ----- + Resolved 5 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + // Export with additional group + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5").arg("--group").arg("foo"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "sniffio-2@1.3.1", + "name": "sniffio", + "version": "1.3.1", + "purl": "pkg:pypi/sniffio@1.3.1" + }, + { + "type": "library", + "bom-ref": "typing-extensions-3@4.10.0", + "name": "typing-extensions", + "version": "4.10.0", + "purl": "pkg:pypi/typing-extensions@4.10.0" + }, + { + "type": "library", + "bom-ref": "urllib3-4@2.2.1", + "name": "urllib3", + "version": "2.2.1", + "purl": "pkg:pypi/urllib3@2.2.1", + "properties": [ + { + "name": "uv:package:marker", + "value": "sys_platform == 'darwin'" + } + ] + } + ], + "dependencies": [ + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "sniffio-2@1.3.1", + "typing-extensions-3@4.10.0", + "urllib3-4@2.2.1" + ] + }, + { + "ref": "sniffio-2@1.3.1", + "dependsOn": [] + }, + { + "ref": "typing-extensions-3@4.10.0", + "dependsOn": [] + }, + { + "ref": "urllib3-4@2.2.1", + "dependsOn": [] + } + ] + } + ----- stderr ----- + Resolved 5 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_non_project() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [tool.uv.workspace] + members = [] + + [dependency-groups] + url = ["urllib3"] + "#, + )?; + + context.lock().assert().success(); + + // Default export with no project section + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ] + }, + "components": [], + "dependencies": [] + } + ----- stderr ----- + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. + Resolved 1 package in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + // Export with group specified + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5").arg("--group").arg("url"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ] + }, + "components": [ + { + "type": "library", + "bom-ref": "urllib3-1@2.2.1", + "name": "urllib3", + "version": "2.2.1", + "purl": "pkg:pypi/urllib3@2.2.1" + } + ], + "dependencies": [ + { + "ref": "urllib3-1@2.2.1", + "dependsOn": [] + } + ] + } + ----- stderr ----- + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. + Resolved 1 package in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_no_emit() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["urllib3==2.2.0", "child"] + + [tool.uv.workspace] + members = ["child"] + + [tool.uv.sources] + child = { workspace = true } + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + let child = context.temp_dir.child("child"); + child.child("pyproject.toml").write_str( + r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig>=2"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + // Exclude `urllib3`. + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5").arg("--no-emit-package").arg("urllib3"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "child-2@0.1.0", + "name": "child", + "version": "0.1.0", + "properties": [ + { + "name": "uv:workspace:path", + "value": "child" + } + ] + }, + { + "type": "library", + "bom-ref": "iniconfig-3@2.0.0", + "name": "iniconfig", + "version": "2.0.0", + "purl": "pkg:pypi/iniconfig@2.0.0" + } + ], + "dependencies": [ + { + "ref": "child-2@0.1.0", + "dependsOn": [ + "iniconfig-3@2.0.0" + ] + }, + { + "ref": "iniconfig-3@2.0.0", + "dependsOn": [] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "child-2@0.1.0" + ] + } + ] + } + ----- stderr ----- + Resolved 4 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + // Exclude `project`. + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5").arg("--no-emit-project"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "child-2@0.1.0", + "name": "child", + "version": "0.1.0", + "properties": [ + { + "name": "uv:workspace:path", + "value": "child" + } + ] + }, + { + "type": "library", + "bom-ref": "iniconfig-3@2.0.0", + "name": "iniconfig", + "version": "2.0.0", + "purl": "pkg:pypi/iniconfig@2.0.0" + }, + { + "type": "library", + "bom-ref": "urllib3-4@2.2.0", + "name": "urllib3", + "version": "2.2.0", + "purl": "pkg:pypi/urllib3@2.2.0" + } + ], + "dependencies": [ + { + "ref": "child-2@0.1.0", + "dependsOn": [ + "iniconfig-3@2.0.0" + ] + }, + { + "ref": "iniconfig-3@2.0.0", + "dependsOn": [] + }, + { + "ref": "urllib3-4@2.2.0", + "dependsOn": [] + } + ] + } + ----- stderr ----- + Resolved 4 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_relative_path() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let dependency = context.temp_dir.child("dependency"); + dependency.child("pyproject.toml").write_str( + r#" + [project] + name = "dependency" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig>=2"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + let project = context.temp_dir.child("project"); + project.child("pyproject.toml").write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["dependency"] + + [tool.uv.sources] + dependency = { path = "../dependency" } + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().current_dir(&project).assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5").current_dir(&project), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "dependency-2@0.1.0", + "name": "dependency", + "version": "0.1.0" + }, + { + "type": "library", + "bom-ref": "iniconfig-3@2.0.0", + "name": "iniconfig", + "version": "2.0.0", + "purl": "pkg:pypi/iniconfig@2.0.0" + } + ], + "dependencies": [ + { + "ref": "dependency-2@0.1.0", + "dependsOn": [ + "iniconfig-3@2.0.0" + ] + }, + { + "ref": "iniconfig-3@2.0.0", + "dependsOn": [] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "dependency-2@0.1.0" + ] + } + ] + } + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 3 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_cyclic_dependencies() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "testtools==2.3.0", + "fixtures==3.0.0", + ] + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "argparse-2@1.4.0", + "name": "argparse", + "version": "1.4.0", + "purl": "pkg:pypi/argparse@1.4.0" + }, + { + "type": "library", + "bom-ref": "extras-3@1.0.0", + "name": "extras", + "version": "1.0.0", + "purl": "pkg:pypi/extras@1.0.0" + }, + { + "type": "library", + "bom-ref": "fixtures-4@3.0.0", + "name": "fixtures", + "version": "3.0.0", + "purl": "pkg:pypi/fixtures@3.0.0" + }, + { + "type": "library", + "bom-ref": "linecache2-5@1.0.0", + "name": "linecache2", + "version": "1.0.0", + "purl": "pkg:pypi/linecache2@1.0.0" + }, + { + "type": "library", + "bom-ref": "pbr-6@6.0.0", + "name": "pbr", + "version": "6.0.0", + "purl": "pkg:pypi/pbr@6.0.0" + }, + { + "type": "library", + "bom-ref": "python-mimeparse-7@1.6.0", + "name": "python-mimeparse", + "version": "1.6.0", + "purl": "pkg:pypi/python-mimeparse@1.6.0" + }, + { + "type": "library", + "bom-ref": "six-8@1.16.0", + "name": "six", + "version": "1.16.0", + "purl": "pkg:pypi/six@1.16.0" + }, + { + "type": "library", + "bom-ref": "testtools-9@2.3.0", + "name": "testtools", + "version": "2.3.0", + "purl": "pkg:pypi/testtools@2.3.0" + }, + { + "type": "library", + "bom-ref": "traceback2-10@1.4.0", + "name": "traceback2", + "version": "1.4.0", + "purl": "pkg:pypi/traceback2@1.4.0" + }, + { + "type": "library", + "bom-ref": "unittest2-11@1.1.0", + "name": "unittest2", + "version": "1.1.0", + "purl": "pkg:pypi/unittest2@1.1.0" + } + ], + "dependencies": [ + { + "ref": "argparse-2@1.4.0", + "dependsOn": [] + }, + { + "ref": "extras-3@1.0.0", + "dependsOn": [] + }, + { + "ref": "fixtures-4@3.0.0", + "dependsOn": [ + "pbr-6@6.0.0", + "six-8@1.16.0", + "testtools-9@2.3.0" + ] + }, + { + "ref": "linecache2-5@1.0.0", + "dependsOn": [] + }, + { + "ref": "pbr-6@6.0.0", + "dependsOn": [] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "fixtures-4@3.0.0", + "testtools-9@2.3.0" + ] + }, + { + "ref": "python-mimeparse-7@1.6.0", + "dependsOn": [] + }, + { + "ref": "six-8@1.16.0", + "dependsOn": [] + }, + { + "ref": "testtools-9@2.3.0", + "dependsOn": [ + "extras-3@1.0.0", + "fixtures-4@3.0.0", + "pbr-6@6.0.0", + "python-mimeparse-7@1.6.0", + "six-8@1.16.0", + "traceback2-10@1.4.0", + "unittest2-11@1.1.0" + ] + }, + { + "ref": "traceback2-10@1.4.0", + "dependsOn": [ + "linecache2-5@1.0.0" + ] + }, + { + "ref": "unittest2-11@1.1.0", + "dependsOn": [ + "argparse-2@1.4.0", + "six-8@1.16.0", + "traceback2-10@1.4.0" + ] + } + ] + } + ----- stderr ----- + Resolved 11 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_dev_dependencies() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + + [tool.uv] + dev-dependencies = ["urllib3"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + // Default export includes dev dependencies + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "typing-extensions-2@4.10.0", + "name": "typing-extensions", + "version": "4.10.0", + "purl": "pkg:pypi/typing-extensions@4.10.0" + }, + { + "type": "library", + "bom-ref": "urllib3-3@2.2.1", + "name": "urllib3", + "version": "2.2.1", + "purl": "pkg:pypi/urllib3@2.2.1" + } + ], + "dependencies": [ + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "typing-extensions-2@4.10.0", + "urllib3-3@2.2.1" + ] + }, + { + "ref": "typing-extensions-2@4.10.0", + "dependsOn": [] + }, + { + "ref": "urllib3-3@2.2.1", + "dependsOn": [] + } + ] + } + ----- stderr ----- + warning: The `tool.uv.dev-dependencies` field (used in `pyproject.toml`) is deprecated and will be removed in a future release; use `dependency-groups.dev` instead + Resolved 3 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + // Export without dev dependencies + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5").arg("--no-dev"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "typing-extensions-2@4.10.0", + "name": "typing-extensions", + "version": "4.10.0", + "purl": "pkg:pypi/typing-extensions@4.10.0" + } + ], + "dependencies": [ + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "typing-extensions-2@4.10.0" + ] + }, + { + "ref": "typing-extensions-2@4.10.0", + "dependsOn": [] + } + ] + } + ----- stderr ----- + warning: The `tool.uv.dev-dependencies` field (used in `pyproject.toml`) is deprecated and will be removed in a future release; use `dependency-groups.dev` instead + Resolved 3 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + // Export only dev dependencies + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5").arg("--only-dev"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "urllib3-2@2.2.1", + "name": "urllib3", + "version": "2.2.1", + "purl": "pkg:pypi/urllib3@2.2.1" + } + ], + "dependencies": [ + { + "ref": "urllib3-2@2.2.1", + "dependsOn": [] + } + ] + } + ----- stderr ----- + warning: The `tool.uv.dev-dependencies` field (used in `pyproject.toml`) is deprecated and will be removed in a future release; use `dependency-groups.dev` instead + Resolved 3 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + Ok(()) +} + +#[test] +fn cyclonedx_export_all_packages_conflicting_workspace_members() -> Result<()> { + let context = TestContext::new("3.12").with_cyclonedx_filters(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["sortedcontainers==2.3.0"] + + [tool.uv.workspace] + members = ["child"] + + [tool.uv] + conflicts = [ + [ + { package = "project" }, + { package = "child" }, + ], + ] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + let child = context.temp_dir.child("child"); + child.child("pyproject.toml").write_str( + r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["sortedcontainers==2.4.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + context.lock().assert().success(); + + // Export with --all-packages to CycloneDX format should succeed as conflict detection is skipped + uv_snapshot!(context.filters(), context.export().arg("--format").arg("cyclonedx1.5").arg("--all-packages"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "[SERIAL_NUMBER]", + "metadata": { + "timestamp": "[TIMESTAMP]", + "tools": [ + { + "vendor": "Astral Software Inc.", + "name": "uv", + "version": "[VERSION]" + } + ], + "component": { + "type": "library", + "bom-ref": "project-3", + "name": "project" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "child-2@0.1.0", + "name": "child", + "version": "0.1.0", + "properties": [ + { + "name": "uv:workspace:path", + "value": "child" + } + ] + }, + { + "type": "library", + "bom-ref": "project-1@0.1.0", + "name": "project", + "version": "0.1.0" + } + ], + "dependencies": [ + { + "ref": "child-2@0.1.0", + "dependsOn": [] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [] + }, + { + "ref": "project-3", + "dependsOn": [ + "child-2@0.1.0", + "project-1@0.1.0" + ] + } + ] + } + ----- stderr ----- + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. + Resolved 4 packages in [TIME] + warning: `uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features sbom-export` to disable this warning. + "#); + + // Should fail when exporting to `requirements.txt` or `pylock.toml`as conflict detection is enabled for these formats + uv_snapshot!(context.filters(), context.export().arg("--format").arg("requirements-txt").arg("--all-packages"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. + Resolved 4 packages in [TIME] + error: Package `child` and package `project` are incompatible with the declared conflicts: {child, project} + "); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("pylock.toml").arg("--all-packages"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning. + Resolved 4 packages in [TIME] + error: Package `child` and package `project` are incompatible with the declared conflicts: {child, project} + "); + Ok(()) +} diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 01b6703c9cf8a..d1ad284e9f15c 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -7831,7 +7831,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | SBOM_EXPORT, ), }, python_preference: Managed, @@ -8059,7 +8059,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | SBOM_EXPORT, ), }, python_preference: Managed, diff --git a/docs/concepts/projects/sync.md b/docs/concepts/projects/sync.md index a5cfcc122a081..054cb5f8667bd 100644 --- a/docs/concepts/projects/sync.md +++ b/docs/concepts/projects/sync.md @@ -186,12 +186,17 @@ environment. ## Exporting the lockfile -If you need to integrate uv with other tools or workflows, you can export `uv.lock` to the -`requirements.txt` format with `uv export --format requirements-txt`. The generated -`requirements.txt` file can then be installed via `uv pip install`, or with other tools like `pip`. +If you need to integrate uv with other tools or workflows, you can export `uv.lock` to different +formats including `requirements.txt`, `pylock.toml` (PEP 751), and CycloneDX SBOM. -In general, we recommend against using both a `uv.lock` and a `requirements.txt` file. If you find -yourself exporting a `uv.lock` file, consider opening an issue to discuss your use case. +```console +$ uv export --format requirements.txt +$ uv export --format pylock.toml +$ uv export --format cyclonedx1.5 +``` + +See the [export guide](../../guides/export.md) for comprehensive documentation on all export formats +and their use cases. ## Partial installations diff --git a/docs/guides/export.md b/docs/guides/export.md new file mode 100644 index 0000000000000..c76a0b9b32cdd --- /dev/null +++ b/docs/guides/export.md @@ -0,0 +1,118 @@ +--- +title: Exporting a lockfile +description: Exporting a lockfile to different formats +--- + +# Exporting a lockfile + +uv can export a lockfile to different formats for integration with other tools and workflows. The +`uv export` command supports multiple output formats, each suited to different use cases. + +For more details on lockfiles and how they're created, see the +[project layout](../concepts/projects/layout.md) and +[locking and syncing](../concepts/projects/sync.md) documentation. + +## Overview of export formats + +uv supports three export formats: + +- `requirements.txt`: The traditional pip-compatible + [requirements file format](https://pip.pypa.io/en/stable/reference/requirements-file-format/). +- `pylock.toml`: The standardized Python lockfile format defined in + [PEP 751](https://peps.python.org/pep-0751/). +- `CycloneDX`: An industry-standard [Software Bill of Materials (SBOM)](https://cyclonedx.org/) + format. + +The format can be specified with the `--format` flag: + +```console +$ uv export --format requirements.txt +$ uv export --format pylock.toml +$ uv export --format cyclonedx1.5 +``` + +!!! tip + + By default, `uv export` prints to stdout. Use `--output-file` to write to a file for any format: + + ```console + $ uv export --format requirements.txt --output-file requirements.txt + $ uv export --format pylock.toml --output-file pylock.toml + $ uv export --format cyclonedx1.5 --output-file sbom.json + ``` + +## `requirements.txt` format + +The `requirements.txt` format is the most widely supported format for Python dependencies. It can be +used with `pip` and other Python package managers. + +### Basic usage + +```console +$ uv export --format requirements.txt +``` + +The generated `requirements.txt` file can then be installed via `uv pip install`, or with other +tools like `pip`. + +!!! note + + In general, we recommend against using both a `uv.lock` and a `requirements.txt` file. The + `uv.lock` format is more powerful and includes features that cannot be expressed in + `requirements.txt`. If you find yourself exporting a `uv.lock` file, consider opening an issue + to discuss your use case. + +## `pylock.toml` format + +[PEP 751](https://peps.python.org/pep-0751/) defines a TOML-based lockfile format for Python +dependencies. uv can export your project's dependency lockfile to this format. + +### Basic usage + +```console +$ uv export --format pylock.toml +``` + +## CycloneDX SBOM format + +uv can export your project's dependency lockfile as a Software Bill of Materials (SBOM) in CycloneDX +format. SBOMs provide a comprehensive inventory of all software components in your application, +which is useful for security auditing, compliance, and supply chain transparency. + +!!! important + + Support for exporting to CycloneDX is in [preview](../concepts/preview.md), so may be subject to change. + +### What is CycloneDX? + +[CycloneDX](https://cyclonedx.org/) is an industry-standard format for creating Software Bill of +Materials. CycloneDX is machine readable and widely supported by security scanning tools, +vulnerability databases, and Software Composition Analysis (SCA) platforms. + +### Basic usage + +To export your project's lockfile as a CycloneDX SBOM: + +```console +$ uv export --format cyclonedx1.5 +``` + +This will generate a JSON-encoded CycloneDX v1.5 document containing your project and all of its +dependencies. + +### SBOM Structure + +The generated SBOM follows the +[CycloneDX specification](https://cyclonedx.org/specification/overview/). uv also includes the +following custom properties on components: + +- `uv:package:marker`: Environment markers (e.g., `python_version >= "3.8"`) +- `uv:workspace:path`: Relative path for workspace members + +## Next steps + +To learn more about lockfiles and exporting, see the +[locking and syncing](../concepts/projects/sync.md) documentation and the +[command reference](../reference/cli.md#uv-export). + +Or, read on to learn how to [build and publish your project to a package index](./package.md). diff --git a/docs/guides/projects.md b/docs/guides/projects.md index cafa0102c4d96..0170ba1e1164b 100644 --- a/docs/guides/projects.md +++ b/docs/guides/projects.md @@ -272,4 +272,4 @@ To learn more about working on projects with uv, see the [projects concept](../concepts/projects/index.md) page and the [command reference](../reference/cli.md#uv). -Or, read on to learn how to [build and publish your project to a package index](./package.md). +Or, read on to learn how to [export a uv lockfile to different formats](./export.md). diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 11ef15ebeae7f..3add58292c250 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1845,12 +1845,13 @@ uv export [OPTIONS]
  • fewest: Optimize for selecting the fewest number of versions for each package. Older versions may be preferred if they are compatible with a wider range of supported Python versions or platforms
  • requires-python: Optimize for selecting latest supported version of each package, for each supported Python version
  • --format format

    The format to which uv.lock should be exported.

    -

    Supports both requirements.txt and pylock.toml (PEP 751) output formats.

    +

    Supports requirements.txt, pylock.toml (PEP 751) and CycloneDX v1.5 JSON output formats.

    uv will infer the output format from the file extension of the output file, if provided. Otherwise, defaults to requirements.txt.

    Possible values:

    • requirements.txt: Export in requirements.txt format
    • pylock.toml: Export in pylock.toml format
    • +
    • cyclonedx1.5: Export in CycloneDX v1.5 JSON format
    --frozen

    Do not update the uv.lock before exporting.

    If a uv.lock does not exist, uv will exit with an error.

    May also be set with the UV_FROZEN environment variable.

    --group group

    Include dependencies from the specified dependency group.

    diff --git a/mkdocs.template.yml b/mkdocs.template.yml index 50f9f4e01ff54..fc18c300e940c 100644 --- a/mkdocs.template.yml +++ b/mkdocs.template.yml @@ -101,6 +101,7 @@ plugins: - guides/scripts.md - guides/tools.md - guides/projects.md + - guides/export.md - guides/package.md Integrations: - guides/integration/docker.md @@ -176,6 +177,7 @@ nav: - Running scripts: guides/scripts.md - Using tools: guides/tools.md - Working on projects: guides/projects.md + - Exporting lockfiles: guides/export.md - Publishing packages: guides/package.md - Migration: - guides/migration/index.md