Add JSON output for uv tree

This commit is contained in:
Charlie Marsh
2026-06-17 19:56:21 -04:00
parent 18898c9821
commit c315807cb4
6 changed files with 844 additions and 4 deletions
+13
View File
@@ -79,6 +79,15 @@ pub enum AuditOutputFormat {
Sarif,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum TreeFormat {
/// Display the dependency graph as a human-readable tree.
#[default]
Text,
/// Display the dependency graph as JSON.
Json,
}
#[derive(Debug, Default, Clone, clap::ValueEnum)]
pub enum ListFormat {
/// Display the list of packages in a human-readable table.
@@ -4736,6 +4745,10 @@ pub struct TreeArgs {
#[arg(long)]
pub universal: bool,
/// The format in which to display the dependency graph.
#[arg(long, value_enum, default_value_t = TreeFormat::default())]
pub format: TreeFormat,
#[command(flatten)]
pub tree: DisplayTreeArgs,
+350 -1
View File
@@ -8,15 +8,17 @@ use petgraph::graph::{EdgeIndex, NodeIndex};
use petgraph::prelude::EdgeRef;
use petgraph::{Direction, Graph};
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
use serde::Serialize;
use uv_configuration::DependencyGroupsWithDefaults;
use uv_console::human_readable_bytes;
use uv_fs::PortablePath;
use uv_normalize::{ExtraName, GroupName, PackageName};
use uv_pep440::Version;
use uv_pep508::MarkerTree;
use uv_pypi_types::ResolverMarkerEnvironment;
use crate::lock::{Package, PackageId};
use crate::lock::{DirectSource, Package, PackageId, RegistrySource, Source};
use crate::{Lock, PackageMap};
#[derive(Debug)]
@@ -647,6 +649,353 @@ impl<'env> TreeDisplay<'env> {
.filter(|extra| package.optional_dependencies.contains_key(*extra))
.collect()
}
/// Serialize the dependency graph in `NetworkX` node-link format.
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(&JsonGraph::from(self))
}
/// Return the shortest distance from any displayed root to every package within the requested
/// depth.
fn json_distances(&self) -> FxHashMap<NodeIndex, usize> {
let mut distances = FxHashMap::default();
let mut queue = VecDeque::new();
for root in &self.roots {
match self.graph[*root] {
Node::Root => {
for edge in self.graph.edges_directed(*root, Direction::Outgoing) {
if matches!(self.graph[edge.target()], Node::Package(_))
&& distances.insert(edge.target(), 0).is_none()
{
queue.push_back(edge.target());
}
}
}
Node::Package(_) => {
if distances.insert(*root, 0).is_none() {
queue.push_back(*root);
}
}
}
}
while let Some(source) = queue.pop_front() {
let distance = distances[&source];
if distance >= self.depth {
continue;
}
for edge in self.graph.edges_directed(source, Direction::Outgoing) {
let target = edge.target();
if !matches!(self.graph[target], Node::Package(_))
|| distances.contains_key(&target)
{
continue;
}
distances.insert(target, distance + 1);
queue.push_back(target);
}
}
distances
}
}
#[derive(Debug, Serialize)]
struct JsonGraph<'env> {
directed: bool,
multigraph: bool,
graph: JsonGraphAttributes<'env>,
nodes: Vec<JsonNode<'env>>,
edges: Vec<JsonEdge<'env>>,
}
impl<'env> From<&TreeDisplay<'env>> for JsonGraph<'env> {
fn from(tree: &TreeDisplay<'env>) -> Self {
let distances = tree.json_distances();
let mut package_nodes = distances.keys().copied().collect::<Vec<_>>();
package_nodes.sort_by_key(|index| &tree.graph[*index]);
let node_ids = package_nodes
.iter()
.enumerate()
.map(|(id, index)| (*index, id))
.collect::<FxHashMap<_, _>>();
let nodes = package_nodes
.iter()
.map(|index| {
let Node::Package(package_id) = tree.graph[*index] else {
unreachable!("JSON nodes only include packages");
};
let package = tree.lock.find_by_id(package_id);
JsonNode {
id: node_ids[index],
name: &package_id.name,
version: package_id.version.as_ref(),
source: JsonSource::from(&package_id.source),
latest_version: tree.latest.get(package_id),
size_bytes: tree
.show_sizes
.then(|| package.wheels.iter().find_map(|wheel| wheel.size))
.flatten(),
}
})
.collect();
let mut roots = tree
.roots
.iter()
.flat_map(|root| match tree.graph[*root] {
Node::Root => tree
.graph
.edges_directed(*root, Direction::Outgoing)
.filter_map(|edge| {
node_ids
.get(&edge.target())
.map(|id| JsonRoot::new(*id, Some(edge.weight())))
})
.collect::<Vec<_>>(),
Node::Package(_) => node_ids
.get(root)
.map(|id| vec![JsonRoot::new(*id, None)])
.unwrap_or_default(),
})
.collect::<Vec<_>>();
roots.sort();
roots.dedup();
let mut graph_edges = tree
.graph
.edge_references()
.filter(|edge| {
distances
.get(&edge.source())
.is_some_and(|distance| *distance < tree.depth)
&& node_ids.contains_key(&edge.source())
&& node_ids.contains_key(&edge.target())
})
.collect::<Vec<_>>();
graph_edges.sort_by(|left, right| {
(
node_ids[&left.source()],
node_ids[&left.target()],
left.weight(),
)
.cmp(&(
node_ids[&right.source()],
node_ids[&right.target()],
right.weight(),
))
});
let mut previous_endpoints = None;
let mut key = 0;
let edges = graph_edges
.into_iter()
.map(|edge| {
let endpoints = (node_ids[&edge.source()], node_ids[&edge.target()]);
if previous_endpoints == Some(endpoints) {
key += 1;
} else {
previous_endpoints = Some(endpoints);
key = 0;
}
JsonEdge::new(endpoints.0, endpoints.1, key, edge.weight())
})
.collect();
Self {
directed: true,
multigraph: true,
graph: JsonGraphAttributes {
schema: JsonSchema {
version: JsonSchemaVersion::Preview,
},
roots,
inverted: tree.invert,
},
nodes,
edges,
}
}
}
#[derive(Debug, Serialize)]
struct JsonGraphAttributes<'env> {
schema: JsonSchema,
roots: Vec<JsonRoot<'env>>,
inverted: bool,
}
#[derive(Debug, Serialize)]
struct JsonSchema {
version: JsonSchemaVersion,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
enum JsonSchemaVersion {
Preview,
}
#[derive(Debug, Serialize)]
struct JsonNode<'env> {
id: usize,
name: &'env PackageName,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<&'env Version>,
source: JsonSource,
#[serde(skip_serializing_if = "Option::is_none")]
latest_version: Option<&'env Version>,
#[serde(skip_serializing_if = "Option::is_none")]
size_bytes: Option<u64>,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
enum JsonSource {
Registry {
registry: String,
},
Git {
git: String,
},
Direct {
url: String,
#[serde(skip_serializing_if = "Option::is_none")]
subdirectory: Option<String>,
},
Path {
path: String,
},
Directory {
directory: String,
},
Editable {
editable: String,
},
Virtual {
r#virtual: String,
},
}
impl From<&Source> for JsonSource {
fn from(source: &Source) -> Self {
match source {
Source::Registry(source) => Self::Registry {
registry: match source {
RegistrySource::Url(url) => url.as_ref().to_string(),
RegistrySource::Path(path) => PortablePath::from(path).to_string(),
},
},
Source::Git(url, _) => Self::Git {
git: url.as_ref().to_string(),
},
Source::Direct(url, DirectSource { subdirectory }) => Self::Direct {
url: url.as_ref().to_string(),
subdirectory: subdirectory
.as_deref()
.map(|path| PortablePath::from(path).to_string()),
},
Source::Path(path) => Self::Path {
path: PortablePath::from(path).to_string(),
},
Source::Directory(path) => Self::Directory {
directory: PortablePath::from(path).to_string(),
},
Source::Editable(path) => Self::Editable {
editable: PortablePath::from(path).to_string(),
},
Source::Virtual(path) => Self::Virtual {
r#virtual: PortablePath::from(path).to_string(),
},
}
}
}
#[derive(Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
struct JsonRoot<'env> {
id: usize,
#[serde(skip_serializing_if = "Option::is_none")]
kind: Option<JsonEdgeKind>,
#[serde(skip_serializing_if = "Option::is_none")]
extra: Option<&'env ExtraName>,
#[serde(skip_serializing_if = "Option::is_none")]
group: Option<&'env GroupName>,
#[serde(skip_serializing_if = "Option::is_none")]
requested_extras: Option<Vec<&'env ExtraName>>,
}
impl<'env> JsonRoot<'env> {
fn new(id: usize, edge: Option<&Edge<'env>>) -> Self {
let kind = match edge {
None | Some(Edge::Prod(_)) => None,
Some(Edge::Optional(_, _)) => Some(JsonEdgeKind::Optional),
Some(Edge::Dev(_, _)) => Some(JsonEdgeKind::Development),
};
let extra = match edge {
Some(Edge::Optional(extra, _)) => Some(*extra),
None | Some(Edge::Prod(_) | Edge::Dev(_, _)) => None,
};
let group = match edge {
Some(Edge::Dev(group, _)) => Some(*group),
None | Some(Edge::Prod(_) | Edge::Optional(_, _)) => None,
};
let requested_extras = edge
.and_then(Edge::extras)
.filter(|extras| !extras.is_empty())
.map(|extras| extras.iter().collect());
Self {
id,
kind,
extra,
group,
requested_extras,
}
}
}
#[derive(Debug, Serialize)]
struct JsonEdge<'env> {
source: usize,
target: usize,
key: usize,
kind: JsonEdgeKind,
#[serde(skip_serializing_if = "Option::is_none")]
extra: Option<&'env ExtraName>,
#[serde(skip_serializing_if = "Option::is_none")]
group: Option<&'env GroupName>,
requested_extras: Vec<&'env ExtraName>,
}
impl<'env> JsonEdge<'env> {
fn new(source: usize, target: usize, key: usize, edge: &Edge<'env>) -> Self {
let (kind, extra, group) = match edge {
Edge::Prod(_) => (JsonEdgeKind::Production, None, None),
Edge::Optional(extra, _) => (JsonEdgeKind::Optional, Some(*extra), None),
Edge::Dev(group, _) => (JsonEdgeKind::Development, None, Some(*group)),
};
Self {
source,
target,
key,
kind,
extra,
group,
requested_extras: edge.extras().into_iter().flatten().collect::<Vec<_>>(),
}
}
}
#[derive(Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
enum JsonEdgeKind {
Production,
Optional,
Development,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+16 -2
View File
@@ -1,3 +1,4 @@
use std::fmt::Write;
use std::path::Path;
use anstream::print;
@@ -5,16 +6,18 @@ use anyhow::{Error, Result};
use futures::StreamExt;
use uv_cache::{Cache, Refresh};
use uv_cache_info::Timestamp;
use uv_cli::TreeFormat;
use uv_client::{BaseClientBuilder, RegistryClientBuilder};
use uv_configuration::{Concurrency, DependencyGroups, TargetTriple};
use uv_distribution_types::IndexCapabilities;
use uv_normalize::DefaultGroups;
use uv_normalize::PackageName;
use uv_preview::Preview;
use uv_preview::{Preview, PreviewFeature};
use uv_python::{PythonDownloads, PythonPreference, PythonRequest, PythonVersion};
use uv_resolver::{PackageMap, TreeDisplay};
use uv_scripts::Pep723Script;
use uv_settings::PythonInstallMirrors;
use uv_warnings::warn_user;
use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache};
use crate::commands::pip::latest::LatestClient;
@@ -41,6 +44,7 @@ pub(crate) async fn tree(
lock_check: LockCheck,
frozen: Option<FrozenSource>,
universal: bool,
format: TreeFormat,
depth: u8,
prune: Vec<PackageName>,
package: Vec<PackageName>,
@@ -63,6 +67,13 @@ pub(crate) async fn tree(
printer: Printer,
preview: Preview,
) -> Result<ExitStatus> {
if matches!(format, TreeFormat::Json) && !preview.is_enabled(PreviewFeature::JsonOutput) {
warn_user!(
"The `--format json` option is experimental and the schema may change without warning. Pass `--preview-features {}` to disable this warning.",
PreviewFeature::JsonOutput
);
}
// Find the project requirements.
let workspace_cache = WorkspaceCache::default();
let workspace;
@@ -304,7 +315,10 @@ pub(crate) async fn tree(
show_sizes,
);
print!("{tree}");
match format {
TreeFormat::Text => print!("{tree}"),
TreeFormat::Json => writeln!(printer.stdout(), "{}", tree.to_json()?)?,
}
Ok(ExitStatus::Success)
}
+1
View File
@@ -2667,6 +2667,7 @@ async fn run_project(
args.lock_check,
args.frozen,
args.universal,
args.format,
args.depth,
args.prune,
args.package,
+4 -1
View File
@@ -19,7 +19,7 @@ use uv_cli::{
PipSyncArgs, PipTreeArgs, PipUninstallArgs, PythonFindArgs, PythonInstallArgs, PythonListArgs,
PythonListFormat, PythonPinArgs, PythonUninstallArgs, PythonUpgradeArgs, RemoveArgs, RunArgs,
SyncArgs, SyncFormat, ToolDirArgs, ToolInstallArgs, ToolListArgs, ToolRunArgs,
ToolUninstallArgs, TreeArgs, UpgradeArgs, VenvArgs, VersionArgs, VersionBumpSpec,
ToolUninstallArgs, TreeArgs, TreeFormat, UpgradeArgs, VenvArgs, VersionArgs, VersionBumpSpec,
VersionFormat,
};
use uv_cli::{
@@ -2645,6 +2645,7 @@ pub(crate) struct TreeSettings {
pub(crate) lock_check: LockCheck,
pub(crate) frozen: Option<FrozenSource>,
pub(crate) universal: bool,
pub(crate) format: TreeFormat,
pub(crate) depth: u8,
pub(crate) prune: Vec<PackageName>,
pub(crate) package: Vec<PackageName>,
@@ -2671,6 +2672,7 @@ impl TreeSettings {
let TreeArgs {
tree,
universal,
format,
dev,
only_dev,
no_dev,
@@ -2728,6 +2730,7 @@ impl TreeSettings {
lock_check: resolve_lock_check(locked),
frozen: resolve_frozen(frozen),
universal,
format,
depth: tree.depth,
prune: tree.prune,
package: tree.package,
+460
View File
@@ -49,6 +49,391 @@ fn nested_dependencies() -> Result<()> {
Ok(())
}
#[test]
fn json_output() -> Result<()> {
let context = uv_test::test_context!("3.12");
context.temp_dir.child("pyproject.toml").write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["package-a[feature]"]
[dependency-groups]
dev = ["package-c"]
[tool.uv.sources]
package-a = { path = "packages/package-a" }
package-c = { path = "packages/package-c" }
"#,
)?;
let package_a = context.temp_dir.child("packages/package-a");
package_a.create_dir_all()?;
package_a.child("pyproject.toml").write_str(
r#"
[project]
name = "package-a"
version = "1.0.0"
requires-python = ">=3.12"
[project.optional-dependencies]
feature = ["package-b"]
[tool.uv.sources]
package-b = { path = "../package-b" }
"#,
)?;
for package in ["package-b", "package-c"] {
let directory = context.temp_dir.child(format!("packages/{package}"));
directory.create_dir_all()?;
directory
.child("pyproject.toml")
.write_str(&formatdoc! {r#"
[project]
name = "{package}"
version = "1.0.0"
requires-python = ">=3.12"
"#})?;
}
uv_snapshot!(context.filters(), context.tree()
.arg("--preview-features")
.arg("json-output")
.arg("--format")
.arg("json")
.arg("--universal"), @r###"
success: true
exit_code: 0
----- stdout -----
{
"directed": true,
"multigraph": true,
"graph": {
"schema": {
"version": "preview"
},
"roots": [
{
"id": 3
}
],
"inverted": false
},
"nodes": [
{
"id": 0,
"name": "package-a",
"version": "1.0.0",
"source": {
"directory": "packages/package-a"
}
},
{
"id": 1,
"name": "package-b",
"version": "1.0.0",
"source": {
"directory": "packages/package-b"
}
},
{
"id": 2,
"name": "package-c",
"version": "1.0.0",
"source": {
"directory": "packages/package-c"
}
},
{
"id": 3,
"name": "project",
"version": "0.1.0",
"source": {
"virtual": "."
}
}
],
"edges": [
{
"source": 0,
"target": 1,
"key": 0,
"kind": "optional",
"extra": "feature",
"requested_extras": []
},
{
"source": 3,
"target": 0,
"key": 0,
"kind": "production",
"requested_extras": [
"feature"
]
},
{
"source": 3,
"target": 2,
"key": 0,
"kind": "development",
"group": "dev",
"requested_extras": []
}
]
}
----- stderr -----
Resolved 4 packages in [TIME]
"###);
uv_snapshot!(context.filters(), context.tree()
.arg("--preview-features")
.arg("json-output")
.arg("--format")
.arg("json")
.arg("--universal")
.arg("--depth")
.arg("1"), @r###"
success: true
exit_code: 0
----- stdout -----
{
"directed": true,
"multigraph": true,
"graph": {
"schema": {
"version": "preview"
},
"roots": [
{
"id": 2
}
],
"inverted": false
},
"nodes": [
{
"id": 0,
"name": "package-a",
"version": "1.0.0",
"source": {
"directory": "packages/package-a"
}
},
{
"id": 1,
"name": "package-c",
"version": "1.0.0",
"source": {
"directory": "packages/package-c"
}
},
{
"id": 2,
"name": "project",
"version": "0.1.0",
"source": {
"virtual": "."
}
}
],
"edges": [
{
"source": 2,
"target": 0,
"key": 0,
"kind": "production",
"requested_extras": [
"feature"
]
},
{
"source": 2,
"target": 1,
"key": 0,
"kind": "development",
"group": "dev",
"requested_extras": []
}
]
}
----- stderr -----
Resolved 4 packages in [TIME]
"###);
uv_snapshot!(context.filters(), context.tree()
.arg("--preview-features")
.arg("json-output")
.arg("--format")
.arg("json")
.arg("--universal")
.arg("--invert")
.arg("--depth")
.arg("1"), @r###"
success: true
exit_code: 0
----- stdout -----
{
"directed": true,
"multigraph": true,
"graph": {
"schema": {
"version": "preview"
},
"roots": [
{
"id": 1
},
{
"id": 2
}
],
"inverted": true
},
"nodes": [
{
"id": 0,
"name": "package-a",
"version": "1.0.0",
"source": {
"directory": "packages/package-a"
}
},
{
"id": 1,
"name": "package-b",
"version": "1.0.0",
"source": {
"directory": "packages/package-b"
}
},
{
"id": 2,
"name": "package-c",
"version": "1.0.0",
"source": {
"directory": "packages/package-c"
}
},
{
"id": 3,
"name": "project",
"version": "0.1.0",
"source": {
"virtual": "."
}
}
],
"edges": [
{
"source": 1,
"target": 0,
"key": 0,
"kind": "optional",
"extra": "feature",
"requested_extras": []
},
{
"source": 2,
"target": 3,
"key": 0,
"kind": "development",
"group": "dev",
"requested_extras": []
}
]
}
----- stderr -----
Resolved 4 packages in [TIME]
"###);
Ok(())
}
#[test]
fn json_output_virtual_root() -> Result<()> {
let context = uv_test::test_context!("3.12");
context.temp_dir.child("pyproject.toml").write_str(
r#"
[dependency-groups]
dev = ["package-a"]
[tool.uv.sources]
package-a = { workspace = true }
[tool.uv.workspace]
members = ["package-a"]
"#,
)?;
let package_a = context.temp_dir.child("package-a");
package_a.create_dir_all()?;
package_a.child("pyproject.toml").write_str(
r#"
[project]
name = "package-a"
version = "1.0.0"
requires-python = ">=3.12"
"#,
)?;
uv_snapshot!(context.filters(), context.tree()
.arg("--preview-features")
.arg("json-output")
.arg("--format")
.arg("json")
.arg("--universal")
.arg("--only-group")
.arg("dev"), @r###"
success: true
exit_code: 0
----- stdout -----
{
"directed": true,
"multigraph": true,
"graph": {
"schema": {
"version": "preview"
},
"roots": [
{
"id": 0
},
{
"id": 0,
"kind": "development",
"group": "dev"
}
],
"inverted": false
},
"nodes": [
{
"id": 0,
"name": "package-a",
"version": "1.0.0",
"source": {
"editable": "package-a"
}
}
],
"edges": []
}
----- stderr -----
Resolved 1 package in [TIME]
"###);
Ok(())
}
#[test]
fn nested_platform_dependencies() -> Result<()> {
let context = uv_test::test_context!("3.12");
@@ -269,6 +654,23 @@ fn outdated() -> Result<()> {
"
);
let output = context
.tree()
.arg("--preview-features")
.arg("json-output")
.arg("--format")
.arg("json")
.arg("--outdated")
.arg("--universal")
.output()?;
output.clone().assert().success();
let report: serde_json::Value = serde_json::from_slice(&output.stdout)?;
let anyio = report["nodes"]
.as_array()
.and_then(|nodes| nodes.iter().find(|node| node["name"] == "anyio"))
.expect("anyio should be included in the dependency graph");
assert_eq!(anyio["latest_version"], "4.3.0");
Ok(())
}
@@ -2258,6 +2660,64 @@ fn show_sizes() -> Result<()> {
"
);
uv_snapshot!(context.filters(), context.tree()
.arg("--preview-features")
.arg("json-output")
.arg("--format")
.arg("json")
.arg("--show-sizes")
.arg("--universal"), @r###"
success: true
exit_code: 0
----- stdout -----
{
"directed": true,
"multigraph": true,
"graph": {
"schema": {
"version": "preview"
},
"roots": [
{
"id": 1
}
],
"inverted": false
},
"nodes": [
{
"id": 0,
"name": "iniconfig",
"version": "2.0.0",
"source": {
"registry": "https://pypi.org/simple"
},
"size_bytes": 5892
},
{
"id": 1,
"name": "project",
"version": "0.1.0",
"source": {
"virtual": "."
}
}
],
"edges": [
{
"source": 1,
"target": 0,
"key": 0,
"kind": "production",
"requested_extras": []
}
]
}
----- stderr -----
Resolved 2 packages in [TIME]
"###);
Ok(())
}