mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-29 08:03:50 +00:00
Use lockfile directly in uv tree
(#5761)
## Summary Ensures that we properly handle (1) duplicated packages and (2) packages that aren't relevant to the current platform. Closes https://github.com/astral-sh/uv/issues/5716. Closes https://github.com/astral-sh/uv/issues/5253.
This commit is contained in:
parent
c5052bc36c
commit
a9a535da14
6 changed files with 371 additions and 40 deletions
|
@ -3,7 +3,7 @@ pub use error::{NoSolutionError, ResolveError};
|
||||||
pub use exclude_newer::ExcludeNewer;
|
pub use exclude_newer::ExcludeNewer;
|
||||||
pub use exclusions::Exclusions;
|
pub use exclusions::Exclusions;
|
||||||
pub use flat_index::FlatIndex;
|
pub use flat_index::FlatIndex;
|
||||||
pub use lock::{Lock, LockError};
|
pub use lock::{Lock, LockError, TreeDisplay};
|
||||||
pub use manifest::Manifest;
|
pub use manifest::Manifest;
|
||||||
pub use options::{Options, OptionsBuilder};
|
pub use options::{Options, OptionsBuilder};
|
||||||
pub use preferences::{Preference, PreferenceError, Preferences};
|
pub use preferences::{Preference, PreferenceError, Preferences};
|
||||||
|
|
|
@ -11,7 +11,7 @@ use std::sync::Arc;
|
||||||
use either::Either;
|
use either::Either;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use petgraph::visit::EdgeRef;
|
use petgraph::visit::EdgeRef;
|
||||||
use rustc_hash::{FxHashMap, FxHashSet};
|
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
|
||||||
use toml_edit::{value, Array, ArrayOfTables, InlineTable, Item, Table, Value};
|
use toml_edit::{value, Array, ArrayOfTables, InlineTable, Item, Table, Value};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
@ -2627,6 +2627,204 @@ fn each_element_on_its_line_array(elements: impl Iterator<Item = impl Into<Value
|
||||||
array
|
array
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TreeDisplay<'env> {
|
||||||
|
/// The underlying [`Lock`] to display.
|
||||||
|
lock: &'env Lock,
|
||||||
|
/// The edges in the [`Lock`].
|
||||||
|
///
|
||||||
|
/// While the dependencies exist on the [`Lock`] directly, if `--invert` is enabled, the
|
||||||
|
/// direction must be inverted when constructing the tree.
|
||||||
|
edges: FxHashMap<&'env DistributionId, Vec<&'env DistributionId>>,
|
||||||
|
/// Maximum display depth of the dependency tree
|
||||||
|
depth: usize,
|
||||||
|
/// Prune the given packages from the display of the dependency tree.
|
||||||
|
prune: Vec<PackageName>,
|
||||||
|
/// Display only the specified packages.
|
||||||
|
package: Vec<PackageName>,
|
||||||
|
/// Whether to de-duplicate the displayed dependencies.
|
||||||
|
no_dedupe: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'env> TreeDisplay<'env> {
|
||||||
|
/// Create a new [`DisplayDependencyGraph`] for the set of installed distributions.
|
||||||
|
pub fn new(
|
||||||
|
lock: &'env Lock,
|
||||||
|
depth: usize,
|
||||||
|
prune: Vec<PackageName>,
|
||||||
|
package: Vec<PackageName>,
|
||||||
|
no_dedupe: bool,
|
||||||
|
invert: bool,
|
||||||
|
) -> Self {
|
||||||
|
let mut edges: FxHashMap<_, Vec<_>> =
|
||||||
|
FxHashMap::with_capacity_and_hasher(lock.by_id.len(), FxBuildHasher);
|
||||||
|
for distribution in &lock.distributions {
|
||||||
|
for dependency in &distribution.dependencies {
|
||||||
|
let parent = if invert {
|
||||||
|
&dependency.distribution_id
|
||||||
|
} else {
|
||||||
|
&distribution.id
|
||||||
|
};
|
||||||
|
let child = if invert {
|
||||||
|
&distribution.id
|
||||||
|
} else {
|
||||||
|
&dependency.distribution_id
|
||||||
|
};
|
||||||
|
edges.entry(parent).or_default().push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
lock,
|
||||||
|
edges,
|
||||||
|
depth,
|
||||||
|
prune,
|
||||||
|
package,
|
||||||
|
no_dedupe,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform a depth-first traversal of the given distribution and its dependencies.
|
||||||
|
fn visit(
|
||||||
|
&self,
|
||||||
|
id: &'env DistributionId,
|
||||||
|
visited: &mut FxHashMap<&'env DistributionId, Vec<&'env DistributionId>>,
|
||||||
|
path: &mut Vec<&'env DistributionId>,
|
||||||
|
) -> Vec<String> {
|
||||||
|
// Short-circuit if the current path is longer than the provided depth.
|
||||||
|
if path.len() > self.depth {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let package_name = &id.name;
|
||||||
|
let line = format!("{} v{}", package_name, id.version);
|
||||||
|
|
||||||
|
// 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(id) {
|
||||||
|
if !self.no_dedupe || path.contains(&id) {
|
||||||
|
return if requirements.is_empty() {
|
||||||
|
vec![line]
|
||||||
|
} else {
|
||||||
|
vec![format!("{} (*)", line)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let edges = self
|
||||||
|
.edges
|
||||||
|
.get(id)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter(|&id| !self.prune.contains(&id.name))
|
||||||
|
.copied()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let mut lines = vec![line];
|
||||||
|
|
||||||
|
// Keep track of the dependency path to avoid cycles.
|
||||||
|
visited.insert(id, edges.clone());
|
||||||
|
path.push(id);
|
||||||
|
|
||||||
|
for (index, req) in edges.iter().enumerate() {
|
||||||
|
// For sub-visited packages, add the prefix to make the tree display user-friendly.
|
||||||
|
// The key observation here is you can group the tree as follows when you're at the
|
||||||
|
// root of the tree:
|
||||||
|
// root_package
|
||||||
|
// ├── level_1_0 // Group 1
|
||||||
|
// │ ├── level_2_0 ...
|
||||||
|
// │ │ ├── level_3_0 ...
|
||||||
|
// │ │ └── level_3_1 ...
|
||||||
|
// │ └── level_2_1 ...
|
||||||
|
// ├── level_1_1 // Group 2
|
||||||
|
// │ ├── level_2_2 ...
|
||||||
|
// │ └── level_2_3 ...
|
||||||
|
// └── level_1_2 // Group 3
|
||||||
|
// └── level_2_4 ...
|
||||||
|
//
|
||||||
|
// The lines in Group 1 and 2 have `├── ` at the top and `| ` at the rest while
|
||||||
|
// those in Group 3 have `└── ` at the top and ` ` at the rest.
|
||||||
|
// This observation is true recursively even when looking at the subtree rooted
|
||||||
|
// at `level_1_0`.
|
||||||
|
let (prefix_top, prefix_rest) = if edges.len() - 1 == index {
|
||||||
|
("└── ", " ")
|
||||||
|
} else {
|
||||||
|
("├── ", "│ ")
|
||||||
|
};
|
||||||
|
|
||||||
|
for (visited_index, visited_line) in self.visit(req, visited, path).iter().enumerate() {
|
||||||
|
let prefix = if visited_index == 0 {
|
||||||
|
prefix_top
|
||||||
|
} else {
|
||||||
|
prefix_rest
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(format!("{prefix}{visited_line}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.pop();
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Depth-first traverse the nodes to render the tree.
|
||||||
|
fn render(&self) -> Vec<String> {
|
||||||
|
let mut visited: FxHashMap<&DistributionId, Vec<&DistributionId>> = FxHashMap::default();
|
||||||
|
let mut path: Vec<&DistributionId> = Vec::new();
|
||||||
|
let mut lines: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
if self.package.is_empty() {
|
||||||
|
// Identify all the root nodes by identifying all the distribution IDs that appear as
|
||||||
|
// dependencies.
|
||||||
|
let children: FxHashSet<_> = self.edges.values().flatten().collect();
|
||||||
|
for id in self.lock.by_id.keys() {
|
||||||
|
if !children.contains(&id) {
|
||||||
|
path.clear();
|
||||||
|
lines.extend(self.visit(id, &mut visited, &mut path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Index all the IDs by package.
|
||||||
|
let by_package: FxHashMap<_, _> =
|
||||||
|
self.lock.by_id.keys().map(|id| (&id.name, id)).collect();
|
||||||
|
for (index, package) in self.package.iter().enumerate() {
|
||||||
|
if index != 0 {
|
||||||
|
lines.push(String::new());
|
||||||
|
}
|
||||||
|
if let Some(id) = by_package.get(package) {
|
||||||
|
path.clear();
|
||||||
|
lines.extend(self.visit(id, &mut visited, &mut path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for TreeDisplay<'_> {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
use owo_colors::OwoColorize;
|
||||||
|
|
||||||
|
let mut deduped = false;
|
||||||
|
for line in self.render() {
|
||||||
|
deduped |= line.contains('*');
|
||||||
|
writeln!(f, "{line}")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if deduped {
|
||||||
|
let message = if self.no_dedupe {
|
||||||
|
"(*) Package tree is a cycle and cannot be shown".italic()
|
||||||
|
} else {
|
||||||
|
"(*) Package tree already displayed".italic()
|
||||||
|
};
|
||||||
|
writeln!(f, "{message}")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use indexmap::IndexMap;
|
|
||||||
use owo_colors::OwoColorize;
|
|
||||||
|
|
||||||
use pep508_rs::PackageName;
|
use pep508_rs::PackageName;
|
||||||
use uv_cache::Cache;
|
use uv_cache::Cache;
|
||||||
|
@ -10,10 +8,10 @@ use uv_client::Connectivity;
|
||||||
use uv_configuration::{Concurrency, PreviewMode};
|
use uv_configuration::{Concurrency, PreviewMode};
|
||||||
use uv_fs::CWD;
|
use uv_fs::CWD;
|
||||||
use uv_python::{PythonFetch, PythonPreference, PythonRequest};
|
use uv_python::{PythonFetch, PythonPreference, PythonRequest};
|
||||||
|
use uv_resolver::TreeDisplay;
|
||||||
use uv_warnings::warn_user_once;
|
use uv_warnings::warn_user_once;
|
||||||
use uv_workspace::{DiscoveryOptions, Workspace};
|
use uv_workspace::{DiscoveryOptions, Workspace};
|
||||||
|
|
||||||
use crate::commands::pip::tree::DisplayDependencyGraph;
|
|
||||||
use crate::commands::project::FoundInterpreter;
|
use crate::commands::project::FoundInterpreter;
|
||||||
use crate::commands::{project, ExitStatus};
|
use crate::commands::{project, ExitStatus};
|
||||||
use crate::printer::Printer;
|
use crate::printer::Printer;
|
||||||
|
@ -31,7 +29,6 @@ pub(crate) async fn tree(
|
||||||
package: Vec<PackageName>,
|
package: Vec<PackageName>,
|
||||||
no_dedupe: bool,
|
no_dedupe: bool,
|
||||||
invert: bool,
|
invert: bool,
|
||||||
show_version_specifiers: bool,
|
|
||||||
python: Option<String>,
|
python: Option<String>,
|
||||||
settings: ResolverSettings,
|
settings: ResolverSettings,
|
||||||
python_preference: PythonPreference,
|
python_preference: PythonPreference,
|
||||||
|
@ -81,38 +78,10 @@ pub(crate) async fn tree(
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Read packages from the lockfile.
|
|
||||||
let mut packages: IndexMap<_, Vec<_>> = IndexMap::new();
|
|
||||||
for dist in lock.lock.into_distributions() {
|
|
||||||
let name = dist.name().clone();
|
|
||||||
let metadata = dist.to_metadata(workspace.install_path())?;
|
|
||||||
packages.entry(name).or_default().push(metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the tree.
|
// Render the tree.
|
||||||
let rendered_tree = DisplayDependencyGraph::new(
|
let tree = TreeDisplay::new(&lock.lock, depth.into(), prune, package, no_dedupe, invert);
|
||||||
depth.into(),
|
|
||||||
prune,
|
|
||||||
package,
|
|
||||||
no_dedupe,
|
|
||||||
invert,
|
|
||||||
show_version_specifiers,
|
|
||||||
interpreter.markers(),
|
|
||||||
packages,
|
|
||||||
)
|
|
||||||
.render()
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
writeln!(printer.stdout(), "{rendered_tree}")?;
|
write!(printer.stdout(), "{tree}")?;
|
||||||
|
|
||||||
if rendered_tree.contains('*') {
|
|
||||||
let message = if no_dedupe {
|
|
||||||
"(*) Package tree is a cycle and cannot be shown".italic()
|
|
||||||
} else {
|
|
||||||
"(*) Package tree already displayed".italic()
|
|
||||||
};
|
|
||||||
writeln!(printer.stdout(), "{message}")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ExitStatus::Success)
|
Ok(ExitStatus::Success)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1122,7 +1122,6 @@ async fn run_project(
|
||||||
args.package,
|
args.package,
|
||||||
args.no_dedupe,
|
args.no_dedupe,
|
||||||
args.invert,
|
args.invert,
|
||||||
args.show_version_specifiers,
|
|
||||||
args.python,
|
args.python,
|
||||||
args.resolver,
|
args.resolver,
|
||||||
globals.python_preference,
|
globals.python_preference,
|
||||||
|
|
|
@ -774,7 +774,6 @@ pub(crate) struct TreeSettings {
|
||||||
pub(crate) package: Vec<PackageName>,
|
pub(crate) package: Vec<PackageName>,
|
||||||
pub(crate) no_dedupe: bool,
|
pub(crate) no_dedupe: bool,
|
||||||
pub(crate) invert: bool,
|
pub(crate) invert: bool,
|
||||||
pub(crate) show_version_specifiers: bool,
|
|
||||||
pub(crate) python: Option<String>,
|
pub(crate) python: Option<String>,
|
||||||
pub(crate) resolver: ResolverSettings,
|
pub(crate) resolver: ResolverSettings,
|
||||||
}
|
}
|
||||||
|
@ -799,7 +798,6 @@ impl TreeSettings {
|
||||||
package: tree.package,
|
package: tree.package,
|
||||||
no_dedupe: tree.no_dedupe,
|
no_dedupe: tree.no_dedupe,
|
||||||
invert: tree.invert,
|
invert: tree.invert,
|
||||||
show_version_specifiers: tree.show_version_specifiers,
|
|
||||||
python,
|
python,
|
||||||
resolver: ResolverSettings::combine(resolver_options(resolver, build), filesystem),
|
resolver: ResolverSettings::combine(resolver_options(resolver, build), filesystem),
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use assert_fs::prelude::*;
|
use assert_fs::prelude::*;
|
||||||
|
|
||||||
use common::{uv_snapshot, TestContext};
|
use common::{uv_snapshot, TestContext};
|
||||||
|
use indoc::formatdoc;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
|
@ -180,3 +181,169 @@ fn frozen() -> Result<()> {
|
||||||
|
|
||||||
Ok(())
|
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"
|
||||||
|
]
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Should include `colorama`, even though it's only included on Windows.
|
||||||
|
uv_snapshot!(context.filters(), context.tree(), @r###"
|
||||||
|
success: true
|
||||||
|
exit_code: 0
|
||||||
|
----- stdout -----
|
||||||
|
project v0.1.0
|
||||||
|
└── black v24.3.0
|
||||||
|
├── click v8.1.7
|
||||||
|
│ └── colorama v0.4.6
|
||||||
|
├── mypy-extensions v1.0.0
|
||||||
|
├── packaging v24.0
|
||||||
|
├── pathspec v0.12.1
|
||||||
|
└── platformdirs v4.2.0
|
||||||
|
|
||||||
|
----- stderr -----
|
||||||
|
warning: `uv tree` is experimental and may change without warning
|
||||||
|
Resolved 8 packages in [TIME]
|
||||||
|
"###
|
||||||
|
);
|
||||||
|
|
||||||
|
// `uv tree` should update the lockfile
|
||||||
|
let lock = fs_err::read_to_string(context.temp_dir.join("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.tree(), @r###"
|
||||||
|
success: true
|
||||||
|
exit_code: 0
|
||||||
|
----- stdout -----
|
||||||
|
project v0.1.0
|
||||||
|
├── anyio v1.4.0
|
||||||
|
│ ├── async-generator v1.10
|
||||||
|
│ ├── idna v3.6
|
||||||
|
│ └── sniffio v1.3.1
|
||||||
|
└── anyio v4.3.0
|
||||||
|
├── idna v3.6
|
||||||
|
└── sniffio v1.3.1
|
||||||
|
|
||||||
|
----- stderr -----
|
||||||
|
warning: `uv tree` is experimental and may change without warning
|
||||||
|
Resolved 6 packages in [TIME]
|
||||||
|
"###
|
||||||
|
);
|
||||||
|
|
||||||
|
// `uv tree` should update the lockfile
|
||||||
|
let lock = fs_err::read_to_string(context.temp_dir.join("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.tree(), @r###"
|
||||||
|
success: true
|
||||||
|
exit_code: 0
|
||||||
|
----- stdout -----
|
||||||
|
project v0.1.0
|
||||||
|
├── dependency v0.0.1
|
||||||
|
│ └── anyio v3.7.0
|
||||||
|
│ ├── idna v3.6
|
||||||
|
│ └── sniffio v1.3.1
|
||||||
|
└── dependency v0.0.1
|
||||||
|
└── anyio v3.0.0
|
||||||
|
├── idna v3.6
|
||||||
|
└── sniffio v1.3.1
|
||||||
|
|
||||||
|
----- stderr -----
|
||||||
|
warning: `uv tree` is experimental and may change without warning
|
||||||
|
Resolved 7 packages in [TIME]
|
||||||
|
"###
|
||||||
|
);
|
||||||
|
|
||||||
|
// `uv tree` should update the lockfile
|
||||||
|
let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?;
|
||||||
|
assert!(!lock.is_empty());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue