diff --git a/Cargo.lock b/Cargo.lock index aaa88132e..9fa9aca3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1108,6 +1108,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" @@ -1475,6 +1510,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" @@ -2837,6 +2881,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" @@ -3214,6 +3267,17 @@ dependencies = [ "syn", ] +[[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" @@ -4292,6 +4356,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" @@ -4338,6 +4411,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" @@ -4665,10 +4760,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]] @@ -4677,6 +4774,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" @@ -5509,7 +5616,7 @@ dependencies = [ "schemars", "serde", "sha2", - "spdx", + "spdx 0.12.0", "tar", "tempfile", "thiserror 2.0.17", @@ -6532,6 +6639,7 @@ dependencies = [ "arcstr", "astral-pubgrub", "clap", + "cyclonedx-bom", "dashmap", "either", "fs-err", @@ -6542,6 +6650,7 @@ dependencies = [ "itertools 0.14.0", "jiff", "owo-colors", + "percent-encoding", "petgraph", "rkyv", "rustc-hash", @@ -6574,6 +6683,7 @@ dependencies = [ "uv-pep440", "uv-pep508", "uv-platform-tags", + "uv-preview", "uv-pypi-types", "uv-python", "uv-redacted", @@ -6582,6 +6692,7 @@ dependencies = [ "uv-static", "uv-torch", "uv-types", + "uv-version", "uv-warnings", "uv-workspace", ] @@ -7515,6 +7626,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 c523ecec0..cc64f1d17 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 fb1dc5870..10b33772b 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -14,8 +14,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, @@ -1442,7 +1442,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. /// @@ -4543,9 +4543,10 @@ pub struct TreeArgs { #[derive(Args)] pub struct ExportArgs { + #[allow(clippy::doc_markdown)] /// 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 c38218dc4..c1e5c57fb 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 c617169a8..1a7ce34d6 100644 --- a/crates/uv-preview/src/lib.rs +++ b/crates/uv-preview/src/lib.rs @@ -25,6 +25,7 @@ bitflags::bitflags! { const WORKSPACE_METADATA = 1 << 13; const WORKSPACE_DIR = 1 << 14; const WORKSPACE_LIST = 1 << 15; + const SBOM_EXPORT = 1 << 16; } } @@ -50,6 +51,7 @@ impl PreviewFeatures { Self::WORKSPACE_METADATA => "workspace-metadata", Self::WORKSPACE_DIR => "workspace-dir", Self::WORKSPACE_LIST => "workspace-list", + Self::SBOM_EXPORT => "sbom-export", _ => panic!("`flag_as_str` can only be used for exactly one feature flag"), } } @@ -103,6 +105,7 @@ impl FromStr for PreviewFeatures { "workspace-metadata" => Self::WORKSPACE_METADATA, "workspace-dir" => Self::WORKSPACE_DIR, "workspace-list" => Self::WORKSPACE_LIST, + "sbom-export" => Self::SBOM_EXPORT, _ => { warn_user_once!("Unknown preview feature: `{part}`"); continue; @@ -278,6 +281,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 158e2967d..9e204df0e 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 00cb9732e..901f8366a 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 000000000..65ea3ceb2 --- /dev/null +++ b/crates/uv-resolver/src/lock/export/cyclonedx_json.rs @@ -0,0 +1,430 @@ +use std::collections::HashMap; +use std::path::Path; + +use cyclonedx_bom::models::component::Classification; +use cyclonedx_bom::models::dependency::{Dependencies, Dependency}; +use cyclonedx_bom::models::metadata::Metadata; +use cyclonedx_bom::models::property::{Properties, Property}; +use cyclonedx_bom::models::tool::{Tool, Tools}; +use cyclonedx_bom::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, RegistrySource, 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 "{package_name}-{id}@{version}" or "{package_name}-{id}" 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(|v| format!("@{}", percent_encode(v.as_bytes(), PURL_ENCODE_SET))) + .unwrap_or_default(); + + let (purl_type, qualifiers) = match &package.id.source { + // By convention all Python packages use the "pypi" purl type, regardless of their source. For packages + // from non-default repositories, we add a qualifier to indicate their source explicitly. + // See the specs at + // https://github.com/package-url/purl-spec/blob/9041aa7/types/pypi-definition.json + // and https://github.com/package-url/purl-spec/blob/9041aa7/purl-specification.md + Source::Registry(registry_source) => { + let qualifiers = match registry_source { + RegistrySource::Url(url) => { + // Only add repository_url qualifier for non-default registries + if !url.as_ref().starts_with("https://pypi.org/") { + vec![("repository_url", url.as_ref())] + } else { + vec![] + } + } + RegistrySource::Path(_) => vec![], + }; + ("pypi", qualifiers) + } + 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) { + 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")) + .sorted_unstable() + .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 f1fd16106..08779add2 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 689e7f4c4..2bab3ab7f 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 252f43faf..a399c1daa 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -228,23 +228,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 { @@ -263,6 +246,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 b1c5501d5..50709c6c9 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; @@ -15,8 +16,8 @@ use uv_python::downloads::ManagedPythonDownloadList; 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}; @@ -75,7 +76,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, @@ -147,16 +148,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) @@ -398,7 +399,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 @@ -455,10 +456,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? } } @@ -613,7 +614,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)?; @@ -704,7 +705,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 1028feb85..32d68bb59 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 cde4671f4..bd0f47664 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -27,8 +27,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, @@ -2184,7 +2185,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 a7e975ffc..1399b584d 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -478,6 +478,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 cb0ed4139..1f40ae859 100644 --- a/crates/uv/tests/it/export.rs +++ b/crates/uv/tests/it/export.rs @@ -4890,3 +4890,3453 @@ 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 = ["urllib3 @ git+https://github.com/urllib3/urllib3.git@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?vcs_url=https://github.com/urllib3/urllib3.git%3Frev%3D2.2.0%2304df048cf4b1c3790c56e26c659db764aad62d6f" + } + ], + "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_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 = [ + "iniconfig==2.0.0", # PyPI registry package + "urllib3 @ git+https://github.com/urllib3/urllib3.git@2.2.0", # Git package + "idna @ https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-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": "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" + }, + { + "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?vcs_url=https://github.com/urllib3/urllib3.git%3Frev%3D2.2.0%2304df048cf4b1c3790c56e26c659db764aad62d6f" + } + ], + "dependencies": [ + { + "ref": "idna-2@3.6", + "dependsOn": [] + }, + { + "ref": "iniconfig-3@2.0.0", + "dependsOn": [] + }, + { + "ref": "mixed-project-1@0.1.0", + "dependsOn": [ + "idna-2@3.6", + "iniconfig-3@2.0.0", + "urllib3-4@2.2.0" + ] + }, + { + "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_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==4.10.0"] + + [project.optional-dependencies] + url = ["urllib3==2.2.0"] + pytest = ["iniconfig==2.0.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": "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==4.10.0"] + + [project.optional-dependencies] + url = ["urllib3==2.2.0"] + pytest = ["iniconfig==2.0.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").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.0.0"] + + [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.0.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").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"] + + [project.optional-dependencies] + url = ["urllib3==2.2.0"] + test = ["iniconfig==2.0.0"] + + [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==4.10.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": "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": "iniconfig-3@2.0.0", + "name": "iniconfig", + "version": "2.0.0", + "purl": "pkg:pypi/iniconfig@2.0.0" + }, + { + "type": "library", + "bom-ref": "typing-extensions-4@4.10.0", + "name": "typing-extensions", + "version": "4.10.0", + "purl": "pkg:pypi/typing-extensions@4.10.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": "child-2@0.1.0", + "dependsOn": [ + "typing-extensions-4@4.10.0" + ] + }, + { + "ref": "iniconfig-3@2.0.0", + "dependsOn": [] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "child-2@0.1.0", + "iniconfig-3@2.0.0", + "urllib3-5@2.2.0" + ] + }, + { + "ref": "typing-extensions-4@4.10.0", + "dependsOn": [] + }, + { + "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_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.0.0"] + + [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.0.0"] + + [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==1.3.1"] + + [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.0.0"] + + [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==1.3.1"] + + [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==2.2.1 ; sys_platform == 'darwin'", + "iniconfig==2.0.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": "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 = [ + "cryptography==42.0.5 ; python_version > '3.11'", + "cryptography==42.0.5 ; 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": "cffi-2@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 platform_python_implementation != 'PyPy') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" + } + ] + }, + { + "type": "library", + "bom-ref": "cryptography-3@42.0.5", + "name": "cryptography", + "version": "42.0.5", + "purl": "pkg:pypi/cryptography@42.0.5", + "properties": [ + { + "name": "uv:package:marker", + "value": "python_full_version >= '3.12' or sys_platform == 'win32'" + } + ] + }, + { + "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": "(python_full_version >= '3.12' and platform_python_implementation != 'PyPy') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" + } + ] + } + ], + "dependencies": [ + { + "ref": "cffi-2@1.16.0", + "dependsOn": [ + "pycparser-4@2.21" + ] + }, + { + "ref": "cryptography-3@42.0.5", + "dependsOn": [ + "cffi-2@1.16.0" + ] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "cryptography-3@42.0.5" + ] + }, + { + "ref": "pycparser-4@2.21", + "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_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 = ["cryptography[ssh]==42.0.5"] + + [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": "bcrypt-2@4.1.2", + "name": "bcrypt", + "version": "4.1.2", + "purl": "pkg:pypi/bcrypt@4.1.2" + }, + { + "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": "platform_python_implementation != 'PyPy'" + } + ] + }, + { + "type": "library", + "bom-ref": "cryptography-4@42.0.5", + "name": "cryptography", + "version": "42.0.5", + "purl": "pkg:pypi/cryptography@42.0.5" + }, + { + "type": "library", + "bom-ref": "pycparser-5@2.21", + "name": "pycparser", + "version": "2.21", + "purl": "pkg:pypi/pycparser@2.21", + "properties": [ + { + "name": "uv:package:marker", + "value": "platform_python_implementation != 'PyPy'" + } + ] + } + ], + "dependencies": [ + { + "ref": "bcrypt-2@4.1.2", + "dependsOn": [] + }, + { + "ref": "cffi-3@1.16.0", + "dependsOn": [ + "pycparser-5@2.21" + ] + }, + { + "ref": "cryptography-4@42.0.5", + "dependsOn": [ + "bcrypt-2@4.1.2", + "cffi-3@1.16.0" + ] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "cryptography-4@42.0.5" + ] + }, + { + "ref": "pycparser-5@2.21", + "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_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==8.6.1" + ] + "#, + )?; + + context.lock().assert().success(); + + 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==4.10.0"] + + [dependency-groups] + foo = ["urllib3==2.2.1 ; sys_platform == 'darwin'"] + bar = ["iniconfig==2.0.0"] + dev = ["sniffio==1.3.1"] + "#, + )?; + + 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==2.2.1"] + "#, + )?; + + 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.0.0"] + + [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.0.0"] + + [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==4.10.0"] + + [tool.uv] + dev-dependencies = ["urllib3==2.2.1"] + + [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(()) +} + +#[test] +fn cyclonedx_export_alternative_registry() -> Result<()> { + let context = TestContext::new("3.12") + .with_cyclonedx_filters() + .with_exclude_newer("2025-01-30T00:00:00Z"); + + 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 = ["torch==2.6.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + + [[tool.uv.index]] + name = "pytorch-cpu" + url = "https://astral-sh.github.io/pytorch-mirror/whl/cpu" + default = true + "#, + )?; + + 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": "filelock-2@3.13.1", + "name": "filelock", + "version": "3.13.1", + "purl": "pkg:pypi/filelock@3.13.1?repository_url=https://astral-sh.github.io/pytorch-mirror/whl/cpu" + }, + { + "type": "library", + "bom-ref": "fsspec-3@2024.6.1", + "name": "fsspec", + "version": "2024.6.1", + "purl": "pkg:pypi/fsspec@2024.6.1?repository_url=https://astral-sh.github.io/pytorch-mirror/whl/cpu" + }, + { + "type": "library", + "bom-ref": "jinja2-4@3.1.4", + "name": "jinja2", + "version": "3.1.4", + "purl": "pkg:pypi/jinja2@3.1.4?repository_url=https://astral-sh.github.io/pytorch-mirror/whl/cpu" + }, + { + "type": "library", + "bom-ref": "markupsafe-5@3.0.2", + "name": "markupsafe", + "version": "3.0.2", + "purl": "pkg:pypi/markupsafe@3.0.2?repository_url=https://astral-sh.github.io/pytorch-mirror/whl/cpu" + }, + { + "type": "library", + "bom-ref": "mpmath-6@1.3.0", + "name": "mpmath", + "version": "1.3.0", + "purl": "pkg:pypi/mpmath@1.3.0?repository_url=https://astral-sh.github.io/pytorch-mirror/whl/cpu" + }, + { + "type": "library", + "bom-ref": "networkx-7@3.3", + "name": "networkx", + "version": "3.3", + "purl": "pkg:pypi/networkx@3.3?repository_url=https://astral-sh.github.io/pytorch-mirror/whl/cpu" + }, + { + "type": "library", + "bom-ref": "setuptools-8@70.2.0", + "name": "setuptools", + "version": "70.2.0", + "purl": "pkg:pypi/setuptools@70.2.0?repository_url=https://astral-sh.github.io/pytorch-mirror/whl/cpu" + }, + { + "type": "library", + "bom-ref": "sympy-9@1.13.1", + "name": "sympy", + "version": "1.13.1", + "purl": "pkg:pypi/sympy@1.13.1?repository_url=https://astral-sh.github.io/pytorch-mirror/whl/cpu" + }, + { + "type": "library", + "bom-ref": "torch-10@2.6.0", + "name": "torch", + "version": "2.6.0", + "purl": "pkg:pypi/torch@2.6.0?repository_url=https://astral-sh.github.io/pytorch-mirror/whl/cpu", + "properties": [ + { + "name": "uv:package:marker", + "value": "sys_platform == 'darwin'" + } + ] + }, + { + "type": "library", + "bom-ref": "torch-11@2.6.0+cpu", + "name": "torch", + "version": "2.6.0+cpu", + "purl": "pkg:pypi/torch@2.6.0%2Bcpu?repository_url=https://astral-sh.github.io/pytorch-mirror/whl/cpu", + "properties": [ + { + "name": "uv:package:marker", + "value": "sys_platform != 'darwin'" + } + ] + }, + { + "type": "library", + "bom-ref": "typing-extensions-12@4.12.2", + "name": "typing-extensions", + "version": "4.12.2", + "purl": "pkg:pypi/typing-extensions@4.12.2?repository_url=https://astral-sh.github.io/pytorch-mirror/whl/cpu" + } + ], + "dependencies": [ + { + "ref": "filelock-2@3.13.1", + "dependsOn": [] + }, + { + "ref": "fsspec-3@2024.6.1", + "dependsOn": [] + }, + { + "ref": "jinja2-4@3.1.4", + "dependsOn": [ + "markupsafe-5@3.0.2" + ] + }, + { + "ref": "markupsafe-5@3.0.2", + "dependsOn": [] + }, + { + "ref": "mpmath-6@1.3.0", + "dependsOn": [] + }, + { + "ref": "networkx-7@3.3", + "dependsOn": [] + }, + { + "ref": "project-1@0.1.0", + "dependsOn": [ + "torch-10@2.6.0", + "torch-11@2.6.0+cpu" + ] + }, + { + "ref": "setuptools-8@70.2.0", + "dependsOn": [] + }, + { + "ref": "sympy-9@1.13.1", + "dependsOn": [ + "mpmath-6@1.3.0" + ] + }, + { + "ref": "torch-10@2.6.0", + "dependsOn": [ + "filelock-2@3.13.1", + "fsspec-3@2024.6.1", + "jinja2-4@3.1.4", + "networkx-7@3.3", + "setuptools-8@70.2.0", + "sympy-9@1.13.1", + "typing-extensions-12@4.12.2" + ] + }, + { + "ref": "torch-11@2.6.0+cpu", + "dependsOn": [ + "filelock-2@3.13.1", + "fsspec-3@2024.6.1", + "jinja2-4@3.1.4", + "networkx-7@3.3", + "setuptools-8@70.2.0", + "sympy-9@1.13.1", + "typing-extensions-12@4.12.2" + ] + }, + { + "ref": "typing-extensions-12@4.12.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(()) +} diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index f57510b33..ac5e86349 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 | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST, + 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 | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST | 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 | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST, + 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 | INIT_PROJECT_FLAG | WORKSPACE_METADATA | WORKSPACE_DIR | WORKSPACE_LIST | SBOM_EXPORT, ), }, python_preference: Managed, diff --git a/docs/concepts/projects/sync.md b/docs/concepts/projects/sync.md index a5cfcc122..054cb5f86 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 000000000..db26fd364 --- /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), and may change in any future release. + +### 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 cafa0102c..0170ba1e1 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 450978ca5..77cec33db 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1855,12 +1855,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 e6823f3db..cfe598cb1 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 @@ -177,6 +178,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