diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 167c920e3536..ae0287fde6dd 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -801,6 +801,8 @@ pub enum ProjectCommand { Export(ExportArgs), /// Display the project's dependency tree. Tree(TreeArgs), + /// Display the project's license information. + License(LicenseArgs), } /// A re-implementation of `Option`, used to avoid Clap's automatic `Option` flattening in @@ -3528,6 +3530,137 @@ pub struct TreeArgs { pub python: Option>, } +#[derive(Args)] +#[allow(clippy::struct_excessive_bools)] +pub struct LicenseArgs { + /// Show full list of platform-independent dependency licenses. + /// + /// Shows resolved package versions for all Python versions and platforms, + /// rather than filtering to those that are relevant for the current + /// environment. + /// + /// Multiple versions may be shown for a each package. + #[arg(long)] + pub universal: bool, + + /// Include the development dependency group. + /// + /// Development dependencies are defined via `dependency-groups.dev` or + /// `tool.uv.dev-dependencies` in a `pyproject.toml`. + /// + /// This option is an alias for `--group dev`. + #[arg(long, overrides_with("no_dev"), hide = true)] + pub dev: bool, + + /// Only include the development dependency group. + /// + /// Omit other dependencies. The project itself will also be omitted. + /// + /// This option is an alias for `--only-group dev`. + #[arg(long, conflicts_with("no_dev"))] + pub only_dev: bool, + + /// Omit the development dependency group. + /// + /// This option is an alias for `--no-group dev`. + #[arg(long, overrides_with("dev"))] + pub no_dev: bool, + + /// Include dependencies from the specified dependency group. + /// + /// May be provided multiple times. + #[arg(long, conflicts_with("only_group"))] + pub group: Vec, + + /// Exclude dependencies from the specified dependency group. + /// + /// May be provided multiple times. + #[arg(long)] + pub no_group: Vec, + + /// Exclude dependencies from default groups. + /// + /// `--group` can be used to include specific groups. + #[arg(long, conflicts_with_all = ["no_group", "only_group"])] + pub no_default_groups: bool, + + /// Only include dependencies from the specified dependency group. + /// + /// May be provided multiple times. + /// + /// The project itself will also be omitted. + #[arg(long, conflicts_with("group"))] + pub only_group: Vec, + + /// Include dependencies from all dependency groups. + /// + /// `--no-group` can be used to exclude specific groups. + #[arg(long, conflicts_with_all = [ "group", "only_group" ])] + pub all_groups: bool, + + /// Display only direct dependencies (default false) + #[arg(long)] + pub direct_deps_only: bool, + + /// Assert that the `uv.lock` will remain unchanged. + /// + /// Requires that the lockfile is up-to-date. If the lockfile is missing or + /// needs to be updated, uv will exit with an error. + #[arg(long, env = EnvVars::UV_LOCKED, value_parser = clap::builder::BoolishValueParser::new(), conflicts_with = "frozen")] + pub locked: bool, + + /// Display the requirements without locking the project. + /// + /// If the lockfile is missing, uv will exit with an error. + #[arg(long, env = EnvVars::UV_FROZEN, value_parser = clap::builder::BoolishValueParser::new(), conflicts_with = "locked")] + pub frozen: bool, + + #[command(flatten)] + pub build: BuildOptionsArgs, + + #[command(flatten)] + pub resolver: ResolverArgs, + + /// The Python version to use when filtering the tree. + /// + /// For example, pass `--python-version 3.10` to display the dependencies + /// that would be included when installing on Python 3.10. + /// + /// Defaults to the version of the discovered Python interpreter. + #[arg(long, conflicts_with = "universal")] + pub python_version: Option, + + /// The platform to use when filtering the tree. + /// + /// For example, pass `--platform windows` to display the dependencies that + /// would be included when installing on Windows. + /// + /// Represented as a "target triple", a string that describes the target + /// platform in terms of its CPU, vendor, and operating system name, like + /// `x86_64-unknown-linux-gnu` or `aarch64-apple-darwin`. + #[arg(long, conflicts_with = "universal")] + pub python_platform: Option, + + /// The Python interpreter to use for locking and filtering. + /// + /// By default, the tree is filtered to match the platform as reported by + /// the Python interpreter. Use `--universal` to display the tree for all + /// platforms, or use `--python-version` or `--python-platform` to override + /// a subset of markers. + /// + /// See `uv help python` for details on Python discovery and supported + /// request formats. + #[arg( + long, + short, + env = EnvVars::UV_PYTHON, + verbatim_doc_comment, + help_heading = "Python options", + value_parser = parse_maybe_string, + )] + pub python: Option>, +} + #[derive(Args)] #[allow(clippy::struct_excessive_bools)] pub struct ExportArgs { diff --git a/crates/uv-distribution-types/src/dependency_metadata.rs b/crates/uv-distribution-types/src/dependency_metadata.rs index 42cc27cc1d30..4899e94880db 100644 --- a/crates/uv-distribution-types/src/dependency_metadata.rs +++ b/crates/uv-distribution-types/src/dependency_metadata.rs @@ -52,6 +52,8 @@ impl DependencyMetadata { requires_python: metadata.requires_python.clone(), provides_extras: metadata.provides_extras.clone(), dynamic: false, + classifiers: metadata.classifiers.clone(), + license: metadata.license.clone(), }) } else { // If no version was requested (i.e., it's a direct URL dependency), allow a single @@ -72,6 +74,8 @@ impl DependencyMetadata { requires_python: metadata.requires_python.clone(), provides_extras: metadata.provides_extras.clone(), dynamic: false, + classifiers: metadata.classifiers.clone(), + license: metadata.license.clone(), }) } } @@ -111,4 +115,6 @@ pub struct StaticMetadata { pub requires_python: Option, #[serde(default)] pub provides_extras: Vec, + pub classifiers: Option>, + pub license: Option, } diff --git a/crates/uv-distribution/src/metadata/mod.rs b/crates/uv-distribution/src/metadata/mod.rs index f470753424a2..7b233753ab5d 100644 --- a/crates/uv-distribution/src/metadata/mod.rs +++ b/crates/uv-distribution/src/metadata/mod.rs @@ -51,6 +51,8 @@ pub struct Metadata { pub provides_extras: Vec, pub dependency_groups: BTreeMap>, pub dynamic: bool, + pub license: Option, + pub classifiers: Option>, } impl Metadata { @@ -69,6 +71,8 @@ impl Metadata { provides_extras: metadata.provides_extras, dependency_groups: BTreeMap::default(), dynamic: metadata.dynamic, + license: metadata.license, + classifiers: metadata.classifiers, } } @@ -112,6 +116,8 @@ impl Metadata { provides_extras, dependency_groups, dynamic: metadata.dynamic, + classifiers: metadata.classifiers, + license: metadata.license, }) } } diff --git a/crates/uv-distribution/src/source/mod.rs b/crates/uv-distribution/src/source/mod.rs index 88ccd9d0cff1..290a521c335d 100644 --- a/crates/uv-distribution/src/source/mod.rs +++ b/crates/uv-distribution/src/source/mod.rs @@ -45,7 +45,9 @@ use uv_metadata::read_archive_metadata; use uv_normalize::PackageName; use uv_pep440::{release_specifiers_to_ranges, Version}; use uv_platform_tags::Tags; -use uv_pypi_types::{HashAlgorithm, HashDigest, Metadata12, RequiresTxt, ResolutionMetadata}; +use uv_pypi_types::{ + HashAlgorithm, HashDigest, Metadata12, Metadata23, RequiresTxt, ResolutionMetadata, +}; use uv_types::{BuildContext, BuildStack, SourceBuildTrait}; use uv_workspace::pyproject::ToolUvSources; use zip::ZipArchive; @@ -2752,6 +2754,7 @@ async fn read_egg_info( // Parse the metadata. let metadata = Metadata12::parse_metadata(&content).map_err(Error::PkgInfo)?; + let metadata23 = Metadata23::parse(&content).map_err(Error::PkgInfo)?; // Determine whether the version is dynamic. let dynamic = metadata.dynamic.iter().any(|field| field == "version"); @@ -2764,6 +2767,9 @@ async fn read_egg_info( requires_dist: requires_txt.requires_dist, provides_extras: requires_txt.provides_extras, dynamic, + classifiers: Some(metadata23.classifiers), + // TODO(RL): collapse metadata23.license / metadata23.license_expression [pep639] / metadata23.license_files + license: metadata23.license, }) } diff --git a/crates/uv-pypi-types/src/metadata/metadata_resolver.rs b/crates/uv-pypi-types/src/metadata/metadata_resolver.rs index b8d576ab53f5..e55ab568f1f9 100644 --- a/crates/uv-pypi-types/src/metadata/metadata_resolver.rs +++ b/crates/uv-pypi-types/src/metadata/metadata_resolver.rs @@ -32,6 +32,10 @@ pub struct ResolutionMetadata { /// Whether the version field is dynamic. #[serde(default)] pub dynamic: bool, + #[serde(default)] + pub classifiers: Option>, + #[serde(default)] + pub license: Option, } /// From @@ -74,6 +78,8 @@ impl ResolutionMetadata { let dynamic = headers .get_all_values("Dynamic") .any(|field| field == "Version"); + let classifiers = Some(headers.get_all_values("Classifier").collect::>()); + let license = headers.get_first_value("License"); Ok(Self { name, @@ -82,6 +88,8 @@ impl ResolutionMetadata { requires_python, provides_extras, dynamic, + classifiers, + license, }) } @@ -149,6 +157,8 @@ impl ResolutionMetadata { } }) .collect::>(); + let classifiers = Some(headers.get_all_values("Classifiers").collect::>()); + let license = headers.get_first_value("License"); Ok(Self { name, @@ -157,6 +167,8 @@ impl ResolutionMetadata { requires_python, provides_extras, dynamic, + classifiers, + license, }) } @@ -240,4 +252,6 @@ mod tests { assert_eq!(meta.version, Version::new([1, 0])); assert_eq!(meta.requires_dist, vec!["foo".parse().unwrap()]); } + + // TODO(RL): write test cases for checking classifier information } diff --git a/crates/uv-pypi-types/src/metadata/pyproject_toml.rs b/crates/uv-pypi-types/src/metadata/pyproject_toml.rs index 8f730c565a15..543bbf6409da 100644 --- a/crates/uv-pypi-types/src/metadata/pyproject_toml.rs +++ b/crates/uv-pypi-types/src/metadata/pyproject_toml.rs @@ -92,6 +92,13 @@ pub(super) fn parse_pyproject_toml( ); provides_extras.push(extra); } + let classifiers = Some( + project + .classifiers + .unwrap_or_default() + .into_iter() + .collect::>(), + ); Ok(ResolutionMetadata { name, @@ -100,6 +107,8 @@ pub(super) fn parse_pyproject_toml( requires_python, provides_extras, dynamic, + classifiers, + license: None, // TODO(RL): come back }) } @@ -143,6 +152,9 @@ pub(super) struct Project { /// Specifies which fields listed by PEP 621 were intentionally unspecified /// so another tool can/will provide such metadata dynamically. pub(super) dynamic: Option>, + // Specifies zero or more "Trove Classifiers" to describe the project. + classifiers: Option>, + // TODO(RL): handle license field properly } #[derive(Deserialize, Debug)] @@ -154,6 +166,7 @@ struct PyprojectTomlWire { dependencies: Option>, optional_dependencies: Option>>, dynamic: Option>, + classifiers: Option>, } impl TryFrom for Project { @@ -168,6 +181,7 @@ impl TryFrom for Project { dependencies: wire.dependencies, optional_dependencies: wire.optional_dependencies, dynamic: wire.dynamic, + classifiers: wire.classifiers, }) } } diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index 629539bace9e..ca21f4629c0f 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -5,8 +5,8 @@ pub use exclusions::Exclusions; pub use flat_index::{FlatDistributions, FlatIndex}; pub use fork_strategy::ForkStrategy; pub use lock::{ - Installable, Lock, LockError, LockVersion, Package, PackageMap, RequirementsTxtExport, - ResolverManifest, SatisfiesResult, TreeDisplay, VERSION, + Installable, LicenseDisplay, Lock, LockError, LockVersion, Package, PackageMap, + RequirementsTxtExport, ResolverManifest, SatisfiesResult, TreeDisplay, VERSION, }; pub use manifest::Manifest; pub use options::{Flexibility, Options, OptionsBuilder}; diff --git a/crates/uv-resolver/src/lock/license.rs b/crates/uv-resolver/src/lock/license.rs new file mode 100644 index 000000000000..3bda39bf89b6 --- /dev/null +++ b/crates/uv-resolver/src/lock/license.rs @@ -0,0 +1,427 @@ +use std::borrow::Cow; +use std::collections::VecDeque; + +use itertools::Itertools; +use petgraph::graph::{EdgeIndex, NodeIndex}; +use petgraph::prelude::EdgeRef; +use petgraph::Direction; +use rustc_hash::{FxHashMap, FxHashSet}; + +use uv_configuration::DevGroupsManifest; +use uv_normalize::{ExtraName, GroupName}; +use uv_pypi_types::ResolverMarkerEnvironment; + +use crate::lock::{Dependency, PackageId}; +use crate::{Lock, PackageMap}; + +#[derive(Debug)] +pub struct LicenseDisplay<'env> { + /// The constructed dependency graph. + graph: petgraph::graph::Graph<&'env PackageId, Edge<'env>, petgraph::Directed>, + /// The packages considered as roots of the dependency tree. + roots: Vec, + /// The discovered license data for each dependency + license: &'env PackageMap, + /// Maximum display depth of the dependency tree. + depth: usize, +} + +impl<'env> LicenseDisplay<'env> { + /// Create a new [`DisplayDependencyGraph`] for the set of installed packages. + pub fn new( + lock: &'env Lock, + markers: Option<&'env ResolverMarkerEnvironment>, + license: &'env PackageMap, + direct_only: bool, + // packages: &[PackageName], + dev: &DevGroupsManifest, + ) -> Self { + let depth = if direct_only { 1 } else { 255 }; + // Identify the workspace members. + let members: FxHashSet<&PackageId> = if lock.members().is_empty() { + lock.root().into_iter().map(|package| &package.id).collect() + } else { + lock.packages + .iter() + .filter_map(|package| { + if lock.members().contains(&package.id.name) { + Some(&package.id) + } else { + None + } + }) + .collect() + }; + + // Create a graph. + let mut graph = petgraph::graph::Graph::<&PackageId, Edge, petgraph::Directed>::new(); + + // Create the complete graph. + let mut inverse = FxHashMap::default(); + for package in &lock.packages { + // Insert the package into the graph. + let package_node = if let Some(index) = inverse.get(&package.id) { + *index + } else { + let index = graph.add_node(&package.id); + inverse.insert(&package.id, index); + index + }; + + if dev.prod() { + for dependency in &package.dependencies { + if markers.is_some_and(|markers| { + !dependency.complexified_marker.evaluate_no_extras(markers) + }) { + continue; + } + + // Insert the dependency into the graph. + let dependency_node = if let Some(index) = inverse.get(&dependency.package_id) { + *index + } else { + let index = graph.add_node(&dependency.package_id); + inverse.insert(&dependency.package_id, index); + index + }; + + // Add an edge between the package and the dependency. + graph.add_edge( + package_node, + dependency_node, + Edge::Prod(Cow::Borrowed(dependency)), + ); + } + } + + if dev.prod() { + for (extra, dependencies) in &package.optional_dependencies { + for dependency in dependencies { + if markers.is_some_and(|markers| { + !dependency.complexified_marker.evaluate_no_extras(markers) + }) { + continue; + } + + // Insert the dependency into the graph. + let dependency_node = + if let Some(index) = inverse.get(&dependency.package_id) { + *index + } else { + let index = graph.add_node(&dependency.package_id); + inverse.insert(&dependency.package_id, index); + index + }; + + // Add an edge between the package and the dependency. + graph.add_edge( + package_node, + dependency_node, + Edge::Optional(extra, Cow::Borrowed(dependency)), + ); + } + } + } + + for (group, dependencies) in &package.dependency_groups { + if dev.contains(group) { + for dependency in dependencies { + if markers.is_some_and(|markers| { + !dependency.complexified_marker.evaluate_no_extras(markers) + }) { + continue; + } + + // Insert the dependency into the graph. + let dependency_node = + if let Some(index) = inverse.get(&dependency.package_id) { + *index + } else { + let index = graph.add_node(&dependency.package_id); + inverse.insert(&dependency.package_id, index); + index + }; + + // Add an edge between the package and the dependency. + graph.add_edge( + package_node, + dependency_node, + Edge::Dev(group, Cow::Borrowed(dependency)), + ); + } + } + } + } + + // Filter the graph to remove any unreachable nodes. + { + let mut reachable = graph + .node_indices() + .filter(|index| members.contains(graph[*index])) + .collect::>(); + let mut stack = reachable.iter().copied().collect::>(); + while let Some(node) = stack.pop_front() { + for edge in graph.edges_directed(node, Direction::Outgoing) { + if reachable.insert(edge.target()) { + stack.push_back(edge.target()); + } + } + } + + // Remove the unreachable nodes from the graph. + graph.retain_nodes(|_, index| reachable.contains(&index)); + } + + // // Filter the graph to those nodes reachable from the target packages. + // if !packages.is_empty() { + // let mut reachable = graph + // .node_indices() + // .filter(|index| packages.contains(&graph[*index].name)) + // .collect::>(); + // let mut stack = reachable.iter().copied().collect::>(); + // while let Some(node) = stack.pop_front() { + // for edge in graph.edges_directed(node, Direction::Outgoing) { + // if reachable.insert(edge.target()) { + // stack.push_back(edge.target()); + // } + // } + // } + + // // Remove the unreachable nodes from the graph. + // graph.retain_nodes(|_, index| reachable.contains(&index)); + // } + + // Compute the list of roots. + let roots = { + let mut edges = vec![]; + + // Remove any cycles. + let feedback_set: Vec = petgraph::algo::greedy_feedback_arc_set(&graph) + .map(|e| e.id()) + .collect(); + for edge_id in feedback_set { + if let Some((source, target)) = graph.edge_endpoints(edge_id) { + if let Some(weight) = graph.remove_edge(edge_id) { + edges.push((source, target, weight)); + } + } + } + + // Find the root nodes. + let mut roots = graph + .node_indices() + .filter(|index| { + graph + .edges_directed(*index, Direction::Incoming) + .next() + .is_none() + }) + .collect::>(); + + // Sort the roots. + roots.sort_by_key(|index| &graph[*index]); + + // Re-add the removed edges. + for (source, target, weight) in edges { + graph.add_edge(source, target, weight); + } + + roots + }; + + Self { + graph, + roots, + license, + depth, + } + } + + /// Perform a depth-first traversal of the given package and its dependencies. + fn visit( + &'env self, + cursor: Cursor, + visited: &mut FxHashMap<&'env PackageId, Vec<&'env PackageId>>, + path: &mut Vec<&'env PackageId>, + ) -> Vec { + let unknown_license = String::from("Unknown License"); + // Short-circuit if the current path is longer than the provided depth. + if path.len() > self.depth { + return Vec::new(); + } + + let package_id = self.graph[cursor.node()]; + let edge = cursor.edge().map(|edge_id| &self.graph[edge_id]); + + if visited.contains_key(&package_id) { + return vec![]; + } + + let line = { + let mut line = format!("{}", package_id.name); + + if let Some(version) = package_id.version.as_ref() { + line.push(' '); + line.push('v'); + line.push_str(&format!("{version}")); + } + + if let Some(edge) = edge { + let extras = &edge.dependency().extra; + if !extras.is_empty() { + line.push('['); + line.push_str(extras.iter().join(", ").as_str()); + line.push(']'); + } + } + + line.push(' '); + line.push_str(self.license.get(package_id).unwrap_or(&unknown_license)); + + if let Some(edge) = edge { + match edge { + Edge::Prod(_) => {} + Edge::Optional(extra, _) => { + line.push_str(&format!(" (extra: {extra})")); + } + Edge::Dev(group, _) => { + line.push_str(&format!(" (group: {group})")); + } + } + } + + line + }; + + // Skip the traversal if: + // 1. The package is in the current traversal path (i.e., a dependency cycle). + // 2. The package has been visited and de-duplication is enabled (default). + if let Some(requirements) = visited.get(package_id) { + if requirements.is_empty() { + return vec![line]; + } + } + + let mut dependencies = self + .graph + .edges_directed(cursor.node(), Direction::Outgoing) + .map(|edge| { + let node = edge.target(); + Cursor::new(node, edge.id()) + }) + .collect::>(); + dependencies.sort_by_key(|node| { + let package_id = self.graph[node.node()]; + let edge = node + .edge() + .map(|edge_id| &self.graph[edge_id]) + .map(Edge::kind); + (edge, package_id) + }); + + let mut lines = vec![line]; + + // Keep track of the dependency path to avoid cycles. + visited.insert( + package_id, + dependencies + .iter() + .map(|node| self.graph[node.node()]) + .collect(), + ); + path.push(package_id); + + for dep in &dependencies { + for visited_line in &self.visit(*dep, visited, path) { + lines.push(visited_line.to_string()); + } + } + + path.pop(); + + lines + } + + /// Depth-first traverse the nodes to render the tree. + fn render(&self) -> Vec { + let mut path = Vec::new(); + let mut lines = Vec::with_capacity(self.graph.node_count()); + let mut visited = + FxHashMap::with_capacity_and_hasher(self.graph.node_count(), rustc_hash::FxBuildHasher); + + for node in &self.roots { + path.clear(); + lines.extend(self.visit(Cursor::root(*node), &mut visited, &mut path)); + } + + lines + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +enum Edge<'env> { + Prod(Cow<'env, Dependency>), + Optional(&'env ExtraName, Cow<'env, Dependency>), + Dev(&'env GroupName, Cow<'env, Dependency>), +} + +impl<'env> Edge<'env> { + fn dependency(&self) -> &Dependency { + match self { + Self::Prod(dependency) => dependency, + Self::Optional(_, dependency) => dependency, + Self::Dev(_, dependency) => dependency, + } + } + + fn kind(&self) -> EdgeKind<'env> { + match self { + Self::Prod(_) => EdgeKind::Prod, + Self::Optional(extra, _) => EdgeKind::Optional(extra), + Self::Dev(group, _) => EdgeKind::Dev(group), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +enum EdgeKind<'env> { + Prod, + Optional(&'env ExtraName), + Dev(&'env GroupName), +} + +/// A node in the dependency graph along with the edge that led to it, or `None` for root nodes. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Ord, PartialOrd)] +struct Cursor(NodeIndex, Option); + +impl Cursor { + /// Create a [`Cursor`] representing a node in the dependency tree. + fn new(node: NodeIndex, edge: EdgeIndex) -> Self { + Self(node, Some(edge)) + } + + /// Create a [`Cursor`] representing a root node in the dependency tree. + fn root(node: NodeIndex) -> Self { + Self(node, None) + } + + /// Return the [`NodeIndex`] of the node. + fn node(&self) -> NodeIndex { + self.0 + } + + /// Return the [`EdgeIndex`] of the edge that led to the node, if any. + fn edge(&self) -> Option { + self.1 + } +} + +impl std::fmt::Display for LicenseDisplay<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + for line in self.render() { + writeln!(f, "{line}")?; + } + + Ok(()) + } +} diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index a66f8fcec51e..363eaf42b5cd 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -15,7 +15,7 @@ use petgraph::visit::EdgeRef; use rustc_hash::{FxHashMap, FxHashSet}; use serde::Serializer; use toml_edit::{value, Array, ArrayOfTables, InlineTable, Item, Table, Value}; -use tracing::trace; +use tracing::{debug, trace}; use url::Url; use uv_cache_key::RepositoryUrl; @@ -43,10 +43,12 @@ use uv_pypi_types::{ Requirement, RequirementSource, }; use uv_types::{BuildContext, HashStrategy}; -use uv_workspace::WorkspaceMember; +use uv_workspace::{Workspace, WorkspaceMember}; use crate::fork_strategy::ForkStrategy; pub use crate::lock::installable::Installable; +pub use crate::lock::license::LicenseDisplay; + pub use crate::lock::map::PackageMap; pub use crate::lock::requirements_txt::RequirementsTxtExport; pub use crate::lock::tree::TreeDisplay; @@ -59,6 +61,7 @@ use crate::{ }; mod installable; +mod license; mod map; mod requirements_txt; mod tree; @@ -2463,6 +2466,92 @@ impl Package { &self.id.name } + fn get_license_string( + license_meta: Option<&String>, + classifiers: Option<&Vec>, + ) -> Option { + // first we'll try trove classifiers + let trove_license = if let Some(classifiers) = classifiers { + let license_prefix = "License ::"; + let license_osi_prefix = "License :: OSI Approved ::"; + Some( + classifiers + .iter() + .filter_map(|c| { + if !c.starts_with(license_prefix) { + None // filter this classifier out if it's not License-related + } else { + if c.starts_with(license_osi_prefix) { + Some(c[license_osi_prefix.len() + 1..].to_string()) + // remove the License & OSI-approved prefixes + } else { + Some(c[license_prefix.len() + 1..].to_string()) // remove the License prefix + } + } + }) + .collect::>() + .join(", "), + ) + .filter(|s| !s.is_empty()) + } else { + None + }; + + // trove_license is none if there were no classifiers that specify the license + if trove_license.is_some() { + return trove_license; + } + + // we did not successfully find and parse a license from a trove classifier + // try the license field + if let Some(license_txt) = license_meta { + if !license_txt.is_empty() { + return Some(license_txt.clone()); + } + } + + None + } + + pub async fn license( + &self, + workspace: &Workspace, + tags: &Tags, + database: &DistributionDatabase<'_, Context>, + ) -> Option { + // parse license information from classifiers + // it is possible that the classifiers field isn't set yet because of the source + // of the package. the package may be populated from the lock file OR the resolver. + // in the case of the former, the package data is incomplete and we must fetch + // the additional data ourselves. + + // TODO(RL): need a smarter check here + // Get the metadata for the distribution (see above for explanation of tags/capabilities). + let dist = self.to_dist( + workspace.install_path(), + TagPolicy::Preferred(tags), + &BuildOptions::default(), + ); + + if let Ok(generated_dist) = dist { + let hasher = HashStrategy::None; + + if let Ok(meta) = database + .get_or_build_wheel_metadata(&generated_dist, hasher.get(&generated_dist)) + .await + { + return Package::get_license_string( + meta.metadata.license.as_ref(), + meta.metadata.classifiers.as_ref(), + ); + } + debug!("package metadata lookup failed"); + return None; + } + debug!("package.to_dist failed"); + None + } + /// Returns the [`Version`] of the package. pub fn version(&self) -> Option<&Version> { self.id.version.as_ref() diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index a3fb800b7249..87e94213370b 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -24,6 +24,7 @@ pub(crate) use pip::uninstall::pip_uninstall; pub(crate) use project::add::add; pub(crate) use project::export::export; pub(crate) use project::init::{init, InitKind, InitProjectKind}; +pub(crate) use project::license::license; pub(crate) use project::lock::lock; pub(crate) use project::remove::remove; pub(crate) use project::run::{run, RunCommand}; diff --git a/crates/uv/src/commands/pip/show.rs b/crates/uv/src/commands/pip/show.rs index 5c03f6d04676..3c86b7aae7b0 100644 --- a/crates/uv/src/commands/pip/show.rs +++ b/crates/uv/src/commands/pip/show.rs @@ -187,6 +187,15 @@ pub(crate) fn pip_show( )?; } } + if let Ok(meta) = distribution.metadata() { + if let Some(classifiers) = meta.classifiers { + if classifiers.is_empty() { + writeln!(printer.stdout(), "Classifiers:")?; + } else { + writeln!(printer.stdout(), "Classifiers: {}", classifiers.join(", "))?; + } + } + } // If requests, show the list of installed files. if files { diff --git a/crates/uv/src/commands/project/license.rs b/crates/uv/src/commands/project/license.rs new file mode 100644 index 000000000000..4c5d4b2fe6e5 --- /dev/null +++ b/crates/uv/src/commands/project/license.rs @@ -0,0 +1,247 @@ +use anstream::print; +use std::path::Path; + +use anyhow::{Error, Result}; + +use futures::StreamExt; +use uv_cache::{Cache, Refresh}; +use uv_cache_info::Timestamp; +use uv_client::{Connectivity, FlatIndexClient, RegistryClientBuilder}; +use uv_configuration::{ + Concurrency, Constraints, DevGroupsSpecification, LowerBound, PreviewMode, TargetTriple, + TrustedHost, +}; +use uv_dispatch::{BuildDispatch, SharedState}; +use uv_distribution::DistributionDatabase; +use uv_distribution_types::Index; +use uv_python::{ + PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest, PythonVersion, +}; +use uv_resolver::{FlatIndex, LicenseDisplay, PackageMap}; +use uv_settings::PythonInstallMirrors; +use uv_types::{BuildIsolation, HashStrategy}; +use uv_workspace::{DiscoveryOptions, Workspace}; + +use crate::commands::pip::loggers::DefaultResolveLogger; +use crate::commands::pip::resolution_markers; +use crate::commands::project::lock::{do_safe_lock, LockMode}; +use crate::commands::project::{ + default_dependency_groups, DependencyGroupsTarget, ProjectError, ProjectInterpreter, +}; +use crate::commands::{diagnostics, ExitStatus}; +use crate::printer::Printer; +use crate::settings::ResolverSettings; + +/// Run a command. +#[allow(clippy::fn_params_excessive_bools)] +pub(crate) async fn license( + project_dir: &Path, + dev: DevGroupsSpecification, + locked: bool, + frozen: bool, + universal: bool, + direct_only: bool, + python_version: Option, + python_platform: Option, + python: Option, + install_mirrors: PythonInstallMirrors, + settings: ResolverSettings, + python_preference: PythonPreference, + python_downloads: PythonDownloads, + connectivity: Connectivity, + concurrency: Concurrency, + native_tls: bool, + allow_insecure_host: &[TrustedHost], + no_config: bool, + cache: &Cache, + printer: Printer, + preview: PreviewMode, +) -> Result { + // Find the project requirements. + let workspace = Workspace::discover(project_dir, &DiscoveryOptions::default()).await?; + + // Validate that any referenced dependency groups are defined in the workspace. + if !frozen { + let target = DependencyGroupsTarget::Workspace(&workspace); + target.validate(&dev)?; + } + + // Determine the default groups to include. + let defaults = default_dependency_groups(workspace.pyproject_toml())?; + + let interpreter = Some( + ProjectInterpreter::discover( + &workspace, + project_dir, + python.as_deref().map(PythonRequest::parse), + python_preference, + python_downloads, + connectivity, + native_tls, + allow_insecure_host, + &install_mirrors, + no_config, + cache, + printer, + ) + .await? + .into_interpreter(), + ); + + // Determine the lock mode. + let mode = if frozen { + LockMode::Frozen + } else if locked { + LockMode::Locked(interpreter.as_ref().unwrap()) + } else { + LockMode::Write(interpreter.as_ref().unwrap()) + }; + + // Initialize any shared state. + let state = SharedState::default(); + let bounds = LowerBound::Allow; + + // Update the lockfile, if necessary. + let lock = match do_safe_lock( + mode, + (&workspace).into(), + settings.as_ref(), + bounds, + &state, + Box::new(DefaultResolveLogger), + connectivity, + concurrency, + native_tls, + allow_insecure_host, + cache, + printer, + preview, + ) + .await + { + Ok(result) => result.into_lock(), + Err(ProjectError::Operation(err)) => { + return diagnostics::OperationDiagnostic::default() + .report(err) + .map_or(Ok(ExitStatus::Failure), |err| Err(err.into())) + } + Err(err) => return Err(err.into()), + }; + + // Determine the markers to use for resolution. + let markers = (!universal).then(|| { + resolution_markers( + python_version.as_ref(), + python_platform.as_ref(), + interpreter.as_ref().unwrap(), + ) + }); + + let ResolverSettings { + index_locations, + index_strategy, + keyring_provider, + resolution: _, + prerelease: _, + fork_strategy: _, + dependency_metadata, + config_setting, + no_build_isolation, + no_build_isolation_package, + exclude_newer, + link_mode, + upgrade: _, + build_options, + sources, + } = settings; + + // Initialize the registry client. + let client: uv_client::RegistryClient = + RegistryClientBuilder::new(cache.clone().with_refresh(Refresh::All(Timestamp::now()))) + .native_tls(native_tls) + .connectivity(connectivity) + .keyring(keyring_provider) + .allow_insecure_host(allow_insecure_host.to_vec()) + .build(); + let environment; + let build_isolation = if no_build_isolation { + environment = PythonEnvironment::from_interpreter(interpreter.as_ref().unwrap().clone()); + BuildIsolation::Shared(&environment) + } else if no_build_isolation_package.is_empty() { + BuildIsolation::Isolated + } else { + environment = PythonEnvironment::from_interpreter(interpreter.as_ref().unwrap().clone()); + BuildIsolation::SharedPackage(&environment, no_build_isolation_package.as_ref()) + }; + + // TODO(charlie): These are all default values. We should consider whether we want to make them + // optional on the downstream APIs. + let build_hasher = HashStrategy::default(); + + // Resolve the flat indexes from `--find-links`. + let flat_index = { + let client = FlatIndexClient::new(&client, cache); + let entries = client + .fetch(index_locations.flat_indexes().map(Index::url)) + .await?; + FlatIndex::from_entries(entries, None, &build_hasher, &build_options) + }; + + // Create a build dispatch. + let build_dispatch = BuildDispatch::new( + &client, + cache, + Constraints::default(), + interpreter.as_ref().unwrap(), + &index_locations, + &flat_index, + &dependency_metadata, + state, + index_strategy, + &config_setting, + build_isolation, + link_mode, + &build_options, + &build_hasher, + exclude_newer, + bounds, + sources, + concurrency, + preview, + ); + let database = DistributionDatabase::new(&client, &build_dispatch, concurrency.downloads); + + let mut licenses = PackageMap::default(); + + let interpret = interpreter.as_ref().expect("need an interpreter").tags()?; + let ws = &workspace; + let db = &database; + let mut fetches = futures::stream::iter(lock.packages()) + .map(|package| async move { + let license = package.license(&ws.clone(), interpret, db).await; + Ok::, Error>(Some((package, license))) + }) + .buffer_unordered(concurrency.downloads); + while let Some(entry) = fetches.next().await.transpose()? { + let Some((package, license)) = entry else { + continue; + }; + match license { + Some(license) => licenses.insert(package.clone(), license), + None => continue, + }; + } + + // Render the license information. + let display = LicenseDisplay::new( + &lock, + markers.as_ref(), + &licenses, + direct_only, + &dev.with_defaults(defaults), + ); + + print!("{display}"); + + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 74cfbe5ffc09..3fe18f71282d 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -56,6 +56,7 @@ pub(crate) mod environment; pub(crate) mod export; pub(crate) mod init; mod install_target; +pub(crate) mod license; pub(crate) mod lock; mod lock_target; pub(crate) mod remove; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 3af174ca7d29..ee977c594aa6 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1662,6 +1662,40 @@ async fn run_project( )) .await } + ProjectCommand::License(args) => { + // Resolve the settings from the command-line arguments and workspace configuration. + let args = settings::LicenseSettings::resolve(args, filesystem); + show_settings!(args); + + // Initialize the cache. + let cache = cache.init()?; + + Box::pin(commands::license( + project_dir, + args.dev, + args.locked, + args.frozen, + args.universal, + args.direct_only, + args.python_version, + args.python_platform, + args.python, + args.install_mirrors, + args.resolver, + globals.python_preference, + globals.python_downloads, + globals.connectivity, + globals.concurrency, + globals.native_tls, + &globals.allow_insecure_host, + no_config, + &cache, + printer, + globals.preview, + )) + .await + } + ProjectCommand::Tree(args) => { // Resolve the settings from the command-line arguments and workspace configuration. let args = settings::TreeSettings::resolve(args, filesystem); diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 1e59e0f94ab5..7a336d97ffa5 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -14,8 +14,8 @@ use uv_cli::{ ToolUpgradeArgs, }; use uv_cli::{ - AddArgs, ColorChoice, ExternalCommand, GlobalArgs, InitArgs, ListFormat, LockArgs, Maybe, - PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs, PipListArgs, PipShowArgs, + AddArgs, ColorChoice, ExternalCommand, GlobalArgs, InitArgs, LicenseArgs, ListFormat, LockArgs, + Maybe, PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs, PipListArgs, PipShowArgs, PipSyncArgs, PipTreeArgs, PipUninstallArgs, PythonFindArgs, PythonInstallArgs, PythonListArgs, PythonListFormat, PythonPinArgs, PythonUninstallArgs, RemoveArgs, RunArgs, SyncArgs, ToolDirArgs, ToolInstallArgs, ToolListArgs, ToolRunArgs, ToolUninstallArgs, TreeArgs, VenvArgs, @@ -1379,6 +1379,72 @@ impl TreeSettings { } } +/// The resolved settings to use for a `tree` invocation. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone)] +pub(crate) struct LicenseSettings { + pub(crate) dev: DevGroupsSpecification, + pub(crate) locked: bool, + pub(crate) frozen: bool, + pub(crate) universal: bool, + pub(crate) direct_only: bool, + pub(crate) python_version: Option, + pub(crate) python_platform: Option, + pub(crate) python: Option, + pub(crate) install_mirrors: PythonInstallMirrors, + pub(crate) resolver: ResolverSettings, +} + +impl LicenseSettings { + /// Resolve the [`LicenseSettings`] from the CLI and workspace configuration. + pub(crate) fn resolve(args: LicenseArgs, filesystem: Option) -> Self { + let LicenseArgs { + universal, + dev, + only_dev, + no_dev, + group, + no_group, + no_default_groups, + only_group, + all_groups, + direct_deps_only, + locked, + frozen, + build, + resolver, + python_version, + python_platform, + python, + } = args; + let install_mirrors = filesystem + .clone() + .map(|fs| fs.install_mirrors.clone()) + .unwrap_or_default(); + Self { + dev: DevGroupsSpecification::from_args( + dev, + no_dev, + only_dev, + group, + no_group, + no_default_groups, + only_group, + all_groups, + ), + locked, + frozen, + universal, + direct_only: direct_deps_only, + python_version, + python_platform, + python: python.and_then(Maybe::into_option), + resolver: ResolverSettings::combine(resolver_options(resolver, build), filesystem), + install_mirrors, + } + } +} + /// The resolved settings to use for an `export` invocation. #[allow(clippy::struct_excessive_bools, dead_code)] #[derive(Debug, Clone)] diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 0a7ca3defb97..ee492b8fda16 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -777,6 +777,14 @@ impl TestContext { command } + /// Create a `uv license` command with options shared across scenarios. + pub fn license(&self) -> Command { + let mut command = self.new_command(); + command.arg("license"); + self.add_shared_args(&mut command, false); + command + } + /// Create a `uv cache clean` command. pub fn clean(&self) -> Command { let mut command = self.new_command(); diff --git a/crates/uv/tests/it/help.rs b/crates/uv/tests/it/help.rs index 93b6ece0251f..85fc7adcf6c8 100644 --- a/crates/uv/tests/it/help.rs +++ b/crates/uv/tests/it/help.rs @@ -5,7 +5,7 @@ fn help() { let context = TestContext::new_with_versions(&[]); // The `uv help` command should show the long help message - uv_snapshot!(context.filters(), context.help(), @r###" + uv_snapshot!(context.filters(), context.help(), @r#" success: true exit_code: 0 ----- stdout ----- @@ -22,6 +22,7 @@ fn help() { lock Update the project's lockfile export Export the project's lockfile to an alternate format tree Display the project's dependency tree + license Display the project's license information tool Run and install commands provided by Python packages python Manage Python versions and installations pip Manage Python packages with a pip-compatible interface @@ -79,14 +80,14 @@ fn help() { ----- stderr ----- - "###); + "#); } #[test] fn help_flag() { let context = TestContext::new_with_versions(&[]); - uv_snapshot!(context.filters(), context.command().arg("--help"), @r###" + uv_snapshot!(context.filters(), context.command().arg("--help"), @r#" success: true exit_code: 0 ----- stdout ----- @@ -103,6 +104,7 @@ fn help_flag() { lock Update the project's lockfile export Export the project's lockfile to an alternate format tree Display the project's dependency tree + license Display the project's license information tool Run and install commands provided by Python packages python Manage Python versions and installations pip Manage Python packages with a pip-compatible interface @@ -158,14 +160,14 @@ fn help_flag() { Use `uv help` for more details. ----- stderr ----- - "###); + "#); } #[test] fn help_short_flag() { let context = TestContext::new_with_versions(&[]); - uv_snapshot!(context.filters(), context.command().arg("-h"), @r###" + uv_snapshot!(context.filters(), context.command().arg("-h"), @r#" success: true exit_code: 0 ----- stdout ----- @@ -182,6 +184,7 @@ fn help_short_flag() { lock Update the project's lockfile export Export the project's lockfile to an alternate format tree Display the project's dependency tree + license Display the project's license information tool Run and install commands provided by Python packages python Manage Python versions and installations pip Manage Python packages with a pip-compatible interface @@ -237,7 +240,7 @@ fn help_short_flag() { Use `uv help` for more details. ----- stderr ----- - "###); + "#); } #[test] @@ -832,7 +835,7 @@ fn help_flag_subsubcommand() { fn help_unknown_subcommand() { let context = TestContext::new_with_versions(&[]); - uv_snapshot!(context.filters(), context.help().arg("foobar"), @r###" + uv_snapshot!(context.filters(), context.help().arg("foobar"), @r" success: false exit_code: 2 ----- stdout ----- @@ -847,6 +850,7 @@ fn help_unknown_subcommand() { lock export tree + license tool python pip @@ -857,9 +861,9 @@ fn help_unknown_subcommand() { self version generate-shell-completion - "###); + "); - uv_snapshot!(context.filters(), context.help().arg("foo").arg("bar"), @r###" + uv_snapshot!(context.filters(), context.help().arg("foo").arg("bar"), @r" success: false exit_code: 2 ----- stdout ----- @@ -874,6 +878,7 @@ fn help_unknown_subcommand() { lock export tree + license tool python pip @@ -884,7 +889,7 @@ fn help_unknown_subcommand() { self version generate-shell-completion - "###); + "); } #[test] @@ -911,7 +916,7 @@ fn help_unknown_subsubcommand() { fn help_with_global_option() { let context = TestContext::new_with_versions(&[]); - uv_snapshot!(context.filters(), context.help().arg("--no-cache"), @r###" + uv_snapshot!(context.filters(), context.help().arg("--no-cache"), @r#" success: true exit_code: 0 ----- stdout ----- @@ -928,6 +933,7 @@ fn help_with_global_option() { lock Update the project's lockfile export Export the project's lockfile to an alternate format tree Display the project's dependency tree + license Display the project's license information tool Run and install commands provided by Python packages python Manage Python versions and installations pip Manage Python packages with a pip-compatible interface @@ -985,7 +991,7 @@ fn help_with_global_option() { ----- stderr ----- - "###); + "#); } #[test] @@ -1027,7 +1033,7 @@ fn help_with_no_pager() { // We can't really test whether the --no-pager option works with a snapshot test. // It's still nice to have a test for the option to confirm the option exists. - uv_snapshot!(context.filters(), context.help().arg("--no-pager"), @r###" + uv_snapshot!(context.filters(), context.help().arg("--no-pager"), @r#" success: true exit_code: 0 ----- stdout ----- @@ -1044,6 +1050,7 @@ fn help_with_no_pager() { lock Update the project's lockfile export Export the project's lockfile to an alternate format tree Display the project's dependency tree + license Display the project's license information tool Run and install commands provided by Python packages python Manage Python versions and installations pip Manage Python packages with a pip-compatible interface @@ -1101,5 +1108,5 @@ fn help_with_no_pager() { ----- stderr ----- - "###); + "#); } diff --git a/crates/uv/tests/it/license.rs b/crates/uv/tests/it/license.rs new file mode 100644 index 000000000000..474407ebddd3 --- /dev/null +++ b/crates/uv/tests/it/license.rs @@ -0,0 +1,549 @@ +use anyhow::Result; +use assert_fs::prelude::*; +use indoc::formatdoc; +use url::Url; + +use crate::common::{uv_snapshot, TestContext}; + +#[test] +fn project_with_no_license() -> Result<()> { + let context = TestContext::new("3.12"); + + 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" + "#, + )?; + + uv_snapshot!(context.filters(), context.license().arg("--universal"), @r" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 Unknown License + + ----- stderr ----- + Resolved 1 package in [TIME] + " + ); + + // `uv tree` should update the lockfile + let lock = context.read("uv.lock"); + assert!(!lock.is_empty()); + + Ok(()) +} + +#[test] +fn project_with_trove_license() -> Result<()> { + let context = TestContext::new("3.12"); + + 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" + classifiers = [ + "License :: Other/Proprietary License" + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.license().arg("--universal"), @r" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 Other/Proprietary License + + ----- stderr ----- + Resolved 1 package in [TIME] + " + ); + + // `uv tree` should update the lockfile + let lock = context.read("uv.lock"); + assert!(!lock.is_empty()); + + Ok(()) +} + +#[test] +fn project_with_trove_osi_license() -> Result<()> { + let context = TestContext::new("3.12"); + + 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" + classifiers = [ + "License :: OSI Approved" + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.license().arg("--universal"), @r" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 OSI Approved + + ----- stderr ----- + Resolved 1 package in [TIME] + " + ); + + // `uv tree` should update the lockfile + let lock = context.read("uv.lock"); + assert!(!lock.is_empty()); + + Ok(()) +} + +#[test] +fn nested_dependencies() -> Result<()> { + let context = TestContext::new("3.12"); + + 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 = [ + "scikit-learn==1.4.1.post1" + ] + classifiers = [ + "License :: OSI Approved :: MIT License" + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.license().arg("--universal"), @r" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 MIT License + scikit-learn v1.4.1.post1 BSD License + joblib v1.3.2 BSD License + numpy v1.26.4 BSD License + scipy v1.12.0 BSD License + threadpoolctl v3.4.0 BSD License + + ----- stderr ----- + Resolved 6 packages in [TIME] + " + ); + + // `uv tree` should update the lockfile + let lock = context.read("uv.lock"); + assert!(!lock.is_empty()); + + Ok(()) +} + +#[test] +fn nested_platform_dependencies() -> Result<()> { + let context = TestContext::new("3.12"); + + 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" + ] + "#, + )?; + + uv_snapshot!(context.filters(), context.license().arg("--python-platform").arg("linux"), @r" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 Unknown License + jupyter-client v8.6.1 BSD License + jupyter-core v5.7.2 BSD License + platformdirs v4.2.0 MIT License + traitlets v5.14.2 BSD License + python-dateutil v2.9.0.post0 BSD License, Apache Software License + six v1.16.0 MIT License + pyzmq v25.1.2 GNU Library or Lesser General Public License (LGPL), BSD License + tornado v6.4 Apache Software License + + ----- stderr ----- + Resolved 12 packages in [TIME] + " + ); + + uv_snapshot!(context.filters(), context.license().arg("--universal"), @r" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 Unknown License + jupyter-client v8.6.1 BSD License + jupyter-core v5.7.2 BSD License + platformdirs v4.2.0 MIT License + pywin32 v306 Python Software Foundation License + traitlets v5.14.2 BSD License + python-dateutil v2.9.0.post0 BSD License, Apache Software License + six v1.16.0 MIT License + pyzmq v25.1.2 GNU Library or Lesser General Public License (LGPL), BSD License + cffi v1.16.0 MIT License + pycparser v2.21 BSD License + tornado v6.4 Apache Software License + + ----- stderr ----- + Resolved 12 packages in [TIME] + " + ); + + // `uv tree` should update the lockfile + let lock = context.read("uv.lock"); + assert!(!lock.is_empty()); + + Ok(()) +} + +#[test] +fn frozen() -> Result<()> { + let context = TestContext::new("3.12"); + + 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 = ["anyio"] + "#, + )?; + + uv_snapshot!(context.filters(), context.license().arg("--universal"), @r" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 Unknown License + anyio v4.3.0 MIT License + idna v3.6 BSD License + sniffio v1.3.1 MIT License, Apache Software License + + ----- stderr ----- + Resolved 4 packages in [TIME] + " + ); + + // `uv tree` should update the lockfile + let lock = context.read("uv.lock"); + assert!(!lock.is_empty()); + + // Update the project dependencies. + 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 = ["iniconfig"] + "#, + )?; + + // Running with `--frozen` should show the stale tree. + uv_snapshot!(context.filters(), context.license().arg("--frozen"), @r" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 Unknown License + anyio v4.3.0 MIT License + idna v3.6 BSD License + sniffio v1.3.1 MIT License, Apache Software License + + ----- stderr ----- + " + ); + + Ok(()) +} + +#[test] +fn platform_dependencies() -> Result<()> { + let context = TestContext::new("3.12"); + + 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 = [ + "black" + ] + "#, + )?; + + // When `--universal` is _not_ provided, `colorama` should _not_ be included. + #[cfg(not(windows))] + uv_snapshot!(context.filters(), context.license(), @r" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 Unknown License + black v24.3.0 MIT License + click v8.1.7 BSD License + mypy-extensions v1.0.0 MIT License + packaging v24.0 Apache Software License, BSD License + pathspec v0.12.1 Mozilla Public License 2.0 (MPL 2.0) + platformdirs v4.2.0 MIT License + + ----- stderr ----- + Resolved 8 packages in [TIME] + "); + + // Unless `--python-platform` is set to `windows`, in which case it should be included. + uv_snapshot!(context.filters(), context.license().arg("--python-platform").arg("windows"), @r" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 Unknown License + black v24.3.0 MIT License + click v8.1.7 BSD License + colorama v0.4.6 BSD License + mypy-extensions v1.0.0 MIT License + packaging v24.0 Apache Software License, BSD License + pathspec v0.12.1 Mozilla Public License 2.0 (MPL 2.0) + platformdirs v4.2.0 MIT License + + ----- stderr ----- + Resolved 8 packages in [TIME] + "); + + // When `--universal` is _not_ provided, should include `colorama`, even though it's only + // included on Windows. + uv_snapshot!(context.filters(), context.license().arg("--universal"), @r" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 Unknown License + black v24.3.0 MIT License + click v8.1.7 BSD License + colorama v0.4.6 BSD License + mypy-extensions v1.0.0 MIT License + packaging v24.0 Apache Software License, BSD License + pathspec v0.12.1 Mozilla Public License 2.0 (MPL 2.0) + platformdirs v4.2.0 MIT License + + ----- stderr ----- + Resolved 8 packages in [TIME] + " + ); + + // `uv tree` should update the lockfile + let lock = context.read("uv.lock"); + assert!(!lock.is_empty()); + + Ok(()) +} + +#[test] +fn repeated_dependencies() -> Result<()> { + let context = TestContext::new("3.12"); + + 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 = [ + "anyio < 2 ; sys_platform == 'win32'", + "anyio > 2 ; sys_platform == 'linux'", + ] + "#, + )?; + + // Should include both versions of `anyio`, which have different dependencies. + uv_snapshot!(context.filters(), context.license().arg("--universal"), @r" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 Unknown License + anyio v1.4.0 MIT License + async-generator v1.10 MIT License, Apache Software License + idna v3.6 BSD License + sniffio v1.3.1 MIT License, Apache Software License + anyio v4.3.0 MIT License + + ----- stderr ----- + Resolved 6 packages in [TIME] + " + ); + + // `uv tree` should update the lockfile + let lock = context.read("uv.lock"); + assert!(!lock.is_empty()); + + Ok(()) +} + +/// In this case, a package is included twice at the same version, but pointing to different direct +/// URLs. +#[test] +fn repeated_version() -> Result<()> { + let context = TestContext::new("3.12"); + + let v1 = context.temp_dir.child("v1"); + fs_err::create_dir_all(&v1)?; + let pyproject_toml = v1.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "dependency" + version = "0.0.1" + requires-python = ">=3.12" + dependencies = ["anyio==3.7.0"] + "#, + )?; + + let v2 = context.temp_dir.child("v2"); + fs_err::create_dir_all(&v2)?; + let pyproject_toml = v2.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "dependency" + version = "0.0.1" + requires-python = ">=3.12" + dependencies = ["anyio==3.0.0"] + "#, + )?; + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(&formatdoc! { + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "dependency @ {} ; sys_platform == 'darwin'", + "dependency @ {} ; sys_platform != 'darwin'", + ] + "#, + Url::from_file_path(context.temp_dir.join("v1")).unwrap(), + Url::from_file_path(context.temp_dir.join("v2")).unwrap(), + })?; + + uv_snapshot!(context.filters(), context.license().arg("--universal"), @r" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 Unknown License + dependency v0.0.1 Unknown License + anyio v3.7.0 MIT License + idna v3.6 BSD License + sniffio v1.3.1 MIT License, Apache Software License + dependency v0.0.1 Unknown License + anyio v3.0.0 MIT License + + ----- stderr ----- + Resolved 7 packages in [TIME] + " + ); + + // `uv tree` should update the lockfile + let lock = context.read("uv.lock"); + assert!(!lock.is_empty()); + + Ok(()) +} + +#[test] +fn workspace_dev() -> Result<()> { + let context = TestContext::new("3.12"); + + 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 = ["anyio"] + + [dependency-groups] + dev = ["child"] + + [tool.uv.workspace] + members = ["child"] + + [tool.uv.sources] + child = { workspace = true } + "#, + )?; + + let child = context.temp_dir.child("child"); + let pyproject_toml = child.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + "#, + )?; + + uv_snapshot!(context.filters(), context.license().arg("--universal"), @r" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 Unknown License + anyio v4.3.0 MIT License + idna v3.6 BSD License + sniffio v1.3.1 MIT License, Apache Software License + child v0.1.0 Unknown License (group: dev) + iniconfig v2.0.0 MIT License + + ----- stderr ----- + Resolved 6 packages in [TIME] + " + ); + + // Under `--no-dev`, the member should still be included, since we show the entire workspace. + // But it shouldn't be considered a dependency of the root. + uv_snapshot!(context.filters(), context.license().arg("--universal").arg("--no-dev"), @r" + success: true + exit_code: 0 + ----- stdout ----- + child v0.1.0 Unknown License + iniconfig v2.0.0 MIT License + project v0.1.0 Unknown License + anyio v4.3.0 MIT License + idna v3.6 BSD License + sniffio v1.3.1 MIT License, Apache Software License + + ----- stderr ----- + Resolved 6 packages in [TIME] + " + ); + + // `uv tree` should update the lockfile + let lock = context.read("uv.lock"); + assert!(!lock.is_empty()); + + Ok(()) +} diff --git a/crates/uv/tests/it/main.rs b/crates/uv/tests/it/main.rs index 44c7488cb542..151e34a39019 100644 --- a/crates/uv/tests/it/main.rs +++ b/crates/uv/tests/it/main.rs @@ -31,6 +31,9 @@ mod help; #[cfg(all(feature = "python", feature = "pypi"))] mod init; +#[cfg(all(feature = "python", feature = "pypi"))] +mod license; + #[cfg(all(feature = "python", feature = "pypi"))] mod lock; diff --git a/crates/uv/tests/it/pip_show.rs b/crates/uv/tests/it/pip_show.rs index 31b4c02cbbca..07023b19e371 100644 --- a/crates/uv/tests/it/pip_show.rs +++ b/crates/uv/tests/it/pip_show.rs @@ -55,7 +55,7 @@ fn show_requires_multiple() -> Result<()> { context.assert_command("import requests").success(); uv_snapshot!(context.filters(), context.pip_show() - .arg("requests"), @r###" + .arg("requests"), @r" success: true exit_code: 0 ----- stdout ----- @@ -64,9 +64,10 @@ fn show_requires_multiple() -> Result<()> { Location: [SITE_PACKAGES]/ Requires: certifi, charset-normalizer, idna, urllib3 Required-by: + Classifiers: Development Status :: 5 - Production/Stable, Environment :: Web Environment, Intended Audience :: Developers, License :: OSI Approved :: Apache Software License, Natural Language :: English, Operating System :: OS Independent, Programming Language :: Python, Programming Language :: Python :: 3, Programming Language :: Python :: 3.7, Programming Language :: Python :: 3.8, Programming Language :: Python :: 3.9, Programming Language :: Python :: 3.10, Programming Language :: Python :: 3.11, Programming Language :: Python :: 3 :: Only, Programming Language :: Python :: Implementation :: CPython, Programming Language :: Python :: Implementation :: PyPy, Topic :: Internet :: WWW/HTTP, Topic :: Software Development :: Libraries ----- stderr ----- - "### + " ); Ok(()) @@ -106,7 +107,7 @@ fn show_python_version_marker() -> Result<()> { } uv_snapshot!(filters, context.pip_show() - .arg("click"), @r###" + .arg("click"), @r" success: true exit_code: 0 ----- stdout ----- @@ -115,9 +116,10 @@ fn show_python_version_marker() -> Result<()> { Location: [SITE_PACKAGES]/ Requires: Required-by: + Classifiers: Development Status :: 5 - Production/Stable, Intended Audience :: Developers, License :: OSI Approved :: BSD License, Operating System :: OS Independent, Programming Language :: Python ----- stderr ----- - "### + " ); Ok(()) @@ -150,7 +152,7 @@ fn show_found_single_package() -> Result<()> { context.assert_command("import markupsafe").success(); uv_snapshot!(context.filters(), context.pip_show() - .arg("markupsafe"), @r###" + .arg("markupsafe"), @r" success: true exit_code: 0 ----- stdout ----- @@ -159,9 +161,10 @@ fn show_found_single_package() -> Result<()> { Location: [SITE_PACKAGES]/ Requires: Required-by: + Classifiers: Development Status :: 5 - Production/Stable, Environment :: Web Environment, Intended Audience :: Developers, License :: OSI Approved :: BSD License, Operating System :: OS Independent, Programming Language :: Python, Topic :: Internet :: WWW/HTTP :: Dynamic Content, Topic :: Text Processing :: Markup :: HTML ----- stderr ----- - "### + " ); Ok(()) @@ -200,7 +203,7 @@ fn show_found_multiple_packages() -> Result<()> { uv_snapshot!(context.filters(), context.pip_show() .arg("markupsafe") - .arg("pip"), @r###" + .arg("pip"), @r" success: true exit_code: 0 ----- stdout ----- @@ -209,15 +212,17 @@ fn show_found_multiple_packages() -> Result<()> { Location: [SITE_PACKAGES]/ Requires: Required-by: + Classifiers: Development Status :: 5 - Production/Stable, Environment :: Web Environment, Intended Audience :: Developers, License :: OSI Approved :: BSD License, Operating System :: OS Independent, Programming Language :: Python, Topic :: Internet :: WWW/HTTP :: Dynamic Content, Topic :: Text Processing :: Markup :: HTML --- Name: pip Version: 21.3.1 Location: [SITE_PACKAGES]/ Requires: Required-by: + Classifiers: Development Status :: 5 - Production/Stable, Intended Audience :: Developers, License :: OSI Approved :: MIT License, Topic :: Software Development :: Build Tools, Programming Language :: Python, Programming Language :: Python :: 3, Programming Language :: Python :: 3 :: Only, Programming Language :: Python :: 3.6, Programming Language :: Python :: 3.7, Programming Language :: Python :: 3.8, Programming Language :: Python :: 3.9, Programming Language :: Python :: 3.10, Programming Language :: Python :: Implementation :: CPython, Programming Language :: Python :: Implementation :: PyPy ----- stderr ----- - "### + " ); Ok(()) @@ -257,7 +262,7 @@ fn show_found_one_out_of_three() -> Result<()> { uv_snapshot!(context.filters(), context.pip_show() .arg("markupsafe") .arg("flask") - .arg("django"), @r###" + .arg("django"), @r" success: true exit_code: 0 ----- stdout ----- @@ -266,10 +271,11 @@ fn show_found_one_out_of_three() -> Result<()> { Location: [SITE_PACKAGES]/ Requires: Required-by: + Classifiers: Development Status :: 5 - Production/Stable, Environment :: Web Environment, Intended Audience :: Developers, License :: OSI Approved :: BSD License, Operating System :: OS Independent, Programming Language :: Python, Topic :: Internet :: WWW/HTTP :: Dynamic Content, Topic :: Text Processing :: Markup :: HTML ----- stderr ----- warning: Package(s) not found for: django, flask - "### + " ); Ok(()) @@ -386,7 +392,7 @@ fn show_editable() -> Result<()> { .success(); uv_snapshot!(context.filters(), context.pip_show() - .arg("poetry-editable"), @r###" + .arg("poetry-editable"), @r" success: true exit_code: 0 ----- stdout ----- @@ -396,9 +402,10 @@ fn show_editable() -> Result<()> { Editable project location: [WORKSPACE]/scripts/packages/poetry_editable Requires: anyio Required-by: + Classifiers: Programming Language :: Python :: 3, Programming Language :: Python :: 3.10, Programming Language :: Python :: 3.11, Programming Language :: Python :: 3.12 ----- stderr ----- - "### + " ); Ok(()) @@ -442,7 +449,7 @@ fn show_required_by_multiple() -> Result<()> { // idna is required by anyio and requests uv_snapshot!(context.filters(), context.pip_show() - .arg("idna"), @r###" + .arg("idna"), @r" success: true exit_code: 0 ----- stdout ----- @@ -451,9 +458,10 @@ fn show_required_by_multiple() -> Result<()> { Location: [SITE_PACKAGES]/ Requires: Required-by: anyio, requests + Classifiers: Development Status :: 5 - Production/Stable, Intended Audience :: Developers, Intended Audience :: System Administrators, License :: OSI Approved :: BSD License, Operating System :: OS Independent, Programming Language :: Python, Programming Language :: Python :: 3, Programming Language :: Python :: 3 :: Only, Programming Language :: Python :: 3.5, Programming Language :: Python :: 3.6, Programming Language :: Python :: 3.7, Programming Language :: Python :: 3.8, Programming Language :: Python :: 3.9, Programming Language :: Python :: 3.10, Programming Language :: Python :: 3.11, Programming Language :: Python :: 3.12, Programming Language :: Python :: Implementation :: CPython, Programming Language :: Python :: Implementation :: PyPy, Topic :: Internet :: Name Service (DNS), Topic :: Software Development :: Libraries :: Python Modules, Topic :: Utilities ----- stderr ----- - "### + " ); Ok(()) @@ -485,7 +493,7 @@ fn show_files() { // Windows has a different files order. #[cfg(not(windows))] - uv_snapshot!(context.filters(), context.pip_show().arg("requests").arg("--files"), @r#" + uv_snapshot!(context.filters(), context.pip_show().arg("requests").arg("--files"), @r" success: true exit_code: 0 ----- stdout ----- @@ -494,6 +502,7 @@ fn show_files() { Location: [SITE_PACKAGES]/ Requires: certifi, charset-normalizer, idna, urllib3 Required-by: + Classifiers: Development Status :: 5 - Production/Stable, Environment :: Web Environment, Intended Audience :: Developers, License :: OSI Approved :: Apache Software License, Natural Language :: English, Operating System :: OS Independent, Programming Language :: Python, Programming Language :: Python :: 3, Programming Language :: Python :: 3.7, Programming Language :: Python :: 3.8, Programming Language :: Python :: 3.9, Programming Language :: Python :: 3.10, Programming Language :: Python :: 3.11, Programming Language :: Python :: 3 :: Only, Programming Language :: Python :: Implementation :: CPython, Programming Language :: Python :: Implementation :: PyPy, Topic :: Internet :: WWW/HTTP, Topic :: Software Development :: Libraries Files: requests-2.31.0.dist-info/INSTALLER requests-2.31.0.dist-info/LICENSE @@ -522,5 +531,5 @@ fn show_files() { requests/utils.py ----- stderr ----- - "#); + "); } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 19d6add46bd4..7c39e8bb1b21 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -28,6 +28,8 @@ uv [OPTIONS]
uv tree

Display the project’s dependency tree

+
uv license

Display the project’s license information

+
uv tool

Run and install commands provided by Python packages

uv python

Manage Python versions and installations

@@ -3001,6 +3003,432 @@ uv tree [OPTIONS]
+## uv license + +Display the project's license information + +

Usage

+ +``` +uv license [OPTIONS] +``` + +

Options

+ +
--all-groups

Include dependencies from all dependency groups.

+ +

--no-group can be used to exclude specific groups.

+ +
--allow-insecure-host allow-insecure-host

Allow insecure connections to a host.

+ +

Can be provided multiple times.

+ +

Expects to receive either a hostname (e.g., localhost), a host-port pair (e.g., localhost:8080), or a URL (e.g., https://localhost).

+ +

WARNING: Hosts included in this list will not be verified against the system’s certificate store. Only use --allow-insecure-host in a secure network with verified sources, as it bypasses SSL verification and could expose you to MITM attacks.

+ +

May also be set with the UV_INSECURE_HOST environment variable.

+
--cache-dir cache-dir

Path to the cache directory.

+ +

Defaults to $XDG_CACHE_HOME/uv or $HOME/.cache/uv on macOS and Linux, and %LOCALAPPDATA%\uv\cache on Windows.

+ +

To view the location of the cache directory, run uv cache dir.

+ +

May also be set with the UV_CACHE_DIR environment variable.

+
--color color-choice

Control the use of color in output.

+ +

By default, uv will automatically detect support for colors when writing to a terminal.

+ +

Possible values:

+ +
    +
  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • + +
  • always: Enables colored output regardless of the detected environment
  • + +
  • never: Disables colored output
  • +
+
--config-file config-file

The path to a uv.toml file to use for configuration.

+ +

While uv configuration can be included in a pyproject.toml file, it is not allowed in this context.

+ +

May also be set with the UV_CONFIG_FILE environment variable.

+
--config-setting, -C config-setting

Settings to pass to the PEP 517 build backend, specified as KEY=VALUE pairs

+ +
--default-index default-index

The URL of the default package index (by default: <https://pypi.org/simple>).

+ +

Accepts either a repository compliant with PEP 503 (the simple repository API), or a local directory laid out in the same format.

+ +

The index given by this flag is given lower priority than all other indexes specified via the --index flag.

+ +

May also be set with the UV_DEFAULT_INDEX environment variable.

+
--direct-deps-only

Display only direct dependencies (default false)

+ +
--directory directory

Change to the given directory prior to running the command.

+ +

Relative paths are resolved with the given directory as the base.

+ +

See --project to only change the project root directory.

+ +
--exclude-newer exclude-newer

Limit candidate packages to those that were uploaded prior to the given date.

+ +

Accepts both RFC 3339 timestamps (e.g., 2006-12-02T02:07:43Z) and local dates in the same format (e.g., 2006-12-02) in your system’s configured time zone.

+ +

May also be set with the UV_EXCLUDE_NEWER environment variable.

+
--extra-index-url extra-index-url

(Deprecated: use --index instead) Extra URLs of package indexes to use, in addition to --index-url.

+ +

Accepts either a repository compliant with PEP 503 (the simple repository API), or a local directory laid out in the same format.

+ +

All indexes provided via this flag take priority over the index specified by --index-url (which defaults to PyPI). When multiple --extra-index-url flags are provided, earlier values take priority.

+ +

May also be set with the UV_EXTRA_INDEX_URL environment variable.

+
--find-links, -f find-links

Locations to search for candidate distributions, in addition to those found in the registry indexes.

+ +

If a path, the target must be a directory that contains packages as wheel files (.whl) or source distributions (e.g., .tar.gz or .zip) at the top level.

+ +

If a URL, the page must contain a flat list of links to package files adhering to the formats described above.

+ +

May also be set with the UV_FIND_LINKS environment variable.

+
--fork-strategy fork-strategy

The strategy to use when selecting multiple versions of a given package across Python versions and platforms.

+ +

By default, uv will optimize for selecting the latest version of each package for each supported Python version (requires-python), while minimizing the number of selected versions across platforms.

+ +

Under fewest, uv will minimize the number of selected versions for each package, preferring older versions that are compatible with a wider range of supported Python versions or platforms.

+ +

May also be set with the UV_FORK_STRATEGY environment variable.

+

Possible values:

+ +
    +
  • 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
  • +
+
--frozen

Display the requirements without locking the project.

+ +

If the lockfile is missing, 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.

+ +

May be provided multiple times.

+ +
--help, -h

Display the concise help for this command

+ +
--index index

The URLs to use when resolving dependencies, in addition to the default index.

+ +

Accepts either a repository compliant with PEP 503 (the simple repository API), or a local directory laid out in the same format.

+ +

All indexes provided via this flag take priority over the index specified by --default-index (which defaults to PyPI). When multiple --index flags are provided, earlier values take priority.

+ +

May also be set with the UV_INDEX environment variable.

+
--index-strategy index-strategy

The strategy to use when resolving against multiple index URLs.

+ +

By default, uv will stop at the first index on which a given package is available, and limit resolutions to those present on that first index (first-index). This prevents "dependency confusion" attacks, whereby an attacker can upload a malicious package under the same name to an alternate index.

+ +

May also be set with the UV_INDEX_STRATEGY environment variable.

+

Possible values:

+ +
    +
  • first-index: Only use results from the first index that returns a match for a given package name
  • + +
  • unsafe-first-match: Search for every package name across all indexes, exhausting the versions from the first index before moving on to the next
  • + +
  • unsafe-best-match: Search for every package name across all indexes, preferring the "best" version found. If a package version is in multiple indexes, only look at the entry for the first index
  • +
+
--index-url, -i index-url

(Deprecated: use --default-index instead) The URL of the Python package index (by default: <https://pypi.org/simple>).

+ +

Accepts either a repository compliant with PEP 503 (the simple repository API), or a local directory laid out in the same format.

+ +

The index given by this flag is given lower priority than all other indexes specified via the --extra-index-url flag.

+ +

May also be set with the UV_INDEX_URL environment variable.

+
--keyring-provider keyring-provider

Attempt to use keyring for authentication for index URLs.

+ +

At present, only --keyring-provider subprocess is supported, which configures uv to use the keyring CLI to handle authentication.

+ +

Defaults to disabled.

+ +

May also be set with the UV_KEYRING_PROVIDER environment variable.

+

Possible values:

+ +
    +
  • disabled: Do not use keyring for credential lookup
  • + +
  • subprocess: Use the keyring command for credential lookup
  • +
+
--link-mode link-mode

The method to use when installing packages from the global cache.

+ +

This option is only used when building source distributions.

+ +

Defaults to clone (also known as Copy-on-Write) on macOS, and hardlink on Linux and Windows.

+ +

May also be set with the UV_LINK_MODE environment variable.

+

Possible values:

+ +
    +
  • clone: Clone (i.e., copy-on-write) packages from the wheel into the site-packages directory
  • + +
  • copy: Copy packages from the wheel into the site-packages directory
  • + +
  • hardlink: Hard link packages from the wheel into the site-packages directory
  • + +
  • symlink: Symbolically link packages from the wheel into the site-packages directory
  • +
+
--locked

Assert that the uv.lock will remain unchanged.

+ +

Requires that the lockfile is up-to-date. If the lockfile is missing or needs to be updated, uv will exit with an error.

+ +

May also be set with the UV_LOCKED environment variable.

+
--native-tls

Whether to load TLS certificates from the platform’s native certificate store.

+ +

By default, uv loads certificates from the bundled webpki-roots crate. The webpki-roots are a reliable set of trust roots from Mozilla, and including them in uv improves portability and performance (especially on macOS).

+ +

However, in some cases, you may want to use the platform’s native certificate store, especially if you’re relying on a corporate trust root (e.g., for a mandatory proxy) that’s included in your system’s certificate store.

+ +

May also be set with the UV_NATIVE_TLS environment variable.

+
--no-binary

Don’t install pre-built wheels.

+ +

The given packages will be built and installed from source. The resolver will still use pre-built wheels to extract package metadata, if available.

+ +
--no-binary-package no-binary-package

Don’t install pre-built wheels for a specific package

+ +
--no-build

Don’t build source distributions.

+ +

When enabled, resolving will not run arbitrary Python code. The cached wheels of already-built source distributions will be reused, but operations that require building distributions will exit with an error.

+ +
--no-build-isolation

Disable isolation when building source distributions.

+ +

Assumes that build dependencies specified by PEP 518 are already installed.

+ +

May also be set with the UV_NO_BUILD_ISOLATION environment variable.

+
--no-build-isolation-package no-build-isolation-package

Disable isolation when building source distributions for a specific package.

+ +

Assumes that the packages’ build dependencies specified by PEP 518 are already installed.

+ +
--no-build-package no-build-package

Don’t build source distributions for a specific package

+ +
--no-cache, -n

Avoid reading from or writing to the cache, instead using a temporary directory for the duration of the operation

+ +

May also be set with the UV_NO_CACHE environment variable.

+
--no-config

Avoid discovering configuration files (pyproject.toml, uv.toml).

+ +

Normally, configuration files are discovered in the current directory, parent directories, or user configuration directories.

+ +

May also be set with the UV_NO_CONFIG environment variable.

+
--no-default-groups

Exclude dependencies from default groups.

+ +

--group can be used to include specific groups.

+ +
--no-dev

Omit the development dependency group.

+ +

This option is an alias for --no-group dev.

+ +
--no-group no-group

Exclude dependencies from the specified dependency group.

+ +

May be provided multiple times.

+ +
--no-index

Ignore the registry index (e.g., PyPI), instead relying on direct URL dependencies and those provided via --find-links

+ +
--no-progress

Hide all progress outputs.

+ +

For example, spinners or progress bars.

+ +

May also be set with the UV_NO_PROGRESS environment variable.

+
--no-python-downloads

Disable automatic downloads of Python.

+ +
--no-sources

Ignore the tool.uv.sources table when resolving dependencies. Used to lock against the standards-compliant, publishable package metadata, as opposed to using any local or Git sources

+ +
--offline

Disable network access.

+ +

When disabled, uv will only use locally cached data and locally available files.

+ +

May also be set with the UV_OFFLINE environment variable.

+
--only-dev

Only include the development dependency group.

+ +

Omit other dependencies. The project itself will also be omitted.

+ +

This option is an alias for --only-group dev.

+ +
--only-group only-group

Only include dependencies from the specified dependency group.

+ +

May be provided multiple times.

+ +

The project itself will also be omitted.

+ +
--prerelease prerelease

The strategy to use when considering pre-release versions.

+ +

By default, uv will accept pre-releases for packages that only publish pre-releases, along with first-party requirements that contain an explicit pre-release marker in the declared specifiers (if-necessary-or-explicit).

+ +

May also be set with the UV_PRERELEASE environment variable.

+

Possible values:

+ +
    +
  • disallow: Disallow all pre-release versions
  • + +
  • allow: Allow all pre-release versions
  • + +
  • if-necessary: Allow pre-release versions if all versions of a package are pre-release
  • + +
  • explicit: Allow pre-release versions for first-party packages with explicit pre-release markers in their version requirements
  • + +
  • if-necessary-or-explicit: Allow pre-release versions if all versions of a package are pre-release, or if the package has an explicit pre-release marker in its version requirements
  • +
+
--project project

Run the command within the given project directory.

+ +

All pyproject.toml, uv.toml, and .python-version files will be discovered by walking up the directory tree from the project root, as will the project’s virtual environment (.venv).

+ +

Other command-line arguments (such as relative paths) will be resolved relative to the current working directory.

+ +

See --directory to change the working directory entirely.

+ +

This setting has no effect when used in the uv pip interface.

+ +
--python, -p python

The Python interpreter to use for locking and filtering.

+ +

By default, the tree is filtered to match the platform as reported by the Python interpreter. Use --universal to display the tree for all platforms, or use --python-version or --python-platform to override a subset of markers.

+ +

See uv python for details on Python discovery and supported request formats.

+ +

May also be set with the UV_PYTHON environment variable.

+
--python-platform python-platform

The platform to use when filtering the tree.

+ +

For example, pass --platform windows to display the dependencies that would be included when installing on Windows.

+ +

Represented as a "target triple", a string that describes the target platform in terms of its CPU, vendor, and operating system name, like x86_64-unknown-linux-gnu or aarch64-apple-darwin.

+ +

Possible values:

+ +
    +
  • windows: An alias for x86_64-pc-windows-msvc, the default target for Windows
  • + +
  • linux: An alias for x86_64-unknown-linux-gnu, the default target for Linux
  • + +
  • macos: An alias for aarch64-apple-darwin, the default target for macOS
  • + +
  • x86_64-pc-windows-msvc: A 64-bit x86 Windows target
  • + +
  • i686-pc-windows-msvc: A 32-bit x86 Windows target
  • + +
  • x86_64-unknown-linux-gnu: An x86 Linux target. Equivalent to x86_64-manylinux_2_17
  • + +
  • aarch64-apple-darwin: An ARM-based macOS target, as seen on Apple Silicon devices
  • + +
  • x86_64-apple-darwin: An x86 macOS target
  • + +
  • aarch64-unknown-linux-gnu: An ARM64 Linux target. Equivalent to aarch64-manylinux_2_17
  • + +
  • aarch64-unknown-linux-musl: An ARM64 Linux target
  • + +
  • x86_64-unknown-linux-musl: An x86_64 Linux target
  • + +
  • x86_64-manylinux2014: An x86_64 target for the manylinux2014 platform. Equivalent to x86_64-manylinux_2_17
  • + +
  • x86_64-manylinux_2_17: An x86_64 target for the manylinux_2_17 platform
  • + +
  • x86_64-manylinux_2_28: An x86_64 target for the manylinux_2_28 platform
  • + +
  • x86_64-manylinux_2_31: An x86_64 target for the manylinux_2_31 platform
  • + +
  • x86_64-manylinux_2_32: An x86_64 target for the manylinux_2_32 platform
  • + +
  • x86_64-manylinux_2_33: An x86_64 target for the manylinux_2_33 platform
  • + +
  • x86_64-manylinux_2_34: An x86_64 target for the manylinux_2_34 platform
  • + +
  • x86_64-manylinux_2_35: An x86_64 target for the manylinux_2_35 platform
  • + +
  • x86_64-manylinux_2_36: An x86_64 target for the manylinux_2_36 platform
  • + +
  • x86_64-manylinux_2_37: An x86_64 target for the manylinux_2_37 platform
  • + +
  • x86_64-manylinux_2_38: An x86_64 target for the manylinux_2_38 platform
  • + +
  • x86_64-manylinux_2_39: An x86_64 target for the manylinux_2_39 platform
  • + +
  • x86_64-manylinux_2_40: An x86_64 target for the manylinux_2_40 platform
  • + +
  • aarch64-manylinux2014: An ARM64 target for the manylinux2014 platform. Equivalent to aarch64-manylinux_2_17
  • + +
  • aarch64-manylinux_2_17: An ARM64 target for the manylinux_2_17 platform
  • + +
  • aarch64-manylinux_2_28: An ARM64 target for the manylinux_2_28 platform
  • + +
  • aarch64-manylinux_2_31: An ARM64 target for the manylinux_2_31 platform
  • + +
  • aarch64-manylinux_2_32: An ARM64 target for the manylinux_2_32 platform
  • + +
  • aarch64-manylinux_2_33: An ARM64 target for the manylinux_2_33 platform
  • + +
  • aarch64-manylinux_2_34: An ARM64 target for the manylinux_2_34 platform
  • + +
  • aarch64-manylinux_2_35: An ARM64 target for the manylinux_2_35 platform
  • + +
  • aarch64-manylinux_2_36: An ARM64 target for the manylinux_2_36 platform
  • + +
  • aarch64-manylinux_2_37: An ARM64 target for the manylinux_2_37 platform
  • + +
  • aarch64-manylinux_2_38: An ARM64 target for the manylinux_2_38 platform
  • + +
  • aarch64-manylinux_2_39: An ARM64 target for the manylinux_2_39 platform
  • + +
  • aarch64-manylinux_2_40: An ARM64 target for the manylinux_2_40 platform
  • +
+
--python-preference python-preference

Whether to prefer uv-managed or system Python installations.

+ +

By default, uv prefers using Python versions it manages. However, it will use system Python installations if a uv-managed Python is not installed. This option allows prioritizing or ignoring system Python installations.

+ +

May also be set with the UV_PYTHON_PREFERENCE environment variable.

+

Possible values:

+ +
    +
  • only-managed: Only use managed Python installations; never use system Python installations
  • + +
  • managed: Prefer managed Python installations over system Python installations
  • + +
  • system: Prefer system Python installations over managed Python installations
  • + +
  • only-system: Only use system Python installations; never use managed Python installations
  • +
+
--python-version python-version

The Python version to use when filtering the tree.

+ +

For example, pass --python-version 3.10 to display the dependencies that would be included when installing on Python 3.10.

+ +

Defaults to the version of the discovered Python interpreter.

+ +
--quiet, -q

Do not print any output

+ +
--resolution resolution

The strategy to use when selecting between the different compatible versions for a given package requirement.

+ +

By default, uv will use the latest compatible version of each package (highest).

+ +

May also be set with the UV_RESOLUTION environment variable.

+

Possible values:

+ +
    +
  • highest: Resolve the highest compatible version of each package
  • + +
  • lowest: Resolve the lowest compatible version of each package
  • + +
  • lowest-direct: Resolve the lowest compatible version of any direct dependencies, and the highest compatible version of any transitive dependencies
  • +
+
--universal

Show full list of platform-independent dependency licenses.

+ +

Shows resolved package versions for all Python versions and platforms, rather than filtering to those that are relevant for the current environment.

+ +

Multiple versions may be shown for a each package.

+ +
--upgrade, -U

Allow package upgrades, ignoring pinned versions in any existing output file. Implies --refresh

+ +
--upgrade-package, -P upgrade-package

Allow upgrades for a specific package, ignoring pinned versions in any existing output file. Implies --refresh-package

+ +
--verbose, -v

Use verbose output.

+ +

You can configure fine-grained logging using the RUST_LOG environment variable. (<https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives>)

+ +
--version, -V

Display the uv version

+ +
+ ## uv tool Run and install commands provided by Python packages diff --git a/uv.schema.json b/uv.schema.json index 52f5e3661a27..cdabf57615eb 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -1749,6 +1749,21 @@ "name" ], "properties": { + "classifiers": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "license": { + "type": [ + "string", + "null" + ] + }, "name": { "$ref": "#/definitions/PackageName" },