ruff/crates/ty_test/src/lib.rs
Matthew Mckee 166b63ad4d
Fix mdtest ignore python code blocks (#20139)
## Summary

Resolves https://github.com/astral-sh/ty/issues/1103

## Test Plan

Edited `crates/ty_python_semantic/resources/mdtest/literal/boolean.md`
with:

```
# Boolean literals

```python
reveal_type(True)  # revealed: Literal[False]
reveal_type(False)  # revealed: Literal[False]
```

Ran `cargo test -p ty_python_semantic --test mdtest -- mdtest__literal_boolean`

And we get a test failure:

```
running 1 test
test mdtest__literal_boolean ... FAILED

failures:

---- mdtest__literal_boolean stdout ----

boolean.md - Boolean literals (c336e1af3d538acd)

crates/ty_python_semantic/resources/mdtest/literal/boolean.md:4
unmatched assertion: revealed: Literal[False]
crates/ty_python_semantic/resources/mdtest/literal/boolean.md:4
unexpected error: 13 [revealed-type] "Revealed type: `Literal[True]`"

To rerun this specific test, set the environment variable:
MDTEST_TEST_FILTER='boolean.md - Boolean literals (c336e1af3d538acd)'
MDTEST_TEST_FILTER='boolean.md - Boolean literals (c336e1af3d538acd)'
cargo test -p ty_python_semantic --test mdtest --
mdtest__literal_boolean

--------------------------------------------------


thread 'mdtest__literal_boolean' panicked at
crates/ty_test/src/lib.rs:138:5:
Some tests failed.
note: run with `RUST_BACKTRACE=1` environment variable to display a
backtrace


failures:
    mdtest__literal_boolean

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 263
filtered out; finished in 0.18s

error: test failed, to rerun pass `-p ty_python_semantic --test mdtest`
```

As expected.

And when i checkout main and keep the same mdtest file all tests pass (as the repro).
2025-08-28 09:59:03 -07:00

653 lines
23 KiB
Rust

use crate::config::Log;
use crate::db::Db;
use crate::parser::{BacktickOffsets, EmbeddedFileSourceMap};
use camino::Utf8Path;
use colored::Colorize;
use config::SystemKind;
use parser as test_parser;
use ruff_db::Db as _;
use ruff_db::diagnostic::{Diagnostic, DisplayDiagnosticConfig};
use ruff_db::files::{File, FileRootKind, system_path_to_file};
use ruff_db::panic::catch_unwind;
use ruff_db::parsed::parsed_module;
use ruff_db::system::{DbWithWritableSystem as _, SystemPath, SystemPathBuf};
use ruff_db::testing::{setup_logging, setup_logging_with_filter};
use ruff_source_file::{LineIndex, OneIndexed};
use std::backtrace::BacktraceStatus;
use std::fmt::Write;
use ty_python_semantic::pull_types::pull_types;
use ty_python_semantic::types::check_types;
use ty_python_semantic::{
Module, Program, ProgramSettings, PythonEnvironment, PythonPlatform, PythonVersionSource,
PythonVersionWithSource, SearchPath, SearchPathSettings, SysPrefixPathOrigin, list_modules,
resolve_module,
};
mod assertion;
mod config;
mod db;
mod diagnostic;
mod matcher;
mod parser;
use ty_static::EnvVars;
/// Run `path` as a markdown test suite with given `title`.
///
/// Panic on test failure, and print failure details.
#[expect(clippy::print_stdout)]
pub fn run(
absolute_fixture_path: &Utf8Path,
relative_fixture_path: &Utf8Path,
snapshot_path: &Utf8Path,
short_title: &str,
test_name: &str,
output_format: OutputFormat,
) {
let source = std::fs::read_to_string(absolute_fixture_path).unwrap();
let suite = match test_parser::parse(short_title, &source) {
Ok(suite) => suite,
Err(err) => {
panic!("Error parsing `{absolute_fixture_path}`: {err:?}")
}
};
let mut db = db::Db::setup();
let filter = std::env::var(EnvVars::MDTEST_TEST_FILTER).ok();
let mut any_failures = false;
for test in suite.tests() {
if filter
.as_ref()
.is_some_and(|f| !(test.uncontracted_name().contains(f) || test.name() == *f))
{
continue;
}
let _tracing = test.configuration().log.as_ref().and_then(|log| match log {
Log::Bool(enabled) => enabled.then(setup_logging),
Log::Filter(filter) => setup_logging_with_filter(filter),
});
let failures = run_test(&mut db, relative_fixture_path, snapshot_path, &test);
let inconsistencies = run_module_resolution_consistency_test(&db);
let this_test_failed = failures.is_err() || inconsistencies.is_err();
any_failures = any_failures || this_test_failed;
if this_test_failed && output_format.is_cli() {
println!("\n{}\n", test.name().bold().underline());
}
if let Err(failures) = failures {
let md_index = LineIndex::from_source_text(&source);
for test_failures in failures {
let source_map =
EmbeddedFileSourceMap::new(&md_index, test_failures.backtick_offsets);
for (relative_line_number, failures) in test_failures.by_line.iter() {
let absolute_line_number =
source_map.to_absolute_line_number(relative_line_number);
for failure in failures {
match output_format {
OutputFormat::Cli => {
let line_info =
format!("{relative_fixture_path}:{absolute_line_number}")
.cyan();
println!(" {line_info} {failure}");
}
OutputFormat::GitHub => println!(
"::error file={absolute_fixture_path},line={absolute_line_number}::{failure}"
),
}
}
}
}
}
if let Err(inconsistencies) = inconsistencies {
any_failures = true;
for inconsistency in inconsistencies {
match output_format {
OutputFormat::Cli => {
let info = relative_fixture_path.to_string().cyan();
println!(" {info} {inconsistency}");
}
OutputFormat::GitHub => {
println!("::error file={absolute_fixture_path}::{inconsistency}");
}
}
}
}
if this_test_failed && output_format.is_cli() {
let escaped_test_name = test.name().replace('\'', "\\'");
println!(
"\nTo rerun this specific test, set the environment variable: {}='{escaped_test_name}'",
EnvVars::MDTEST_TEST_FILTER,
);
println!(
"{}='{escaped_test_name}' cargo test -p ty_python_semantic --test mdtest -- {test_name}",
EnvVars::MDTEST_TEST_FILTER,
);
}
}
println!("\n{}\n", "-".repeat(50));
assert!(!any_failures, "Some tests failed.");
}
/// Defines the format in which mdtest should print an error to the terminal
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
/// The format `cargo test` should use by default.
Cli,
/// A format that will provide annotations from GitHub Actions
/// if mdtest fails on a PR.
/// See <https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#setting-an-error-message>
GitHub,
}
impl OutputFormat {
const fn is_cli(self) -> bool {
matches!(self, OutputFormat::Cli)
}
}
fn run_test(
db: &mut db::Db,
relative_fixture_path: &Utf8Path,
snapshot_path: &Utf8Path,
test: &parser::MarkdownTest,
) -> Result<(), Failures> {
// Initialize the system and remove all files and directories to reset the system to a clean state.
match test.configuration().system.unwrap_or_default() {
SystemKind::InMemory => {
db.use_in_memory_system();
}
SystemKind::Os => {
let dir = tempfile::TempDir::new().expect("Creating a temporary directory to succeed");
let root_path = dir
.path()
.canonicalize()
.expect("Canonicalizing to succeed");
let root_path = SystemPathBuf::from_path_buf(root_path)
.expect("Temp directory to be a valid UTF8 path")
.simplified()
.to_path_buf();
db.use_os_system_with_temp_dir(root_path, dir);
}
}
let project_root = SystemPathBuf::from("/src");
db.create_directory_all(&project_root)
.expect("Creating the project root to succeed");
db.files()
.try_add_root(db, &project_root, FileRootKind::Project);
let src_path = project_root.clone();
let custom_typeshed_path = test.configuration().typeshed();
let python_version = test.configuration().python_version().unwrap_or_default();
let mut typeshed_files = vec![];
let mut has_custom_versions_file = false;
let test_files: Vec<_> = test
.files()
.filter_map(|embedded| {
if embedded.lang == "ignore" {
return None;
}
assert!(
matches!(embedded.lang, "py" | "pyi" | "python" | "text" | "cfg"),
"Supported file types are: py (or python), pyi, text, cfg and ignore"
);
let mut full_path = embedded.full_path(&project_root);
if let Some(relative_path_to_custom_typeshed) = custom_typeshed_path
.and_then(|typeshed| full_path.strip_prefix(typeshed.join("stdlib")).ok())
{
if relative_path_to_custom_typeshed.as_str() == "VERSIONS" {
has_custom_versions_file = true;
} else if relative_path_to_custom_typeshed
.extension()
.is_some_and(|ext| ext == "pyi")
{
typeshed_files.push(relative_path_to_custom_typeshed.to_path_buf());
}
} else if let Some(component_index) = full_path
.components()
.position(|c| c.as_str() == "<path-to-site-packages>")
{
// If the path contains `<path-to-site-packages>`, we need to replace it with the
// actual site-packages directory based on the Python platform and version.
let mut components = full_path.components();
let mut new_path: SystemPathBuf =
components.by_ref().take(component_index).collect();
if cfg!(target_os = "windows") {
new_path.extend(["Lib", "site-packages"]);
} else {
new_path.push("lib");
new_path.push(format!("python{python_version}"));
new_path.push("site-packages");
}
new_path.extend(components.skip(1));
full_path = new_path;
}
db.write_file(&full_path, &embedded.code).unwrap();
if !(full_path.starts_with(&src_path)
&& matches!(embedded.lang, "py" | "python" | "pyi"))
{
// These files need to be written to the file system (above), but we don't run any checks on them.
return None;
}
let file = system_path_to_file(db, full_path).unwrap();
Some(TestFile {
file,
backtick_offsets: embedded.backtick_offsets.clone(),
})
})
.collect();
// Create a custom typeshed `VERSIONS` file if none was provided.
if let Some(typeshed_path) = custom_typeshed_path {
db.files()
.try_add_root(db, typeshed_path, FileRootKind::LibrarySearchPath);
if !has_custom_versions_file {
let versions_file = typeshed_path.join("stdlib/VERSIONS");
let contents = typeshed_files
.iter()
.fold(String::new(), |mut content, path| {
// This is intentionally kept simple:
let module_name = path
.as_str()
.trim_end_matches(".pyi")
.trim_end_matches("/__init__")
.replace('/', ".");
let _ = writeln!(content, "{module_name}: 3.8-");
content
});
db.write_file(&versions_file, contents).unwrap();
}
}
let configuration = test.configuration();
let site_packages_paths = if let Some(python) = configuration.python() {
let environment =
PythonEnvironment::new(python, SysPrefixPathOrigin::PythonCliFlag, db.system())
.expect("Python environment to point to a valid path");
environment
.site_packages_paths(db.system())
.expect("Python environment to be valid")
.into_vec()
} else {
vec![]
};
let settings = ProgramSettings {
python_version: PythonVersionWithSource {
version: python_version,
source: PythonVersionSource::Cli,
},
python_platform: configuration
.python_platform()
.unwrap_or(PythonPlatform::Identifier("linux".to_string())),
search_paths: SearchPathSettings {
src_roots: vec![src_path],
extra_paths: configuration.extra_paths().unwrap_or_default().to_vec(),
custom_typeshed: custom_typeshed_path.map(SystemPath::to_path_buf),
site_packages_paths,
real_stdlib_path: None,
}
.to_search_paths(db.system(), db.vendored())
.expect("Failed to resolve search path settings"),
};
Program::init_or_update(db, settings);
// When snapshot testing is enabled, this is populated with
// all diagnostics. Otherwise it remains empty.
let mut snapshot_diagnostics = vec![];
let mut any_pull_types_failures = false;
let mut failures: Failures = test_files
.iter()
.filter_map(|test_file| {
let pull_types_result = attempt_test(
db,
pull_types,
test_file,
"\"pull types\"",
Some(
"Note: either fix the panic or add the `<!-- pull-types:skip -->` \
directive to this test",
),
);
match pull_types_result {
Ok(()) => {}
Err(failures) => {
any_pull_types_failures = true;
if !test.should_skip_pulling_types() {
return Some(failures);
}
}
}
let parsed = parsed_module(db, test_file.file).load(db);
let mut diagnostics: Vec<Diagnostic> = parsed
.errors()
.iter()
.map(|error| Diagnostic::invalid_syntax(test_file.file, &error.error, error))
.collect();
diagnostics.extend(
parsed
.unsupported_syntax_errors()
.iter()
.map(|error| Diagnostic::invalid_syntax(test_file.file, error, error)),
);
let mdtest_result = attempt_test(db, check_types, test_file, "run mdtest", None);
let type_diagnostics = match mdtest_result {
Ok(diagnostics) => diagnostics,
Err(failures) => return Some(failures),
};
diagnostics.extend(type_diagnostics);
diagnostics.sort_by(|left, right| {
left.rendering_sort_key(db)
.cmp(&right.rendering_sort_key(db))
});
let failure = match matcher::match_file(db, test_file.file, &diagnostics) {
Ok(()) => None,
Err(line_failures) => Some(FileFailures {
backtick_offsets: test_file.backtick_offsets.clone(),
by_line: line_failures,
}),
};
if test.should_snapshot_diagnostics() {
snapshot_diagnostics.extend(diagnostics);
}
failure
})
.collect();
if test.should_skip_pulling_types() && !any_pull_types_failures {
let mut by_line = matcher::FailuresByLine::default();
by_line.push(
OneIndexed::from_zero_indexed(0),
vec![
"Remove the `<!-- pull-types:skip -->` directive from this test: pulling types \
succeeded for all files in the test."
.to_string(),
],
);
let failure = FileFailures {
backtick_offsets: test_files[0].backtick_offsets.clone(),
by_line,
};
failures.push(failure);
}
if snapshot_diagnostics.is_empty() && test.should_snapshot_diagnostics() {
panic!(
"Test `{}` requested snapshotting diagnostics but it didn't produce any.",
test.name()
);
} else if !snapshot_diagnostics.is_empty() {
let snapshot =
create_diagnostic_snapshot(db, relative_fixture_path, test, snapshot_diagnostics);
let name = test.name().replace(' ', "_").replace(':', "__");
insta::with_settings!(
{
snapshot_path => snapshot_path,
input_file => name.clone(),
filters => vec![(r"\\", "/")],
prepend_module_to_snapshot => false,
},
{ insta::assert_snapshot!(name, snapshot) }
);
}
if failures.is_empty() {
Ok(())
} else {
Err(failures)
}
}
/// Reports an inconsistency between "list modules" and "resolve module."
///
/// Values of this type are only constructed when `from_list` and
/// `from_resolve` are not equivalent.
struct ModuleInconsistency<'db> {
db: &'db db::Db,
/// The module returned from `list_module`.
from_list: Module<'db>,
/// The module returned, if any, from `resolve_module`.
from_resolve: Option<Module<'db>>,
}
/// Tests that "list modules" is consistent with "resolve module."
///
/// This only checks that everything returned by `list_module` is the
/// identical module we get back from `resolve_module`. It does not
/// check that all possible outputs of `resolve_module` are captured by
/// `list_module`.
fn run_module_resolution_consistency_test(db: &db::Db) -> Result<(), Vec<ModuleInconsistency<'_>>> {
let mut errs = vec![];
for from_list in list_modules(db) {
errs.push(match resolve_module(db, from_list.name(db)) {
None => ModuleInconsistency {
db,
from_list,
from_resolve: None,
},
Some(from_resolve) if from_list != from_resolve => ModuleInconsistency {
db,
from_list,
from_resolve: Some(from_resolve),
},
_ => continue,
});
}
if errs.is_empty() { Ok(()) } else { Err(errs) }
}
impl std::fmt::Display for ModuleInconsistency<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
fn fmt_module(
db: &db::Db,
f: &mut std::fmt::Formatter,
module: &Module<'_>,
) -> std::fmt::Result {
let name = module.name(db);
let path = module
.file(db)
.map(|file| file.path(db).to_string())
.unwrap_or_else(|| "N/A".to_string());
let search_path = module
.search_path(db)
.map(SearchPath::to_string)
.unwrap_or_else(|| "N/A".to_string());
let known = module
.known(db)
.map(|known| known.to_string())
.unwrap_or_else(|| "N/A".to_string());
write!(
f,
"Module(\
name={name}, \
file={path}, \
kind={kind:?}, \
search_path={search_path}, \
known={known}\
)",
kind = module.kind(db),
)
}
write!(f, "Found ")?;
fmt_module(self.db, f, &self.from_list)?;
match self.from_resolve {
None => write!(
f,
" when listing modules, but `resolve_module` returned `None`",
)?,
Some(ref got) => {
write!(f, " when listing modules, but `resolve_module` returned ",)?;
fmt_module(self.db, f, got)?;
}
}
Ok(())
}
}
type Failures = Vec<FileFailures>;
/// The failures for a single file in a test by line number.
struct FileFailures {
/// Positional information about the code block(s) to reconstruct absolute line numbers.
backtick_offsets: Vec<BacktickOffsets>,
/// The failures by lines in the file.
by_line: matcher::FailuresByLine,
}
/// File in a test.
struct TestFile {
file: File,
/// Positional information about the code block(s) to reconstruct absolute line numbers.
backtick_offsets: Vec<BacktickOffsets>,
}
fn create_diagnostic_snapshot(
db: &mut db::Db,
relative_fixture_path: &Utf8Path,
test: &parser::MarkdownTest,
diagnostics: impl IntoIterator<Item = Diagnostic>,
) -> String {
let display_config = DisplayDiagnosticConfig::default().color(false);
let mut snapshot = String::new();
writeln!(snapshot).unwrap();
writeln!(snapshot, "---").unwrap();
writeln!(snapshot, "mdtest name: {}", test.uncontracted_name()).unwrap();
writeln!(snapshot, "mdtest path: {relative_fixture_path}").unwrap();
writeln!(snapshot, "---").unwrap();
writeln!(snapshot).unwrap();
writeln!(snapshot, "# Python source files").unwrap();
writeln!(snapshot).unwrap();
for file in test.files() {
writeln!(snapshot, "## {}", file.relative_path()).unwrap();
writeln!(snapshot).unwrap();
// Note that we don't use ```py here because the line numbering
// we add makes it invalid Python. This sacrifices syntax
// highlighting when you look at the snapshot on GitHub,
// but the line numbers are extremely useful for analyzing
// snapshots. So we keep them.
writeln!(snapshot, "```").unwrap();
let line_number_width = file.code.lines().count().to_string().len();
for (i, line) in file.code.lines().enumerate() {
let line_number = i + 1;
writeln!(snapshot, "{line_number:>line_number_width$} | {line}").unwrap();
}
writeln!(snapshot, "```").unwrap();
writeln!(snapshot).unwrap();
}
writeln!(snapshot, "# Diagnostics").unwrap();
writeln!(snapshot).unwrap();
for (i, diag) in diagnostics.into_iter().enumerate() {
if i > 0 {
writeln!(snapshot).unwrap();
}
writeln!(snapshot, "```").unwrap();
write!(snapshot, "{}", diag.display(db, &display_config)).unwrap();
writeln!(snapshot, "```").unwrap();
}
snapshot
}
/// Run a function over an embedded test file, catching any panics that occur in the process.
///
/// If no panic occurs, the result of the function is returned as an `Ok()` variant.
///
/// If a panic occurs, a nicely formatted [`FileFailures`] is returned as an `Err()` variant.
/// This will be formatted into a diagnostic message by `ty_test`.
fn attempt_test<'db, T, F>(
db: &'db Db,
test_fn: F,
test_file: &TestFile,
action: &str,
clarification: Option<&str>,
) -> Result<T, FileFailures>
where
F: FnOnce(&'db dyn ty_python_semantic::Db, File) -> T + std::panic::UnwindSafe,
{
catch_unwind(|| test_fn(db, test_file.file)).map_err(|info| {
let mut by_line = matcher::FailuresByLine::default();
let mut messages = vec![];
match info.location {
Some(location) => messages.push(format!(
"Attempting to {action} caused a panic at {location}"
)),
None => messages.push(format!(
"Attempting to {action} caused a panic at an unknown location",
)),
}
if let Some(clarification) = clarification {
messages.push(clarification.to_string());
}
messages.push(String::new());
match info.payload.as_str() {
Some(message) => messages.push(message.to_string()),
// Mimic the default panic hook's rendering of the panic payload if it's
// not a string.
None => messages.push("Box<dyn Any>".to_string()),
}
messages.push(String::new());
if let Some(backtrace) = info.backtrace {
match backtrace.status() {
BacktraceStatus::Disabled => {
let msg =
"run with `RUST_BACKTRACE=1` environment variable to display a backtrace";
messages.push(msg.to_string());
}
BacktraceStatus::Captured => {
messages.extend(backtrace.to_string().split('\n').map(String::from));
}
_ => {}
}
}
if let Some(backtrace) = info.salsa_backtrace {
salsa::attach(db, || {
messages.extend(format!("{backtrace:#}").split('\n').map(String::from));
});
}
by_line.push(OneIndexed::from_zero_indexed(0), messages);
FileFailures {
backtick_offsets: test_file.backtick_offsets.clone(),
by_line,
}
})
}