Add SBOM export support (#16523)

Co-authored-by: Will Rollason <william.rollason@snyk.io>
This commit is contained in:
Tom Schafer 2025-11-20 17:52:31 +00:00 committed by GitHub
parent 7d8634bf35
commit fd7e6d0a05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 4265 additions and 52 deletions

119
Cargo.lock generated
View File

@ -1108,6 +1108,41 @@ dependencies = [
"windows-sys 0.61.0", "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]] [[package]]
name = "dashmap" name = "dashmap"
version = "6.1.0" version = "6.1.0"
@ -1475,6 +1510,15 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@ -2837,6 +2881,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 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]] [[package]]
name = "ordered-multimap" name = "ordered-multimap"
version = "0.7.3" version = "0.7.3"
@ -3214,6 +3267,17 @@ dependencies = [
"syn", "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]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.3" version = "0.38.3"
@ -4292,6 +4356,15 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "spdx" name = "spdx"
version = "0.12.0" version = "0.12.0"
@ -4338,6 +4411,28 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 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]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@ -4665,10 +4760,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa",
"num-conv", "num-conv",
"powerfmt", "powerfmt",
"serde", "serde",
"time-core", "time-core",
"time-macros",
] ]
[[package]] [[package]]
@ -4677,6 +4774,16 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" 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]] [[package]]
name = "tiny-keccak" name = "tiny-keccak"
version = "2.0.2" version = "2.0.2"
@ -5509,7 +5616,7 @@ dependencies = [
"schemars", "schemars",
"serde", "serde",
"sha2", "sha2",
"spdx", "spdx 0.12.0",
"tar", "tar",
"tempfile", "tempfile",
"thiserror 2.0.17", "thiserror 2.0.17",
@ -6532,6 +6639,7 @@ dependencies = [
"arcstr", "arcstr",
"astral-pubgrub", "astral-pubgrub",
"clap", "clap",
"cyclonedx-bom",
"dashmap", "dashmap",
"either", "either",
"fs-err", "fs-err",
@ -6542,6 +6650,7 @@ dependencies = [
"itertools 0.14.0", "itertools 0.14.0",
"jiff", "jiff",
"owo-colors", "owo-colors",
"percent-encoding",
"petgraph", "petgraph",
"rkyv", "rkyv",
"rustc-hash", "rustc-hash",
@ -6574,6 +6683,7 @@ dependencies = [
"uv-pep440", "uv-pep440",
"uv-pep508", "uv-pep508",
"uv-platform-tags", "uv-platform-tags",
"uv-preview",
"uv-pypi-types", "uv-pypi-types",
"uv-python", "uv-python",
"uv-redacted", "uv-redacted",
@ -6582,6 +6692,7 @@ dependencies = [
"uv-static", "uv-static",
"uv-torch", "uv-torch",
"uv-types", "uv-types",
"uv-version",
"uv-warnings", "uv-warnings",
"uv-workspace", "uv-workspace",
] ]
@ -7515,6 +7626,12 @@ dependencies = [
"rustix 1.0.8", "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]] [[package]]
name = "xmlparser" name = "xmlparser"
version = "0.13.6" version = "0.13.6"

View File

@ -103,6 +103,7 @@ configparser = { version = "3.1.0" }
console = { version = "0.16.0", default-features = false, features = ["std"] } console = { version = "0.16.0", default-features = false, features = ["std"] }
csv = { version = "1.3.0" } csv = { version = "1.3.0" }
ctrlc = { version = "3.4.5" } ctrlc = { version = "3.4.5" }
cyclonedx-bom = { version = "0.8.0" }
dashmap = { version = "6.1.0" } dashmap = { version = "6.1.0" }
data-encoding = { version = "2.6.0" } data-encoding = { version = "2.6.0" }
dotenvy = { version = "0.15.7" } dotenvy = { version = "0.15.7" }

View File

@ -14,8 +14,8 @@ use clap::{Args, Parser, Subcommand};
use uv_auth::Service; use uv_auth::Service;
use uv_cache::CacheArgs; use uv_cache::CacheArgs;
use uv_configuration::{ use uv_configuration::{
ExportFormat, IndexStrategy, KeyringProviderType, PackageNameSpecifier, ProjectBuildBackend, ExportFormat, IndexStrategy, KeyringProviderType, PackageNameSpecifier, PipCompileFormat,
TargetTriple, TrustedHost, TrustedPublishing, VersionControlSystem, ProjectBuildBackend, TargetTriple, TrustedHost, TrustedPublishing, VersionControlSystem,
}; };
use uv_distribution_types::{ use uv_distribution_types::{
ConfigSettingEntry, ConfigSettingPackageEntry, Index, IndexUrl, Origin, PipExtraIndex, 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 /// uv will infer the output format from the file extension of the output file, if
/// provided. Otherwise, defaults to `requirements.txt`. /// provided. Otherwise, defaults to `requirements.txt`.
#[arg(long, value_enum)] #[arg(long, value_enum)]
pub format: Option<ExportFormat>, pub format: Option<PipCompileFormat>,
/// Include extras in the output file. /// Include extras in the output file.
/// ///
@ -4543,9 +4543,10 @@ pub struct TreeArgs {
#[derive(Args)] #[derive(Args)]
pub struct ExportArgs { pub struct ExportArgs {
#[allow(clippy::doc_markdown)]
/// The format to which `uv.lock` should be exported. /// 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 /// uv will infer the output format from the file extension of the output file, if
/// provided. Otherwise, defaults to `requirements.txt`. /// provided. Otherwise, defaults to `requirements.txt`.

View File

@ -15,4 +15,30 @@ pub enum ExportFormat {
#[serde(rename = "pylock.toml", alias = "pylock-toml")] #[serde(rename = "pylock.toml", alias = "pylock-toml")]
#[cfg_attr(feature = "clap", clap(name = "pylock.toml", alias = "pylock-toml"))] #[cfg_attr(feature = "clap", clap(name = "pylock.toml", alias = "pylock-toml"))]
PylockToml, 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,
} }

View File

@ -25,6 +25,7 @@ bitflags::bitflags! {
const WORKSPACE_METADATA = 1 << 13; const WORKSPACE_METADATA = 1 << 13;
const WORKSPACE_DIR = 1 << 14; const WORKSPACE_DIR = 1 << 14;
const WORKSPACE_LIST = 1 << 15; const WORKSPACE_LIST = 1 << 15;
const SBOM_EXPORT = 1 << 16;
} }
} }
@ -50,6 +51,7 @@ impl PreviewFeatures {
Self::WORKSPACE_METADATA => "workspace-metadata", Self::WORKSPACE_METADATA => "workspace-metadata",
Self::WORKSPACE_DIR => "workspace-dir", Self::WORKSPACE_DIR => "workspace-dir",
Self::WORKSPACE_LIST => "workspace-list", Self::WORKSPACE_LIST => "workspace-list",
Self::SBOM_EXPORT => "sbom-export",
_ => panic!("`flag_as_str` can only be used for exactly one feature flag"), _ => 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-metadata" => Self::WORKSPACE_METADATA,
"workspace-dir" => Self::WORKSPACE_DIR, "workspace-dir" => Self::WORKSPACE_DIR,
"workspace-list" => Self::WORKSPACE_LIST, "workspace-list" => Self::WORKSPACE_LIST,
"sbom-export" => Self::SBOM_EXPORT,
_ => { _ => {
warn_user_once!("Unknown preview feature: `{part}`"); warn_user_once!("Unknown preview feature: `{part}`");
continue; continue;
@ -278,6 +281,7 @@ mod tests {
); );
assert_eq!(PreviewFeatures::FORMAT.flag_as_str(), "format"); assert_eq!(PreviewFeatures::FORMAT.flag_as_str(), "format");
assert_eq!(PreviewFeatures::S3_ENDPOINT.flag_as_str(), "s3-endpoint"); assert_eq!(PreviewFeatures::S3_ENDPOINT.flag_as_str(), "s3-endpoint");
assert_eq!(PreviewFeatures::SBOM_EXPORT.flag_as_str(), "sbom-export");
} }
#[test] #[test]

View File

@ -33,6 +33,7 @@ uv-once-map = { workspace = true }
uv-pep440 = { workspace = true } uv-pep440 = { workspace = true }
uv-pep508 = { workspace = true } uv-pep508 = { workspace = true }
uv-platform-tags = { workspace = true } uv-platform-tags = { workspace = true }
uv-preview = { workspace = true }
uv-pypi-types = { workspace = true } uv-pypi-types = { workspace = true }
uv-python = { workspace = true } uv-python = { workspace = true }
uv-redacted = { workspace = true } uv-redacted = { workspace = true }
@ -41,11 +42,13 @@ uv-small-str = { workspace = true }
uv-static = { workspace = true } uv-static = { workspace = true }
uv-torch = { workspace = true } uv-torch = { workspace = true }
uv-types = { workspace = true } uv-types = { workspace = true }
uv-version = { workspace = true }
uv-warnings = { workspace = true } uv-warnings = { workspace = true }
uv-workspace = { workspace = true } uv-workspace = { workspace = true }
arcstr = { workspace = true } arcstr = { workspace = true }
clap = { workspace = true, features = ["derive"], optional = true } clap = { workspace = true, features = ["derive"], optional = true }
cyclonedx-bom = { workspace = true }
dashmap = { workspace = true } dashmap = { workspace = true }
either = { workspace = true } either = { workspace = true }
fs-err = { workspace = true, features = ["tokio"] } fs-err = { workspace = true, features = ["tokio"] }
@ -55,6 +58,7 @@ indexmap = { workspace = true }
itertools = { workspace = true } itertools = { workspace = true }
jiff = { workspace = true, features = ["serde"] } jiff = { workspace = true, features = ["serde"] }
owo-colors = { workspace = true } owo-colors = { workspace = true }
percent-encoding = { workspace = true }
petgraph = { workspace = true } petgraph = { workspace = true }
pubgrub = { workspace = true } pubgrub = { workspace = true }
rkyv = { workspace = true } rkyv = { workspace = true }

View File

@ -9,7 +9,7 @@ pub use fork_strategy::ForkStrategy;
pub use lock::{ pub use lock::{
Installable, Lock, LockError, LockVersion, Package, PackageMap, PylockToml, Installable, Lock, LockError, LockVersion, Package, PackageMap, PylockToml,
PylockTomlErrorKind, RequirementsTxtExport, ResolverManifest, SatisfiesResult, TreeDisplay, PylockTomlErrorKind, RequirementsTxtExport, ResolverManifest, SatisfiesResult, TreeDisplay,
VERSION, VERSION, cyclonedx_json,
}; };
pub use manifest::Manifest; pub use manifest::Manifest;
pub use options::{Flexibility, Options, OptionsBuilder}; pub use options::{Flexibility, Options, OptionsBuilder};

View File

@ -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 (<https://github.com/scm-rs/packageurl.rs/blob/a725aa0ab332934c350641508017eb09ddfa0813/src/purl.rs#L18>).
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<String> {
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<String> {
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<Bom, LockError> {
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::<Vec<_>>().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::<FxHashSet<_>>();
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::<Result<Vec<_>, 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<Dependency> {
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,
}

View File

@ -23,6 +23,7 @@ pub use crate::lock::export::requirements_txt::RequirementsTxtExport;
use crate::universal_marker::resolve_conflicts; use crate::universal_marker::resolve_conflicts;
use crate::{Installable, LockError, Package}; use crate::{Installable, LockError, Package};
pub mod cyclonedx_json;
mod pylock_toml; mod pylock_toml;
mod requirements_txt; mod requirements_txt;

View File

@ -52,7 +52,7 @@ use uv_workspace::{Editability, WorkspaceMember};
use crate::fork_strategy::ForkStrategy; use crate::fork_strategy::ForkStrategy;
pub(crate) use crate::lock::export::PylockTomlPackage; pub(crate) use crate::lock::export::PylockTomlPackage;
pub use crate::lock::export::RequirementsTxtExport; 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::installable::Installable;
pub use crate::lock::map::PackageMap; pub use crate::lock::map::PackageMap;
pub use crate::lock::tree::TreeDisplay; pub use crate::lock::tree::TreeDisplay;
@ -5963,6 +5963,12 @@ enum LockErrorKind {
#[source] #[source]
err: toml::de::Error, 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. /// An error that occurs when a source string could not be parsed.

View File

@ -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. /// Commit the buffer to the output file.
async fn commit(self) -> std::io::Result<()> { async fn commit(self) -> std::io::Result<()> {
if let Some(output_file) = self.output_file { 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<usize> {
// 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"). /// Given a list of names, return a conjunction of the names (e.g., "Alice, Bob, and Charlie").
pub(super) fn conjunction(names: Vec<String>) -> String { pub(super) fn conjunction(names: Vec<String>) -> String {
let mut names = names.into_iter(); let mut names = names.into_iter();

View File

@ -1,6 +1,7 @@
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::env; use std::env;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::io::Write;
use std::path::Path; use std::path::Path;
use std::str::FromStr; use std::str::FromStr;
@ -15,8 +16,8 @@ use uv_python::downloads::ManagedPythonDownloadList;
use uv_cache::Cache; use uv_cache::Cache;
use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder};
use uv_configuration::{ use uv_configuration::{
BuildIsolation, BuildOptions, Concurrency, Constraints, ExportFormat, ExtrasSpecification, BuildIsolation, BuildOptions, Concurrency, Constraints, ExtrasSpecification, IndexStrategy,
IndexStrategy, NoBinary, NoBuild, Reinstall, SourceStrategy, Upgrade, NoBinary, NoBuild, PipCompileFormat, Reinstall, SourceStrategy, Upgrade,
}; };
use uv_configuration::{KeyringProviderType, TargetTriple}; use uv_configuration::{KeyringProviderType, TargetTriple};
use uv_dispatch::{BuildDispatch, SharedState}; use uv_dispatch::{BuildDispatch, SharedState};
@ -75,7 +76,7 @@ pub(crate) async fn pip_compile(
extras: ExtrasSpecification, extras: ExtrasSpecification,
groups: GroupsSpecification, groups: GroupsSpecification,
output_file: Option<&Path>, output_file: Option<&Path>,
format: Option<ExportFormat>, format: Option<PipCompileFormat>,
resolution_mode: ResolutionMode, resolution_mode: ResolutionMode,
prerelease_mode: PrereleaseMode, prerelease_mode: PrereleaseMode,
fork_strategy: ForkStrategy, fork_strategy: ForkStrategy,
@ -147,16 +148,16 @@ pub(crate) async fn pip_compile(
let format = format.unwrap_or_else(|| { let format = format.unwrap_or_else(|| {
let extension = output_file.and_then(Path::extension); let extension = output_file.and_then(Path::extension);
if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("txt")) { 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")) { } else if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("toml")) {
ExportFormat::PylockToml PipCompileFormat::PylockToml
} else { } else {
ExportFormat::RequirementsTxt PipCompileFormat::RequirementsTxt
} }
}); });
// If the user is exporting to PEP 751, ensure the filename matches the specification. // 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 if let Some(file_name) = output_file
.and_then(Path::file_name) .and_then(Path::file_name)
.and_then(OsStr::to_str) .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 // Generate, but don't enforce hashes for the requirements. PEP 751 _requires_ a hash to be
// present, but otherwise, we omit them by default. // 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) HashStrategy::Generate(HashGeneration::All)
} else { } else {
HashStrategy::None HashStrategy::None
@ -455,10 +456,10 @@ pub(crate) async fn pip_compile(
let LockedRequirements { preferences, git } = let LockedRequirements { preferences, git } =
if let Some(output_file) = output_file.filter(|output_file| output_file.exists()) { if let Some(output_file) = output_file.filter(|output_file| output_file.exists()) {
match format { match format {
ExportFormat::RequirementsTxt => LockedRequirements::from_preferences( PipCompileFormat::RequirementsTxt => LockedRequirements::from_preferences(
read_requirements_txt(output_file, &upgrade).await?, read_requirements_txt(output_file, &upgrade).await?,
), ),
ExportFormat::PylockToml => { PipCompileFormat::PylockToml => {
read_pylock_toml_requirements(output_file, &upgrade).await? read_pylock_toml_requirements(output_file, &upgrade).await?
} }
} }
@ -613,7 +614,7 @@ pub(crate) async fn pip_compile(
} }
match format { match format {
ExportFormat::RequirementsTxt => { PipCompileFormat::RequirementsTxt => {
if include_marker_expression { if include_marker_expression {
if let Some(marker_env) = resolver_env.marker_environment() { if let Some(marker_env) = resolver_env.marker_environment() {
let relevant_markers = resolution.marker_tree(&top_level_index, marker_env)?; 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 { if include_marker_expression {
warn_user!( warn_user!(
"The `--emit-marker-expression` option is not supported for `pylock.toml` output" "The `--emit-marker-expression` option is not supported for `pylock.toml` output"

View File

@ -1,5 +1,6 @@
use std::env; use std::env;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
@ -15,7 +16,7 @@ use uv_normalize::{DefaultExtras, DefaultGroups, PackageName};
use uv_preview::Preview; use uv_preview::Preview;
use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; use uv_python::{PythonDownloads, PythonPreference, PythonRequest};
use uv_requirements::is_pylock_toml; use uv_requirements::is_pylock_toml;
use uv_resolver::{PylockToml, RequirementsTxtExport}; use uv_resolver::{PylockToml, RequirementsTxtExport, cyclonedx_json};
use uv_scripts::Pep723Script; use uv_scripts::Pep723Script;
use uv_settings::PythonInstallMirrors; use uv_settings::PythonInstallMirrors;
use uv_workspace::{DiscoveryOptions, MemberDiscovery, VirtualProject, Workspace, WorkspaceCache}; 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. // Validate that the set of requested extras and development groups are defined in the lockfile.
target.validate_extras(&extras)?; target.validate_extras(&extras)?;
target.validate_groups(&groups)?; 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 the user is exporting to PEP 751, ensure the filename matches the specification.
if matches!(format, ExportFormat::PylockToml) { if matches!(format, ExportFormat::PylockToml) {
if let Some(file_name) = output_file if let Some(file_name) = output_file
@ -376,6 +379,20 @@ pub(crate) async fn export(
} }
write!(writer, "{}", export.to_toml()?)?; 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?; writer.commit().await?;

View File

@ -27,8 +27,9 @@ use uv_client::Connectivity;
use uv_configuration::{ use uv_configuration::{
BuildIsolation, BuildOptions, Concurrency, DependencyGroups, DryRun, EditableMode, EnvFile, BuildIsolation, BuildOptions, Concurrency, DependencyGroups, DryRun, EditableMode, EnvFile,
ExportFormat, ExtrasSpecification, HashCheckingMode, IndexStrategy, InstallOptions, ExportFormat, ExtrasSpecification, HashCheckingMode, IndexStrategy, InstallOptions,
KeyringProviderType, NoBinary, NoBuild, ProjectBuildBackend, Reinstall, RequiredVersion, KeyringProviderType, NoBinary, NoBuild, PipCompileFormat, ProjectBuildBackend, Reinstall,
SourceStrategy, TargetTriple, TrustedHost, TrustedPublishing, Upgrade, VersionControlSystem, RequiredVersion, SourceStrategy, TargetTriple, TrustedHost, TrustedPublishing, Upgrade,
VersionControlSystem,
}; };
use uv_distribution_types::{ use uv_distribution_types::{
ConfigSettings, DependencyMetadata, ExtraBuildVariables, Index, IndexLocations, IndexUrl, ConfigSettings, DependencyMetadata, ExtraBuildVariables, Index, IndexLocations, IndexUrl,
@ -2184,7 +2185,7 @@ impl FormatSettings {
/// The resolved settings to use for a `pip compile` invocation. /// The resolved settings to use for a `pip compile` invocation.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct PipCompileSettings { pub(crate) struct PipCompileSettings {
pub(crate) format: Option<ExportFormat>, pub(crate) format: Option<PipCompileFormat>,
pub(crate) src_file: Vec<PathBuf>, pub(crate) src_file: Vec<PathBuf>,
pub(crate) constraints: Vec<PathBuf>, pub(crate) constraints: Vec<PathBuf>,
pub(crate) overrides: Vec<PathBuf>, pub(crate) overrides: Vec<PathBuf>,

View File

@ -478,6 +478,27 @@ impl TestContext {
self 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. /// Add a filter that collapses duplicate whitespace.
#[must_use] #[must_use]
pub fn with_collapsed_whitespace(mut self) -> Self { pub fn with_collapsed_whitespace(mut self) -> Self {

File diff suppressed because it is too large Load Diff

View File

@ -7831,7 +7831,7 @@ fn preview_features() {
show_settings: true, show_settings: true,
preview: Preview { preview: Preview {
flags: PreviewFeatures( 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, python_preference: Managed,
@ -8059,7 +8059,7 @@ fn preview_features() {
show_settings: true, show_settings: true,
preview: Preview { preview: Preview {
flags: PreviewFeatures( 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, python_preference: Managed,

View File

@ -186,12 +186,17 @@ environment.
## Exporting the lockfile ## Exporting the lockfile
If you need to integrate uv with other tools or workflows, you can export `uv.lock` to the If you need to integrate uv with other tools or workflows, you can export `uv.lock` to different
`requirements.txt` format with `uv export --format requirements-txt`. The generated formats including `requirements.txt`, `pylock.toml` (PEP 751), and CycloneDX SBOM.
`requirements.txt` file can then be installed via `uv pip install`, or with other tools like `pip`.
In general, we recommend against using both a `uv.lock` and a `requirements.txt` file. If you find ```console
yourself exporting a `uv.lock` file, consider opening an issue to discuss your use case. $ 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 ## Partial installations

118
docs/guides/export.md Normal file
View File

@ -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).

View File

@ -272,4 +272,4 @@ To learn more about working on projects with uv, see the
[projects concept](../concepts/projects/index.md) page and the [projects concept](../concepts/projects/index.md) page and the
[command reference](../reference/cli.md#uv). [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).

View File

@ -1855,12 +1855,13 @@ uv export [OPTIONS]
<li><code>fewest</code>: 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</li> <li><code>fewest</code>: 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</li>
<li><code>requires-python</code>: Optimize for selecting latest supported version of each package, for each supported Python version</li> <li><code>requires-python</code>: Optimize for selecting latest supported version of each package, for each supported Python version</li>
</ul></dd><dt id="uv-export--format"><a href="#uv-export--format"><code>--format</code></a> <i>format</i></dt><dd><p>The format to which <code>uv.lock</code> should be exported.</p> </ul></dd><dt id="uv-export--format"><a href="#uv-export--format"><code>--format</code></a> <i>format</i></dt><dd><p>The format to which <code>uv.lock</code> should be exported.</p>
<p>Supports both <code>requirements.txt</code> and <code>pylock.toml</code> (PEP 751) output formats.</p> <p>Supports <code>requirements.txt</code>, <code>pylock.toml</code> (PEP 751) and CycloneDX v1.5 JSON output formats.</p>
<p>uv will infer the output format from the file extension of the output file, if provided. Otherwise, defaults to <code>requirements.txt</code>.</p> <p>uv will infer the output format from the file extension of the output file, if provided. Otherwise, defaults to <code>requirements.txt</code>.</p>
<p>Possible values:</p> <p>Possible values:</p>
<ul> <ul>
<li><code>requirements.txt</code>: Export in <code>requirements.txt</code> format</li> <li><code>requirements.txt</code>: Export in <code>requirements.txt</code> format</li>
<li><code>pylock.toml</code>: Export in <code>pylock.toml</code> format</li> <li><code>pylock.toml</code>: Export in <code>pylock.toml</code> format</li>
<li><code>cyclonedx1.5</code>: Export in <code>CycloneDX</code> v1.5 JSON format</li>
</ul></dd><dt id="uv-export--frozen"><a href="#uv-export--frozen"><code>--frozen</code></a></dt><dd><p>Do not update the <code>uv.lock</code> before exporting.</p> </ul></dd><dt id="uv-export--frozen"><a href="#uv-export--frozen"><code>--frozen</code></a></dt><dd><p>Do not update the <code>uv.lock</code> before exporting.</p>
<p>If a <code>uv.lock</code> does not exist, uv will exit with an error.</p> <p>If a <code>uv.lock</code> does not exist, uv will exit with an error.</p>
<p>May also be set with the <code>UV_FROZEN</code> environment variable.</p></dd><dt id="uv-export--group"><a href="#uv-export--group"><code>--group</code></a> <i>group</i></dt><dd><p>Include dependencies from the specified dependency group.</p> <p>May also be set with the <code>UV_FROZEN</code> environment variable.</p></dd><dt id="uv-export--group"><a href="#uv-export--group"><code>--group</code></a> <i>group</i></dt><dd><p>Include dependencies from the specified dependency group.</p>

View File

@ -101,6 +101,7 @@ plugins:
- guides/scripts.md - guides/scripts.md
- guides/tools.md - guides/tools.md
- guides/projects.md - guides/projects.md
- guides/export.md
- guides/package.md - guides/package.md
Integrations: Integrations:
- guides/integration/docker.md - guides/integration/docker.md
@ -177,6 +178,7 @@ nav:
- Running scripts: guides/scripts.md - Running scripts: guides/scripts.md
- Using tools: guides/tools.md - Using tools: guides/tools.md
- Working on projects: guides/projects.md - Working on projects: guides/projects.md
- Exporting lockfiles: guides/export.md
- Publishing packages: guides/package.md - Publishing packages: guides/package.md
- Migration: - Migration:
- guides/migration/index.md - guides/migration/index.md