[red-knot] Fix `python` setting in mdtests, and rewrite a `site-packages` test as an mdtest (#17222)

## Summary

This PR does the following things:
- Fixes the `python` configuration setting for mdtest (added in
https://github.com/astral-sh/ruff/pull/17221) so that it expects a path
pointing to a venv's `sys.prefix` variable rather than the a path
pointing to the venv's `site-packages` subdirectory. This brings the
`python` setting in mdtest in sync with our CLI `--python` flag.
- Tweaks mdtest so that it automatically creates a valid `pyvenv.cfg`
file for you if you don't specify one. This makes it much more ergonomic
to write an mdtest with a custom `python` setting: red-knot will reject
a `python` setting that points to a directory that doesn't have a
`pyvenv.cfg` file in it
- Tweaks mdtest so that it doesn't check a custom `pyvenv.cfg` as Python
source code if you _do_ add a custom `pyvenv.cfg` file for your mock
virtual environment in an mdtest. (You get a lot of diagnostics about
Python syntax errors in the `pyvenv.cfg` file, otherwise!)
- Rewrites the test added in
https://github.com/astral-sh/ruff/pull/17178 as an mdtest, and deletes
the original test that was added in that PR

## Test Plan

I verified that the new mdtest fails if I revert the changes to
`resolver.rs` that were added in
https://github.com/astral-sh/ruff/pull/17178
This commit is contained in:
Alex Waygood 2025-04-06 18:24:32 +01:00 committed by GitHub
parent 73a9974d8a
commit ac5d220d75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 132 additions and 51 deletions

View File

@ -236,3 +236,36 @@ X: int = 42
```py ```py
from .parser import X # error: [unresolved-import] from .parser import X # error: [unresolved-import]
``` ```
## Relative imports in `site-packages`
Relative imports in `site-packages` are correctly resolved even when the `site-packages` search path
is a subdirectory of the first-party search path. Note that mdtest sets the first-party search path
to `/src/`, which is why the virtual environment in this test is a subdirectory of `/src/`, even
though this is not how a typical Python project would be structured:
```toml
[environment]
python = "/src/.venv"
python-version = "3.13"
```
`/src/bar.py`:
```py
from foo import A
reveal_type(A) # revealed: Literal[A]
```
`/src/.venv/<path-to-site-packages>/foo/__init__.py`:
```py
from .a import A as A
```
`/src/.venv/<path-to-site-packages>/foo/a.py`:
```py
class A: ...
```

View File

@ -19,7 +19,7 @@ pub(crate) mod tests {
use std::sync::Arc; use std::sync::Arc;
use crate::program::{Program, SearchPathSettings}; use crate::program::{Program, SearchPathSettings};
use crate::{default_lint_registry, ProgramSettings, PythonPath, PythonPlatform}; use crate::{default_lint_registry, ProgramSettings, PythonPlatform};
use super::Db; use super::Db;
use crate::lint::{LintRegistry, RuleSelection}; use crate::lint::{LintRegistry, RuleSelection};
@ -139,8 +139,6 @@ pub(crate) mod tests {
python_version: PythonVersion, python_version: PythonVersion,
/// Target Python platform /// Target Python platform
python_platform: PythonPlatform, python_platform: PythonPlatform,
/// Paths to the directory to use for `site-packages`
site_packages: Vec<SystemPathBuf>,
/// Path and content pairs for files that should be present /// Path and content pairs for files that should be present
files: Vec<(&'a str, &'a str)>, files: Vec<(&'a str, &'a str)>,
} }
@ -150,7 +148,6 @@ pub(crate) mod tests {
Self { Self {
python_version: PythonVersion::default(), python_version: PythonVersion::default(),
python_platform: PythonPlatform::default(), python_platform: PythonPlatform::default(),
site_packages: vec![],
files: vec![], files: vec![],
} }
} }
@ -169,14 +166,6 @@ pub(crate) mod tests {
self self
} }
pub(crate) fn with_site_packages_search_path(
mut self,
path: &(impl AsRef<SystemPath> + ?Sized),
) -> Self {
self.site_packages.push(path.as_ref().to_path_buf());
self
}
pub(crate) fn build(self) -> anyhow::Result<TestDb> { pub(crate) fn build(self) -> anyhow::Result<TestDb> {
let mut db = TestDb::new(); let mut db = TestDb::new();
@ -186,15 +175,12 @@ pub(crate) mod tests {
db.write_files(self.files) db.write_files(self.files)
.context("Failed to write test files")?; .context("Failed to write test files")?;
let mut search_paths = SearchPathSettings::new(vec![src_root]);
search_paths.python_path = PythonPath::KnownSitePackages(self.site_packages);
Program::from_settings( Program::from_settings(
&db, &db,
ProgramSettings { ProgramSettings {
python_version: self.python_version, python_version: self.python_version,
python_platform: self.python_platform, python_platform: self.python_platform,
search_paths, search_paths: SearchPathSettings::new(vec![src_root]),
}, },
) )
.context("Failed to configure Program settings")?; .context("Failed to configure Program settings")?;

View File

@ -10,6 +10,7 @@ pub use module_resolver::{resolve_module, system_module_search_paths, KnownModul
pub use program::{Program, ProgramSettings, PythonPath, SearchPathSettings}; pub use program::{Program, ProgramSettings, PythonPath, SearchPathSettings};
pub use python_platform::PythonPlatform; pub use python_platform::PythonPlatform;
pub use semantic_model::{HasType, SemanticModel}; pub use semantic_model::{HasType, SemanticModel};
pub use site_packages::SysPrefixPathOrigin;
pub mod ast_node_ref; pub mod ast_node_ref;
mod db; mod db;

View File

@ -7392,7 +7392,7 @@ impl StringPartsCollector {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::db::tests::{setup_db, TestDb, TestDbBuilder}; use crate::db::tests::{setup_db, TestDb};
use crate::semantic_index::definition::Definition; use crate::semantic_index::definition::Definition;
use crate::semantic_index::symbol::FileScopeId; use crate::semantic_index::symbol::FileScopeId;
use crate::semantic_index::{global_scope, semantic_index, symbol_table, use_def_map}; use crate::semantic_index::{global_scope, semantic_index, symbol_table, use_def_map};
@ -7400,7 +7400,7 @@ mod tests {
use crate::types::check_types; use crate::types::check_types;
use ruff_db::diagnostic::Diagnostic; use ruff_db::diagnostic::Diagnostic;
use ruff_db::files::{system_path_to_file, File}; use ruff_db::files::{system_path_to_file, File};
use ruff_db::system::{DbWithWritableSystem as _, SystemPath}; use ruff_db::system::DbWithWritableSystem as _;
use ruff_db::testing::{assert_function_query_was_not_run, assert_function_query_was_run}; use ruff_db::testing::{assert_function_query_was_not_run, assert_function_query_was_run};
use super::*; use super::*;
@ -7556,26 +7556,6 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn relative_import_resolution_in_site_packages_when_site_packages_is_subdirectory_of_first_party_search_path(
) {
let project_root = SystemPath::new("/src");
let foo_dot_py = project_root.join("foo.py");
let site_packages = project_root.join(".venv/lib/python3.13/site-packages");
let db = TestDbBuilder::new()
.with_site_packages_search_path(&site_packages)
.with_file(&foo_dot_py, "from bar import A")
.with_file(&site_packages.join("bar/__init__.py"), "from .a import *")
.with_file(&site_packages.join("bar/a.py"), "class A: ...")
.build()
.unwrap();
assert_file_diagnostics(&db, foo_dot_py.as_str(), &[]);
let a_symbol = get_symbol(&db, foo_dot_py.as_str(), &[], "A");
assert!(a_symbol.expect_type().is_class_literal());
}
#[test] #[test]
fn pep695_type_params() { fn pep695_type_params() {
let mut db = setup_db(); let mut db = setup_db();

View File

@ -314,6 +314,45 @@ typeshed = "/typeshed"
For more details, take a look at the [custom-typeshed Markdown test]. For more details, take a look at the [custom-typeshed Markdown test].
### Mocking a virtual environment
Mdtest supports mocking a virtual environment for a specific test at an arbitrary location, again
using the `[environment]` configuration option:
````markdown
```toml
[environment]
python = ".venv"
```
````
Red-knot will reject virtual environments that do not have valid `pyvenv.cfg` files at the
virtual-environment directory root (here, `.venv/pyvenv.cfg`). However, if a `pyvenv.cfg` file does
not have its contents specified by the test, mdtest will automatically generate one for you, to
make mocking a virtual environment more ergonomic.
Mdtest also makes it easy to write Python packages to the mock virtual environment's
`site-packages` directory using the `<path-to-site-packages>` magic path segment. This would
otherwise be hard, due to the fact that the `site-packages` subdirectory in a virtual environment
is located at a different relative path depending on the platform the virtual environment was
created on. In the following test, mdtest will write the Python file to
`.venv/Lib/site-packages/foo.py` in its in-memory filesystem used for the test if the test is being
executed on Windows, and `.venv/lib/python3.13/site-packages/foo.py` otherwise:
````markdown
```toml
[environment]
python = ".venv"
python-version = "3.13"
```
`.venv/<path-to-site-packages>/foo.py`:
```py
X = 1
```
````
## Documentation of tests ## Documentation of tests
Arbitrary Markdown syntax (including of course normal prose paragraphs) is permitted (and ignored by Arbitrary Markdown syntax (including of course normal prose paragraphs) is permitted (and ignored by

View File

@ -5,7 +5,9 @@ use colored::Colorize;
use config::SystemKind; use config::SystemKind;
use parser as test_parser; use parser as test_parser;
use red_knot_python_semantic::types::check_types; use red_knot_python_semantic::types::check_types;
use red_knot_python_semantic::{Program, ProgramSettings, PythonPath, SearchPathSettings}; use red_knot_python_semantic::{
Program, ProgramSettings, PythonPath, SearchPathSettings, SysPrefixPathOrigin,
};
use ruff_db::diagnostic::{create_parse_diagnostic, Diagnostic, DisplayDiagnosticConfig}; use ruff_db::diagnostic::{create_parse_diagnostic, Diagnostic, DisplayDiagnosticConfig};
use ruff_db::files::{system_path_to_file, File}; use ruff_db::files::{system_path_to_file, File};
use ruff_db::panic::catch_unwind; use ruff_db::panic::catch_unwind;
@ -158,8 +160,12 @@ fn run_test(
let src_path = project_root.clone(); let src_path = project_root.clone();
let custom_typeshed_path = test.configuration().typeshed(); let custom_typeshed_path = test.configuration().typeshed();
let python_path = test.configuration().python();
let python_version = test.configuration().python_version().unwrap_or_default();
let mut typeshed_files = vec![]; let mut typeshed_files = vec![];
let mut has_custom_versions_file = false; let mut has_custom_versions_file = false;
let mut has_custom_pyvenv_cfg_file = false;
let test_files: Vec<_> = test let test_files: Vec<_> = test
.files() .files()
@ -169,11 +175,11 @@ fn run_test(
} }
assert!( assert!(
matches!(embedded.lang, "py" | "pyi" | "python" | "text"), matches!(embedded.lang, "py" | "pyi" | "python" | "text" | "cfg"),
"Supported file types are: py (or python), pyi, text, and ignore" "Supported file types are: py (or python), pyi, text, cfg and ignore"
); );
let full_path = embedded.full_path(&project_root); let mut full_path = embedded.full_path(&project_root);
if let Some(typeshed_path) = custom_typeshed_path { if let Some(typeshed_path) = custom_typeshed_path {
if let Ok(relative_path) = full_path.strip_prefix(typeshed_path.join("stdlib")) { if let Ok(relative_path) = full_path.strip_prefix(typeshed_path.join("stdlib")) {
@ -183,11 +189,35 @@ fn run_test(
typeshed_files.push(relative_path.to_path_buf()); typeshed_files.push(relative_path.to_path_buf());
} }
} }
} else if let Some(python_path) = python_path {
if let Ok(relative_path) = full_path.strip_prefix(python_path) {
if relative_path.as_str() == "pyvenv.cfg" {
has_custom_pyvenv_cfg_file = true;
} else {
let mut new_path = SystemPathBuf::new();
for component in full_path.components() {
let component = component.as_str();
if component == "<path-to-site-packages>" {
if cfg!(target_os = "windows") {
new_path.push("Lib");
new_path.push("site-packages");
} else {
new_path.push("lib");
new_path.push(format!("python{python_version}"));
new_path.push("site-packages");
}
} else {
new_path.push(component);
}
}
full_path = new_path;
}
}
} }
db.write_file(&full_path, &embedded.code).unwrap(); db.write_file(&full_path, &embedded.code).unwrap();
if !full_path.starts_with(&src_path) || embedded.lang == "text" { if !(full_path.starts_with(&src_path) && matches!(embedded.lang, "py" | "pyi")) {
// These files need to be written to the file system (above), but we don't run any checks on them. // These files need to be written to the file system (above), but we don't run any checks on them.
return None; return None;
} }
@ -221,22 +251,34 @@ fn run_test(
} }
} }
if let Some(python_path) = python_path {
if !has_custom_pyvenv_cfg_file {
let pyvenv_cfg_file = python_path.join("pyvenv.cfg");
let home_directory = SystemPathBuf::from(format!("/Python{python_version}"));
db.create_directory_all(&home_directory).unwrap();
db.write_file(&pyvenv_cfg_file, format!("home = {home_directory}"))
.unwrap();
}
}
let configuration = test.configuration(); let configuration = test.configuration();
let settings = ProgramSettings { let settings = ProgramSettings {
python_version: configuration.python_version().unwrap_or_default(), python_version,
python_platform: configuration.python_platform().unwrap_or_default(), python_platform: configuration.python_platform().unwrap_or_default(),
search_paths: SearchPathSettings { search_paths: SearchPathSettings {
src_roots: vec![src_path], src_roots: vec![src_path],
extra_paths: configuration.extra_paths().unwrap_or_default().to_vec(), extra_paths: configuration.extra_paths().unwrap_or_default().to_vec(),
custom_typeshed: custom_typeshed_path.map(SystemPath::to_path_buf), custom_typeshed: custom_typeshed_path.map(SystemPath::to_path_buf),
python_path: PythonPath::KnownSitePackages( python_path: configuration
configuration .python()
.python() .map(|sys_prefix| {
.into_iter() PythonPath::SysPrefix(
.map(SystemPath::to_path_buf) sys_prefix.to_path_buf(),
.collect(), SysPrefixPathOrigin::PythonCliFlag,
), )
})
.unwrap_or(PythonPath::KnownSitePackages(vec![])),
}, },
}; };