From c315807cb4e28a07563ba8022eba794d1286fc9e Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 17 Jun 2026 19:56:21 -0400 Subject: [PATCH] Add JSON output for uv tree --- crates/uv-cli/src/lib.rs | 13 + crates/uv-resolver/src/lock/tree.rs | 351 ++++++++++++++++++- crates/uv/src/commands/project/tree.rs | 18 +- crates/uv/src/lib.rs | 1 + crates/uv/src/settings.rs | 5 +- crates/uv/tests/project/tree.rs | 460 +++++++++++++++++++++++++ 6 files changed, 844 insertions(+), 4 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index bffce38d89..198c41fbad 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -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, diff --git a/crates/uv-resolver/src/lock/tree.rs b/crates/uv-resolver/src/lock/tree.rs index cd388545cb..f3d5982458 100644 --- a/crates/uv-resolver/src/lock/tree.rs +++ b/crates/uv-resolver/src/lock/tree.rs @@ -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 { + 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 { + 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>, + edges: Vec>, +} + +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::>(); + package_nodes.sort_by_key(|index| &tree.graph[*index]); + + let node_ids = package_nodes + .iter() + .enumerate() + .map(|(id, index)| (*index, id)) + .collect::>(); + + 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::>(), + Node::Package(_) => node_ids + .get(root) + .map(|id| vec![JsonRoot::new(*id, None)]) + .unwrap_or_default(), + }) + .collect::>(); + 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::>(); + 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>, + 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, +} + +#[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, + }, + 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, + #[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>, +} + +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::>(), + } + } +} + +#[derive(Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +enum JsonEdgeKind { + Production, + Optional, + Development, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 1acbd7cf02..777f81145d 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -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, universal: bool, + format: TreeFormat, depth: u8, prune: Vec, package: Vec, @@ -63,6 +67,13 @@ pub(crate) async fn tree( printer: Printer, preview: Preview, ) -> Result { + 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) } diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 18e8370b20..1101de728e 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -2667,6 +2667,7 @@ async fn run_project( args.lock_check, args.frozen, args.universal, + args.format, args.depth, args.prune, args.package, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index fd823aa841..2f3737b255 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -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, pub(crate) universal: bool, + pub(crate) format: TreeFormat, pub(crate) depth: u8, pub(crate) prune: Vec, pub(crate) package: Vec, @@ -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, diff --git a/crates/uv/tests/project/tree.rs b/crates/uv/tests/project/tree.rs index 0dcaf86287..9ddf9d513a 100644 --- a/crates/uv/tests/project/tree.rs +++ b/crates/uv/tests/project/tree.rs @@ -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(()) }