Allow passing a virtual environment to ruff analyze graph (#17743)

Summary
--

Fixes #16598 by adding the `--python` flag to `ruff analyze graph`,
which adds a `PythonPath` to the `SearchPathSettings` for module
resolution. For the [albatross-virtual-workspace] example from the uv
repo, this updates the output from the initial issue:

```shell
> ruff analyze graph packages/albatross
{
  "packages/albatross/check_installed_albatross.py": [
    "packages/albatross/src/albatross/__init__.py"
  ],
  "packages/albatross/src/albatross/__init__.py": []
}
```

To include both the the workspace `bird_feeder` import _and_ the
third-party `tqdm` import in the output:

```shell
> myruff analyze graph packages/albatross --python .venv
{
  "packages/albatross/check_installed_albatross.py": [
    "packages/albatross/src/albatross/__init__.py"
  ],
  "packages/albatross/src/albatross/__init__.py": [
    ".venv/lib/python3.12/site-packages/tqdm/__init__.py",
    "packages/bird-feeder/src/bird_feeder/__init__.py"
  ]
}
```

Note the hash in the uv link! I was temporarily very confused why my
local tests were showing an `iniconfig` import instead of `tqdm` until I
realized that the example has been updated on the uv main branch, which
I had locally.

Test Plan
--

A new integration test with a stripped down venv based on the
`albatross` example.

[albatross-virtual-workspace]:
aa629c4a54/scripts/workspaces/albatross-virtual-workspace
This commit is contained in:
Brent Westbrook 2025-05-01 11:29:52 -04:00 committed by GitHub
parent 75effb8ed7
commit 163d526407
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 164 additions and 2 deletions

View file

@ -177,6 +177,9 @@ pub struct AnalyzeGraphCommand {
/// The minimum Python version that should be supported.
#[arg(long, value_enum)]
target_version: Option<PythonVersion>,
/// Path to a virtual environment to use for resolving additional dependencies
#[arg(long)]
python: Option<PathBuf>,
}
// The `Parser` derive is for ruff_dev, for ruff `Args` would be sufficient
@ -796,6 +799,7 @@ impl AnalyzeGraphCommand {
let format_arguments = AnalyzeGraphArgs {
files: self.files,
direction: self.direction,
python: self.python,
};
let cli_overrides = ExplicitConfigOverrides {
@ -1261,6 +1265,7 @@ impl LineColumnParseError {
pub struct AnalyzeGraphArgs {
pub files: Vec<PathBuf>,
pub direction: Direction,
pub python: Option<PathBuf>,
}
/// Configuration overrides provided via dedicated CLI flags:

View file

@ -75,6 +75,8 @@ pub(crate) fn analyze_graph(
.target_version
.as_tuple()
.into(),
args.python
.and_then(|python| SystemPathBuf::from_path_buf(python).ok()),
)?;
let imports = {

View file

@ -422,3 +422,153 @@ fn nested_imports() -> Result<()> {
Ok(())
}
/// Test for venv resolution with the `--python` flag.
///
/// Based on the [albatross-virtual-workspace] example from the uv repo and the report in [#16598].
///
/// [albatross-virtual-workspace]: https://github.com/astral-sh/uv/tree/aa629c4a/scripts/workspaces/albatross-virtual-workspace
/// [#16598]: https://github.com/astral-sh/ruff/issues/16598
#[test]
fn venv() -> Result<()> {
let tempdir = TempDir::new()?;
let root = ChildPath::new(tempdir.path());
// packages
// ├── albatross
// │ ├── check_installed_albatross.py
// │ ├── pyproject.toml
// │ └── src
// │ └── albatross
// │ └── __init__.py
// └── bird-feeder
// ├── check_installed_bird_feeder.py
// ├── pyproject.toml
// └── src
// └── bird_feeder
// └── __init__.py
let packages = root.child("packages");
let albatross = packages.child("albatross");
albatross
.child("check_installed_albatross.py")
.write_str("from albatross import fly")?;
albatross
.child("pyproject.toml")
.write_str(indoc::indoc! {r#"
[project]
name = "albatross"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["bird-feeder", "tqdm>=4,<5"]
[tool.uv.sources]
bird-feeder = { workspace = true }
"#})?;
albatross
.child("src")
.child("albatross")
.child("__init__.py")
.write_str("import tqdm; from bird_feeder import use")?;
let bird_feeder = packages.child("bird-feeder");
bird_feeder
.child("check_installed_bird_feeder.py")
.write_str("from bird_feeder import use; from albatross import fly")?;
bird_feeder
.child("pyproject.toml")
.write_str(indoc::indoc! {r#"
[project]
name = "bird-feeder"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = ["anyio>=4.3.0,<5"]
"#})?;
bird_feeder
.child("src")
.child("bird_feeder")
.child("__init__.py")
.write_str("import anyio")?;
let venv = root.child(".venv");
let bin = venv.child("bin");
bin.child("python").touch()?;
let home = format!("home = {}", bin.to_string_lossy());
venv.child("pyvenv.cfg").write_str(&home)?;
let site_packages = venv.child("lib").child("python3.12").child("site-packages");
site_packages
.child("_albatross.pth")
.write_str(&albatross.join("src").to_string_lossy())?;
site_packages
.child("_bird_feeder.pth")
.write_str(&bird_feeder.join("src").to_string_lossy())?;
site_packages.child("tqdm").child("__init__.py").touch()?;
// without `--python .venv`, the result should only include dependencies within the albatross
// package
insta::with_settings!({
filters => INSTA_FILTERS.to_vec(),
}, {
assert_cmd_snapshot!(
command().arg("packages/albatross").current_dir(&root),
@r#"
success: true
exit_code: 0
----- stdout -----
{
"packages/albatross/check_installed_albatross.py": [
"packages/albatross/src/albatross/__init__.py"
],
"packages/albatross/src/albatross/__init__.py": []
}
----- stderr -----
"#);
});
// with `--python .venv` both workspace and third-party dependencies are included
insta::with_settings!({
filters => INSTA_FILTERS.to_vec(),
}, {
assert_cmd_snapshot!(
command().args(["--python", ".venv"]).arg("packages/albatross").current_dir(&root),
@r#"
success: true
exit_code: 0
----- stdout -----
{
"packages/albatross/check_installed_albatross.py": [
"packages/albatross/src/albatross/__init__.py"
],
"packages/albatross/src/albatross/__init__.py": [
".venv/lib/python3.12/site-packages/tqdm/__init__.py",
"packages/bird-feeder/src/bird_feeder/__init__.py"
]
}
----- stderr -----
"#);
});
// test the error message for a non-existent venv. it's important that the `ruff analyze graph`
// flag matches the red-knot flag used to generate the error message (`--python`)
insta::with_settings!({
filters => INSTA_FILTERS.to_vec(),
}, {
assert_cmd_snapshot!(
command().args(["--python", "none"]).arg("packages/albatross").current_dir(&root),
@r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
ruff failed
Cause: Invalid search path settings
Cause: Failed to discover the site-packages directory: Invalid `--python` argument: `none` could not be canonicalized
");
});
Ok(())
}

View file

@ -4,7 +4,8 @@ use zip::CompressionMethod;
use red_knot_python_semantic::lint::{LintRegistry, RuleSelection};
use red_knot_python_semantic::{
default_lint_registry, Db, Program, ProgramSettings, PythonPlatform, SearchPathSettings,
default_lint_registry, Db, Program, ProgramSettings, PythonPath, PythonPlatform,
SearchPathSettings,
};
use ruff_db::files::{File, Files};
use ruff_db::system::{OsSystem, System, SystemPathBuf};
@ -32,8 +33,12 @@ impl ModuleDb {
pub fn from_src_roots(
src_roots: Vec<SystemPathBuf>,
python_version: PythonVersion,
venv_path: Option<SystemPathBuf>,
) -> Result<Self> {
let search_paths = SearchPathSettings::new(src_roots);
let mut search_paths = SearchPathSettings::new(src_roots);
if let Some(venv_path) = venv_path {
search_paths.python_path = PythonPath::from_cli_flag(venv_path);
}
let db = Self::default();
Program::from_settings(