diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 302d025764..347bbc29bd 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -1227,7 +1227,7 @@ jobs:
echo '```console' > "$GITHUB_STEP_SUMMARY"
# Enable color output for prek and remove it for the summary
# Use --hook-stage=manual to enable slower hooks that are skipped by default
- SKIP=rustfmt uv run --only-dev --locked prek run --all-files --show-diff-on-failure --color always --hook-stage manual | \
+ SKIP=rustfmt,cargo-doc uv run --only-dev --locked prek run --all-files --show-diff-on-failure --color always --hook-stage manual | \
tee >(sed -E 's/\x1B\[([0-9]{1,2}(;[0-9]{1,2})*)?[mGK]//g' >> "$GITHUB_STEP_SUMMARY") >&1
exit_code="${PIPESTATUS[0]}"
echo '```' >> "$GITHUB_STEP_SUMMARY"
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 32d926f708..d33a431e1f 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -79,14 +79,15 @@ repos:
- id: cargo-doc
name: check `cargo doc` and `cargo test --doc`
entry: >
- bash -c 'RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --document-private-items -p ty_python_semantic
- -p ty_python_core -p ty_module_resolver -p ty_site_packages -p ty_combine -p ty_project -p ty_ide -p ty_wasm
- -p ty_vendored -p ty_static -p ty -p ty_test -p ruff_db -p ruff_python_formatter
- && cargo test --all-features --doc'
+ bash -c 'RUSTDOCFLAGS="-D warnings" cargo doc --all --no-deps
+ && RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --document-private-items -p ty_python_semantic
+ -p ty_python_core -p ty_module_resolver -p ty_site_packages -p ty_combine -p ty_project -p ty_ide
+ -p ty_wasm -p ty_vendored -p ty_static -p ty -p ty_test -p ruff_db -p ruff_python_formatter
+ && cargo test --all-features --doc'
types: [rust]
language: system
pass_filenames: false
- priority: 0
+ stages: [pre-push]
# Prettier
- repo: https://github.com/rbubley/mirrors-prettier
diff --git a/Cargo.lock b/Cargo.lock
index 5fcc5eb587..9ca868479f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5151,6 +5151,7 @@ dependencies = [
"jod-thread",
"libc",
"lsp-server",
+ "rand 0.10.2",
"regex",
"ruff_db",
"ruff_diagnostics",
@@ -5179,6 +5180,7 @@ dependencies = [
"ty_project",
"ty_python_core",
"ty_python_semantic",
+ "ty_static",
]
[[package]]
diff --git a/crates/by_build/src/lib.rs b/crates/by_build/src/lib.rs
index f3add74dd5..23ac643d2c 100644
--- a/crates/by_build/src/lib.rs
+++ b/crates/by_build/src/lib.rs
@@ -22,8 +22,18 @@ pub(crate) mod annotate;
pub struct Artifact {
/// the generated C, kept for `--annotate` and for debugging
pub source: PathBuf,
- /// the loadable extension
- pub extension: PathBuf,
+ /// the header every generated `.c` includes, at the root of the tree
+ pub header: PathBuf,
+ /// the loadable extension, or `None` when only the C was emitted
+ ///
+ /// a caller that records what the build wrote — so that a later one can take
+ /// a stale file back — has to be told the difference. naming the extension an
+ /// `--emit-c-only` run *would* have built is not harmless: the name is
+ /// `.so`, which is not where any real build puts it (that is
+ /// `Toolchain::extension_path`, tagged with the interpreter's abi), so a
+ /// caller checking the file system for it finds nothing, records nothing, and
+ /// prunes the extension a previous real build left
+ pub extension: Option,
/// the `--annotate` report, when one was asked for
pub annotation: Option,
}
@@ -113,7 +123,8 @@ fn emit_verified(module: &ModuleIr, out_dir: &Path, options: &Options) -> Result
}
fs::create_dir_all(out_dir)
.with_context(|| format!("could not create {}", out_dir.display()))?;
- fs::write(out_dir.join(by_rt::BY_H_NAME), by_rt::BY_H)?;
+ let header = out_dir.join(by_rt::BY_H_NAME);
+ fs::write(&header, by_rt::BY_H)?;
let source_path = out_dir.join(module.name.relative_path(".c"));
create_parent(&source_path)?;
@@ -123,7 +134,8 @@ fn emit_verified(module: &ModuleIr, out_dir: &Path, options: &Options) -> Result
Ok(Built {
artifact: Artifact {
source: source_path,
- extension: out_dir.join(module.name.relative_path(".so")),
+ header,
+ extension: None,
annotation: write_annotation(module, out_dir, options)?,
},
declined: module.declined.clone(),
@@ -364,7 +376,8 @@ pub fn build_module(module: &ModuleIr, toolchain: &Toolchain, out_dir: &Path) ->
Ok(Artifact {
source,
- extension,
+ header,
+ extension: Some(extension),
annotation: None,
})
}
diff --git a/crates/by_build/tests/end_to_end.rs b/crates/by_build/tests/end_to_end.rs
index 8c9e20da2f..d9678fbcff 100644
--- a/crates/by_build/tests/end_to_end.rs
+++ b/crates/by_build/tests/end_to_end.rs
@@ -81,7 +81,10 @@ fn built(module: &ModuleIr, toolchain: &Toolchain, tag: &str) -> Option
let _ = std::fs::remove_dir_all(&dir);
match build_module(module, toolchain, &dir) {
Ok(artifact) => {
- assert!(artifact.extension.exists(), "the extension was written");
+ assert!(
+ extension_of(&artifact).exists(),
+ "the extension was written"
+ );
Some(dir)
}
Err(error) => {
@@ -218,6 +221,18 @@ fn fib_module() -> ModuleIr {
}
}
+/// The extension a build wrote.
+///
+/// `Artifact::extension` is `None` only for an `--emit-c-only` emit, which
+/// nothing in this file does — every case here goes through `build_module` or
+/// `build_lowered`, both of which invoke the C compiler.
+fn extension_of(artifact: &by_build::Artifact) -> &std::path::Path {
+ artifact
+ .extension
+ .as_deref()
+ .expect("this build compiled an extension, so it has to name one")
+}
+
#[test]
fn integer_arithmetic_computes_the_same_answers_as_python() {
let Some((python, toolchain)) = environment() else {
@@ -931,7 +946,7 @@ fn an_unchanged_module_is_not_recompiled() {
.and_then(|meta| meta.modified())
.expect("the artifact exists")
};
- let before = stamp(&first.artifact.extension);
+ let before = stamp(extension_of(&first.artifact));
let second = build_source(
source,
@@ -941,7 +956,11 @@ fn an_unchanged_module_is_not_recompiled() {
&Options::default(),
)
.expect("the toolchain already worked");
- assert_eq!(stamp(&second.artifact.extension), before, "it recompiled");
+ assert_eq!(
+ stamp(extension_of(&second.artifact)),
+ before,
+ "it recompiled"
+ );
// and a real change does rebuild
let changed = "def double(a: int) -> int:\n return a * 3\n";
@@ -954,7 +973,7 @@ fn an_unchanged_module_is_not_recompiled() {
)
.expect("the toolchain already worked");
assert_ne!(
- stamp(&third.artifact.extension),
+ stamp(extension_of(&third.artifact)),
before,
"it skipped a change"
);
@@ -980,7 +999,7 @@ fn a_stale_extension_is_rebuilt_even_when_the_c_is_unchanged() {
eprintln!("skipping: no working C toolchain");
return;
};
- std::fs::remove_file(&built.artifact.extension).expect("the artifact exists");
+ std::fs::remove_file(extension_of(&built.artifact)).expect("the artifact exists");
let again = build_source(
source,
"by_e2e_rebuild_stale",
@@ -989,7 +1008,7 @@ fn a_stale_extension_is_rebuilt_even_when_the_c_is_unchanged() {
&Options::default(),
)
.expect("the toolchain already worked");
- assert!(again.artifact.extension.exists());
+ assert!(extension_of(&again.artifact).exists());
}
/// a class on a base from outside the module, holding storage of its own
@@ -1251,10 +1270,10 @@ fn a_package_is_built_as_a_tree_and_imports_under_its_dotted_names() {
return;
};
assert!(
- built.artifact.extension.exists(),
+ extension_of(&built.artifact).exists(),
"{} was written to {}",
name.dotted(),
- built.artifact.extension.display()
+ extension_of(&built.artifact).display()
);
}
diff --git a/crates/by_stage/src/project.rs b/crates/by_stage/src/project.rs
index 98877c7338..326bd1e454 100644
--- a/crates/by_stage/src/project.rs
+++ b/crates/by_stage/src/project.rs
@@ -14,11 +14,13 @@
//! build would have transpiled it, and the only way to guarantee that is for there
//! to be one implementation of each.
+use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use anyhow::Context;
-use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf};
+use ruff_db::Db as _;
+use ruff_db::system::{OsSystem, System, SystemPath, SystemPathBuf};
use ty_project::{Db, ProjectDatabase, ProjectMetadata};
/// Everything needed to build a project db a second time.
@@ -308,7 +310,8 @@ pub(crate) fn project_sources(
root: &Path,
output: Option<&Path>,
) -> Vec<(PathBuf, ruff_db::files::File)> {
- db.project()
+ let candidates: Vec<(PathBuf, ruff_db::files::File)> = db
+ .project()
.files(db)
.into_iter()
.filter(|file| {
@@ -328,9 +331,75 @@ pub(crate) fn project_sources(
// this build is about to read, and reading those instead would build the
// project into itself, one directory deeper each time
.filter(|(path, _)| output.is_none_or(|output| !path.starts_with(output)))
+ .collect();
+ // and nor is any *other* build's output. `--out` can name any directory, and
+ // a project can have several, so the one this run was given is not the only
+ // one that has to be turned away
+ let mut known = HashMap::new();
+ candidates
+ .into_iter()
+ .filter(|(path, _)| !inside_build_output(db.system(), path, root, &mut known))
.collect()
}
+/// Whether `path` sits inside a tree that some build already wrote.
+///
+/// A build output is self-describing: it holds a `.by-manifest` naming everything
+/// that build put there. That marker is what identifies it, rather than the
+/// directory's name. [`NON_SOURCE_DIRS`] already turns away the two default names,
+/// but `--out` can say anything, and only the tree this run was *given* is known
+/// to be an output from the arguments — a project that builds into two of them has
+/// to have the other recognised on sight.
+///
+/// It has to be turned away because the tree holds a *copy* of the project: every
+/// hand-written `.py`, and every `.by` too when `build.sources` is on. Reading one
+/// back means each of those modules is claimed twice — once where it was written
+/// and once where it was copied — and the build stops, telling the author that two
+/// files they never put side by side would compile to the same module.
+///
+/// The project root is never itself an output, whatever it holds: a `.by-manifest`
+/// left at the root would otherwise empty the project of every file it has.
+fn inside_build_output(
+ system: &dyn System,
+ path: &Path,
+ root: &Path,
+ known: &mut HashMap,
+) -> bool {
+ let Some(parent) = path.parent() else {
+ return false;
+ };
+ let mut current = parent;
+ while current != root && current.starts_with(root) {
+ let is_output = match known.get(current) {
+ Some(answer) => *answer,
+ None => {
+ let answer = holds_manifest(system, current);
+ known.insert(current.to_path_buf(), answer);
+ answer
+ }
+ };
+ if is_output {
+ return true;
+ }
+ match current.parent() {
+ Some(next) => current = next,
+ None => return false,
+ }
+ }
+ false
+}
+
+/// Whether `directory` carries the manifest that marks it a build output.
+///
+/// Asked of the db's [`System`] rather than of `std::fs`, so that it is the same
+/// file system every other question in a build is asked — the language server's
+/// re-stage runs this on a warm db, and an in-memory test system has to be able
+/// to answer it.
+pub(crate) fn holds_manifest(system: &dyn System, directory: &Path) -> bool {
+ SystemPath::from_std_path(directory)
+ .is_some_and(|path| system.is_file(&path.join(crate::staging::MANIFEST_FILENAME)))
+}
+
/// The project's first-party module roots, longest first, as absolute paths.
///
/// These are the directories a module name is resolved against — for a
@@ -351,9 +420,60 @@ pub fn module_roots(db: &ProjectDatabase, cwd: &Path) -> Vec {
#[cfg(test)]
mod tests {
- use super::is_hidden_within;
+ use super::{inside_build_output, is_hidden_within};
+ use ruff_db::system::{OsSystem, SystemPath};
+ use std::collections::HashMap;
use std::path::Path;
+ /// `--out` can name any directory and a project can hold several, so a build
+ /// output is recognised by the manifest it carries rather than by its name.
+ #[test]
+ fn a_directory_holding_a_manifest_is_a_build_output() {
+ let directory = tempfile::tempdir().expect("tempdir");
+ let root = directory.path();
+ let output = root.join("anything");
+ std::fs::create_dir_all(output.join("pkg")).expect("create");
+ std::fs::write(output.join(".by-manifest"), "").expect("write");
+ let plain = root.join("src");
+ std::fs::create_dir_all(&plain).expect("create");
+
+ let system =
+ OsSystem::new(SystemPath::from_std_path(root).expect("the temp directory is utf-8"));
+ let mut known = HashMap::new();
+ assert!(inside_build_output(
+ &system,
+ &output.join("a.by"),
+ root,
+ &mut known
+ ));
+ assert!(
+ inside_build_output(&system, &output.join("pkg").join("a.by"), root, &mut known),
+ "a file deeper inside the output is inside it too"
+ );
+ assert!(
+ !inside_build_output(&system, &plain.join("a.by"), root, &mut known),
+ "a directory with no manifest is ordinary source"
+ );
+ }
+
+ /// A manifest at the root would otherwise empty the project of every file.
+ #[test]
+ fn the_project_root_is_never_itself_an_output() {
+ let directory = tempfile::tempdir().expect("tempdir");
+ let root = directory.path();
+ std::fs::write(root.join(".by-manifest"), "").expect("write");
+
+ let system =
+ OsSystem::new(SystemPath::from_std_path(root).expect("the temp directory is utf-8"));
+ let mut known = HashMap::new();
+ assert!(!inside_build_output(
+ &system,
+ &root.join("a.by"),
+ root,
+ &mut known
+ ));
+ }
+
#[test]
fn a_hidden_directory_is_not_project_source() {
let root = Path::new("/p");
diff --git a/crates/by_stage/src/staging.rs b/crates/by_stage/src/staging.rs
index 62c83a0c46..ea08eee095 100644
--- a/crates/by_stage/src/staging.rs
+++ b/crates/by_stage/src/staging.rs
@@ -20,13 +20,16 @@ use anyhow::Context;
/// until one shadows a module that moved. The manifest is what makes the output a
/// mirror rather than a pile: what the previous build wrote and this one did not
/// is deleted.
-const MANIFEST_FILENAME: &str = ".by-manifest";
+pub(crate) const MANIFEST_FILENAME: &str = ".by-manifest";
/// An output tree being written.
pub struct Staging {
out: PathBuf,
/// relative destination -> the source it came from, for collision reporting
written: BTreeMap>,
+ /// destinations another writer produced, held apart from `written` so a
+ /// source that would land on one is reported rather than overwriting it
+ recorded: BTreeSet,
}
impl Staging {
@@ -34,6 +37,7 @@ impl Staging {
Self {
out: out.to_path_buf(),
written: BTreeMap::new(),
+ recorded: BTreeSet::new(),
}
}
@@ -78,6 +82,32 @@ impl Staging {
.with_context(|| format!("could not write {}", destination.display()))
}
+ /// Record a file another writer put into the tree at `relative`.
+ ///
+ /// `by compile` writes its extensions through `by_build`, which lays them out
+ /// itself rather than through a staging. They still belong in the manifest:
+ /// an extension left behind by a module that has since been deleted keeps
+ /// importing, and — because python's finder prefers an extension to source —
+ /// it goes on shadowing the `.py` a later `by build` writes in its place.
+ ///
+ /// A recorded path is also claimed, so a file the project keeps where an
+ /// artifact lands is reported rather than copied over it. The check runs in
+ /// both directions for whichever order the caller happens to use; today
+ /// `compile` records every artifact before it stages anything, so in practice
+ /// it is the staging side that reports.
+ pub fn record(&mut self, relative: &Path) -> anyhow::Result<()> {
+ if let Some((previous, source)) = self.written.get_key_value(relative) {
+ anyhow::bail!(
+ "the compiler wrote an artifact to `{}`, where {} is already staged \
+ — one of them has to be renamed",
+ previous.display(),
+ claimant(source.as_deref()),
+ );
+ }
+ self.recorded.insert(relative.to_path_buf());
+ Ok(())
+ }
+
/// Copy `source` to `relative` verbatim.
pub(crate) fn copy(&mut self, relative: &Path, source: &Path) -> anyhow::Result<()> {
self.claim(relative, Some(source))?;
@@ -94,17 +124,27 @@ impl Staging {
}
fn claim(&mut self, relative: &Path, source: Option<&Path>) -> anyhow::Result<()> {
+ // a project that keeps a file of its own where an artifact lands — a
+ // hand-written `main.c` beside `main.by`, say. copying over it would
+ // leave a tree whose generated half is somebody else's file, and say
+ // nothing about it
+ if self.recorded.contains(relative) {
+ anyhow::bail!(
+ "{} would be carried over to `{}`, where the compiler \
+ already wrote an artifact of that name — rename one of them, or \
+ keep the file out of the build with `build.exclude`",
+ claimant(source),
+ relative.display(),
+ );
+ }
if let Some((previous, Some(previous_source))) = self.written.get_key_value(relative)
&& Some(previous_source.as_path()) != source
{
- let claimant = source.map_or_else(
- || "the build".to_owned(),
- |source| format!("`{}`", source.display()),
- );
anyhow::bail!(
- "`{}` and {claimant} both build to `{}` — \
+ "`{}` and {} both build to `{}` — \
they are the same module, so one of them has to be renamed",
previous_source.display(),
+ claimant(source),
previous.display(),
);
}
@@ -118,7 +158,12 @@ impl Staging {
pub fn finish(self) -> anyhow::Result<()> {
let manifest = self.out.join(MANIFEST_FILENAME);
let previous = read_manifest(&manifest);
- let current: BTreeSet<&Path> = self.written.keys().map(PathBuf::as_path).collect();
+ let current: BTreeSet<&Path> = self
+ .written
+ .keys()
+ .chain(self.recorded.iter())
+ .map(PathBuf::as_path)
+ .collect();
let mut emptied: BTreeSet = BTreeSet::new();
for stale in &previous {
@@ -156,6 +201,14 @@ impl Staging {
}
}
+/// How a collision names the thing that wanted the destination.
+fn claimant(source: Option<&Path>) -> String {
+ source.map_or_else(
+ || "the build".to_owned(),
+ |source| format!("`{}`", source.display()),
+ )
+}
+
fn create_parent(path: &Path) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
diff --git a/crates/by_stage/src/verbatim.rs b/crates/by_stage/src/verbatim.rs
index d87ea88308..cab6374dcd 100644
--- a/crates/by_stage/src/verbatim.rs
+++ b/crates/by_stage/src/verbatim.rs
@@ -16,11 +16,12 @@
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
+use ruff_db::Db as _;
use ruff_db::system::SystemPath;
use ty_project::{Db, ProjectDatabase};
use walkdir::WalkDir;
-use crate::project::may_hold_build_content;
+use crate::project::{holds_manifest, may_hold_build_content};
use crate::staging::{Staging, relative_destination};
/// One file carried into the output unchanged.
@@ -56,6 +57,19 @@ fn verbatim_files(
if entry.path() == out {
return false;
}
+ // nor is any *other* build's output. a project can have several —
+ // `by build --out one` beside `by build --out two` — and each holds a
+ // copy of every file this walk carries, so one that was not turned
+ // away would be copied into the next wholesale, a tree deep in a
+ // tree. it is recognised by the manifest it carries rather than by
+ // its name, because `--out` can say anything. the root is exempt:
+ // a manifest there would otherwise carry nothing over at all
+ if entry.file_type().is_dir()
+ && entry.path() != root
+ && holds_manifest(db.system(), entry.path())
+ {
+ return false;
+ }
if !may_hold_build_content(entry) {
return false;
}
diff --git a/crates/by_transforms/src/reverse_transforms/identity_swap.rs b/crates/by_transforms/src/reverse_transforms/identity_swap.rs
index d3616fec8c..23b9177827 100644
--- a/crates/by_transforms/src/reverse_transforms/identity_swap.rs
+++ b/crates/by_transforms/src/reverse_transforms/identity_swap.rs
@@ -7,12 +7,23 @@
//! basedpython's `is` is the instance check, so a python identity comparison
//! round-trips to `===` / `!==` and an `isinstance` call round-trips to `is`
//!
-//! a literal right-hand side is left alone, mirroring the forward transform's
-//! own literal guard: `x is None` is identity in both languages, so rewriting
-//! it to `x === None` would churn idiomatic source for no change in meaning.
-//! this is why the operator must be rewritten rather than skipped — leaving a
-//! python `is not` in place re-reads it as `not isinstance(...)` on the way
-//! back out
+//! `x is None` is left alone: basedpython reads it as a type test against the
+//! type `None`, which is the same runtime check, so rewriting it to
+//! `x === None` would churn idiomatic source for no change in meaning. Every
+//! *other* literal must still be rewritten — `x is 3` is identity in python but
+//! a test for the type `Literal[3]` in basedpython, and those differ for an int
+//! outside the interned range.
+//!
+//! This is also why the operator has to be rewritten rather than skipped:
+//! leaving a python `is not` in place re-reads it as a type test on the way
+//! back out.
+//!
+//! An `isinstance` call is only rewritten when its second argument is
+//! something a type expression can say. A tuple of classes is `isinstance`'s
+//! own spelling for a union and reads as a *tuple type* in a type expression,
+//! so it is written back out as `A | B`; anything else — `type(y)`, a variable
+//! holding classinfo — keeps the call it was, which basedpython runs just as
+//! python does
use ruff_diagnostics::{Edit, Fix};
use ruff_python_ast::visitor::{Visitor, walk_expr, walk_stmt};
@@ -45,9 +56,9 @@ impl<'src> IdentitySwapReverse<'src> {
for (op, rhs) in c.ops.iter().zip(c.comparators.iter()) {
let rhs_start = rhs.range().start();
let between = &self.source[usize::from(lhs_end)..usize::from(rhs_start)];
- // a literal rhs means identity in basedpython too, so the forward
- // transform leaves it as `is` — mirror that and don't churn it
- if !rhs.is_literal_expr() {
+ // `is None` reads the same in both languages; every other literal
+ // rhs changes meaning, so it takes the identity spelling
+ if !rhs.is_none_literal_expr() {
let words: &[&str] = match op {
CmpOp::Is => &["is"],
CmpOp::IsNot => &["is", "not"],
@@ -123,13 +134,13 @@ impl<'src> IdentitySwapReverse<'src> {
let Some((x, y)) = isinstance_operands(call) else {
return;
};
- let (x_src, y_src) = (
- self.src(x.range()).to_owned(),
- self.src(y.range()).to_owned(),
- );
+ let Some(target) = self.type_expression_target(y) else {
+ return;
+ };
+ let x_src = self.src(x.range()).to_owned();
self.folded_into_not.push(call.range());
self.edits.push(Fix::safe_edit(Edit::range_replacement(
- format!("{x_src} is not {y_src}"),
+ format!("{x_src} is not {target}"),
unary.range(),
)));
}
@@ -141,15 +152,45 @@ impl<'src> IdentitySwapReverse<'src> {
let Some((x, y)) = isinstance_operands(call) else {
return;
};
- let (x_src, y_src) = (
- self.src(x.range()).to_owned(),
- self.src(y.range()).to_owned(),
- );
+ let Some(target) = self.type_expression_target(y) else {
+ return;
+ };
+ let x_src = self.src(x.range()).to_owned();
self.edits.push(Fix::safe_edit(Edit::range_replacement(
- format!("{x_src} is {y_src}"),
+ format!("{x_src} is {target}"),
call.range(),
)));
}
+
+ /// how an `isinstance` classinfo argument is written as the type expression
+ /// a type test takes, or `None` when its shape is not one a type expression
+ /// has and the call has to stay a call.
+ ///
+ /// A tuple is `isinstance`'s spelling for "any of these", which a type
+ /// expression spells `A | B` — writing the tuple back out would name the
+ /// *tuple type* instead, a different test entirely.
+ fn type_expression_target(&self, classinfo: &Expr) -> Option {
+ match classinfo {
+ Expr::Name(_) | Expr::Attribute(_) | Expr::Subscript(_) => {
+ Some(self.src(classinfo.range()).to_owned())
+ }
+ Expr::BinOp(binop) if binop.op == ruff_python_ast::Operator::BitOr => {
+ Some(self.src(classinfo.range()).to_owned())
+ }
+ Expr::Tuple(tuple) => {
+ let arms: Option> = tuple
+ .elts
+ .iter()
+ .map(|element| self.type_expression_target(element))
+ .collect();
+ let arms = arms?;
+ // an empty `isinstance(x, ())` is always `False`; there is no
+ // union to write for it
+ (!arms.is_empty()).then(|| arms.join(" | "))
+ }
+ _ => None,
+ }
+ }
}
/// the two operands of an `isinstance(x, y)` call. anything else — a keyword
@@ -328,14 +369,43 @@ mod tests {
);
}
- /// a literal rhs is identity in both languages, so the forward transform
- /// leaves it as `is` and the reverse must not churn it
#[test]
- fn literal_comparisons_left_alone() {
+ fn none_comparisons_left_alone() {
+ // basedpython reads `is None` as a test for the type `None`, which is
+ // the same runtime check python's identity performs
check("y = a is None\n", "y = a is None\n");
check("y = a is not None\n", "y = a is not None\n");
- check("y = a is True\n", "y = a is True\n");
- check("y = a is not 1\n", "y = a is not 1\n");
+ }
+
+ #[test]
+ fn a_classinfo_tuple_becomes_a_union() {
+ // `isinstance`'s tuple means "any of these"; a type expression spells
+ // that `A | B`, and a tuple there is the tuple *type*
+ check("y = isinstance(a, (int, str))\n", "y = a is int | str\n");
+ check(
+ "y = not isinstance(a, (int, (str, bytes)))\n",
+ "y = a is not int | str | bytes\n",
+ );
+ }
+
+ #[test]
+ fn a_classinfo_no_type_expression_can_say_keeps_the_call() {
+ // a call is not a type expression, and neither is the empty tuple —
+ // whose test is always `False` and has no union to write
+ check(
+ "y = isinstance(a, type(a))\n",
+ "y = isinstance(a, type(a))\n",
+ );
+ check("y = isinstance(a, ())\n", "y = isinstance(a, ())\n");
+ }
+
+ #[test]
+ fn other_literal_comparisons_take_the_identity_operator() {
+ // `a is 1` is identity in python but a test for the type `Literal[1]`
+ // in basedpython, and those part company for an int outside the
+ // interned range — so the operator has to be written back out
+ check("y = a is True\n", "y = a === True\n");
+ check("y = a is not 1\n", "y = a !== 1\n");
}
/// the comment case cannot round-trip byte for byte — the operator's layout
diff --git a/crates/by_transforms/src/transforms/ast_driver.rs b/crates/by_transforms/src/transforms/ast_driver.rs
index 9766d46ea2..add7ce5cc3 100644
--- a/crates/by_transforms/src/transforms/ast_driver.rs
+++ b/crates/by_transforms/src/transforms/ast_driver.rs
@@ -111,6 +111,17 @@ pub(crate) struct PassContext {
/// statement materialized inside an expression is a syntax error, not a
/// composition
pub(crate) statement_inserts: Vec<(TextSize, Vec)>,
+ /// Sub-statement edits standing in for a construct the same pass re-emits
+ /// somewhere else: the `_MISSING` a mutable default leaves in the
+ /// signature, whose written value the body guard evaluates instead.
+ ///
+ /// Identical to a [`template_edits`](Self::template_edits) entry except
+ /// that it leads every other edit at its span. The others are rewrites of
+ /// the construct, and the construct is no longer here — they materialize
+ /// where the pass re-emits it. Without this they would be ordered against
+ /// the substitution by shape alone, which cannot tell two substitutions of
+ /// one span apart
+ pub(crate) relocating_edits: Vec<(TextRange, Vec)>,
/// Hard transpile errors a pass surfaced — abort the pipeline rather
/// than emit partial / invalid output. Each entry is a human-readable
/// message suitable for showing the user
@@ -211,6 +222,11 @@ enum SubPatch {
/// [`PassContext::statement_inserts`]. Materializes exactly like
/// [`SubPatch::Template`]; the difference is only in what may claim it
Statement(Vec),
+ /// a template standing in for a construct the same pass re-emits somewhere
+ /// else — see [`PassContext::relocating_edits`]. Materializes exactly like
+ /// [`SubPatch::Template`]; the difference is only that it leads every other
+ /// edit at its span
+ Relocating(Vec),
}
/// The sub-edits a template materializes, in position order: those nested in
@@ -325,7 +341,9 @@ fn apply_within(
out.push_source(source, cursor, s);
match &all[idx].2 {
SubPatch::Text(t) => out.push_generated(t, s),
- SubPatch::Template(frags) | SubPatch::Statement(frags) => {
+ SubPatch::Template(frags)
+ | SubPatch::Statement(frags)
+ | SubPatch::Relocating(frags) => {
let inner: Vec = contained[k + 1..]
.iter()
.copied()
@@ -637,9 +655,9 @@ pub(crate) fn run_against_source<'a>(
// keep their source bytes and the lowerings inside them compose
&destructure_pass,
// text-edit-emitting passes first (read source ranges).
- // type_is must run before identity_swap so type-position `a is T`
- // wins the first-wins overlap dedup over identity_swap's
- // value-context `isinstance(a, T)` rewrite
+ // type_is rewrites a `-> a is T` return guard into `TypeIs[T]`, which
+ // claims the whole guard; `parametric_is` would otherwise lower the
+ // same `is` pair inside it
&type_is_pass,
// `from x export y` → `from x import y as y`: two source edits inside
// an import statement, independent of every other pass
@@ -1078,6 +1096,13 @@ pub(crate) fn run_against_source<'a>(
let at = usize::from(at);
(at, at, SubPatch::Statement(frags))
}))
+ .chain(ctx.relocating_edits.into_iter().map(|(r, frags)| {
+ (
+ usize::from(r.start()),
+ usize::from(r.end()),
+ SubPatch::Relocating(frags),
+ )
+ }))
.collect();
// start asc. tie-break by edit shape:
// 1. zero-width insertions first — they don't consume bytes, so any
@@ -1087,30 +1112,34 @@ pub(crate) fn run_against_source<'a>(
// 2. then wider replacements before narrower ones — so a wider edit
// wins over (or, for templates, absorbs) a narrow one nested inside
// it
- // 3. at one identical span, a *substitution* — plain text, or a template
- // with no `Src` passthrough — ahead of a *rewrite*, a template that
- // re-emits part of the span. a substitution says the construct does not
- // appear here at all, which a rewrite of it cannot outrank: the pass
- // that substitutes may be relocating the construct (default
- // re-evaluation moves a parameter default into the body), and the
- // rewrite still materializes wherever the passthrough re-emits it
+ // 3. at one identical span, a *relocating* edit leads: it says the
+ // construct has moved, and the pass that moved it re-emits the span
+ // itself, so every other edit there materializes at the new home
+ // 4. then a *substitution* — plain text, or a template with no `Src`
+ // passthrough — ahead of a *rewrite*, a template that re-emits part of
+ // the span. a substitution says the construct does not appear here at
+ // all, which a rewrite of it cannot outrank
sub_edits.sort_by(|a, b| {
let priority = |e: &(usize, usize, SubPatch)| {
let rewrites = i64::from(match &e.2 {
SubPatch::Text(_) => false,
- SubPatch::Template(frags) | SubPatch::Statement(frags) => {
+ SubPatch::Template(frags)
+ | SubPatch::Statement(frags)
+ | SubPatch::Relocating(frags) => {
frags.iter().any(|frag| matches!(frag, Fragment::Src(_)))
}
});
let statement = i64::from(!matches!(e.2, SubPatch::Statement(_)));
+ let relocating = i64::from(!matches!(e.2, SubPatch::Relocating(_)));
// (start, is_replacement_not_insertion, statement-insert-first,
- // neg_end-for-wider-first, substitution-before-rewrite)
+ // neg_end-for-wider-first, relocating-first,
+ // substitution-before-rewrite)
if e.1 == e.0 {
- (e.0, 0i64, statement, 0i64, rewrites) // insertion
+ (e.0, 0i64, statement, 0i64, relocating, rewrites) // insertion
} else {
#[allow(clippy::cast_possible_wrap)]
let neg_end = -(e.1 as i64);
- (e.0, 1i64, statement, neg_end, rewrites)
+ (e.0, 1i64, statement, neg_end, relocating, rewrites)
}
};
priority(a).cmp(&priority(b))
@@ -1134,7 +1163,7 @@ pub(crate) fn run_against_source<'a>(
}
let is_template = matches!(
sub_edits[i].2,
- SubPatch::Template(_) | SubPatch::Statement(_)
+ SubPatch::Template(_) | SubPatch::Statement(_) | SubPatch::Relocating(_)
);
for (m, edit) in sub_edits.iter().enumerate() {
if m == i || claimed[m] {
@@ -1182,7 +1211,9 @@ pub(crate) fn run_against_source<'a>(
if !claimed[j] {
match &sub_edits[j].2 {
SubPatch::Text(t) => combined.push_generated(t, start),
- SubPatch::Template(frags) | SubPatch::Statement(frags) => {
+ SubPatch::Template(frags)
+ | SubPatch::Statement(frags)
+ | SubPatch::Relocating(frags) => {
let contained = template_claimees(frags, &sub_edits, &claimed, j, None);
materialize_fragments(
&mut combined,
@@ -1204,7 +1235,9 @@ pub(crate) fn run_against_source<'a>(
let repl = match &sub_edits[i].2 {
// a plain-text replacement wins over anything inside it
SubPatch::Text(t) => Replacement::generated(t, start),
- SubPatch::Template(frags) | SubPatch::Statement(frags) => {
+ SubPatch::Template(frags)
+ | SubPatch::Statement(frags)
+ | SubPatch::Relocating(frags) => {
// the claimees nested in this span materialize inside the
// template's `Src` passthrough fragments
let contained =
diff --git a/crates/by_transforms/src/transforms/checked_cast.rs b/crates/by_transforms/src/transforms/checked_cast.rs
index 24bb5c43d5..f26717d3af 100644
--- a/crates/by_transforms/src/transforms/checked_cast.rs
+++ b/crates/by_transforms/src/transforms/checked_cast.rs
@@ -20,12 +20,10 @@
//! still compose and it is evaluated exactly once.
//!
//! How a *checked* form validates is decided by the **same engine that decides
-//! `x is T`** — [`build_predicate`], via [`TypeInfo::parametric_cast_plan`]. The
-//! two forms ask one question (does this value satisfy this specialization at
-//! runtime) and differ only in two parameters:
+//! `x is T`** — [`build_predicate`], via [`TypeInfo::parametric_is_plan`]. The
+//! two forms ask one question (does this value satisfy this type at runtime)
+//! over the same kind of expression, and differ in one parameter:
//!
-//! - [`TargetPosition::Type`], because a cast's target is a *type* expression
-//! while an `is`-rhs is a value expression, so ty infers it differently;
//! - [`ProbeStrictness::Lenient`], because a cast is an assertion: arguments the
//! runtime cannot see are not held against the value, keeping
//! `[1, 2] cast! list[int]` legal. An `is`-test is strict — a `True` narrows,
@@ -52,9 +50,7 @@ use ruff_python_ast::{CastKind, Expr, Stmt};
use ruff_text_size::{Ranged, TextRange};
use super::ast_driver::{Fragment, PassContext, TypeAwarePass};
-use super::parametric_is::{
- PARAMETRIC_IS_RUNTIME, PROTOCOL_IS_RUNTIME, ProbeStrictness, TargetPosition, build_predicate,
-};
+use super::parametric_is::{PredicateRuntime, ProbeStrictness, build_predicate};
use crate::type_info::{CastCheck, SoundnessCheck, TypeInfo};
/// the lambda parameter a predicate-form cast binds its value to, so the value
@@ -151,9 +147,8 @@ struct CastLower<'a> {
types: &'a dyn TypeInfo,
edits: Vec<(TextRange, Vec)>,
used: BTreeSet,
- needs_parametric: bool,
- needs_protocol: bool,
- needs_conformance: bool,
+ /// the runtime helpers the emitted predicates call
+ runtimes: BTreeSet,
}
impl<'a> CastLower<'a> {
@@ -162,9 +157,7 @@ impl<'a> CastLower<'a> {
types,
edits: Vec::new(),
used: BTreeSet::new(),
- needs_parametric: false,
- needs_protocol: false,
- needs_conformance: false,
+ runtimes: BTreeSet::new(),
}
}
@@ -189,13 +182,10 @@ impl<'a> CastLower<'a> {
&value_ref,
value_arg,
type_arg,
- TargetPosition::Type,
ProbeStrictness::Lenient,
);
if !needs.all_plain && !needs.erased {
- self.needs_parametric |= needs.parametric_runtime;
- self.needs_protocol |= needs.protocol_runtime;
- self.needs_conformance |= needs.conformance_runtime;
+ self.runtimes.extend(PredicateRuntime::used(&needs));
let mut fragments = vec![Fragment::Lit(format!("lambda {CAST_VALUE_PARAM}: "))];
fragments.extend(predicate);
return (helper.as_predicate(), fragments);
@@ -292,15 +282,8 @@ impl TypeAwarePass for CheckedCastPass {
}
// `_parametric_is` / `_by_protocol_is` must precede the predicates that
// call them
- if inner.needs_parametric {
- ctx.required_imports.push(PARAMETRIC_IS_RUNTIME.to_owned());
- }
- if inner.needs_protocol {
- ctx.required_imports.push(PROTOCOL_IS_RUNTIME.to_owned());
- }
- if inner.needs_conformance {
- ctx.required_imports
- .push(super::conformance::WITNESS_RUNTIME.to_owned());
+ for runtime in inner.runtimes {
+ ctx.required_imports.push(runtime.source().to_owned());
}
for helper in &inner.used {
ctx.required_imports.push(helper.runtime().to_owned());
@@ -371,9 +354,13 @@ mod tests {
let out = check(
"from typing import Literal\n\ndef f(a: object):\n b = a cast! Literal[\"x\", \"y\"]\n",
);
+ // each arm pins the class as well as the value: python's `1 == True`
+ // would otherwise let a `bool` satisfy `Literal[1]`
assert!(
out.contains(
- "b = _checked_cast_pred(a, lambda _by_cast_value: _by_cast_value in (\"x\", \"y\"))"
+ "b = _checked_cast_pred(a, lambda _by_cast_value: \
+ ((type(_by_cast_value) is str and _by_cast_value == \"x\") or \
+ (type(_by_cast_value) is str and _by_cast_value == \"y\")))"
),
"got:\n{out}"
);
@@ -387,7 +374,9 @@ mod tests {
);
assert!(
out.contains(
- "b = _try_cast_pred(a, lambda _by_cast_value: _by_cast_value in (\"x\", \"y\"))"
+ "b = _try_cast_pred(a, lambda _by_cast_value: \
+ ((type(_by_cast_value) is str and _by_cast_value == \"x\") or \
+ (type(_by_cast_value) is str and _by_cast_value == \"y\")))"
),
"got:\n{out}"
);
@@ -400,7 +389,9 @@ mod tests {
let out =
check("from typing import Literal\n\ndef f(a: object):\n b = a cast? Literal[7]\n");
assert!(
- out.contains("lambda _by_cast_value: _by_cast_value in (7,)"),
+ out.contains(
+ "lambda _by_cast_value: (type(_by_cast_value) is int and _by_cast_value == 7)"
+ ),
"got:\n{out}"
);
}
@@ -411,7 +402,10 @@ mod tests {
"from typing import Literal\n\ndef f(a: object):\n b = a cast? Literal[True] | None\n",
);
assert!(
- out.contains("lambda _by_cast_value: _by_cast_value in (True, None)"),
+ out.contains(
+ "lambda _by_cast_value: ((type(_by_cast_value) is bool and \
+ _by_cast_value == True) or _by_cast_value is None)"
+ ),
"got:\n{out}"
);
}
@@ -488,7 +482,7 @@ mod tests {
fn union_arms_are_decomposed() {
let out = check("def f(a: object):\n b = a cast? list[int] | None\n");
assert!(
- out.contains("b = _try_cast_pred(a, lambda _by_cast_value: _parametric_is_lenient(_by_cast_value, list[int], (0,)) or _by_cast_value is None)"),
+ out.contains("b = _try_cast_pred(a, lambda _by_cast_value: (_parametric_is_lenient(_by_cast_value, list[int], (0,)) or _by_cast_value is None))"),
"got:\n{out}"
);
}
@@ -684,8 +678,8 @@ mod tests {
assert!(
out.contains(
"b = _checked_cast_pred(a, lambda _by_cast_value: \
- _parametric_is_lenient(_by_cast_value, A[int], (0,)) \
- or isinstance(_by_cast_value, str))"
+ (_parametric_is_lenient(_by_cast_value, A[int], (0,)) \
+ or isinstance(_by_cast_value, str)))"
),
"each arm lowered by its own kind: {out}"
);
diff --git a/crates/by_transforms/src/transforms/coalesce_chain.rs b/crates/by_transforms/src/transforms/coalesce_chain.rs
index 25c1b5ea4d..d7c6898b02 100644
--- a/crates/by_transforms/src/transforms/coalesce_chain.rs
+++ b/crates/by_transforms/src/transforms/coalesce_chain.rs
@@ -384,6 +384,9 @@ fn compare_is_not_none(left: Expr) -> Expr {
range: TextRange::default(),
left: Box::new(left),
ops: Box::new([CmpOp::IsNot]),
+ // python identity against `None`, written `is not` — the lowering emits
+ // python, so it never wants basedpython's `!==` spelling
+ identity_ops: None,
comparators: Box::new([Expr::NoneLiteral(ExprNoneLiteral {
node_index: AtomicNodeIndex::NONE,
range: TextRange::default(),
diff --git a/crates/by_transforms/src/transforms/context_sensitive.rs b/crates/by_transforms/src/transforms/context_sensitive.rs
index dcfc4b656e..d08fc82175 100644
--- a/crates/by_transforms/src/transforms/context_sensitive.rs
+++ b/crates/by_transforms/src/transforms/context_sensitive.rs
@@ -209,9 +209,10 @@ mod tests {
indoc! {"
from __future__ import annotations
from dataclasses import dataclass
- from typing import final
+ from typing import final, ClassVar
class Shape:
- pass
+ Circle: ClassVar[type[_Shape_Circle]]
+ Empty: ClassVar[_Shape_Empty]
@final
@dataclass(frozen=True, slots=True)
@@ -308,9 +309,10 @@ mod tests {
indoc! {"
from __future__ import annotations
from dataclasses import dataclass
- from typing import final
+ from typing import final, ClassVar
class Shape:
- pass
+ Circle: ClassVar[type[_Shape_Circle]]
+ Empty: ClassVar[_Shape_Empty]
@final
@dataclass(frozen=True, slots=True)
diff --git a/crates/by_transforms/src/transforms/enums.rs b/crates/by_transforms/src/transforms/enums.rs
index 08baf667da..635a54f835 100644
--- a/crates/by_transforms/src/transforms/enums.rs
+++ b/crates/by_transforms/src/transforms/enums.rs
@@ -414,6 +414,26 @@ fn emit_sealed_hierarchy(
.unwrap_or_default();
out.push_gen(&format!("{vis}class {name}{params}:\n"));
+ // each variant is *declared* in the body and *assigned* below it. the
+ // assignment is what the runtime needs; the declaration is what lets a
+ // reader — and the checker — see that `Shape.Point` is a value and
+ // `Shape.Circle` a class, which an assignment from outside the body does
+ // not say. the annotations are strings (the lowering emits `from __future__
+ // import annotations`), so naming a class defined further down costs
+ // nothing at run time
+ if !variants.is_empty() {
+ imports.add("typing", "ClassVar");
+ }
+ for variant in variants {
+ let variant_class = format!("_{name}_{}", variant.name);
+ let declared = match variant.kind {
+ // a payload variant is the class its call constructs
+ VariantKind::Tuple => format!("type[{variant_class}]"),
+ // a unit variant is the one instance of its class
+ VariantKind::Unit => variant_class,
+ };
+ out.push_gen(&format!(" {}: ClassVar[{declared}]\n", variant.name));
+ }
// ordinary members (methods, classmethods, constants) — copied verbatim,
// already indented under the enum in the source. they may refer to variants
// (`A.Foo`) freely: the references resolve lazily at call time, by which
@@ -421,7 +441,7 @@ fn emit_sealed_hierarchy(
for member in members {
emit_member(out, source, member);
}
- if members.is_empty() {
+ if members.is_empty() && variants.is_empty() {
out.push_gen(" pass\n");
}
// variant subclasses, emitted at module level and attached to the enum
@@ -722,9 +742,9 @@ mod tests {
indoc! {"
from __future__ import annotations
from dataclasses import dataclass
- from typing import final
+ from typing import final, ClassVar
class _Shape:
- pass
+ Circle: ClassVar[type[_Shape_Circle]]
@final
@dataclass(frozen=True, slots=True)
@@ -751,7 +771,9 @@ mod tests {
"},
indoc! {"
from __future__ import annotations
+ from typing import ClassVar
class E:
+ A: ClassVar[_E_A]
MAX: int = 10
class _E_A(E):
@@ -776,9 +798,10 @@ mod tests {
indoc! {"
from __future__ import annotations
from dataclasses import dataclass
- from typing import final
+ from typing import final, ClassVar
class Shape:
- pass
+ Circle: ClassVar[type[_Shape_Circle]]
+ Point: ClassVar[_Shape_Point]
@final
@dataclass(frozen=True, slots=True)
@@ -812,9 +835,10 @@ mod tests {
indoc! {"
from __future__ import annotations
from dataclasses import dataclass
- from typing import final
+ from typing import final, ClassVar
class Shape:
- pass
+ Rectangle: ClassVar[type[_Shape_Rectangle]]
+ Polygon: ClassVar[type[_Shape_Polygon]]
@final
@dataclass(frozen=True, slots=True)
@@ -848,9 +872,10 @@ mod tests {
indoc! {"
from __future__ import annotations
from dataclasses import dataclass
- from typing import final
+ from typing import final, ClassVar
class Value:
- pass
+ Pair: ClassVar[type[_Value_Pair]]
+ Nothing: ClassVar[_Value_Nothing]
@final
@dataclass(frozen=True, slots=True)
@@ -888,8 +913,9 @@ mod tests {
from __future__ import annotations
from ty_extensions import JustFloat
from dataclasses import dataclass
- from typing import final
+ from typing import final, ClassVar
class Shape:
+ Circle: ClassVar[type[_Shape_Circle]]
def area(self) -> JustFloat:
return 0.0
@@ -940,9 +966,9 @@ mod tests {
indoc! {"
from __future__ import annotations
from dataclasses import dataclass
- from typing import final
+ from typing import final, ClassVar
class Shape:
- pass
+ Circle: ClassVar[type[_Shape_Circle]]
@final
@dataclass(frozen=True)
@@ -969,9 +995,10 @@ mod tests {
indoc! {"
from __future__ import annotations
from dataclasses import dataclass
- from typing import final
+ from typing import final, ClassVar
class E:
- pass
+ A: ClassVar[type[_E_A]]
+ B: ClassVar[_E_B]
@final
@dataclass(frozen=True, slots=True)
@@ -1008,11 +1035,12 @@ mod tests {
from __future__ import annotations
from typing import TypeVar, Generic
from dataclasses import dataclass
- from typing import final
+ from typing import final, ClassVar
_T = TypeVar(\"_T\")
_E = TypeVar(\"_E\")
class Result(Generic[_T, _E]):
- pass
+ Ok: ClassVar[type[_Result_Ok]]
+ Err: ClassVar[type[_Result_Err]]
@final
@dataclass(frozen=True, slots=True)
@@ -1045,9 +1073,10 @@ mod tests {
indoc! {"
from __future__ import annotations
from dataclasses import dataclass
- from typing import final
+ from typing import final, ClassVar
class Result[T, E]:
- pass
+ Ok: ClassVar[type[_Result_Ok]]
+ Err: ClassVar[type[_Result_Err]]
@final
@dataclass(frozen=True, slots=True)
@@ -1083,9 +1112,10 @@ mod tests {
indoc! {"
from __future__ import annotations
from dataclasses import dataclass
- from typing import final
+ from typing import final, ClassVar
class Tree[T]:
- pass
+ Leaf: ClassVar[_Tree_Leaf]
+ Node: ClassVar[type[_Tree_Node]]
class _Tree_Leaf(Tree):
__slots__ = ()
diff --git a/crates/by_transforms/src/transforms/identity_swap.rs b/crates/by_transforms/src/transforms/identity_swap.rs
index e815ae55bb..4501643eb7 100644
--- a/crates/by_transforms/src/transforms/identity_swap.rs
+++ b/crates/by_transforms/src/transforms/identity_swap.rs
@@ -1,24 +1,18 @@
-//! `===` / `!==` are real python identity; `is` / `is not` mean
-//! `isinstance` / `not isinstance`. parser flattens both spellings to
-//! `CmpOp::Is`/`IsNot`, so disambiguation reads the operator text from
-//! source.
+//! `===` / `!==` are real python identity, which python spells `is` / `is not`.
+//! basedpython gives the `is` keyword to the type test instead, and the parser
+//! folds both spellings onto the same [`CmpOp`], recording which one it saw.
+//!
+//! This pass lowers only the identity spelling, by replacing the operator text.
+//! Every type test belongs to [`parametric_is`](super::parametric_is), which
+//! decides its lowering from the target's *type* rather than from the shape the
+//! target was written in.
use ruff_python_ast::visitor::{Visitor, walk_expr, walk_stmt};
-use ruff_python_ast::{
- Arguments, AtomicNodeIndex, CmpOp, Expr, ExprCall, ExprContext, ExprName, ExprUnaryOp,
- ModModule, Operator, Stmt, UnaryOp, name::Name,
-};
+use ruff_python_ast::{CmpOp, Expr, ModModule, Stmt};
+use ruff_python_trivia::{SimpleTokenKind, SimpleTokenizer};
use ruff_text_size::{Ranged, TextRange, TextSize};
-use super::ast_driver::{AstPass, PassContext, render_expr};
-
-/// whether the type-aware `parametric_is` pass owns lowering this `is`-rhs, so
-/// `identity_swap` must leave it alone. it owns a name / attribute / subscript
-/// target (may name a specialization or alias) and a `|` union of them
-fn owned_by_parametric_is(rhs: &Expr) -> bool {
- matches!(rhs, Expr::Name(_) | Expr::Attribute(_) | Expr::Subscript(_))
- || matches!(rhs, Expr::BinOp(binop) if binop.op == Operator::BitOr)
-}
+use super::ast_driver::{AstPass, PassContext};
pub(crate) struct IdentitySwap<'src> {
source: &'src str,
@@ -51,93 +45,64 @@ struct State<'src> {
impl State<'_> {
fn process_compare(&mut self, c: &ruff_python_ast::ExprCompare) {
let mut lhs_end = c.left.range().end();
- let mut lhs: &Expr = c.left.as_ref();
- for (op, rhs) in c.ops.iter().zip(c.comparators.iter()) {
- let rhs_start = rhs.range().start();
- let between = &self.source[usize::from(lhs_end)..usize::from(rhs_start)];
- let trimmed = between.trim();
- match op {
- CmpOp::Is => {
- if trimmed.starts_with("===") {
- if let Some(pos) = between.find("===") {
- let op_start = lhs_end + TextSize::try_from(pos).unwrap();
- let op_range =
- TextRange::new(op_start, op_start + TextSize::from(3u32));
- self.edits.push((op_range, "is".to_owned()));
- }
- } else if trimmed == "is"
- && !rhs.is_literal_expr()
- && !owned_by_parametric_is(rhs)
- {
- let call = isinstance_call(lhs.clone(), rhs.clone(), false);
- let pair_range = TextRange::new(lhs.range().start(), rhs.range().end());
- self.edits.push((pair_range, render_expr(&call)));
- }
+ for (index, rhs) in c.comparators.iter().enumerate() {
+ let gap = TextRange::new(lhs_end, rhs.range().start());
+ if c.is_identity_operator(index) {
+ let replacement = match c.ops.get(index) {
+ Some(CmpOp::Is) => "is",
+ Some(CmpOp::IsNot) => "is not",
+ _ => unreachable!("only `is` / `is not` carry the identity spelling"),
+ };
+ if let Some(range) = identity_operator_range(self.source, gap) {
+ // `===` needs no space around it and `is` does, so an
+ // operator the source wrote tight against its operands
+ // (`a===b`) has to bring its own
+ let before = self.source[..usize::from(range.start())]
+ .ends_with(|c: char| c.is_whitespace());
+ let after = self.source[usize::from(range.end())..]
+ .starts_with(|c: char| c.is_whitespace());
+ let padded = format!(
+ "{}{replacement}{}",
+ if before { "" } else { " " },
+ if after { "" } else { " " },
+ );
+ self.edits.push((range, padded));
}
- CmpOp::IsNot => {
- if trimmed.starts_with("!==") {
- if let Some(pos) = between.find("!==") {
- let op_start = lhs_end + TextSize::try_from(pos).unwrap();
- let op_range =
- TextRange::new(op_start, op_start + TextSize::from(3u32));
- self.edits.push((op_range, "is not".to_owned()));
- }
- } else if !rhs.is_literal_expr() && !owned_by_parametric_is(rhs) {
- let call = isinstance_call(lhs.clone(), rhs.clone(), true);
- let pair_range = TextRange::new(lhs.range().start(), rhs.range().end());
- self.edits.push((pair_range, render_expr(&call)));
- }
- }
- _ => {}
}
lhs_end = rhs.range().end();
- lhs = rhs;
}
}
}
-fn isinstance_call(lhs: Expr, rhs: Expr, negate: bool) -> Expr {
- let call = Expr::Call(ExprCall {
- node_index: AtomicNodeIndex::NONE,
- range_start: ruff_text_size::TextSize::default(),
- func: Box::new(Expr::Name(ExprName {
- node_index: AtomicNodeIndex::NONE,
- range: TextRange::default(),
- id: Name::from("isinstance"),
- ctx: ExprContext::Load,
- })),
- arguments: Arguments {
- node_index: AtomicNodeIndex::NONE,
- range: TextRange::default(),
- args: Box::new([lhs, rhs]),
- keywords: thin_vec::ThinVec::new(),
- },
- cast_kind: None,
- is_string_tag: false,
- });
- if negate {
- Expr::UnaryOp(ExprUnaryOp {
- node_index: AtomicNodeIndex::NONE,
- range: TextRange::default(),
- op: UnaryOp::Not,
- operand: Box::new(call),
- })
- } else {
- call
- }
+/// the source range of the `===` / `!==` written in `gap` — the span between
+/// the two operands it joins.
+///
+/// The operand ranges stop inside any parentheses wrapping them, and a comment
+/// may sit in the gap of a bracketed expression, so the operator is found by
+/// tokenizing rather than by searching for its text.
+fn identity_operator_range(source: &str, gap: TextRange) -> Option {
+ let start = SimpleTokenizer::new(source, gap)
+ .skip_trivia()
+ .find(|token| token.kind() != SimpleTokenKind::RParen)?
+ .start();
+ let rest = &source[usize::from(start)..];
+ ["===", "!=="]
+ .into_iter()
+ .find(|symbol| rest.starts_with(symbol))
+ .map(|_| TextRange::at(start, TextSize::from(3u32)))
}
impl<'ast> Visitor<'ast> for State<'_> {
+ fn visit_stmt(&mut self, stmt: &'ast Stmt) {
+ walk_stmt(self, stmt);
+ }
+
fn visit_expr(&mut self, expr: &'ast Expr) {
- if let Expr::Compare(c) = expr {
- self.process_compare(c);
+ if let Expr::Compare(compare) = expr {
+ self.process_compare(compare);
}
walk_expr(self, expr);
}
-
- fn visit_stmt(&mut self, stmt: &'ast Stmt) {
- walk_stmt(self, stmt);
- }
}
#[cfg(test)]
@@ -167,6 +132,14 @@ mod tests {
check("x is int\n", "isinstance(x, int)\n");
}
+ #[test]
+ fn parenthesized_operands_still_lower() {
+ // an operand's own range stops inside its parentheses, so the rewrite
+ // has to close what it swallowed and open what the source closes after
+ check("(x) is int\n", "(isinstance(x, int))\n");
+ check("x is ( int )\n", "(isinstance(x, int) )\n");
+ }
+
#[test]
fn is_not_to_not_isinstance() {
check("x is not int\n", "not isinstance(x, int)\n");
@@ -188,24 +161,21 @@ mod tests {
}
#[test]
- fn is_bool_kept() {
- check("a is True\n", "a is True\n");
- check("a is False\n", "a is False\n");
- }
-
- #[test]
- fn is_number_kept() {
- check("a is 0\n", "a is 0\n");
- }
-
- #[test]
- fn is_string_kept() {
- check("a is \"x\"\n", "a is \"x\"\n");
- }
-
- #[test]
- fn is_ellipsis_kept() {
- check("a is ...\n", "a is ...\n");
+ fn is_literal_tests_the_literal_type() {
+ // a literal names a type holding exactly the values equal to it, and the
+ // class guard is what keeps python's `1 == True` from widening that
+ check(
+ "def f(a: object):\n return a is True\n",
+ "def f(a: object):\n return (type(a) is bool and a == True)\n",
+ );
+ check(
+ "def f(a: object):\n return a is 0\n",
+ "def f(a: object):\n return (type(a) is int and a == 0)\n",
+ );
+ check(
+ "def f(a: object):\n return a is \"x\"\n",
+ "def f(a: object):\n return (type(a) is str and a == \"x\")\n",
+ );
}
#[test]
@@ -218,6 +188,13 @@ mod tests {
check("a !== None\n", "a is not None\n");
}
+ #[test]
+ fn an_unspaced_identity_operator_brings_its_own_spaces() {
+ // `===` needs no space around it; `is` does
+ check("c = a===b\n", "c = a is b\n");
+ check("d = a!==b\n", "d = a is not b\n");
+ }
+
#[test]
fn python_eq_unchanged() {
unchanged("a == b\n");
diff --git a/crates/by_transforms/src/transforms/init_method.rs b/crates/by_transforms/src/transforms/init_method.rs
index 9f74d082f3..a22b68bf84 100644
--- a/crates/by_transforms/src/transforms/init_method.rs
+++ b/crates/by_transforms/src/transforms/init_method.rs
@@ -56,6 +56,7 @@ impl TypeAwarePass for InitMethod<'_> {
float_literals: self.float_literals,
edits: RefCell::new(Vec::new()),
templates: RefCell::new(Vec::new()),
+ relocating: RefCell::new(Vec::new()),
errors: RefCell::new(Vec::new()),
needs_missing: RefCell::new(false),
};
@@ -67,6 +68,7 @@ impl TypeAwarePass for InitMethod<'_> {
}
ctx.text_edits.extend(state.edits.into_inner());
ctx.template_edits.extend(state.templates.into_inner());
+ ctx.relocating_edits.extend(state.relocating.into_inner());
ctx.errors.extend(state.errors.into_inner());
}
}
@@ -101,6 +103,8 @@ struct State<'src> {
float_literals: FloatLiteralLowering,
edits: RefCell>,
templates: RefCell)>>,
+ /// the `_MISSING` substitutions, whose defaults the guards re-evaluate
+ relocating: RefCell)>>,
errors: RefCell>,
/// whether the `_MISSING` sentinel reached the output
needs_missing: RefCell,
@@ -302,7 +306,11 @@ impl State<'_> {
// the whole body is written here, so the guards a defaulted or
// relaxed-order parameter needs are written here too — `mutable_
// defaults` has no source statement to anchor them before
- let (sentinels, guards) = parameter_guards(func, self.types);
+ let super::mutable_defaults::ParameterGuards {
+ sentinels,
+ written,
+ guards,
+ } = parameter_guards(func, self.types);
let header_indent = self.line_indent(func.range.start()).to_owned();
let body_indent = format!("{header_indent} ");
let mut frags = vec![Fragment::Lit(":".to_owned())];
@@ -319,7 +327,8 @@ impl State<'_> {
}
// an inherited default is a signature edit that needs no guard, so the sentinels
// are written whether or not the body gains anything
- self.templates.borrow_mut().extend(sentinels);
+ self.relocating.borrow_mut().extend(sentinels);
+ self.templates.borrow_mut().extend(written);
let pos = func.range.end();
if guards.is_empty() {
// no passthrough to carry, so emit plain text: a template
diff --git a/crates/by_transforms/src/transforms/intersection.rs b/crates/by_transforms/src/transforms/intersection.rs
index 488e163b81..b593ff1479 100644
--- a/crates/by_transforms/src/transforms/intersection.rs
+++ b/crates/by_transforms/src/transforms/intersection.rs
@@ -558,4 +558,99 @@ mod tests {
"},
);
}
+
+ // a handful of typing constructs spell a type through a call rather than through an
+ // annotation. those arguments are type expressions, so the surface syntax written in
+ // one lowers exactly as it does after a `:` — an unlowered `A & B` reaching the output
+ // is a runtime `A.__and__(B)`
+
+ #[test]
+ fn typevar_bound_lowers() {
+ check(
+ "import typing\nT = typing.TypeVar(\"T\", bound=A & B)\n",
+ indoc! {r#"
+ from ty_extensions import Intersection
+ import typing
+ T = typing.TypeVar("T", bound=Intersection[A, B])
+ "#},
+ );
+ }
+
+ #[test]
+ fn typevar_constraints_lower() {
+ check(
+ "import typing\nT = typing.TypeVar(\"T\", A & B, C or D)\n",
+ indoc! {r#"
+ from ty_extensions import Intersection
+ import typing
+ T = typing.TypeVar("T", Intersection[A, B], C | D)
+ "#},
+ );
+ }
+
+ #[test]
+ fn paramspec_default_elements_lower() {
+ check(
+ "import typing\nP = typing.ParamSpec(\"P\", default=[A & B, C])\n",
+ indoc! {r#"
+ from ty_extensions import Intersection
+ import typing
+ P = typing.ParamSpec("P", default=[Intersection[A, B], C])
+ "#},
+ );
+ }
+
+ #[test]
+ fn newtype_base_lowers() {
+ check(
+ "import typing\nD = typing.NewType(\"D\", A or B)\n",
+ indoc! {r#"
+ import typing
+ D = typing.NewType("D", A | B)
+ "#},
+ );
+ }
+
+ #[test]
+ fn functional_named_tuple_field_lowers() {
+ check(
+ "import typing\nNT = typing.NamedTuple(\"NT\", [(\"f\", A & B)])\n",
+ indoc! {r#"
+ from ty_extensions import Intersection
+ import typing
+ NT = typing.NamedTuple("NT", [("f", Intersection[A, B])])
+ "#},
+ );
+ }
+
+ #[test]
+ fn functional_typed_dict_field_lowers() {
+ check(
+ "import typing\nTD = typing.TypedDict(\"TD\", {\"f\": A & B})\n",
+ indoc! {r#"
+ from ty_extensions import Intersection
+ import typing
+ TD = typing.TypedDict("TD", {"f": Intersection[A, B]})
+ "#},
+ );
+ }
+
+ #[test]
+ fn type_alias_type_value_lowers() {
+ check_py312(
+ "import typing\nAL = typing.TypeAliasType(\"AL\", A & B)\n",
+ indoc! {r#"
+ from ty_extensions import Intersection
+ import typing
+ AL = typing.TypeAliasType("AL", Intersection[A, B])
+ "#},
+ );
+ }
+
+ // the keyword is only a type operator where the call really is one of those
+ // constructs; an ordinary call's arguments stay boolean expressions
+ #[test]
+ fn ordinary_call_argument_untouched() {
+ unchanged("f(A and B)\n");
+ }
}
diff --git a/crates/by_transforms/src/transforms/mutable_defaults.rs b/crates/by_transforms/src/transforms/mutable_defaults.rs
index 9f1d129c21..ccd776af42 100644
--- a/crates/by_transforms/src/transforms/mutable_defaults.rs
+++ b/crates/by_transforms/src/transforms/mutable_defaults.rs
@@ -88,6 +88,8 @@ struct MutableDefaults<'src> {
source: &'src str,
types: &'src dyn TypeInfo,
edits: Vec<(TextRange, Vec)>,
+ /// the `_MISSING` substitutions, whose defaults the guards re-evaluate
+ relocating: Vec<(TextRange, Vec)>,
/// the guard suites, anchored at the body statement they precede
guards: Vec<(TextSize, Vec)>,
used: bool,
@@ -122,11 +124,21 @@ fn written_default(pw: &ParameterWithDefault, value: &str) -> (TextRange, Vec (Vec<(TextRange, Vec)>, Vec) {
+pub(crate) struct ParameterGuards {
+ /// the `_MISSING` left where a written default stood. *relocating*: the
+ /// guard evaluates the default's own source instead, so at this span this
+ /// edit leads every lowering written inside the default
+ pub(crate) sentinels: Vec<(TextRange, Vec)>,
+ /// a default written into the signature that the source did not write — an
+ /// inherited one, or the `_MISSING` a relaxed-order parameter needs. these
+ /// relocate nothing; they add text at the end of a parameter
+ pub(crate) written: Vec<(TextRange, Vec)>,
+ pub(crate) guards: Vec,
+}
+
+pub(crate) fn parameter_guards(f: &StmtFunctionDef, types: &dyn TypeInfo) -> ParameterGuards {
let mut sentinels = Vec::new();
+ let mut written = Vec::new();
let mut guards = Vec::new();
let params = f.parameters.as_ref();
// positional parameters: swap non-scalar defaults for the sentinel, and give
@@ -150,10 +162,10 @@ pub(crate) fn parameter_guards(
// the parameters after it "after a default", exactly as a written one would
None if let Some(value) = types.inherited_parameter_default(pw) => {
seen_default = true;
- sentinels.push(written_default(pw, &value));
+ written.push(written_default(pw, &value));
}
None if seen_default => {
- sentinels.push(written_default(pw, "_MISSING"));
+ written.push(written_default(pw, "_MISSING"));
guards.push(Guard::Required {
name: pw.parameter.name.id.to_string(),
function: f.name.id.to_string(),
@@ -174,12 +186,16 @@ pub(crate) fn parameter_guards(
Some(_) => {}
None => {
if let Some(value) = types.inherited_parameter_default(pw) {
- sentinels.push(written_default(pw, &value));
+ written.push(written_default(pw, &value));
}
}
}
}
- (sentinels, guards)
+ ParameterGuards {
+ sentinels,
+ written,
+ guards,
+ }
}
/// Whether the *callee's* body could evaluate `default` at all.
@@ -237,8 +253,13 @@ impl MutableDefaults<'_> {
if is_bodyless_init_shorthand(f) {
return;
}
- let (sentinels, guards) = parameter_guards(f, self.types);
- self.edits.extend(sentinels);
+ let ParameterGuards {
+ sentinels,
+ written,
+ guards,
+ } = parameter_guards(f, self.types);
+ self.relocating.extend(sentinels);
+ self.edits.extend(written);
if guards.is_empty() {
return;
}
@@ -281,6 +302,7 @@ impl TypeAwarePass for MutableDefaultsPass<'_> {
source: self.source,
types,
edits: Vec::new(),
+ relocating: Vec::new(),
guards: Vec::new(),
used: false,
unanchored: Vec::new(),
@@ -303,6 +325,7 @@ impl TypeAwarePass for MutableDefaultsPass<'_> {
ctx.required_imports.push("_MISSING = object()".to_owned());
}
ctx.template_edits.extend(inner.edits);
+ ctx.relocating_edits.extend(inner.relocating);
ctx.statement_inserts.extend(inner.guards);
}
}
@@ -880,7 +903,8 @@ mod tests {
fn default_lowerings_survive() {
// the default is re-emitted in the body through a `Src` passthrough, so
// the lowerings written inside it land in the guard rather than being
- // dropped with the signature they came from
+ // dropped with the signature they came from. `1 is int` is a type test
+ // the checker settles, so what lands is the constant it settled on
check(
indoc! {"
def f(x = [1 is int]):
@@ -890,7 +914,7 @@ mod tests {
_MISSING = object()
def f(x = _MISSING):
if x is _MISSING:
- x = [isinstance(1, int)]
+ x = [True]
return x
"},
);
@@ -899,8 +923,9 @@ mod tests {
#[test]
fn default_lowering_spanning_the_whole_default_survives() {
// the sentinel and the `is` lowering claim the *same* span. the sentinel
- // substitutes it and the lowering rewrites it, so the substitution
- // decides the signature and the rewrite materializes in the guard
+ // relocates the default, so it decides the signature and the lowering
+ // materializes in the guard — which holds even here, where the lowering
+ // settles to a constant and so replaces the span just as flatly
check(
indoc! {"
def f(x = 1 is int):
@@ -910,7 +935,7 @@ mod tests {
_MISSING = object()
def f(x = _MISSING):
if x is _MISSING:
- x = isinstance(1, int)
+ x = True
return x
"},
);
diff --git a/crates/by_transforms/src/transforms/parametric_is.rs b/crates/by_transforms/src/transforms/parametric_is.rs
index d5667430ee..77be606b25 100644
--- a/crates/by_transforms/src/transforms/parametric_is.rs
+++ b/crates/by_transforms/src/transforms/parametric_is.rs
@@ -42,22 +42,20 @@
//!
//! [`build_predicate`] is the core of this pass *and* of
//! [`checked_cast`](super::checked_cast): both ask one question — does this
-//! value satisfy this specialization at runtime — over the same
-//! [`ParametricIsPlan`]. they differ only in two parameters:
-//!
-//! - [`TargetPosition`], because a cast's target is a type expression while an
-//! `is`-rhs is a value expression, so ty infers the two differently
-//! - [`ProbeStrictness`], because an `is`-test must *earn* a `True` (it narrows)
-//! while a cast is an assertion that only holds the value to arguments the
-//! runtime can actually see
+//! value satisfy this type at runtime — over the same [`ParametricIsPlan`],
+//! built from the same type expression. they differ in one parameter,
+//! [`ProbeStrictness`]: an `is`-test must *earn* a `True` (it narrows) while a
+//! cast is an assertion that only holds the value to arguments the runtime can
+//! actually see
+use std::collections::BTreeSet;
use std::fmt::Write as _;
use ruff_python_ast::visitor::{Visitor, walk_expr, walk_stmt};
-use ruff_python_ast::{self as ast, CmpOp, Expr, Stmt};
+use ruff_python_ast::{self as ast, CmpOp, Expr, PySourceType, Stmt};
+use ruff_python_trivia::{SimpleTokenKind, SimpleTokenizer};
use ruff_text_size::{Ranged, TextRange};
-use ty_python_semantic::reified::is_keyword_comparison;
-use ty_python_semantic::{ArgVariance, ParametricIsPlan, ProtocolMemberCheck};
+use ty_python_semantic::{ArgVariance, ParametricIsPlan, ProtocolMemberCheck, TargetSpelling};
use super::ast_driver::{Fragment, PassContext, TypeAwarePass};
use crate::type_info::TypeInfo;
@@ -257,7 +255,7 @@ def _parametric_is_lenient(value, alias, variances):
/// covariant → subtype, 2 contravariant → supertype, 3 bivariant → any).
/// annotations are read with `typing.get_type_hints` (resolving string
/// annotations and inherited members), falling back to a raw `__mro__` walk
-pub(crate) const PROTOCOL_IS_RUNTIME: &str = "\
+const PROTOCOL_IS_RUNTIME: &str = "\
_by_proto_missing = object()
def _by_member_annotation(klass, name):
@@ -392,6 +390,20 @@ def _by_protocol_is(value, members):
return True
";
+/// matches a value against a template literal type — a pattern such as
+/// `f"a{int}b"`, whose type is the set of strings it can produce.
+///
+/// the regular expression comes from the checker, which builds it from the same
+/// reading of the pattern's holes that decides the static answer, so the test
+/// accepts exactly the strings the type contains. a non-`str` value is not one
+/// of them
+const PATTERN_IS_RUNTIME: &str = "\
+import re as _by_re
+
+def _by_pattern_is(value, pattern):
+ return isinstance(value, str) and _by_re.fullmatch(pattern, value) is not None
+";
+
/// the runtime variance code `_by_variance_ok` expects
fn variance_code(variance: ArgVariance) -> u8 {
match variance {
@@ -437,17 +449,6 @@ fn protocol_members_literal(checks: &[ProtocolMemberCheck]) -> String {
format!("[{entries}]")
}
-/// which inference position a target expression lives in. this is the *only*
-/// difference between an `is`-test and a checked cast at the front end: an
-/// `is`-rhs is a value expression (its type is the class object), a `cast`
-/// target is a type expression (its type is the instance). both then classify
-/// through the same engine
-#[derive(Clone, Copy, PartialEq, Eq)]
-pub(crate) enum TargetPosition {
- Value,
- Type,
-}
-
/// how a runtime probe treats a value that carries no reification
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum ProbeStrictness {
@@ -474,6 +475,8 @@ pub(crate) struct PredicateNeeds {
pub(crate) protocol_runtime: bool,
/// the predicate calls `_by_conforms`
pub(crate) conformance_runtime: bool,
+ /// the predicate calls `_by_pattern_is`
+ pub(crate) pattern_runtime: bool,
/// no arm carried a parametric claim — a plain `isinstance` covers the whole
/// target, so a caller may use its compact shallow form instead
pub(crate) all_plain: bool,
@@ -497,72 +500,21 @@ impl PredicateNeeds {
}
}
-/// the plan for one `(value, target)` pair, resolved through the position's
-/// inference rules
-fn plan_for(
- types: &dyn TypeInfo,
- value_expr: &Expr,
- target: &Expr,
- position: TargetPosition,
-) -> Option {
- match position {
- TargetPosition::Value => types.parametric_is_plan(value_expr, target),
- TargetPosition::Type => types.parametric_cast_plan(value_expr, target),
- }
-}
-
/// the bare (non-negated) runtime predicate for one target arm, referencing the
/// value through `value_ref` so the caller controls how it is bound.
///
/// this is the shared core of `x is T` and `x cast T`: both ask the same
-/// question — does this value satisfy this specialization at runtime — and
-/// differ only in `position` (how the target is inferred) and `probe` (what an
-/// unreified value means).
+/// question — does this value satisfy this type at runtime — and differ only in
+/// `probe`, which decides what an unreified value means.
fn arm_predicate(
types: &dyn TypeInfo,
value_ref: &dyn Fn() -> Fragment,
value_expr: &Expr,
arm: &Expr,
- position: TargetPosition,
probe: ProbeStrictness,
needs: &mut PredicateNeeds,
) -> Vec {
- // a `None` arm (an `X | None` optional) is an identity check, not
- // `isinstance(_, None)` — `None` is a value, not a class
- if matches!(arm, Expr::NoneLiteral(_)) {
- needs.all_true = false;
- needs.references_value = true;
- return vec![value_ref(), Fragment::Lit(" is None".to_owned())];
- }
- // an interface something visibly conforms to cannot be answered by
- // `isinstance`: a conforming type is not a subclass, and a protocol is not
- // even a legal `isinstance` target. the registry answers first, and a value
- // nothing registered falls back to carrying the requirements
- if let Some(test) = types.conformance_test(arm) {
- needs.all_plain = false;
- needs.all_true = false;
- needs.references_value = true;
- needs.conformance_runtime = true;
- let members = match &test.members {
- Some(names) => {
- let mut spelled = String::from(", (");
- for name in names {
- let _ = write!(spelled, "\"{name}\", ");
- }
- spelled.push(')');
- spelled
- }
- None => String::new(),
- };
- return vec![
- Fragment::Lit("_by_conforms(".to_owned()),
- value_ref(),
- Fragment::Lit(", ".to_owned()),
- Fragment::Src(arm.range()),
- Fragment::Lit(format!("{members})")),
- ];
- }
- let Some(plan) = plan_for(types, value_expr, arm, position) else {
+ let Some(plan) = types.parametric_is_plan(value_expr, arm) else {
needs.all_true = false;
needs.references_value = true;
return vec![
@@ -573,9 +525,33 @@ fn arm_predicate(
Fragment::Lit(")".to_owned()),
];
};
- needs.all_plain = false;
- if !matches!(plan, ParametricIsPlan::Fold(true)) {
- needs.all_true = false;
+ fragments_for_plan(types, plan, value_ref, arm, probe, needs)
+}
+
+/// the fragments one already-classified plan lowers to. split out from
+/// [`arm_predicate`] so a union plan can build its arms, which the source need
+/// not spell separately — a PEP 695 alias names a whole union with one word
+fn fragments_for_plan(
+ types: &dyn TypeInfo,
+ plan: ParametricIsPlan,
+ value_ref: &dyn Fn() -> Fragment,
+ arm: &Expr,
+ probe: ProbeStrictness,
+ needs: &mut PredicateNeeds,
+) -> Vec {
+ // a union reports through its arms instead: a disjunction of plain
+ // `isinstance` calls is still a plain check, and it holds as soon as one arm
+ // does
+ if !matches!(plan, ParametricIsPlan::Union(_)) {
+ if !matches!(
+ plan,
+ ParametricIsPlan::Isinstance(_) | ParametricIsPlan::Unresolved
+ ) {
+ needs.all_plain = false;
+ }
+ if !matches!(plan, ParametricIsPlan::Fold(true)) {
+ needs.all_true = false;
+ }
}
match plan {
// an erased arm can't be checked at runtime; ty reports the error (a
@@ -597,7 +573,7 @@ fn arm_predicate(
frags.push(Fragment::Lit(")".to_owned()));
frags
}
- ParametricIsPlan::Probe(variances) => {
+ ParametricIsPlan::Probe { target, variances } => {
needs.parametric_runtime = true;
needs.references_value = true;
let codes: Vec = variances.iter().copied().map(variance_code).collect();
@@ -605,13 +581,14 @@ fn arm_predicate(
ProbeStrictness::Strict => "_parametric_is(",
ProbeStrictness::Lenient => "_parametric_is_lenient(",
};
- vec![
+ let mut frags = vec![
Fragment::Lit(call.to_owned()),
value_ref(),
Fragment::Lit(", ".to_owned()),
- Fragment::Src(arm.range()),
- Fragment::Lit(format!(", {})", variance_tuple(&codes))),
- ]
+ target_fragment(&target, arm),
+ ];
+ frags.push(Fragment::Lit(format!(", {})", variance_tuple(&codes))));
+ frags
}
ParametricIsPlan::ProtocolStructural(checks) => {
needs.protocol_runtime = true;
@@ -623,35 +600,192 @@ fn arm_predicate(
Fragment::Lit(format!(", {members})")),
]
}
+ // nothing is known about the target, so the source's own spelling is
+ // the only thing to test against — and the error that left it unknown
+ // is already reported
+ // an interface something visibly conforms to cannot be answered by
+ // `isinstance`: a conforming type is not a subclass. the registry
+ // answers first, and a value nothing registered falls back to carrying
+ // the requirements
+ ParametricIsPlan::Conformance { target, members } => {
+ needs.references_value = true;
+ needs.conformance_runtime = true;
+ let mut spelled = String::from(", (");
+ for name in &members {
+ let _ = write!(spelled, "\"{name}\", ");
+ }
+ spelled.push(')');
+ vec![
+ Fragment::Lit("_by_conforms(".to_owned()),
+ value_ref(),
+ Fragment::Lit(", ".to_owned()),
+ target_fragment(&target, arm),
+ Fragment::Lit(format!("{spelled})")),
+ ]
+ }
+ // the checker could not read the target as a type. that is how a unit
+ // enum variant arrives: the enum lowering has already rewritten it into
+ // a singleton instance, so what the source named as a type names a
+ // value here — and identity is the test for a value. anything else
+ // keeps the plain instance check, and whatever left the target unknown
+ // is already reported where it is written
+ ParametricIsPlan::Unresolved => {
+ needs.references_value = true;
+ if types.is_plain_value(arm) {
+ return vec![
+ value_ref(),
+ Fragment::Lit(" is ".to_owned()),
+ Fragment::Src(arm.range()),
+ ];
+ }
+ vec![
+ Fragment::Lit("isinstance(".to_owned()),
+ value_ref(),
+ Fragment::Lit(", ".to_owned()),
+ Fragment::Src(arm.range()),
+ Fragment::Lit(")".to_owned()),
+ ]
+ }
+ ParametricIsPlan::Isinstance(target) => {
+ needs.references_value = true;
+ let mut frags = vec![
+ Fragment::Lit("isinstance(".to_owned()),
+ value_ref(),
+ Fragment::Lit(", ".to_owned()),
+ ];
+ frags.push(target_fragment(&target, arm));
+ frags.push(Fragment::Lit(")".to_owned()));
+ frags
+ }
+ // `None` is a value, not a class, so `isinstance` cannot take it — and
+ // there is only one `None`, which makes identity the whole test
+ // a bare `Callable` asks exactly what `callable()` answers
+ ParametricIsPlan::IsCallable => {
+ needs.references_value = true;
+ vec![
+ Fragment::Lit("callable(".to_owned()),
+ value_ref(),
+ Fragment::Lit(")".to_owned()),
+ ]
+ }
+ ParametricIsPlan::IsNone => {
+ needs.references_value = true;
+ vec![value_ref(), Fragment::Lit(" is None".to_owned())]
+ }
+ // the class guard is not redundant: python's `1 == True` would let a
+ // `bool` satisfy `Literal[1]` without it
+ ParametricIsPlan::Equality { class, value } => {
+ needs.references_value = true;
+ vec![
+ Fragment::Lit("(type(".to_owned()),
+ value_ref(),
+ Fragment::Lit(format!(") is {class} and ")),
+ value_ref(),
+ Fragment::Lit(" == ".to_owned()),
+ target_fragment(&value, arm),
+ Fragment::Lit(")".to_owned()),
+ ]
+ }
+ ParametricIsPlan::Identity(target) => {
+ needs.references_value = true;
+ vec![
+ value_ref(),
+ Fragment::Lit(" is ".to_owned()),
+ target_fragment(&target, arm),
+ ]
+ }
+ ParametricIsPlan::Subclass(target) => {
+ needs.references_value = true;
+ let mut frags = vec![Fragment::Lit("(isinstance(".to_owned()), value_ref()];
+ frags.push(Fragment::Lit(", type) and issubclass(".to_owned()));
+ frags.push(value_ref());
+ frags.push(Fragment::Lit(", ".to_owned()));
+ frags.push(target_fragment(&target, arm));
+ frags.push(Fragment::Lit("))".to_owned()));
+ frags
+ }
+ // a template literal type is a set of strings; the regular expression
+ // ty built spells exactly the strings it produces
+ ParametricIsPlan::Pattern(pattern) => {
+ needs.pattern_runtime = true;
+ needs.references_value = true;
+ vec![
+ Fragment::Lit("_by_pattern_is(".to_owned()),
+ value_ref(),
+ Fragment::Lit(format!(", {})", python_string_literal(&pattern))),
+ ]
+ }
+ // a value satisfies a union as soon as one arm holds
+ ParametricIsPlan::Union(arms) => {
+ let mut frags = vec![Fragment::Lit("(".to_owned())];
+ let mut any_true = false;
+ for (index, arm_plan) in arms.iter().enumerate() {
+ if index > 0 {
+ frags.push(Fragment::Lit(" or ".to_owned()));
+ }
+ any_true |= matches!(arm_plan, ParametricIsPlan::Fold(true));
+ frags.extend(fragments_for_plan(
+ types,
+ arm_plan.clone(),
+ value_ref,
+ arm,
+ probe,
+ needs,
+ ));
+ }
+ frags.push(Fragment::Lit(")".to_owned()));
+ if any_true {
+ needs.all_true = true;
+ }
+ frags
+ }
+ }
+}
+
+/// how a plan's target is written: its own spelling, or the source the target
+/// was written as when the plan carries none
+fn target_fragment(target: &TargetSpelling, arm: &Expr) -> Fragment {
+ match target {
+ TargetSpelling::Written => Fragment::Src(arm.range()),
+ TargetSpelling::Rebuilt(text) => Fragment::Lit(text.clone()),
}
}
-/// the full runtime predicate for a target expression, splitting a union into
-/// the disjunction of its arms. shared by `is` and `cast`.
+/// a python string literal for `text`, escaped so the emitted source reads it
+/// back byte for byte. only the characters that end or reinterpret a
+/// single-quoted literal need escaping
+fn python_string_literal(text: &str) -> String {
+ let mut out = String::with_capacity(text.len() + 2);
+ out.push('"');
+ for ch in text.chars() {
+ match ch {
+ '"' => out.push_str("\\\""),
+ '\\' => out.push_str("\\\\"),
+ '\n' => out.push_str("\\n"),
+ '\r' => out.push_str("\\r"),
+ '\t' => out.push_str("\\t"),
+ _ => out.push(ch),
+ }
+ }
+ out.push('"');
+ out
+}
+
+/// the full runtime predicate for a target expression. shared by `is` and
+/// `cast`.
+///
+/// A union target needs no splitting here: the target is a type expression, so
+/// its type is the union and the plan carries one arm per member — including
+/// the arms of a union the source never spelled as one.
pub(crate) fn build_predicate(
types: &dyn TypeInfo,
value_ref: &dyn Fn() -> Fragment,
value_expr: &Expr,
target: &Expr,
- position: TargetPosition,
probe: ProbeStrictness,
) -> (Vec, PredicateNeeds) {
let mut needs = PredicateNeeds::new();
- let Some(arms) = union_arms(target) else {
- let frags = arm_predicate(
- types, value_ref, value_expr, target, position, probe, &mut needs,
- );
- return (frags, needs);
- };
- let mut frags = Vec::new();
- for (index, arm) in arms.iter().enumerate() {
- if index > 0 {
- frags.push(Fragment::Lit(" or ".to_owned()));
- }
- frags.extend(arm_predicate(
- types, value_ref, value_expr, arm, position, probe, &mut needs,
- ));
- }
+ let frags = arm_predicate(types, value_ref, value_expr, target, probe, &mut needs);
(frags, needs)
}
@@ -664,9 +798,8 @@ struct ParametricIs<'src, 'ti> {
source: &'src str,
types: &'ti dyn TypeInfo,
edits: Vec<(TextRange, Vec)>,
- needs_probe: bool,
- needs_protocol: bool,
- needs_conformance: bool,
+ /// the runtime helpers the predicates emitted so far call
+ runtimes: BTreeSet,
}
impl ParametricIs<'_, '_> {
@@ -687,18 +820,26 @@ impl ParametricIs<'_, '_> {
/// predicate builder and then wrapped for this form: negation, and keeping
/// an effectful lhs alive when the predicate doesn't mention it
fn lower_pair(&mut self, lhs: &Expr, rhs: &Expr, negate: bool) -> Vec {
- let value = || Fragment::Src(lhs.range());
- let (frags, needs) = build_predicate(
- self.types,
- &value,
- lhs,
- rhs,
- TargetPosition::Value,
- ProbeStrictness::Strict,
- );
- self.needs_probe |= needs.parametric_runtime;
- self.needs_protocol |= needs.protocol_runtime;
- self.needs_conformance |= needs.conformance_runtime;
+ // a predicate may mention the value more than once — a union tests each
+ // arm, an equality also checks the class — so an effectful left operand
+ // is bound to a lambda parameter and evaluated once. counting the
+ // references is what tells the two cases apart: a single mention needs
+ // no binding, and the lambda would only obscure the output
+ let mentions = std::cell::Cell::new(0usize);
+ let counting = || {
+ mentions.set(mentions.get() + 1);
+ Fragment::Src(lhs.range())
+ };
+ let (frags, needs) =
+ build_predicate(self.types, &counting, lhs, rhs, ProbeStrictness::Strict);
+ let via_lambda = mentions.get() > 1 && !effect_free(lhs);
+ let (frags, needs) = if via_lambda {
+ let param = || Fragment::Lit(UNION_VALUE_PARAM.to_owned());
+ build_predicate(self.types, ¶m, lhs, rhs, ProbeStrictness::Strict)
+ } else {
+ (frags, needs)
+ };
+ self.runtimes.extend(PredicateRuntime::used(&needs));
// a predicate that folded to a constant inverts in place rather than
// growing a `not`
@@ -713,10 +854,34 @@ impl ParametricIs<'_, '_> {
return Self::with_lhs_effects(lhs, vec![Fragment::Lit(value.to_owned())]);
}
+ // `x is not None` rather than `not x is None`: the same test, and the
+ // one a reader (and every linter) expects. an identity predicate is the
+ // only shape python can negate in place, and it is spelled with the
+ // operator in a literal fragment of its own
+ if negate
+ && let [value, Fragment::Lit(operator), rest @ ..] = frags.as_slice()
+ && operator.starts_with(" is ")
+ {
+ let mut negated = vec![
+ value.clone(),
+ Fragment::Lit(operator.replacen(" is ", " is not ", 1)),
+ ];
+ negated.extend_from_slice(rest);
+ return negated;
+ }
+
let mut result = Vec::new();
if negate {
result.push(Fragment::Lit("not ".to_owned()));
}
+ if via_lambda {
+ result.push(Fragment::Lit(format!("(lambda {UNION_VALUE_PARAM}: ")));
+ result.extend(frags);
+ result.push(Fragment::Lit(")(".to_owned()));
+ result.push(Fragment::Src(lhs.range()));
+ result.push(Fragment::Lit(")".to_owned()));
+ return result;
+ }
result.extend(frags);
if needs.references_value {
result
@@ -725,122 +890,94 @@ impl ParametricIs<'_, '_> {
}
}
- /// one arm of a union `is`-target, delegated to the shared predicate
- /// builder so `is` and `cast` stay in lockstep
- fn lower_arm(&mut self, value: &dyn Fn() -> Fragment, lhs: &Expr, arm: &Expr) -> Vec {
- let mut needs = PredicateNeeds::new();
- let frags = arm_predicate(
- self.types,
- value,
- lhs,
- arm,
- TargetPosition::Value,
- ProbeStrictness::Strict,
- &mut needs,
- );
- self.needs_probe |= needs.parametric_runtime;
- self.needs_protocol |= needs.protocol_runtime;
- self.needs_conformance |= needs.conformance_runtime;
- frags
- }
-
- /// `lhs is (T1 | T2 | …)` — a test against a union type — is the disjunction
- /// of the per-arm tests (`type(lhs) <: Ti` for any arm). the lhs is bound
- /// once: referenced directly when it has no effects, else through a lambda
- /// parameter so the arms share a single evaluation
- fn lower_union(&mut self, lhs: &Expr, arms: &[&Expr], negate: bool) -> Vec {
- let via_lambda = !effect_free(lhs);
- let value = || {
- if via_lambda {
- Fragment::Lit(UNION_VALUE_PARAM.to_owned())
- } else {
- Fragment::Src(lhs.range())
- }
- };
- let mut inner: Vec = Vec::new();
- for (index, arm) in arms.iter().enumerate() {
- if index > 0 {
- inner.push(Fragment::Lit(" or ".to_owned()));
- }
- inner.extend(self.lower_arm(&value, lhs, arm));
- }
- let mut frags = Vec::new();
- if via_lambda {
- frags.push(Fragment::Lit(format!(
- "{}(lambda {UNION_VALUE_PARAM}: ",
- if negate { "not " } else { "" }
- )));
- frags.extend(inner);
- frags.push(Fragment::Lit(")(".to_owned()));
- frags.push(Fragment::Src(lhs.range()));
- frags.push(Fragment::Lit(")".to_owned()));
- } else {
- frags.push(Fragment::Lit(if negate { "not (" } else { "(" }.to_owned()));
- frags.extend(inner);
- frags.push(Fragment::Lit(")".to_owned()));
- }
- frags
- }
-
fn process_compare(&mut self, compare: &ast::ExprCompare) {
let mut lhs: &Expr = &compare.left;
- for (op, rhs) in compare.ops.iter().zip(&compare.comparators) {
- // identity_swap defers every non-literal name/attribute/subscript
- // and union `is`-rhs to this pass, which owns the
- // isinstance-vs-parametric decision (a bare class → isinstance, a
- // specialization or an alias to one → a parametric test, a union →
- // the disjunction of the arms)
- if matches!(op, CmpOp::Is | CmpOp::IsNot)
- && is_keyword_comparison(self.source, *op, lhs, rhs)
- // a subscript that resolves to a plain value (`candidates[0]`
- // holding an enum member) keeps python identity semantics,
- // same as identity_swap's rule for unsubscripted rhs
- && !self.types.is_keeps_identity(rhs)
- {
- let negate = matches!(op, CmpOp::IsNot);
- let replacement =
- if matches!(rhs, Expr::Name(_) | Expr::Attribute(_) | Expr::Subscript(_)) {
- Some(self.lower_pair(lhs, rhs, negate))
- } else {
- union_arms(rhs).map(|arms| self.lower_union(lhs, &arms, negate))
- };
- if let Some(replacement) = replacement {
- let pair_range = TextRange::new(lhs.range().start(), rhs.range().end());
- self.edits.push((pair_range, replacement));
- }
+ for (index, rhs) in compare.comparators.iter().enumerate() {
+ // every type test is lowered here, whatever shape its target was
+ // written in. the plan comes from the target's *type*, so a target
+ // the source spells as one word (`x is Alias`) and the union that
+ // word stands for reach the same lowering
+ if compare.is_type_test(index, PySourceType::BasedPython) {
+ let negate = compare.ops.get(index) == Some(&CmpOp::IsNot);
+ let (opening, closing) = paren_padding(self.source, lhs, rhs);
+ let mut replacement = Vec::new();
+ replacement.push(Fragment::Lit("(".repeat(opening)));
+ replacement.extend(self.lower_pair(lhs, rhs, negate));
+ replacement.push(Fragment::Lit(")".repeat(closing)));
+ let pair_range = TextRange::new(lhs.range().start(), rhs.range().end());
+ self.edits.push((pair_range, replacement));
}
lhs = rhs;
}
}
}
-/// the lambda parameter that binds an effectful union-test lhs for its arms.
-/// unlikely to collide: an `is`-target arm is a type expression, and this name
-/// would have to appear free inside one
-const UNION_VALUE_PARAM: &str = "_by_is_value";
+/// a runtime helper one of the emitted predicates calls. the preamble emits one
+/// definition per helper a pass actually used, in this order — `_parametric_is`
+/// and `_by_protocol_is` must precede the predicates that call them
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) enum PredicateRuntime {
+ Parametric,
+ Protocol,
+ Conformance,
+ Pattern,
+}
-/// the flat arms of a union type expression (`A | B | C` → `[A, B, C]`), or
-/// `None` when `expr` is not a `|` union
-fn union_arms(expr: &Expr) -> Option> {
- if !matches!(expr, Expr::BinOp(binop) if binop.op == ast::Operator::BitOr) {
- return None;
+impl PredicateRuntime {
+ /// the definitions this helper needs in the emitted module
+ pub(crate) fn source(self) -> &'static str {
+ match self {
+ Self::Parametric => PARAMETRIC_IS_RUNTIME,
+ Self::Protocol => PROTOCOL_IS_RUNTIME,
+ Self::Conformance => super::conformance::WITNESS_RUNTIME,
+ Self::Pattern => PATTERN_IS_RUNTIME,
+ }
}
- let mut arms = Vec::new();
- collect_union_arms(expr, &mut arms);
- Some(arms)
-}
-fn collect_union_arms<'a>(expr: &'a Expr, arms: &mut Vec<&'a Expr>) {
- if let Expr::BinOp(binop) = expr
- && binop.op == ast::Operator::BitOr
- {
- collect_union_arms(&binop.left, arms);
- collect_union_arms(&binop.right, arms);
- } else {
- arms.push(expr);
+ /// the helpers `needs` recorded, in preamble order
+ pub(crate) fn used(needs: &PredicateNeeds) -> impl Iterator {
+ [
+ needs.parametric_runtime.then_some(Self::Parametric),
+ needs.protocol_runtime.then_some(Self::Protocol),
+ needs.conformance_runtime.then_some(Self::Conformance),
+ needs.pattern_runtime.then_some(Self::Pattern),
+ ]
+ .into_iter()
+ .flatten()
}
}
+/// the lambda parameter that binds an effectful test value for a predicate that
+/// mentions it more than once. unlikely to collide: an `is`-target is a type
+/// expression, and this name would have to appear free inside one
+const UNION_VALUE_PARAM: &str = "_by_is_value";
+
+/// how many parentheses a replacement for one comparison pair must supply
+/// itself, because the source wrote them around an operand rather than around
+/// the pair.
+///
+/// An operand's range stops inside its own parentheses, so the pair
+/// `lhs.start() .. rhs.end()` of `(a) is str` swallows the `)` while leaving the
+/// `(` outside it. Rather than guess which outer `(` that was — the `(` of an
+/// enclosing call looks exactly the same — the replacement closes what it
+/// swallowed and opens what the source will close after it.
+fn paren_padding(source: &str, lhs: &Expr, rhs: &Expr) -> (usize, usize) {
+ let gap = TextRange::new(lhs.range().end(), rhs.range().start());
+ let tokens: Vec<_> = SimpleTokenizer::new(source, gap).skip_trivia().collect();
+ // parens closing around the left operand come first in the gap, parens
+ // opening around the right operand last; the operator sits between them
+ let closing = tokens
+ .iter()
+ .take_while(|token| token.kind() == SimpleTokenKind::RParen)
+ .count();
+ let opening = tokens
+ .iter()
+ .rev()
+ .take_while(|token| token.kind() == SimpleTokenKind::LParen)
+ .count();
+ (opening, closing)
+}
+
impl<'ast> Visitor<'ast> for ParametricIs<'_, '_> {
fn visit_stmt(&mut self, stmt: &'ast Stmt) {
walk_stmt(self, stmt);
@@ -870,9 +1007,7 @@ impl TypeAwarePass for ParametricIsPass<'_> {
source: self.source,
types,
edits: Vec::new(),
- needs_probe: false,
- needs_protocol: false,
- needs_conformance: false,
+ runtimes: BTreeSet::new(),
};
for stmt in stmts {
inner.visit_stmt(stmt);
@@ -882,15 +1017,8 @@ impl TypeAwarePass for ParametricIsPass<'_> {
// (`T == list[int]`), which is already restricted to 3.12+ by the
// reified-generic requirement; a user-generic probe (`A[int]`) works
// on any target
- if inner.needs_probe {
- ctx.required_imports.push(PARAMETRIC_IS_RUNTIME.to_owned());
- }
- if inner.needs_protocol {
- ctx.required_imports.push(PROTOCOL_IS_RUNTIME.to_owned());
- }
- if inner.needs_conformance {
- ctx.required_imports
- .push(super::conformance::WITNESS_RUNTIME.to_owned());
+ for runtime in inner.runtimes {
+ ctx.required_imports.push(runtime.source().to_owned());
}
ctx.template_edits.extend(inner.edits);
}
@@ -1344,7 +1472,8 @@ mod tests {
#[test]
fn implicit_alias_target_probes_like_the_specialization() {
// `X = A[int]` binds `X` to the specialization itself, so `y is X`
- // resolves exactly as `y is A[int]` would — a probe here
+ // resolves exactly as `y is A[int]` would — and is written back out
+ // that way, which is what the runtime probe needs to unwind
let out = out(indoc! {"
class A[T]:
def __init__(self, v: T):
@@ -1354,30 +1483,30 @@ mod tests {
return y is X
"});
assert!(
- out.contains("return _parametric_is(y, X, (0,))"),
- "alias name probes through `X`: {out}"
+ out.contains("return _parametric_is(y, A[int], (0,))"),
+ "alias name probes against the specialization it binds: {out}"
);
}
#[test]
fn implicit_alias_builtin_target_probes() {
// an alias to a builtin specialization probes just like the direct form
- // — the alias name `X` is passed through so the runtime unwinds it
let out = out(indoc! {"
X = list[int]
def f(y: object) -> bool:
return y is X
"});
assert!(
- out.contains("return _parametric_is(y, X, (0,))"),
- "alias to a builtin probes through `X`: {out}"
+ out.contains("return _parametric_is(y, list[int], (0,))"),
+ "alias to a builtin probes against the specialization: {out}"
);
}
#[test]
fn pep695_type_alias_target_probes_through_value() {
- // `type W = A[int]` is a `TypeAliasType`; the probe unwraps `.__value__`
- // at runtime, so `y is W` still resolves against `A[int]`
+ // `type W = A[int]` evaluates to a `TypeAliasType`, which `isinstance`
+ // would refuse — so the specialization it stands for is written out
+ // instead of the name
let out = out(indoc! {"
class A[T]:
def __init__(self, v: T):
@@ -1387,8 +1516,8 @@ mod tests {
return y is W
"});
assert!(
- out.contains("return _parametric_is(y, W, (0,))"),
- "type alias probes through `W`: {out}"
+ out.contains("return _parametric_is(y, A[int], (0,))"),
+ "type alias probes against its value: {out}"
);
}
@@ -1428,10 +1557,10 @@ mod tests {
def __init__(self, v: T):
self.v: list[T] = [v]
def f(a: object) -> bool:
- return a is A[int] | object
+ return a is A[int] | str
"});
assert!(
- out.contains("return (_parametric_is(a, A[int], (0,)) or isinstance(a, object))"),
+ out.contains("return (_parametric_is(a, A[int], (0,)) or isinstance(a, str))"),
"each arm lowered by its own kind: {out}"
);
}
@@ -1488,8 +1617,8 @@ mod tests {
"});
assert!(
out.contains(
- "return (lambda _by_is_value: isinstance(_by_is_value, int) or \
- isinstance(_by_is_value, str))(g())"
+ "return (lambda _by_is_value: (isinstance(_by_is_value, int) or \
+ isinstance(_by_is_value, str)))(g())"
),
"effectful lhs bound once: {out}"
);
@@ -1620,25 +1749,24 @@ mod tests {
}
#[test]
- fn a_target_outside_the_union_compares_the_cell() {
- // no arm matches `list[bytes]`, so this can never be true. it used to
- // fold to `False` statically; now the parameter carries a reified type
- // parameter constrained to `(int, str)`, so it lowers to a cell
- // comparison that is always false at runtime instead. same answer, one
- // comparison rather than a constant — the fold is not recoverable from
- // the token path, which carries source ranges rather than types
+ fn a_target_outside_the_union_can_never_hold() {
+ // no arm matches `list[bytes]`, so this can never be true — and the
+ // constraint `(int, str)` on the reified parameter says so statically,
+ // before any runtime residue is reached
let out = out(indoc! {"
def f(x: list[int] | list[str]) -> bool:
return x is list[bytes]
"});
assert!(
- out.contains("return (__by_erased_0 == bytes)"),
- "target outside the union compares the cell: {out}"
+ out.contains("return False"),
+ "a target outside the union can never hold: {out}"
);
}
#[test]
- fn value_subscript_rhs_falls_back_to_isinstance() {
+ fn a_subscript_of_a_value_keeps_the_plain_instance_check() {
+ // `pair[0]` is not something a type expression can say, so the checker
+ // reports it and the lowering keeps the `isinstance` the source wrote
let out = out(indoc! {"
class A: ...
pair = (A, A)
@@ -1680,9 +1808,9 @@ mod tests {
#[test]
fn stdlib_enum_member_rhs_keeps_identity() {
- // an enum member is a singleton instance, not a class, so
- // `isinstance(x, Color.RED)` would be a runtime TypeError; the pair
- // must keep `is` / `is not`
+ // `Color.RED` names the type `Literal[Color.RED]`, which holds exactly
+ // one object — so the test is identity, and `isinstance(x, Color.RED)`
+ // (a runtime `TypeError`) is never emitted
let out = out(indoc! {"
from enum import Enum
@@ -1690,17 +1818,106 @@ mod tests {
RED = 1
GREEN = 2
- print(Color.RED is Color.RED)
- print(Color.RED is not Color.GREEN)
+ def f(c: Color) -> None:
+ print(c is Color.RED)
+ print(c is not Color.GREEN)
"});
assert!(
- out.contains("print(Color.RED is Color.RED)"),
- "enum member rhs keeps identity: {out}"
+ out.contains("print(c is Color.RED)"),
+ "enum member rhs is an identity check: {out}"
);
assert!(
- out.contains("print(Color.RED is not Color.GREEN)"),
- "enum member rhs keeps identity under is not: {out}"
+ out.contains("print(c is not Color.GREEN)"),
+ "enum member rhs is an identity check under `is not`: {out}"
+ );
+ }
+
+ #[test]
+ fn an_alias_to_the_enum_still_reaches_the_variant() {
+ // the target is decided from the *type* it names, so a binding standing
+ // in for the enum reaches the same variant. a match on the written name
+ // would miss this one and emit `isinstance` against a singleton
+ let out = out(indoc! {"
+ enum class Shape:
+ case Circle(radius: float)
+ case Point
+
+ S = Shape
+
+ def f(s: Shape) -> bool:
+ return s is S.Point
+ "});
+ assert!(
+ out.contains("return s is S.Point"),
+ "an aliased enum's unit variant is still an identity check: {out}"
+ );
+ }
+
+ #[test]
+ fn a_binding_shadowing_the_enum_names_what_it_holds() {
+ // the same rule the other way: `Shape` here is the local class, whose
+ // `Point` is an ordinary class and therefore an instance check
+ let out = out(indoc! {"
+ enum class Shape:
+ case Circle(radius: float)
+ case Point
+
+ class Other:
+ class Point: pass
+
+ def f(x: object) -> bool:
+ Shape = Other
+ return x is Shape.Point
+ "});
+ assert!(
+ out.contains("return isinstance(x, Shape.Point)"),
+ "a shadowed name names what the binding holds: {out}"
+ );
+ }
+
+ #[test]
+ fn a_literal_target_keeps_the_source_spelling() {
+ // the source already wrote a literal that is valid where it sits —
+ // rebuilding one would pick its own quote character, which a python
+ // before 3.12 forbids reusing inside an f-string
+ let out = out(indoc! {"
+ def f(x: object) -> str:
+ return f\"{x is 'q'}\"
+ "});
+ assert!(
+ out.contains("x == 'q'"),
+ "the literal is re-emitted as written: {out}"
+ );
+ }
+
+ #[test]
+ fn an_effectful_value_is_evaluated_once_for_a_literal_target() {
+ // the equality check mentions the value twice — once for the class
+ // guard — so an effectful operand is bound rather than repeated
+ let out = out(indoc! {"
+ def g() -> object:
+ return 1
+
+ def f() -> bool:
+ return g() is 1
+ "});
+ assert_eq!(out.matches("g()").count(), 2, "single evaluation:\n{out}");
+ }
+
+ #[test]
+ fn a_pattern_target_escapes_what_the_source_cannot_carry() {
+ // a control character has no raw spelling in python source — CPython
+ // refuses a file containing a NUL outright — so the regex carries its
+ // escape instead
+ let out = out(indoc! {"
+ def f(s: str) -> bool:
+ return s is f\"a\\x00b{int}\"
+ "});
+ assert!(
+ out.contains("\\\\x00") || out.contains("a\\\\x00b"),
+ "the pattern escapes the control character: {out}"
);
+ assert!(!out.contains('\0'), "no raw NUL reaches the output: {out}");
}
#[test]
@@ -1710,11 +1927,12 @@ mod tests {
enum class Genre:
case A, B
- print(Genre.A is not Genre.B)
+ def f(g: Genre) -> None:
+ print(g is not Genre.B)
"});
assert!(
- out.contains("print(Genre.A is not Genre.B)"),
- "caseless variant rhs keeps identity: {out}"
+ out.contains("print(g is not Genre.B)"),
+ "caseless variant rhs is an identity check: {out}"
);
}
@@ -1727,12 +1945,12 @@ mod tests {
case Circle(radius: float)
case Point
- c = Shape.Circle(1.0)
- print(c is Shape.Circle)
- print(c is not Shape.Point)
+ def f(c: Shape) -> None:
+ print(c is Shape.Circle)
+ print(c is not Shape.Point)
"});
assert!(
- out.contains("print(isinstance(c, Shape.Circle))"),
+ out.contains("print(isinstance(c, _Shape_Circle))"),
"payload variant rhs is a class and lowers to isinstance: {out}"
);
assert!(
diff --git a/crates/by_transforms/src/transforms/reified_class.rs b/crates/by_transforms/src/transforms/reified_class.rs
index 51984d3f55..fe2ae49431 100644
--- a/crates/by_transforms/src/transforms/reified_class.rs
+++ b/crates/by_transforms/src/transforms/reified_class.rs
@@ -227,7 +227,7 @@ impl<'src> ReifiedClass<'src> {
}
fn specialize(&mut self, class: &StmtClassDef) {
- let reads = reified_class_reads(self.source, PySourceType::BasedPython, class);
+ let reads = reified_class_reads(PySourceType::BasedPython, class);
if reads.names.is_empty() {
return;
}
diff --git a/crates/by_transforms/src/transforms/reified_generic.rs b/crates/by_transforms/src/transforms/reified_generic.rs
index d845701762..e965deadd6 100644
--- a/crates/by_transforms/src/transforms/reified_generic.rs
+++ b/crates/by_transforms/src/transforms/reified_generic.rs
@@ -230,7 +230,7 @@ impl<'src> ReifiedGeneric<'src> {
}
fn wrap(&mut self, function: &StmtFunctionDef) {
- if reified_type_param_names(self.source, PySourceType::BasedPython, function).is_empty() {
+ if reified_type_param_names(PySourceType::BasedPython, function).is_empty() {
return;
}
if !self.supports_native_generics {
diff --git a/crates/by_transforms/src/transforms/type_expr_walker.rs b/crates/by_transforms/src/transforms/type_expr_walker.rs
index 101903c6a0..de6c993228 100644
--- a/crates/by_transforms/src/transforms/type_expr_walker.rs
+++ b/crates/by_transforms/src/transforms/type_expr_walker.rs
@@ -32,6 +32,11 @@
//! 9. `Annotated[T, meta…]` only the first arg
//! 10. `Callable[[P1, P2], R]` parameter list elements + return type
//! 12. class base list
+//! 13. the type-expression arguments of a typing construct that spells a type through a
+//! call — `NewType("D", int)`, `TypeVar("T", bound=int)`, the functional
+//! `NamedTuple` and `TypedDict`. which arguments those are is the type checker's
+//! answer rather than a list kept here, so a form ty learns to check as a type is
+//! one this lowers without further change
use ruff_python_ast::helpers::declaration_annotation_type;
use ruff_python_ast::visitor::{Visitor, walk_expr, walk_stmt};
@@ -374,6 +379,36 @@ impl<'ast> Visitor<'ast> for TypePosWalker<'_> {
}
return;
}
+
+ // a typing construct that spells a type through a call
+ if let Some(types) = self.types {
+ let type_arguments = types.call_type_expression_arguments(c);
+ if !type_arguments.is_empty() {
+ // a form can hold its types inside a literal — a `NamedTuple`'s
+ // field list, a `TypedDict`'s field dict — so an argument counts as
+ // handled once it *contains* one, and is not walked again as a value
+ let holds_type_argument = |expr: &Expr| {
+ type_arguments
+ .iter()
+ .any(|argument| expr.range().contains_range(argument.range()))
+ };
+ for argument in &type_arguments {
+ self.visit_type_expr(argument, TypePos::Root);
+ }
+ // everything else the call holds is an ordinary value
+ for arg in &c.arguments.args {
+ if !holds_type_argument(arg) {
+ self.visit_expr(arg);
+ }
+ }
+ for kw in &c.arguments.keywords {
+ if !holds_type_argument(&kw.value) {
+ self.visit_expr(&kw.value);
+ }
+ }
+ return;
+ }
+ }
}
// lambda parameter annotations (basedpython supports typed lambdas)
if let Expr::Lambda(l) = expr {
diff --git a/crates/by_transforms/src/transforms/type_is.rs b/crates/by_transforms/src/transforms/type_is.rs
index 88eea4e5ea..49ddc402e0 100644
--- a/crates/by_transforms/src/transforms/type_is.rs
+++ b/crates/by_transforms/src/transforms/type_is.rs
@@ -8,11 +8,10 @@
//! identical to PEP 742 `TypeIs[T]`; the parameter name is lost in
//! lowering since `TypeIs` doesn't carry it.
//!
-//! traversal is delegated to [`type_expr_walker`] (with `types = None` —
-//! value-position `a is T` is *not* a type expression here; it's the
-//! basedpython surface form for `isinstance(a, T)`, owned by
-//! `identity_swap`). running before `identity_swap` in the `AstPass` list so
-//! type-position rewrites win the first-wins overlap dedup
+//! traversal is delegated to [`type_expr_walker`] (with `types = None`): the
+//! `a is T` written in a *return guard* is what this rewrites, and the same
+//! pair written in a body is a type test that [`parametric_is`] lowers. this
+//! pass claims the guard first, so the two never rewrite the same span
use ruff_python_ast::helpers::{ReturnGuardForm, return_guards};
use ruff_python_ast::visitor::{Visitor, walk_stmt};
diff --git a/crates/by_transforms/src/type_info.rs b/crates/by_transforms/src/type_info.rs
index cf9df35f79..35da2c5acc 100644
--- a/crates/by_transforms/src/type_info.rs
+++ b/crates/by_transforms/src/type_info.rs
@@ -10,6 +10,7 @@ use ruff_python_stdlib::basedpython::IMPLICIT_TYPING_NAMES;
use ruff_text_size::TextRange;
use ty_python_core::scope::ScopeKind;
use ty_python_core::{global_scope, place_table, semantic_index};
+use ty_python_semantic::types::call_type_forms::CallTypeForm;
use ty_python_semantic::types::{
DisplaySettings, DynamicType, KnownClass, KnownInstanceType, Type, UnpackedKwargs, character,
};
@@ -91,6 +92,18 @@ pub(crate) trait TypeInfo {
/// unresolved name should be treated as a runtime subscript, not a type)
fn subscript_is_known_type_context(&self, value: &Expr) -> bool;
+ /// the arguments of `call` that are type expressions, because the call is one of the
+ /// typing constructs that spells a type through a call — `NewType("D", int)`,
+ /// `TypeVar("T", bound=int)`, the functional `NamedTuple` and `TypedDict`
+ ///
+ /// the type checker decides this, from the same
+ /// [`CallTypeForm`](ty_python_semantic::types::call_type_forms::CallTypeForm) it uses
+ /// to check those arguments as types. a form ty checks as a type and the transpiler
+ /// leaves alone is a silent miscompilation: the surface syntax survives into the
+ /// output as ordinary python, where `A & B` is a runtime `__and__` call and
+ /// `A and B or C` evaluates to `C`
+ fn call_type_expression_arguments<'ast>(&self, call: &'ast ExprCall) -> Vec<&'ast Expr>;
+
fn is_function(&self, name: &ExprName) -> bool;
/// basedpython: how many entries the class pattern's class lists in
@@ -154,22 +167,16 @@ pub(crate) trait TypeInfo {
rhs: &Expr,
) -> Option;
- /// [`Self::parametric_is_plan`] for a checked cast's `(value, target)` pair.
- /// the same classification engine backs both; only the target's inference
- /// position differs (a cast target is a type expression)
- fn parametric_cast_plan(
- &self,
- value: &Expr,
- target: &Expr,
- ) -> Option;
-
- /// whether an `is`/`is not` comparison whose rhs is `expr` keeps python
- /// identity semantics instead of lowering to `isinstance`: true when
- /// `expr` resolves to a plain *value* — an enum member (`Color.RED`, a
- /// based-enum unit variant like `Shape.Point`), another literal, or an
- /// instance of a concrete non-type class — which `isinstance` would
- /// reject as its classinfo argument at runtime
- fn is_keeps_identity(&self, expr: &Expr) -> bool;
+ /// whether the *value* `expr` evaluates to is a plain value rather than a
+ /// class — an enum member, a literal, an instance of a concrete non-type
+ /// class. `isinstance` would reject such a value as its classinfo argument,
+ /// so the runtime test for it is identity.
+ ///
+ /// Only consulted for a target the checker could not read as a type at all.
+ /// The enum lowering rewrites a unit variant into a singleton instance
+ /// before this pass runs, so `s is Shape.Point` reaches here naming
+ /// something that is a type in the source and a value in what is emitted
+ fn is_plain_value(&self, expr: &Expr) -> bool;
/// whether `expr` is a PEP 604 union standing where the runtime will
/// evaluate it — `isinstance(x, int | str)`, a `cast` target, an alias
@@ -222,11 +229,6 @@ pub(crate) trait TypeInfo {
attribute: &ruff_python_ast::ExprAttribute,
) -> Option;
- /// how `x is ` is answered when `target` is an interface something
- /// visibly conforms to. `None` when the ordinary `isinstance` lowering is
- /// still right
- fn conformance_test(&self, target: &Expr) -> Option;
-
/// the conversions a statement's value needs: an annotated assignment, an
/// attribute assignment, or a `return`. one wrap for a value that converts
/// whole, or one per element for a collection literal
@@ -626,6 +628,14 @@ impl TypeInfo for SemanticModel<'_> {
}
}
+ fn call_type_expression_arguments<'ast>(&self, call: &'ast ExprCall) -> Vec<&'ast Expr> {
+ call.func
+ .inferred_type(self)
+ .and_then(|callee| CallTypeForm::of(self.db(), callee))
+ .map(|form| form.type_expressions(&call.arguments))
+ .unwrap_or_default()
+ }
+
fn is_function(&self, name: &ExprName) -> bool {
name.inferred_type(self)
.is_some_and(|ty| ty.as_function_literal().is_some())
@@ -683,22 +693,8 @@ impl TypeInfo for SemanticModel<'_> {
SemanticModel::parametric_is_plan(self, lhs, rhs)
}
- fn parametric_cast_plan(
- &self,
- value: &Expr,
- target: &Expr,
- ) -> Option {
- SemanticModel::parametric_cast_plan(self, value, target)
- }
-
- fn is_keeps_identity(&self, expr: &Expr) -> bool {
- expr.inferred_type(self).is_some_and(|ty| {
- ty_python_semantic::types::basedpython_is_keeps_identity(
- self.db(),
- &self.program_environment(),
- ty,
- )
- })
+ fn is_plain_value(&self, expr: &Expr) -> bool {
+ SemanticModel::denotes_plain_value(self, expr)
}
fn is_runtime_union(&self, expr: &Expr) -> bool {
@@ -752,10 +748,6 @@ impl TypeInfo for SemanticModel<'_> {
SemanticModel::witness_dispatch(self, attribute)
}
- fn conformance_test(&self, target: &Expr) -> Option {
- SemanticModel::conformance_test(self, target)
- }
-
fn statement_conversions(
&self,
stmt: &Stmt,
diff --git a/crates/by_transforms/tests/parametric_is_runtime.rs b/crates/by_transforms/tests/parametric_is_runtime.rs
index 311b81f375..669147ee48 100644
--- a/crates/by_transforms/tests/parametric_is_runtime.rs
+++ b/crates/by_transforms/tests/parametric_is_runtime.rs
@@ -339,6 +339,111 @@ print("ok")
/// Returns `None` (test skips) when none is found — PEP 695 class syntax is a
/// hard requirement here.
/// pep 695 type parameters — what [`PROGRAM`] needs (3.12+)
+/// basedpython whose module-level `assert`s exercise the runtime check each
+/// *non-parametric* target lowers to. The checker's own tests pin the type it
+/// reads and the transpiler's pin the text it emits; only running the result
+/// says whether the check accepts the values the type contains and no others.
+const TARGETS_PROGRAM: &str = r#"
+from typing import Callable, Literal, Protocol, runtime_checkable
+
+class A: ...
+class B(A): ...
+
+# a class is the `isinstance` it looks like
+assert (B() is A) is True, "a subclass instance is an A"
+assert (A() is B) is False, "a base instance is not a B"
+
+# `type[C]` asks two things, and the runtime can answer both
+assert (B is type[A]) is True, "a subclass is a type[A]"
+assert (B() is type[A]) is False, "an instance is not a type[A]"
+assert (int is type[A]) is False, "an unrelated class is not a type[A]"
+
+# a literal names the values equal to it, with the class pinned: `1 == True` in
+# python, and `Literal[1]` does not contain `True`
+def literal_one(v: object) -> bool:
+ return v is Literal[1]
+
+assert literal_one(1) is True, "1 is a Literal[1]"
+assert literal_one(True) is False, "True is not a Literal[1]"
+assert literal_one(2) is False, "2 is not a Literal[1]"
+
+def literal_true(v: object) -> bool:
+ return v is Literal[True]
+
+assert literal_true(True) is True, "True is a Literal[True]"
+assert literal_true(1) is False, "1 is not a Literal[True]"
+
+# a union holds when any arm does, including one the source spells with a
+# single word
+type Key = int | str
+
+def key(v: object) -> bool:
+ return v is Key
+
+assert key(1) is True, "an int is a Key"
+assert key("a") is True, "a str is a Key"
+assert key(1.5) is False, "a float is not a Key"
+
+# a template literal type is the set of strings its pattern produces
+def item(s: str) -> bool:
+ return s is f"item-{int}"
+
+assert item("item-12") is True, "item-12 matches"
+assert item("item--3") is True, "a negative renders with its sign"
+assert item("item-0") is True, "zero renders as one digit"
+assert item("item--0") is False, "str(-0) is 0, so -0 renders no such string"
+assert item("item-01") is False, "a leading zero is not an int rendering"
+assert item("item-ab") is False, "a non-numeric tail does not match"
+assert item("item-") is False, "an empty hole does not match"
+assert (1 is f"item-{int}") is False, "a non-str is never one of the strings"
+
+# a pattern's literal text matches itself, metacharacters and all
+def dotted(s: str) -> bool:
+ return s is f"a.b{int}"
+
+assert dotted("a.b1") is True, "the dot is literal text"
+assert dotted("axb1") is False, "and does not stand for any character"
+
+# `None` is a value, so its test is the identity python already performs
+def optional(v: object) -> bool:
+ return v is None
+
+assert optional(None) is True, "None is None"
+assert optional(0) is False, "0 is not None"
+
+# a bare `Callable` asks exactly what `callable()` answers
+def call(v: object) -> bool:
+ return v is Callable
+
+assert call(len) is True, "a builtin is callable"
+assert call(A) is True, "a class is callable"
+assert call(1) is False, "an int is not"
+
+# a `@runtime_checkable` protocol is the check python itself performs
+@runtime_checkable
+class HasName(Protocol):
+ name: str
+
+class Named:
+ def __init__(self) -> None:
+ self.name = "x"
+
+assert (Named() is HasName) is True, "a value with the member satisfies it"
+assert (1 is HasName) is False, "one without does not"
+
+# `is not` is the negation, evaluated once
+calls = []
+
+def once() -> object:
+ calls.append(1)
+ return 1
+
+assert (once() is not Literal[2]) is True, "the negation holds"
+assert len(calls) == 1, "an effectful value is evaluated once"
+
+print("ok")
+"#;
+
const PEP695_PROBE: &str = "type X[T] = T";
/// pep 696 defaults in native syntax plus the `has_default()` accessor the probe
@@ -406,6 +511,19 @@ fn parametric_protocol_checks_run_correctly() {
run_program(&python, PROGRAM);
}
+#[test]
+#[expect(
+ clippy::print_stderr,
+ reason = "a skipped test must say why it skipped, or it reads as a pass"
+)]
+fn every_target_kind_checks_correctly_at_runtime() {
+ let Some(python) = python_supporting(PEP695_PROBE) else {
+ eprintln!("skipping type-test target runtime test: no PEP 695-capable interpreter found");
+ return;
+ };
+ run_program(&python, TARGETS_PROGRAM);
+}
+
#[test]
#[expect(
clippy::print_stderr,
diff --git a/crates/ruff_dev/src/generate_ty_env_vars_reference.rs b/crates/ruff_dev/src/generate_ty_env_vars_reference.rs
index a9a474fe6f..fa404d6186 100644
--- a/crates/ruff_dev/src/generate_ty_env_vars_reference.rs
+++ b/crates/ruff_dev/src/generate_ty_env_vars_reference.rs
@@ -91,10 +91,12 @@ fn generate() -> String {
output.push_str("# Environment variables\n\n");
- // Partition and sort environment variables into TY_ and external variables.
+ // Partition and sort environment variables into the ones ty defines and the ones it only
+ // reads. `BY_` as well as `TY_`, because basedpython's own variables are as much ty's as
+ // the rest — listing them beside `VIRTUAL_ENV` would say ty merely observes them.
let (ty_vars, external_vars): (BTreeSet<_>, BTreeSet<_>) = EnvVars::metadata()
.iter()
- .partition(|(var, _)| var.starts_with("TY_"));
+ .partition(|(var, _)| var.starts_with("TY_") || var.starts_with("BY_"));
output.push_str("ty defines and respects the following environment variables:\n\n");
diff --git a/crates/ruff_linter/resources/test/fixtures/basedpython/BY001.by b/crates/ruff_linter/resources/test/fixtures/basedpython/BY001.by
index 623b7c8278..1f808f6ebb 100644
--- a/crates/ruff_linter/resources/test/fixtures/basedpython/BY001.by
+++ b/crates/ruff_linter/resources/test/fixtures/basedpython/BY001.by
@@ -26,12 +26,14 @@ def not_coalescing(a, b, c):
# a falsiness test is not the same test
_ = a if a else b
- # not a `None` comparison
- _ = a if a is not b else b
+ # not a `None` comparison. written with the identity operator because `is`
+ # takes a type, and `b` is a value
+ _ = a if a !== b else b
_ = a if a == None else b
- # a chained comparison guards more than one thing
- _ = a if a is not None is not b else b
+ # a chained comparison guards more than one thing. it is written with the
+ # identity operator because a type test may not join a chain at all
+ _ = a if a !== None !== b else b
# the left operand has to be evaluated twice to write it this way, so it
# cannot be anything that runs code — a subscription included
diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B007_basedpython.by b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B007_basedpython.by
new file mode 100644
index 0000000000..df94bfc0c5
--- /dev/null
+++ b/crates/ruff_linter/resources/test/fixtures/flake8_bugbear/B007_basedpython.by
@@ -0,0 +1,31 @@
+"""a destructuring loop binds its control variables in the header's pattern rather
+than in its target, which holds only the synthetic binder the pattern takes apart"""
+
+
+class Point:
+ __match_args__ = ("x", "y")
+ x: int
+ y: int
+
+
+def unused_capture(points: list[Point]) -> None:
+ for Point(x, y) in points: # B007 on `y`
+ print(x)
+
+
+def both_used(points: list[Point]) -> None:
+ for Point(x, y) in points:
+ print(x, y)
+
+
+# an underscore-prefixed capture says the omission is deliberate, as it does for a
+# tuple target
+def deliberately_unused(points: list[Point]) -> None:
+ for Point(x, _y) in points:
+ print(x)
+
+
+# nesting reaches the captures at every depth
+def nested(pairs: list[tuple[Point, Point]]) -> None:
+ for (Point(a, b), Point(c, d)) in pairs: # B007 on `b`, `c` and `d`
+ print(a)
diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM222_basedpython.by b/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM222_basedpython.by
new file mode 100644
index 0000000000..15ce8cea79
--- /dev/null
+++ b/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM222_basedpython.by
@@ -0,0 +1,20 @@
+# `or` and `and` inside a type expression are basedpython's keyword spellings of
+# union and intersection, so what looks like boolean logic there is type syntax:
+# `A or B` is the type `A | B`, not an expression whose value is always `A`
+type Names = "foo" or "bar"
+
+type Both = int and str
+
+
+def annotated(a: str or bytes, b: "x" or "y") -> int or None: ...
+
+
+def nested(handlers: list[int or str]) -> None: ...
+
+
+# a boolean operator in a value expression is untouched
+def values(a: int, b: int) -> None:
+ if a or True: # SIM222
+ pass
+ if b and False: # SIM223
+ pass
diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM300.py b/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM300.py
index 0745cc6108..b6c12cdd86 100644
--- a/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM300.py
+++ b/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM300.py
@@ -44,3 +44,18 @@
# https://github.com/astral-sh/ruff/issues/14761
{"": print(1)} == print(2)
{0: 1, **print(2)} == print(4)
+
+
+# A type parameter is a name the caller chooses, not a constant, however the
+# one-letter convention spells it
+def compare_type_param[T](value: T) -> None:
+ if T == int:
+ pass
+ if T > value:
+ pass
+
+
+class Box[T]:
+ def check(self) -> None:
+ if T == int:
+ pass
diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM300_basedpython.by b/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM300_basedpython.by
new file mode 100644
index 0000000000..603b283e4d
--- /dev/null
+++ b/crates/ruff_linter/resources/test/fixtures/flake8_simplify/SIM300_basedpython.by
@@ -0,0 +1,16 @@
+# a `type def` computes a type from its type parameters, so its body compares
+# those parameters against types — none of which is a constant on the left
+type def Widen[X]:
+ if X <= int:
+ return int
+ return str
+
+
+def reified_cell[reified T](x: T) -> None:
+ assert T == int
+
+
+# a genuine yoda condition still fires
+def yoda(age: int) -> None:
+ if 42 == age: # SIM300
+ pass
diff --git a/crates/ruff_linter/resources/test/fixtures/pycodestyle/E70_basedpython.by b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E70_basedpython.by
index 44ef59d12f..ca0d0fe3b0 100644
--- a/crates/ruff_linter/resources/test/fixtures/pycodestyle/E70_basedpython.by
+++ b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E70_basedpython.by
@@ -14,3 +14,55 @@ class Members:
# a class definition written on one line is still reported
class OneLiner: x = 1
+
+
+# a match type's `case` arms are type expressions, so the `:` in one separates a
+# pattern from a type and opens no suite
+class A: ...
+
+
+class B: ...
+
+
+type Swap[*Ts] = match *Ts:
+ case (A, B): (B, A)
+ case _: ()
+
+
+def nested_alias[T]() -> None:
+ type Widen[X] = match X:
+ case int: float
+ case _: X
+
+ # a `match` statement after the alias is a compound statement again
+ match T:
+ case int(): pass
+ case _: pass
+
+
+# a statement-expression `match` really does hold statements
+def statement_expression(v: object) -> int:
+ result = match v:
+ case int(): 1
+ case _: 0
+ return result
+
+
+class Holder:
+ type Inner[X] = match X:
+ case int: float
+ case _: X
+
+ def method(self, v: object) -> None:
+ match v:
+ case int(): pass
+
+
+def guarded(flag: bool) -> None:
+ if flag:
+ type Guarded[X] = match X:
+ case int: float
+ case _: X
+
+ match flag:
+ case True: pass
diff --git a/crates/ruff_linter/resources/test/fixtures/pycodestyle/E721_basedpython.by b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E721_basedpython.by
new file mode 100644
index 0000000000..8f5f5953c6
--- /dev/null
+++ b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E721_basedpython.by
@@ -0,0 +1,11 @@
+# `is` is basedpython's isinstance check and `===` its identity comparison, so
+# the two operators this rule names are not the ones python's message names
+def compare(a: object, b: object) -> None:
+ if type(a) == type(b):
+ pass
+ if type(a) == int:
+ pass
+
+
+def reified[reified T](x: T) -> None:
+ assert T == int
diff --git a/crates/ruff_linter/resources/test/fixtures/pyflakes/F632_basedpython.by b/crates/ruff_linter/resources/test/fixtures/pyflakes/F632_basedpython.by
new file mode 100644
index 0000000000..d57c6a2936
--- /dev/null
+++ b/crates/ruff_linter/resources/test/fixtures/pyflakes/F632_basedpython.by
@@ -0,0 +1,36 @@
+# basedpython spells identity `===` / `!==`; a plain `is` is a parametric type
+# test, which the parser flattens to the same operator. Only the identity
+# spellings, and an `is` whose right-hand side is a literal, compare identity
+def identity(x: object) -> None:
+ print(1 === 1) # F632
+ print(x !== "a") # F632
+
+
+def literal_right_hand_side(x: object) -> None:
+ print(1 is 1) # F632
+ print(x is not "a") # F632
+
+
+def type_tests[T](x: T, xs: list[int]) -> None:
+ print(1 is T)
+ print(xs is list[int])
+ # this one lowers to `isinstance([], [])`, which raises at runtime — but the
+ # mistake in it is the target, not the operator, so it is ty's to report
+ print([] is [])
+
+
+# which spelling was written is recorded on the comparison, not recovered from the
+# source between the operands, so it survives everything that can sit there
+def spelling_survives_what_sits_between(x: object) -> None:
+ a = (1) === (1) # F632
+ b = 1 \
+ === 1 # F632
+ c = (1 === # a comment between the operands
+ 1) # F632
+ d = 1===1 # F632
+
+
+# each operator of a chain answers for its own spelling
+def chained(x: object) -> None:
+ e = x < 1 === 1 # F632
+ f = 1 === 1 !== 2 # F632
diff --git a/crates/ruff_linter/resources/test/fixtures/pyflakes/F841_basedpython.by b/crates/ruff_linter/resources/test/fixtures/pyflakes/F841_basedpython.by
index 57e96f2e30..2e4bd0ebf2 100644
--- a/crates/ruff_linter/resources/test/fixtures/pyflakes/F841_basedpython.by
+++ b/crates/ruff_linter/resources/test/fixtures/pyflakes/F841_basedpython.by
@@ -45,3 +45,39 @@ def ordinary_capture(value: object) -> int:
return 1
case _:
return 0
+
+
+# a destructuring binder always binds, so its captures are the ones the
+# equivalent python spelling makes: `for Point(x, y) in points` binds like
+# `for x, y in points`, and `let Point(x, y) := p` like `x, y = p`. None of
+# those are the plain assignment this rule reports
+class Point:
+ __match_args__ = ("x", "y")
+ x: int
+ y: int
+
+
+def destructuring_binders(points: list[Point], p: Point) -> None:
+ let Point(a, b) := p
+ print(a)
+
+ for Point(c, d) in points:
+ print(c)
+
+ with open("f") as Point(e, g):
+ print(e)
+
+
+def destructuring_parameter(Point(h, i): Point) -> int:
+ return h
+
+
+# a `match` case and an `if let` bind only when the pattern matches, which is
+# what python's own capture pattern does, so an unused capture still fires
+def matching_binders(p: Point) -> int:
+ if let Point(j, k) := p: # F841 on `k`
+ return j
+ match p:
+ case Point(l, m): # F841 on `m`
+ return l
+ return 0
diff --git a/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP037_basedpython.by b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP037_basedpython.by
new file mode 100644
index 0000000000..3e394d6bc4
--- /dev/null
+++ b/crates/ruff_linter/resources/test/fixtures/pyupgrade/UP037_basedpython.by
@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+
+# basedpython reads a bare string in a type position as the literal type
+# `Literal["c"]` rather than as a forward reference, so its quotes carry the
+# meaning and removing them would name a variable instead
+c: "c" = "b"
+
+
+def parameter(mode: "read") -> "write": ...
+
+
+def body() -> None:
+ local: "held" = "held"
+ print(local)
+
+
+# a forward reference is written the way every other type is
+class Node:
+ parent: Node | None
diff --git a/crates/ruff_linter/resources/test/fixtures/ruff/RUF021_basedpython.by b/crates/ruff_linter/resources/test/fixtures/ruff/RUF021_basedpython.by
new file mode 100644
index 0000000000..6f5b554ff6
--- /dev/null
+++ b/crates/ruff_linter/resources/test/fixtures/ruff/RUF021_basedpython.by
@@ -0,0 +1,21 @@
+# `A and B or C` in a type expression is the intersection of `A` and `B` unioned
+# with `C`, which the keywords already spell unambiguously — there is no python
+# precedence to make clear with parentheses
+class A: ...
+
+
+class B: ...
+
+
+class C: ...
+
+
+def annotated(x: A and B or C) -> None: ...
+
+
+type Alias = A and B or C
+
+
+# a boolean expression in a value position still wants the parentheses
+def values(a: bool, b: bool, c: bool) -> bool:
+ return a and b or c
diff --git a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs
index 33c3cf36ed..5af4e1dc93 100644
--- a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs
+++ b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs
@@ -1689,13 +1689,14 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
comparators,
range: _,
node_index: _,
+ identity_ops: _,
},
) => {
if checker.any_rule_enabled(&[Rule::NoneComparison, Rule::TrueFalseComparison]) {
pycodestyle::rules::literal_comparisons(checker, compare);
}
if checker.is_rule_enabled(Rule::IsLiteral) {
- pyflakes::rules::invalid_literal_comparison(checker, left, ops, comparators, expr);
+ pyflakes::rules::invalid_literal_comparison(checker, compare);
}
if checker.is_rule_enabled(Rule::TypeComparison) {
pycodestyle::rules::type_comparison(checker, compare);
@@ -1966,6 +1967,12 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
}
}
Expr::BoolOp(bool_op) => {
+ // basedpython spells union and intersection with `or` and `and` inside a
+ // type expression, so `A or B` there is the type `A | B` rather than a
+ // boolean expression. None of the rules below describe that operator
+ if checker.semantic().in_basedpython_type_expression() {
+ return;
+ }
if checker.is_rule_enabled(Rule::BooleanChainedComparison) {
pylint::rules::boolean_chained_comparison(checker, bool_op);
}
diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs
index 40a469e076..87868f759a 100644
--- a/crates/ruff_linter/src/checkers/ast/mod.rs
+++ b/crates/ruff_linter/src/checkers/ast/mod.rs
@@ -39,7 +39,7 @@ use ruff_python_ast::identifier::Identifier;
use ruff_python_ast::name::QualifiedName;
use ruff_python_ast::str::Quote;
use ruff_python_ast::token::Tokens;
-use ruff_python_ast::visitor::{Visitor, walk_except_handler, walk_pattern};
+use ruff_python_ast::visitor::{Visitor, walk_except_handler, walk_pattern, walk_with_item};
use ruff_python_ast::{
self as ast, AnyParameterRef, ArgOrKeyword, Comprehension, ElifElseClause, ExceptHandler, Expr,
ExprContext, ExprFString, ExprTString, InterpolatedStringElement, Keyword, MatchCase,
@@ -194,6 +194,26 @@ impl ExpectedDocstringKind {
}
}
+/// basedpython: how a pattern's captures are bound, which follows the binder the
+/// pattern sits in rather than the pattern itself
+///
+/// a `match` case or an `if let` binds only when the pattern matches, which is what
+/// python's own capture pattern does. the destructuring binders bind unconditionally,
+/// and each one has a python spelling whose bindings they should match: `for Point(x,
+/// y) in points` binds `x` and `y` the way `for x, y in points` does, and `let Point(x,
+/// y) := p` the way `x, y = p` does
+#[derive(Copy, Clone, Debug, Default)]
+enum PatternBinder {
+ /// `case :`, `if let := ...` — a conditional capture
+ #[default]
+ Match,
+ /// `for in ...` — a loop variable
+ Loop,
+ /// `let := ...`, `with ... as `, `def f(: T)` — the
+ /// captures come out of unpacking a value that is always there
+ Unpacking,
+}
+
pub(crate) struct Checker<'a> {
/// The [`Parsed`] output for the source code.
parsed: &'a Parsed,
@@ -250,6 +270,8 @@ pub(crate) struct Checker<'a> {
semantic_checker: SemanticSyntaxChecker,
/// Errors collected by the `semantic_checker`.
semantic_errors: RefCell>,
+ /// basedpython: the binder whose pattern is currently being visited
+ pattern_binder: PatternBinder,
context: &'a LintContext<'a>,
}
@@ -307,6 +329,7 @@ impl<'a> Checker<'a> {
target_version,
semantic_checker: SemanticSyntaxChecker::new(),
semantic_errors: RefCell::default(),
+ pattern_binder: PatternBinder::default(),
context,
}
}
@@ -1703,6 +1726,19 @@ impl<'a> Visitor<'a> for Checker<'a> {
self.visit_body(orelse);
self.semantic.flags = flags_snapshot;
}
+ // basedpython: `let := ` unpacks the subject, and falls
+ // through to the `else` block when the pattern does not match
+ Stmt::Let(ast::StmtLet {
+ pattern,
+ value,
+ orelse,
+ range: _,
+ node_index: _,
+ }) => {
+ self.visit_expr(value);
+ self.visit_pattern_binder(PatternBinder::Unpacking, pattern);
+ self.visit_body(orelse);
+ }
Stmt::For(ast::StmtFor {
node_index: _,
range: _,
@@ -1717,7 +1753,7 @@ impl<'a> Visitor<'a> for Checker<'a> {
self.visit_expr(target);
// basedpython: a destructuring loop binds the pattern's captures
if let Some(pattern) = pattern {
- self.visit_pattern(pattern);
+ self.visit_pattern_binder(PatternBinder::Loop, pattern);
}
self.visit_body(body);
let flags_snapshot = self.semantic.flags;
@@ -1739,7 +1775,7 @@ impl<'a> Visitor<'a> for Checker<'a> {
// its subject against the pattern rather than testing it
if let Some(pattern) = pattern {
self.visit_expr(test);
- self.visit_pattern(pattern);
+ self.visit_pattern_binder(PatternBinder::Match, pattern);
} else {
self.visit_boolean_test(test);
}
@@ -1822,15 +1858,21 @@ impl<'a> Visitor<'a> for Checker<'a> {
&& (self.semantic.in_annotation() || self.source_type.is_stub())
{
if let Expr::StringLiteral(string_literal) = expr {
- self.visit
- .string_type_definitions
- .push((string_literal, self.semantic.snapshot()));
+ // basedpython promotes bare string literals in type positions to
+ // `Literal["..."]` rather than treating them as forward references,
+ // so there is no annotation inside the quotes to defer
+ if !self.source_type.is_basedpython() {
+ self.visit
+ .string_type_definitions
+ .push((string_literal, self.semantic.snapshot()));
+ return;
+ }
} else {
self.visit
.future_type_definitions
.push((expr, self.semantic.snapshot()));
+ return;
}
- return;
}
self.semantic.push_node(expr);
@@ -2515,13 +2557,21 @@ impl<'a> Visitor<'a> for Checker<'a> {
// basedpython: a destructuring parameter binds its pattern's captures in
// the same scope the parameter itself is bound in
if let Some(pattern) = parameter.pattern.as_deref() {
- self.visit_pattern(pattern);
+ self.visit_pattern_binder(PatternBinder::Unpacking, pattern);
}
// Step 4: Analysis
analyze::parameter(parameter, self);
}
+ fn visit_with_item(&mut self, with_item: &'a ast::WithItem) {
+ // basedpython: `with ctx() as Point(x, y):` unpacks the bound value. a with item
+ // holds no statements, so the binder can stand for the whole walk
+ let snapshot = std::mem::replace(&mut self.pattern_binder, PatternBinder::Unpacking);
+ walk_with_item(self, with_item);
+ self.pattern_binder = snapshot;
+ }
+
fn visit_pattern(&mut self, pattern: &'a Pattern) {
// Step 1: Binding
if let Pattern::MatchAs(ast::PatternMatchAs {
@@ -2543,12 +2593,20 @@ impl<'a> Visitor<'a> for Checker<'a> {
// `is_basedpython_transpile_resolved_name` makes applies: a name that
// matches no variant at all stays an ordinary capture, and ty reports
// one whose subject does not accept it
- let flags = if self.semantic.is_based_enum_case_name(name.as_str()) {
+ let mut flags = if self.semantic.is_based_enum_case_name(name.as_str()) {
BindingFlags::BASED_ENUM_CASE_NAME
} else {
BindingFlags::empty()
};
- self.add_binding(name, name.range(), BindingKind::Assignment, flags);
+ let kind = match self.pattern_binder {
+ PatternBinder::Match => BindingKind::Assignment,
+ PatternBinder::Loop => BindingKind::LoopVar,
+ PatternBinder::Unpacking => {
+ flags |= BindingFlags::UNPACKED_ASSIGNMENT;
+ BindingKind::Assignment
+ }
+ };
+ self.add_binding(name, name.range(), kind, flags);
}
// Step 2: Traversal
@@ -2569,7 +2627,7 @@ impl<'a> Visitor<'a> for Checker<'a> {
}
fn visit_match_case(&mut self, match_case: &'a MatchCase) {
- self.visit_pattern(&match_case.pattern);
+ self.visit_pattern_binder(PatternBinder::Match, &match_case.pattern);
if let Some(expr) = &match_case.guard {
self.visit_boolean_test(expr);
}
@@ -2870,12 +2928,20 @@ impl<'a> Checker<'a> {
self.semantic.flags = snapshot;
}
+ /// basedpython: visit a [`Pattern`] as the given binder's, so that its captures are
+ /// bound the way that binder's python spelling binds them
+ fn visit_pattern_binder(&mut self, binder: PatternBinder, pattern: &'a Pattern) {
+ let snapshot = std::mem::replace(&mut self.pattern_binder, binder);
+ self.visit_pattern(pattern);
+ self.pattern_binder = snapshot;
+ }
+
/// Visit an [`ElifElseClause`]
fn visit_elif_else_clause(&mut self, clause: &'a ElifElseClause) {
if let Some(test) = &clause.test {
if let Some(pattern) = &clause.pattern {
self.visit_expr(test);
- self.visit_pattern(pattern);
+ self.visit_pattern_binder(PatternBinder::Match, pattern);
} else {
self.visit_boolean_test(test);
}
diff --git a/crates/ruff_linter/src/rules/basedpython/rules/manual_isinstance.rs b/crates/ruff_linter/src/rules/basedpython/rules/manual_isinstance.rs
index 52ce7d36de..1384ab5f6c 100644
--- a/crates/ruff_linter/src/rules/basedpython/rules/manual_isinstance.rs
+++ b/crates/ruff_linter/src/rules/basedpython/rules/manual_isinstance.rs
@@ -1,6 +1,6 @@
use ruff_diagnostics::Applicability;
use ruff_macros::{ViolationMetadata, derive_message_formats};
-use ruff_python_ast::{self as ast, Expr, UnaryOp};
+use ruff_python_ast::{self as ast, Expr, Operator, UnaryOp};
use ruff_text_size::{Ranged, TextRange};
use crate::checkers::ast::Checker;
@@ -43,7 +43,7 @@ use crate::{AlwaysFixableViolation, Edit, Fix};
/// than as several classes.
///
/// ## References
-/// - [basedpython documentation: identity and isinstance](https://docs.basedpython.org/features/identity-swap)
+/// - [basedpython documentation: type tests and identity](https://docs.basedpython.org/features/identity-swap)
#[derive(ViolationMetadata)]
#[violation_metadata(stable_since = "0.0.1-a10", category = Category::Style)]
pub(crate) struct ManualIsinstance;
@@ -59,6 +59,24 @@ impl AlwaysFixableViolation for ManualIsinstance {
}
}
+/// whether `classinfo` is something a type expression can name — a name, a
+/// dotted name, a subscript of one, or a `|` union of those.
+///
+/// `isinstance` takes a runtime value, and most of what can stand there is not
+/// a type expression: a tuple means the tuple *type* rather than a choice of
+/// classes, and `type(y)` or `registry["a"]` mean nothing there at all.
+fn names_a_type(classinfo: &Expr) -> bool {
+ match classinfo {
+ Expr::Name(_) => true,
+ Expr::Attribute(attribute) => names_a_type(&attribute.value),
+ Expr::Subscript(subscript) => names_a_type(&subscript.value),
+ Expr::BinOp(binop) if binop.op == Operator::BitOr => {
+ names_a_type(&binop.left) && names_a_type(&binop.right)
+ }
+ _ => false,
+ }
+}
+
/// BY003
pub(crate) fn manual_isinstance(checker: &Checker, call: &ast::ExprCall) {
if !checker.source_type.is_basedpython() {
@@ -76,9 +94,11 @@ pub(crate) fn manual_isinstance(checker: &Checker, call: &ast::ExprCall) {
if !call.arguments.keywords.is_empty() {
return;
}
- // a tuple in the class position reads as a tuple type, not as a choice of
- // classes
- if class.is_tuple_expr() {
+ // `is` takes a *type expression*, so the rewrite is only available when the
+ // classinfo argument is something one can say. a tuple reads as the tuple
+ // type rather than as a choice of classes; a call or a subscript of a value
+ // is not a type expression at all
+ if !names_a_type(class) {
return;
}
diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs b/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs
index 7a2cc0413b..06e879914b 100644
--- a/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs
+++ b/crates/ruff_linter/src/rules/flake8_bugbear/mod.rs
@@ -77,6 +77,7 @@ mod tests {
#[test_case(Rule::UnintentionalTypeAnnotation, Path::new("B032.py"))]
#[test_case(Rule::UnreliableCallableCheck, Path::new("B004.py"))]
#[test_case(Rule::UnusedLoopControlVariable, Path::new("B007.py"))]
+ #[test_case(Rule::UnusedLoopControlVariable, Path::new("B007_basedpython.by"))]
#[test_case(Rule::UselessComparison, Path::new("B015.ipynb"))]
#[test_case(Rule::UselessComparison, Path::new("B015.py"))]
#[test_case(Rule::UselessContextlibSuppress, Path::new("B022.py"))]
diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/rules/unused_loop_control_variable.rs b/crates/ruff_linter/src/rules/flake8_bugbear/rules/unused_loop_control_variable.rs
index c896454d8d..20398c64bf 100644
--- a/crates/ruff_linter/src/rules/flake8_bugbear/rules/unused_loop_control_variable.rs
+++ b/crates/ruff_linter/src/rules/flake8_bugbear/rules/unused_loop_control_variable.rs
@@ -2,9 +2,10 @@ use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast as ast;
use ruff_python_ast::helpers;
use ruff_python_ast::helpers::{NameFinder, StoredNameFinder};
-use ruff_python_ast::visitor::Visitor;
+use ruff_python_ast::visitor::{Visitor, walk_pattern};
use ruff_python_semantic::Binding;
-use ruff_text_size::Ranged;
+use ruff_text_size::{Ranged, TextRange};
+use rustc_hash::FxHashMap;
use crate::checkers::ast::Checker;
use crate::codes::Category;
@@ -83,10 +84,26 @@ impl Violation for UnusedLoopControlVariable {
/// B007
pub(crate) fn unused_loop_control_variable(checker: &Checker, stmt_for: &ast::StmtFor) {
- let control_names = {
+ let control_names: FxHashMap<&str, TextRange> = {
let mut finder = StoredNameFinder::default();
finder.visit_expr(stmt_for.target.as_ref());
- finder.names
+ let mut names: FxHashMap<&str, TextRange> = finder
+ .names
+ .into_iter()
+ .map(|(name, expr)| (name, expr.range()))
+ .collect();
+
+ // basedpython: a destructuring loop binds its control variables in the header's
+ // pattern rather than in its target, which holds only the synthetic binder the
+ // pattern takes apart. `for Point(x, y) in points` controls `x` and `y` exactly
+ // as `for x, y in points` does
+ if let Some(pattern) = &stmt_for.pattern {
+ let mut finder = PatternCaptureFinder::default();
+ finder.visit_pattern(pattern);
+ names.extend(finder.names);
+ }
+
+ names
};
let used_names = {
@@ -101,7 +118,7 @@ pub(crate) fn unused_loop_control_variable(checker: &Checker, stmt_for: &ast::St
clippy::iter_over_hash_type,
reason = "iteration order does not affect the diagnostics or fixes produced"
)]
- for (name, expr) in control_names {
+ for (name, range) in control_names {
// Ignore names that are already underscore-prefixed.
if checker.settings().ignores_unused_binding(name) {
continue;
@@ -137,7 +154,7 @@ pub(crate) fn unused_loop_control_variable(checker: &Checker, stmt_for: &ast::St
rename: rename.clone(),
certainty,
},
- expr.range(),
+ range,
);
if certainty == Certainty::Certain {
@@ -152,19 +169,43 @@ pub(crate) fn unused_loop_control_variable(checker: &Checker, stmt_for: &ast::St
if scope
.get_all(name)
.map(|binding_id| checker.semantic().binding(binding_id))
- .filter(|binding| binding.start() >= expr.start())
+ .filter(|binding| binding.start() >= range.start())
.all(Binding::is_unused)
{
- diagnostic.set_fix(Fix::unsafe_edit(Edit::range_replacement(
- rename,
- expr.range(),
- )));
+ diagnostic.set_fix(Fix::unsafe_edit(Edit::range_replacement(rename, range)));
}
}
}
}
}
+/// A [`Visitor`] that collects the names a pattern captures, with the range of each.
+///
+/// basedpython binds a destructuring loop's control variables here rather than in the
+/// loop target, so the rule has to read them out of the pattern to see them at all.
+#[derive(Default)]
+struct PatternCaptureFinder<'a> {
+ names: Vec<(&'a str, TextRange)>,
+}
+
+impl<'a> Visitor<'a> for PatternCaptureFinder<'a> {
+ fn visit_pattern(&mut self, pattern: &'a ast::Pattern) {
+ if let ast::Pattern::MatchAs(ast::PatternMatchAs {
+ name: Some(name), ..
+ })
+ | ast::Pattern::MatchStar(ast::PatternMatchStar {
+ name: Some(name), ..
+ })
+ | ast::Pattern::MatchMapping(ast::PatternMatchMapping {
+ rest: Some(name), ..
+ }) = pattern
+ {
+ self.names.push((name.as_str(), name.range()));
+ }
+ walk_pattern(self, pattern);
+ }
+}
+
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum Certainty {
Certain,
diff --git a/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__unused-loop-control-variable_B007_basedpython.by.snap b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__unused-loop-control-variable_B007_basedpython.by.snap
new file mode 100644
index 0000000000..3cbc372032
--- /dev/null
+++ b/crates/ruff_linter/src/rules/flake8_bugbear/snapshots/ruff_linter__rules__flake8_bugbear__tests__unused-loop-control-variable_B007_basedpython.by.snap
@@ -0,0 +1,73 @@
+---
+source: crates/ruff_linter/src/rules/flake8_bugbear/mod.rs
+---
+B007 [*] Loop control variable `y` not used within loop body
+ --> B007_basedpython.by:12:18
+ |
+11 | def unused_capture(points: list[Point]) -> None:
+12 | for Point(x, y) in points: # B007 on `y`
+ | ^
+13 | print(x)
+ |
+help: Rename unused `y` to `_y`
+ |
+11 | def unused_capture(points: list[Point]) -> None:
+ - for Point(x, y) in points: # B007 on `y`
+12 + for Point(x, _y) in points: # B007 on `y`
+13 | print(x)
+ |
+note: This is an unsafe fix and may change runtime behavior
+
+B007 [*] Loop control variable `b` not used within loop body
+ --> B007_basedpython.by:30:19
+ |
+28 | # nesting reaches the captures at every depth
+29 | def nested(pairs: list[tuple[Point, Point]]) -> None:
+30 | for (Point(a, b), Point(c, d)) in pairs: # B007 on `b`, `c` and `d`
+ | ^
+31 | print(a)
+ |
+help: Rename unused `b` to `_b`
+ |
+29 | def nested(pairs: list[tuple[Point, Point]]) -> None:
+ - for (Point(a, b), Point(c, d)) in pairs: # B007 on `b`, `c` and `d`
+30 + for (Point(a, _b), Point(c, d)) in pairs: # B007 on `b`, `c` and `d`
+31 | print(a)
+ |
+note: This is an unsafe fix and may change runtime behavior
+
+B007 [*] Loop control variable `c` not used within loop body
+ --> B007_basedpython.by:30:29
+ |
+28 | # nesting reaches the captures at every depth
+29 | def nested(pairs: list[tuple[Point, Point]]) -> None:
+30 | for (Point(a, b), Point(c, d)) in pairs: # B007 on `b`, `c` and `d`
+ | ^
+31 | print(a)
+ |
+help: Rename unused `c` to `_c`
+ |
+29 | def nested(pairs: list[tuple[Point, Point]]) -> None:
+ - for (Point(a, b), Point(c, d)) in pairs: # B007 on `b`, `c` and `d`
+30 + for (Point(a, b), Point(_c, d)) in pairs: # B007 on `b`, `c` and `d`
+31 | print(a)
+ |
+note: This is an unsafe fix and may change runtime behavior
+
+B007 [*] Loop control variable `d` not used within loop body
+ --> B007_basedpython.by:30:32
+ |
+28 | # nesting reaches the captures at every depth
+29 | def nested(pairs: list[tuple[Point, Point]]) -> None:
+30 | for (Point(a, b), Point(c, d)) in pairs: # B007 on `b`, `c` and `d`
+ | ^
+31 | print(a)
+ |
+help: Rename unused `d` to `_d`
+ |
+29 | def nested(pairs: list[tuple[Point, Point]]) -> None:
+ - for (Point(a, b), Point(c, d)) in pairs: # B007 on `b`, `c` and `d`
+30 + for (Point(a, b), Point(c, _d)) in pairs: # B007 on `b`, `c` and `d`
+31 | print(a)
+ |
+note: This is an unsafe fix and may change runtime behavior
diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/assertion.rs b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/assertion.rs
index 52261b2409..a0968cbf41 100644
--- a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/assertion.rs
+++ b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/assertion.rs
@@ -322,7 +322,7 @@ pub(crate) fn unittest_assertion(
&& checker.semantic().current_expression_parent().is_none()
&& !checker.comment_ranges().intersects(expr.range())
{
- if let Ok(stmt) = unittest_assert.generate_assert(args, keywords) {
+ if let Ok(stmt) = unittest_assert.generate_assert(args, keywords, checker.source_type) {
diagnostic.set_fix(Fix::unsafe_edit(Edit::range_replacement(
checker.generator().stmt(&stmt),
parenthesized_range(
diff --git a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/unittest_assert.rs b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/unittest_assert.rs
index 987184bcfa..01f0b69041 100644
--- a/crates/ruff_linter/src/rules/flake8_pytest_style/rules/unittest_assert.rs
+++ b/crates/ruff_linter/src/rules/flake8_pytest_style/rules/unittest_assert.rs
@@ -1,7 +1,8 @@
use anyhow::{Result, anyhow, bail};
use ruff_python_ast::name::Name;
use ruff_python_ast::{
- self as ast, Arguments, CmpOp, Expr, ExprContext, Identifier, Keyword, Stmt, UnaryOp,
+ self as ast, Arguments, CmpOp, Expr, ExprContext, Identifier, Keyword, PySourceType, Stmt,
+ UnaryOp,
};
use ruff_text_size::TextRange;
use rustc_hash::{FxBuildHasher, FxHashMap};
@@ -170,11 +171,26 @@ fn assert(expr: &Expr, msg: Option<&Expr>) -> Stmt {
})
}
-fn compare(left: &Expr, cmp_op: CmpOp, right: &Expr) -> Expr {
+fn compare(left: &Expr, cmp_op: CmpOp, right: &Expr, source_type: PySourceType) -> Expr {
Expr::Compare(ast::ExprCompare {
left: Box::new(left.clone()),
ops: Box::from([cmp_op]),
comparators: Box::from([right.clone()]),
+ // `assertIs` asserts python *identity*, which basedpython writes `===`
+ // — its `is` keyword is a type test, a different assertion entirely.
+ // recording no spelling prints `is`, so this has to say so.
+ //
+ // `assertIsNone` is the exception: `is None` is a test for the type
+ // `None`, which holds one object, so it is the same test either way —
+ // and it is the spelling a reader expects
+ identity_ops: (source_type.is_basedpython()
+ && matches!(cmp_op, CmpOp::Is | CmpOp::IsNot)
+ && !right.is_none_literal_expr())
+ .then(|| {
+ Box::new(ast::IdentityOperators {
+ ops: Box::from([true]),
+ })
+ }),
range: TextRange::default(),
node_index: ruff_python_ast::AtomicNodeIndex::NONE,
})
@@ -278,7 +294,12 @@ impl UnittestAssert {
Ok(args_map)
}
- pub(crate) fn generate_assert(self, args: &[Expr], keywords: &[Keyword]) -> Result {
+ pub(crate) fn generate_assert(
+ self,
+ args: &[Expr],
+ keywords: &[Keyword],
+ source_type: PySourceType,
+ ) -> Result {
let args = self.args_map(args, keywords)?;
match self {
UnittestAssert::True
@@ -339,7 +360,7 @@ impl UnittestAssert {
UnittestAssert::IsNot => CmpOp::IsNot,
_ => unreachable!(),
};
- let expr = compare(first, cmp_op, second);
+ let expr = compare(first, cmp_op, second, source_type);
Ok(assert(&expr, msg))
}
UnittestAssert::In | UnittestAssert::NotIn => {
@@ -355,7 +376,7 @@ impl UnittestAssert {
} else {
CmpOp::NotIn
};
- let expr = compare(member, cmp_op, container);
+ let expr = compare(member, cmp_op, container, source_type);
Ok(assert(&expr, msg))
}
UnittestAssert::IsNone | UnittestAssert::IsNotNone => {
@@ -372,7 +393,7 @@ impl UnittestAssert {
range: TextRange::default(),
node_index: ruff_python_ast::AtomicNodeIndex::NONE,
});
- let expr = compare(expr, cmp_op, &node);
+ let expr = compare(expr, cmp_op, &node, source_type);
Ok(assert(&expr, msg))
}
UnittestAssert::IsInstance | UnittestAssert::NotIsInstance => {
diff --git a/crates/ruff_linter/src/rules/flake8_simplify/mod.rs b/crates/ruff_linter/src/rules/flake8_simplify/mod.rs
index 209479f8be..6f94b4434e 100644
--- a/crates/ruff_linter/src/rules/flake8_simplify/mod.rs
+++ b/crates/ruff_linter/src/rules/flake8_simplify/mod.rs
@@ -49,7 +49,10 @@ mod tests {
#[test_case(Rule::ExprOrNotExpr, Path::new("SIM221.py"))]
#[test_case(Rule::ExprOrTrue, Path::new("SIM222.py"))]
#[test_case(Rule::ExprAndFalse, Path::new("SIM223.py"))]
+ #[test_case(Rule::ExprOrTrue, Path::new("SIM222_basedpython.by"))]
+ #[test_case(Rule::ExprAndFalse, Path::new("SIM222_basedpython.by"))]
#[test_case(Rule::YodaConditions, Path::new("SIM300.py"))]
+ #[test_case(Rule::YodaConditions, Path::new("SIM300_basedpython.by"))]
#[test_case(Rule::IfElseBlockInsteadOfDictGet, Path::new("SIM401.py"))]
#[test_case(Rule::SplitStaticString, Path::new("SIM905.py"))]
#[test_case(Rule::DictGetWithNoneDefault, Path::new("SIM910.py"))]
diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_bool_op.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_bool_op.rs
index 9e5259bbb0..f0987d1871 100644
--- a/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_bool_op.rs
+++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_bool_op.rs
@@ -466,6 +466,7 @@ fn match_eq_target(expr: &Expr) -> Option<(&Name, &Expr)> {
comparators,
range: _,
node_index: _,
+ identity_ops: _,
}) = expr
else {
return None;
@@ -554,6 +555,7 @@ pub(crate) fn compare_with_tuple(checker: &Checker, expr: &Expr) {
left: Box::new(node1.into()),
ops: Box::from([CmpOp::In]),
comparators: Box::from([node.into()]),
+ identity_ops: None,
range: TextRange::default(),
node_index: ruff_python_ast::AtomicNodeIndex::NONE,
};
diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_unary_op.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_unary_op.rs
index 2bb0aa9dee..4c932cd679 100644
--- a/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_unary_op.rs
+++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/ast_unary_op.rs
@@ -158,6 +158,7 @@ pub(crate) fn negation_with_equal_op(checker: &Checker, expr: &Expr, op: UnaryOp
comparators,
range: _,
node_index: _,
+ identity_ops: _,
}) = operand
else {
return;
@@ -189,6 +190,7 @@ pub(crate) fn negation_with_equal_op(checker: &Checker, expr: &Expr, op: UnaryOp
left: left.clone(),
ops: Box::from([CmpOp::NotEq]),
comparators: comparators.clone(),
+ identity_ops: None,
range: TextRange::default(),
node_index: ruff_python_ast::AtomicNodeIndex::NONE,
};
@@ -214,6 +216,7 @@ pub(crate) fn negation_with_not_equal_op(
comparators,
range: _,
node_index: _,
+ identity_ops: _,
}) = operand
else {
return;
@@ -245,6 +248,7 @@ pub(crate) fn negation_with_not_equal_op(
left: left.clone(),
ops: Box::from([CmpOp::Eq]),
comparators: comparators.clone(),
+ identity_ops: None,
range: TextRange::default(),
node_index: ruff_python_ast::AtomicNodeIndex::NONE,
};
diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_dict_get.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_dict_get.rs
index 41f3703a57..a484d18333 100644
--- a/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_dict_get.rs
+++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_dict_get.rs
@@ -136,6 +136,7 @@ pub(crate) fn if_else_block_instead_of_dict_get(checker: &Checker, stmt_if: &ast
comparators: test_dict,
range: _,
node_index: _,
+ identity_ops: _,
}) = &**test
else {
return;
@@ -268,6 +269,7 @@ pub(crate) fn if_exp_instead_of_dict_get(
comparators: test_dict,
range: _,
node_index: _,
+ identity_ops: _,
}) = test
else {
return;
diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_dict_lookup.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_dict_lookup.rs
index 6d5d387002..bdfe4d7626 100644
--- a/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_dict_lookup.rs
+++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/if_else_block_instead_of_dict_lookup.rs
@@ -65,6 +65,7 @@ pub(crate) fn if_else_block_instead_of_dict_lookup(checker: &Checker, stmt_if: &
comparators,
range: _,
node_index: _,
+ identity_ops: _,
}) = test.as_ref()
else {
return;
@@ -152,6 +153,7 @@ pub(crate) fn if_else_block_instead_of_dict_lookup(checker: &Checker, stmt_if: &
comparators,
range: _,
node_index: _,
+ identity_ops: _,
})) => {
let Expr::Name(ast::ExprName { id, .. }) = left.as_ref() else {
return;
diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/needless_bool.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/needless_bool.rs
index 08db5e741a..1cdc999113 100644
--- a/crates/ruff_linter/src/rules/flake8_simplify/rules/needless_bool.rs
+++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/needless_bool.rs
@@ -250,6 +250,7 @@ pub(crate) fn needless_bool(checker: &Checker, stmt: &Stmt) {
ops,
left,
comparators,
+ identity_ops,
..
}) if matches!(
ops.as_ref(),
@@ -269,6 +270,10 @@ pub(crate) fn needless_bool(checker: &Checker, stmt: &Stmt) {
ops: Box::new([op.negate()]),
left: left.clone(),
comparators: Box::new([right.clone()]),
+ // negating `is` gives `is not`, which is still the
+ // basedpython type test the source spelled; dropping the
+ // flag would rewrite it to the `!==` identity operator
+ identity_ops: identity_ops.clone(),
range: TextRange::default(),
node_index: ruff_python_ast::AtomicNodeIndex::NONE,
}))
diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/reimplemented_builtin.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/reimplemented_builtin.rs
index 20bc7e42f8..d33cb98720 100644
--- a/crates/ruff_linter/src/rules/flake8_simplify/rules/reimplemented_builtin.rs
+++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/reimplemented_builtin.rs
@@ -155,6 +155,7 @@ pub(crate) fn convert_for_loop_to_any_all(checker: &Checker, stmt: &Stmt) {
comparators,
range: _,
node_index: _,
+ identity_ops,
}) = &loop_.test
{
if let ([op], [comparator]) = (&**ops, &**comparators) {
@@ -174,6 +175,9 @@ pub(crate) fn convert_for_loop_to_any_all(checker: &Checker, stmt: &Stmt) {
left: left.clone(),
ops: Box::from([op]),
comparators: Box::from([comparator.clone()]),
+ // the negation keeps the spelling: `is` becomes
+ // `is not`, never the `!==` identity operator
+ identity_ops: identity_ops.clone(),
range: TextRange::default(),
node_index: ruff_python_ast::AtomicNodeIndex::NONE,
};
diff --git a/crates/ruff_linter/src/rules/flake8_simplify/rules/yoda_conditions.rs b/crates/ruff_linter/src/rules/flake8_simplify/rules/yoda_conditions.rs
index 2cf102c870..8cd4af2eec 100644
--- a/crates/ruff_linter/src/rules/flake8_simplify/rules/yoda_conditions.rs
+++ b/crates/ruff_linter/src/rules/flake8_simplify/rules/yoda_conditions.rs
@@ -6,6 +6,7 @@ use libcst_native::CompOp;
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::{self as ast, CmpOp, Expr, UnaryOp};
use ruff_python_codegen::Stylist;
+use ruff_python_semantic::SemanticModel;
use ruff_python_stdlib::str::{self};
use ruff_text_size::Ranged;
@@ -84,48 +85,58 @@ enum ConstantLikelihood {
Definitely = 2,
}
-impl From<&Expr> for ConstantLikelihood {
+impl ConstantLikelihood {
/// Determine the [`ConstantLikelihood`] of an expression.
- fn from(expr: &Expr) -> Self {
+ fn of(expr: &Expr, semantic: &SemanticModel) -> Self {
+ let of = |expr: &Expr| ConstantLikelihood::of(expr, semantic);
match expr {
_ if expr.is_literal_expr() => ConstantLikelihood::Definitely,
Expr::Attribute(ast::ExprAttribute { attr, .. }) => {
ConstantLikelihood::from_identifier(attr)
}
- Expr::Name(ast::ExprName { id, .. }) => ConstantLikelihood::from_identifier(id),
+ Expr::Name(name) => {
+ // A type parameter is not a constant, however it is spelled: `T` in
+ // `def f[T](...)` names a type the caller chooses. The one-letter
+ // convention makes almost every one of them read as `SCREAMING_CASE`,
+ // so without this a comparison against a type parameter is a Yoda
+ // condition whose "fix" reverses a perfectly ordinary comparison.
+ if semantic
+ .resolve_name(name)
+ .is_some_and(|id| semantic.binding(id).kind.is_type_param())
+ {
+ ConstantLikelihood::Unlikely
+ } else {
+ ConstantLikelihood::from_identifier(&name.id)
+ }
+ }
Expr::Tuple(tuple) => tuple
.iter()
- .map(ConstantLikelihood::from)
+ .map(of)
.min()
.unwrap_or(ConstantLikelihood::Definitely),
Expr::List(list) => list
.iter()
- .map(ConstantLikelihood::from)
+ .map(of)
.min()
.unwrap_or(ConstantLikelihood::Definitely),
Expr::Dict(dict) => dict
.items
.iter()
.flat_map(|item| std::iter::once(&item.value).chain(item.key.as_ref()))
- .map(ConstantLikelihood::from)
+ .map(of)
.min()
.unwrap_or(ConstantLikelihood::Definitely),
- Expr::BinOp(ast::ExprBinOp { left, right, .. }) => cmp::min(
- ConstantLikelihood::from(&**left),
- ConstantLikelihood::from(&**right),
- ),
+ Expr::BinOp(ast::ExprBinOp { left, right, .. }) => cmp::min(of(left), of(right)),
Expr::UnaryOp(ast::ExprUnaryOp {
op: UnaryOp::UAdd | UnaryOp::USub | UnaryOp::Invert,
operand,
range: _,
node_index: _,
- }) => ConstantLikelihood::from(&**operand),
+ }) => of(operand),
_ => ConstantLikelihood::Unlikely,
}
}
-}
-impl ConstantLikelihood {
/// Determine the [`ConstantLikelihood`] of an identifier.
fn from_identifier(identifier: &str) -> Self {
if str::is_cased_uppercase(identifier) {
@@ -223,7 +234,8 @@ pub(crate) fn yoda_conditions(
return;
}
- if ConstantLikelihood::from(left) <= ConstantLikelihood::from(right) {
+ let semantic = checker.semantic();
+ if ConstantLikelihood::of(left, semantic) <= ConstantLikelihood::of(right, semantic) {
return;
}
diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__expr-and-false_SIM222_basedpython.by.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__expr-and-false_SIM222_basedpython.by.snap
new file mode 100644
index 0000000000..1add375dbe
--- /dev/null
+++ b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__expr-and-false_SIM222_basedpython.by.snap
@@ -0,0 +1,20 @@
+---
+source: crates/ruff_linter/src/rules/flake8_simplify/mod.rs
+---
+SIM223 [*] Use `False` instead of `... and False`
+ --> SIM222_basedpython.by:19:8
+ |
+17 | if a or True: # SIM222
+18 | pass
+19 | if b and False: # SIM223
+ | ^^^^^^^^^^^
+20 | pass
+ |
+help: Replace with `False`
+ |
+18 | pass
+ - if b and False: # SIM223
+19 + if False: # SIM223
+20 | pass
+ |
+note: This is an unsafe fix and may change runtime behavior
diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__expr-or-true_SIM222_basedpython.by.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__expr-or-true_SIM222_basedpython.by.snap
new file mode 100644
index 0000000000..a09f0910fe
--- /dev/null
+++ b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__expr-or-true_SIM222_basedpython.by.snap
@@ -0,0 +1,21 @@
+---
+source: crates/ruff_linter/src/rules/flake8_simplify/mod.rs
+---
+SIM222 [*] Use `True` instead of `... or True`
+ --> SIM222_basedpython.by:17:8
+ |
+15 | # a boolean operator in a value expression is untouched
+16 | def values(a: int, b: int) -> None:
+17 | if a or True: # SIM222
+ | ^^^^^^^^^
+18 | pass
+19 | if b and False: # SIM223
+ |
+help: Replace with `True`
+ |
+16 | def values(a: int, b: int) -> None:
+ - if a or True: # SIM222
+17 + if True: # SIM222
+18 | pass
+ |
+note: This is an unsafe fix and may change runtime behavior
diff --git a/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__yoda-conditions_SIM300_basedpython.by.snap b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__yoda-conditions_SIM300_basedpython.by.snap
new file mode 100644
index 0000000000..08d2702906
--- /dev/null
+++ b/crates/ruff_linter/src/rules/flake8_simplify/snapshots/ruff_linter__rules__flake8_simplify__tests__yoda-conditions_SIM300_basedpython.by.snap
@@ -0,0 +1,19 @@
+---
+source: crates/ruff_linter/src/rules/flake8_simplify/mod.rs
+---
+SIM300 [*] Yoda condition detected
+ --> SIM300_basedpython.by:15:8
+ |
+13 | # a genuine yoda condition still fires
+14 | def yoda(age: int) -> None:
+15 | if 42 == age: # SIM300
+ | ^^^^^^^^^
+16 | pass
+ |
+help: Rewrite as `age == 42`
+ |
+14 | def yoda(age: int) -> None:
+ - if 42 == age: # SIM300
+15 + if age == 42: # SIM300
+16 | pass
+ |
diff --git a/crates/ruff_linter/src/rules/pycodestyle/mod.rs b/crates/ruff_linter/src/rules/pycodestyle/mod.rs
index 937a024266..89f5afe288 100644
--- a/crates/ruff_linter/src/rules/pycodestyle/mod.rs
+++ b/crates/ruff_linter/src/rules/pycodestyle/mod.rs
@@ -67,6 +67,7 @@ mod tests {
#[test_case(Rule::TrailingWhitespace, Path::new("W291.py"))]
#[test_case(Rule::TrueFalseComparison, Path::new("E712.py"))]
#[test_case(Rule::TypeComparison, Path::new("E721.py"))]
+ #[test_case(Rule::TypeComparison, Path::new("E721_basedpython.by"))]
#[test_case(Rule::UselessSemicolon, Path::new("E70.py"))]
#[test_case(Rule::UselessSemicolon, Path::new("E703.ipynb"))]
#[test_case(Rule::WhitespaceAfterDecorator, Path::new("E204.py"))]
diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/compound_statements.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/compound_statements.rs
index dc9968d024..6c39e4e4b4 100644
--- a/crates/ruff_linter/src/rules/pycodestyle/rules/compound_statements.rs
+++ b/crates/ruff_linter/src/rules/pycodestyle/rules/compound_statements.rs
@@ -130,6 +130,14 @@ pub(crate) fn compound_statements(
// This is used to allow `class C: ...`-style definitions in stubs.
let mut allow_ellipsis = false;
+ // basedpython: `type Swap[*Ts] = match *Ts:` is a type whose `case` arms are type
+ // expressions rather than statements, so the `:` in `case (A, B): (B, A)` separates
+ // a pattern from a type and opens no suite. `type_match` holds the indent of the
+ // header line for as long as the arms it opened last, and `type_keyword` records the
+ // `type` that tells such a header from `x = match ...`, which really is a statement
+ let mut type_match: Option = None;
+ let mut type_keyword = false;
+
// Track indentation.
let mut indent = 0u32;
@@ -147,6 +155,9 @@ pub(crate) fn compound_statements(
}
TokenKind::Dedent => {
indent = indent.saturating_sub(1);
+ if type_match.is_some_and(|header| indent <= header) {
+ type_match = None;
+ }
}
_ => {}
}
@@ -194,6 +205,7 @@ pub(crate) fn compound_statements(
try_ = None;
while_ = None;
with = None;
+ type_keyword = false;
}
TokenKind::Colon => {
if case.is_some()
@@ -273,7 +285,12 @@ pub(crate) fn compound_statements(
with = None;
}
TokenKind::Case => {
- case = Some(token.range());
+ if type_match.is_none() {
+ case = Some(token.range());
+ }
+ }
+ TokenKind::Type => {
+ type_keyword = true;
}
TokenKind::If => {
if_ = Some(token.range());
@@ -313,6 +330,9 @@ pub(crate) fn compound_statements(
}
TokenKind::Match => {
match_ = Some(token.range());
+ if source_type.is_basedpython() && type_keyword {
+ type_match = Some(indent);
+ }
}
_ => {}
}
diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/literal_comparisons.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/literal_comparisons.rs
index c7b0cde1a1..6ee27706ff 100644
--- a/crates/ruff_linter/src/rules/pycodestyle/rules/literal_comparisons.rs
+++ b/crates/ruff_linter/src/rules/pycodestyle/rules/literal_comparisons.rs
@@ -403,6 +403,7 @@ pub(crate) fn literal_comparisons(checker: &Checker, compare: &ast::ExprCompare)
&compare.left,
&ops,
&compare.comparators,
+ compare.identity_ops.as_deref(),
compare.into(),
tokens,
source,
@@ -413,6 +414,7 @@ pub(crate) fn literal_comparisons(checker: &Checker, compare: &ast::ExprCompare)
&compare.left,
&ops,
&compare.comparators,
+ compare.identity_ops.as_deref(),
compare.into(),
tokens,
source,
diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/not_tests.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/not_tests.rs
index 2f1eb95385..2d37ba4038 100644
--- a/crates/ruff_linter/src/rules/pycodestyle/rules/not_tests.rs
+++ b/crates/ruff_linter/src/rules/pycodestyle/rules/not_tests.rs
@@ -92,6 +92,7 @@ pub(crate) fn not_tests(checker: &Checker, unary_op: &ast::ExprUnaryOp) {
comparators,
range: _,
node_index: _,
+ identity_ops,
}) = unary_op.operand.as_ref()
else {
return;
@@ -106,6 +107,7 @@ pub(crate) fn not_tests(checker: &Checker, unary_op: &ast::ExprUnaryOp) {
left,
&[CmpOp::NotIn],
comparators,
+ identity_ops.as_deref(),
unary_op.into(),
checker.tokens(),
checker.source(),
@@ -124,6 +126,7 @@ pub(crate) fn not_tests(checker: &Checker, unary_op: &ast::ExprUnaryOp) {
left,
&[CmpOp::IsNot],
comparators,
+ identity_ops.as_deref(),
unary_op.into(),
checker.tokens(),
checker.source(),
diff --git a/crates/ruff_linter/src/rules/pycodestyle/rules/type_comparison.rs b/crates/ruff_linter/src/rules/pycodestyle/rules/type_comparison.rs
index 2835593119..e889c2dc7a 100644
--- a/crates/ruff_linter/src/rules/pycodestyle/rules/type_comparison.rs
+++ b/crates/ruff_linter/src/rules/pycodestyle/rules/type_comparison.rs
@@ -51,13 +51,21 @@ use crate::codes::Category;
/// ```
#[derive(ViolationMetadata)]
#[violation_metadata(stable_since = "v0.0.39", category = Category::Pedantic)]
-pub(crate) struct TypeComparison;
+pub(crate) struct TypeComparison {
+ basedpython: bool,
+}
impl Violation for TypeComparison {
#[derive_message_formats]
fn message(&self) -> String {
- "Use `is` and `is not` for type comparisons, or `isinstance()` for isinstance checks"
- .to_string()
+ // basedpython spells identity `===` and an isinstance check `x is C`, so the
+ // python wording names the wrong two operators there
+ if self.basedpython {
+ "Use `===` and `!==` for type comparisons, or `is` for isinstance checks".to_string()
+ } else {
+ "Use `is` and `is not` for type comparisons, or `isinstance()` for isinstance checks"
+ .to_string()
+ }
}
}
@@ -78,7 +86,12 @@ pub(crate) fn type_comparison(checker: &Checker, compare: &ast::ExprCompare) {
}
// Disallow the comparison.
- checker.report_diagnostic(TypeComparison, compare.range());
+ checker.report_diagnostic(
+ TypeComparison {
+ basedpython: checker.source_type.is_basedpython(),
+ },
+ compare.range(),
+ );
}
}
}
diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-statements-on-one-line-colon_E70_basedpython.by.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-statements-on-one-line-colon_E70_basedpython.by.snap
index 11aae13b0f..4f8f16da18 100644
--- a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-statements-on-one-line-colon_E70_basedpython.by.snap
+++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__multiple-statements-on-one-line-colon_E70_basedpython.by.snap
@@ -7,3 +7,57 @@ E701 Multiple statements on one line (colon)
15 | # a class definition written on one line is still reported
16 | class OneLiner: x = 1
| ^
+
+E701 Multiple statements on one line (colon)
+ --> E70_basedpython.by:39:19
+ |
+37 | # a `match` statement after the alias is a compound statement again
+38 | match T:
+39 | case int(): pass
+ | ^
+40 | case _: pass
+ |
+
+E701 Multiple statements on one line (colon)
+ --> E70_basedpython.by:40:15
+ |
+38 | match T:
+39 | case int(): pass
+40 | case _: pass
+ | ^
+
+E701 Multiple statements on one line (colon)
+ --> E70_basedpython.by:46:19
+ |
+44 | def statement_expression(v: object) -> int:
+45 | result = match v:
+46 | case int(): 1
+ | ^
+47 | case _: 0
+48 | return result
+ |
+
+E701 Multiple statements on one line (colon)
+ --> E70_basedpython.by:47:15
+ |
+45 | result = match v:
+46 | case int(): 1
+47 | case _: 0
+ | ^
+48 | return result
+ |
+
+E701 Multiple statements on one line (colon)
+ --> E70_basedpython.by:58:23
+ |
+56 | def method(self, v: object) -> None:
+57 | match v:
+58 | case int(): pass
+ | ^
+
+E701 Multiple statements on one line (colon)
+ --> E70_basedpython.by:68:18
+ |
+67 | match flag:
+68 | case True: pass
+ | ^
diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__type-comparison_E721_basedpython.by.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__type-comparison_E721_basedpython.by.snap
new file mode 100644
index 0000000000..51126e3190
--- /dev/null
+++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__type-comparison_E721_basedpython.by.snap
@@ -0,0 +1,30 @@
+---
+source: crates/ruff_linter/src/rules/pycodestyle/mod.rs
+---
+E721 Use `===` and `!==` for type comparisons, or `is` for isinstance checks
+ --> E721_basedpython.by:4:8
+ |
+2 | # the two operators this rule names are not the ones python's message names
+3 | def compare(a: object, b: object) -> None:
+4 | if type(a) == type(b):
+ | ^^^^^^^^^^^^^^^^^^
+5 | pass
+6 | if type(a) == int:
+ |
+
+E721 Use `===` and `!==` for type comparisons, or `is` for isinstance checks
+ --> E721_basedpython.by:6:8
+ |
+4 | if type(a) == type(b):
+5 | pass
+6 | if type(a) == int:
+ | ^^^^^^^^^^^^^^
+7 | pass
+ |
+
+E721 Use `===` and `!==` for type comparisons, or `is` for isinstance checks
+ --> E721_basedpython.by:11:12
+ |
+10 | def reified[reified T](x: T) -> None:
+11 | assert T == int
+ | ^^^^^^^^
diff --git a/crates/ruff_linter/src/rules/pyflakes/mod.rs b/crates/ruff_linter/src/rules/pyflakes/mod.rs
index 6cda6ca437..2ad8b998e6 100644
--- a/crates/ruff_linter/src/rules/pyflakes/mod.rs
+++ b/crates/ruff_linter/src/rules/pyflakes/mod.rs
@@ -91,6 +91,7 @@ mod tests {
#[test_case(Rule::MultipleStarredExpressions, Path::new("F622.py"))]
#[test_case(Rule::AssertTuple, Path::new("F631.py"))]
#[test_case(Rule::IsLiteral, Path::new("F632.py"))]
+ #[test_case(Rule::IsLiteral, Path::new("F632_basedpython.by"))]
#[test_case(Rule::InvalidPrintSyntax, Path::new("F633.py"))]
#[test_case(Rule::IfTuple, Path::new("F634.py"))]
// basedpython: a pattern clause's subject is a value, not a test
diff --git a/crates/ruff_linter/src/rules/pyflakes/rules/invalid_literal_comparisons.rs b/crates/ruff_linter/src/rules/pyflakes/rules/invalid_literal_comparisons.rs
index 6a8b9b164e..cc33093673 100644
--- a/crates/ruff_linter/src/rules/pyflakes/rules/invalid_literal_comparisons.rs
+++ b/crates/ruff_linter/src/rules/pyflakes/rules/invalid_literal_comparisons.rs
@@ -3,7 +3,7 @@ use anyhow::{Error, bail};
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::helpers;
use ruff_python_ast::token::{TokenKind, Tokens};
-use ruff_python_ast::{CmpOp, Expr};
+use ruff_python_ast::{self as ast, CmpOp};
use ruff_text_size::{Ranged, TextRange};
use crate::checkers::ast::Checker;
@@ -55,6 +55,8 @@ use crate::{AlwaysFixableViolation, Edit, Fix};
#[violation_metadata(stable_since = "v0.0.39", category = Category::Suspicious)]
pub(crate) struct IsLiteral {
cmp_op: IsCmpOp,
+ /// Whether the operator was written with basedpython's `===` / `!==`
+ spells_identity: bool,
}
impl AlwaysFixableViolation for IsLiteral {
@@ -67,41 +69,57 @@ impl AlwaysFixableViolation for IsLiteral {
}
fn fix_title(&self) -> String {
- let title = match self.cmp_op {
- IsCmpOp::Is => "Replace `is` with `==`",
- IsCmpOp::IsNot => "Replace `is not` with `!=`",
+ // basedpython's `===` / `!==` are the identity operators this rule replaces there
+ let title = match (self.cmp_op, self.spells_identity) {
+ (IsCmpOp::Is, false) => "Replace `is` with `==`",
+ (IsCmpOp::Is, true) => "Replace `===` with `==`",
+ (IsCmpOp::IsNot, false) => "Replace `is not` with `!=`",
+ (IsCmpOp::IsNot, true) => "Replace `!==` with `!=`",
};
title.to_string()
}
}
/// F632
-pub(crate) fn invalid_literal_comparison(
- checker: &Checker,
- left: &Expr,
- ops: &[CmpOp],
- comparators: &[Expr],
- expr: &Expr,
-) {
+pub(crate) fn invalid_literal_comparison(checker: &Checker, compare: &ast::ExprCompare) {
+ // basedpython keeps `is` as python identity only where its right-hand side is a
+ // literal; anywhere else `is` is a type test, and `===` is the spelling that always
+ // compares identity. the parser records which of the two was written, so the tokens
+ // are needed only to place a fix
let mut lazy_located = None;
- let mut left = left;
- for (index, (op, right)) in ops.iter().zip(comparators).enumerate() {
+ let mut left = &*compare.left;
+ for (index, (op, right)) in compare.ops.iter().zip(&compare.comparators).enumerate() {
+ let spells_identity = compare.is_identity_operator(index);
+
if matches!(op, CmpOp::Is | CmpOp::IsNot)
&& (helpers::is_constant_non_singleton(left)
|| helpers::is_constant_non_singleton(right)
|| helpers::is_mutable_iterable_initializer(left)
|| helpers::is_mutable_iterable_initializer(right))
+ && (!checker.source_type.is_basedpython() || spells_identity || right.is_literal_expr())
{
- let mut diagnostic =
- checker.report_diagnostic(IsLiteral { cmp_op: op.into() }, expr.range());
+ let mut diagnostic = checker.report_diagnostic(
+ IsLiteral {
+ cmp_op: op.into(),
+ spells_identity,
+ },
+ compare.range(),
+ );
if lazy_located.is_none() {
- lazy_located = Some(locate_cmp_ops(expr, checker.tokens()));
+ lazy_located = Some(locate_cmp_ops(compare.range(), checker.tokens()));
}
diagnostic.try_set_optional_fix(|| {
- if let Some(located_op) =
- lazy_located.as_ref().and_then(|located| located.get(index))
- {
- assert_eq!(located_op.op, *op);
+ let located_op = lazy_located.as_ref().and_then(|located| located.get(index));
+ // the tokens and the operators come from one parse, so they line up in
+ // either language: basedpython's `===` and `!==` scan to the same
+ // `CmpOp` the parser recorded for them. a dropped fix rather than a
+ // panic if that ever stops holding
+ debug_assert!(
+ located_op.is_none_or(|located_op| located_op.op == *op),
+ "located `{:?}` where the comparison has `{op:?}`",
+ located_op.map(|located_op| located_op.op)
+ );
+ if let Some(located_op) = located_op.filter(|located_op| located_op.op == *op) {
if let Ok(content) = match located_op.op {
CmpOp::Is => Ok::("==".to_string()),
CmpOp::IsNot => Ok("!=".to_string()),
@@ -145,9 +163,9 @@ impl From<&CmpOp> for IsCmpOp {
///
/// This method iterates over the token stream and re-identifies [`CmpOp`] nodes, annotating them
/// with valid ranges.
-fn locate_cmp_ops(expr: &Expr, tokens: &Tokens) -> Vec {
+fn locate_cmp_ops(range: TextRange, tokens: &Tokens) -> Vec {
let mut tok_iter = tokens
- .in_range(expr.range())
+ .in_range(range)
.iter()
.filter(|token| !token.kind().is_trivia())
.peekable();
@@ -197,6 +215,15 @@ fn locate_cmp_ops(expr: &Expr, tokens: &Tokens) -> Vec {
};
ops.push(op);
}
+ // basedpython's identity operators, which parse to the same `CmpOp` as
+ // the `is` keyword — scanned so the operators keep lining up with the
+ // comparison's own, whichever spelling the source used
+ TokenKind::EqEqEqual => {
+ ops.push(LocatedCmpOp::new(token.range(), CmpOp::Is));
+ }
+ TokenKind::BangEqEqual => {
+ ops.push(LocatedCmpOp::new(token.range(), CmpOp::IsNot));
+ }
TokenKind::NotEqual => {
ops.push(LocatedCmpOp::new(token.range(), CmpOp::NotEq));
}
@@ -242,13 +269,13 @@ mod tests {
use ruff_python_ast::CmpOp;
use ruff_python_parser::parse_expression;
- use ruff_text_size::TextSize;
+ use ruff_text_size::{Ranged, TextSize};
use super::{LocatedCmpOp, locate_cmp_ops};
fn extract_cmp_op_locations(source: &str) -> Result> {
let parsed = parse_expression(source)?;
- Ok(locate_cmp_ops(parsed.expr(), parsed.tokens()))
+ Ok(locate_cmp_ops(parsed.expr().range(), parsed.tokens()))
}
#[test]
diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__is-literal_F632_basedpython.by.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__is-literal_F632_basedpython.by.snap
new file mode 100644
index 0000000000..b6e703c28f
--- /dev/null
+++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__is-literal_F632_basedpython.by.snap
@@ -0,0 +1,182 @@
+---
+source: crates/ruff_linter/src/rules/pyflakes/mod.rs
+---
+F632 [*] Use `==` to compare constant literals
+ --> F632_basedpython.by:5:11
+ |
+3 | # spellings, and an `is` whose right-hand side is a literal, compare identity
+4 | def identity(x: object) -> None:
+5 | print(1 === 1) # F632
+ | ^^^^^^^
+6 | print(x !== "a") # F632
+ |
+help: Replace `===` with `==`
+ |
+4 | def identity(x: object) -> None:
+ - print(1 === 1) # F632
+5 + print(1 == 1) # F632
+6 | print(x !== "a") # F632
+ |
+
+F632 [*] Use `!=` to compare constant literals
+ --> F632_basedpython.by:6:11
+ |
+4 | def identity(x: object) -> None:
+5 | print(1 === 1) # F632
+6 | print(x !== "a") # F632
+ | ^^^^^^^^^
+help: Replace `!==` with `!=`
+ |
+5 | print(1 === 1) # F632
+ - print(x !== "a") # F632
+6 + print(x != "a") # F632
+7 |
+ |
+
+F632 [*] Use `==` to compare constant literals
+ --> F632_basedpython.by:10:11
+ |
+ 9 | def literal_right_hand_side(x: object) -> None:
+10 | print(1 is 1) # F632
+ | ^^^^^^
+11 | print(x is not "a") # F632
+ |
+help: Replace `is` with `==`
+ |
+9 | def literal_right_hand_side(x: object) -> None:
+ - print(1 is 1) # F632
+10 + print(1 == 1) # F632
+11 | print(x is not "a") # F632
+ |
+
+F632 [*] Use `!=` to compare constant literals
+ --> F632_basedpython.by:11:11
+ |
+ 9 | def literal_right_hand_side(x: object) -> None:
+10 | print(1 is 1) # F632
+11 | print(x is not "a") # F632
+ | ^^^^^^^^^^^^
+help: Replace `is not` with `!=`
+ |
+10 | print(1 is 1) # F632
+ - print(x is not "a") # F632
+11 + print(x != "a") # F632
+12 |
+ |
+
+F632 [*] Use `==` to compare constant literals
+ --> F632_basedpython.by:25:9
+ |
+23 | # source between the operands, so it survives everything that can sit there
+24 | def spelling_survives_what_sits_between(x: object) -> None:
+25 | a = (1) === (1) # F632
+ | ^^^^^^^^^^^
+26 | b = 1 \
+27 | === 1 # F632
+ |
+help: Replace `===` with `==`
+ |
+24 | def spelling_survives_what_sits_between(x: object) -> None:
+ - a = (1) === (1) # F632
+25 + a = (1) == (1) # F632
+26 | b = 1 \
+ |
+
+F632 [*] Use `==` to compare constant literals
+ --> F632_basedpython.by:26:9
+ |
+24 | def spelling_survives_what_sits_between(x: object) -> None:
+25 | a = (1) === (1) # F632
+26 | b = 1 \
+ | _________^
+27 | | === 1 # F632
+ | |_____________^
+28 | c = (1 === # a comment between the operands
+29 | 1) # F632
+ |
+help: Replace `===` with `==`
+ |
+26 | b = 1 \
+ - === 1 # F632
+27 + == 1 # F632
+28 | c = (1 === # a comment between the operands
+ |
+
+F632 [*] Use `==` to compare constant literals
+ --> F632_basedpython.by:28:10
+ |
+26 | b = 1 \
+27 | === 1 # F632
+28 | c = (1 === # a comment between the operands
+ | __________^
+29 | | 1) # F632
+ | |__________^
+30 | d = 1===1 # F632
+ |
+help: Replace `===` with `==`
+ |
+27 | === 1 # F632
+ - c = (1 === # a comment between the operands
+28 + c = (1 == # a comment between the operands
+29 | 1) # F632
+ |
+
+F632 [*] Use `==` to compare constant literals
+ --> F632_basedpython.by:30:9
+ |
+28 | c = (1 === # a comment between the operands
+29 | 1) # F632
+30 | d = 1===1 # F632
+ | ^^^^^
+help: Replace `===` with `==`
+ |
+29 | 1) # F632
+ - d = 1===1 # F632
+30 + d = 1==1 # F632
+31 |
+ |
+
+F632 [*] Use `==` to compare constant literals
+ --> F632_basedpython.by:35:9
+ |
+33 | # each operator of a chain answers for its own spelling
+34 | def chained(x: object) -> None:
+35 | e = x < 1 === 1 # F632
+ | ^^^^^^^^^^^
+36 | f = 1 === 1 !== 2 # F632
+ |
+help: Replace `===` with `==`
+ |
+34 | def chained(x: object) -> None:
+ - e = x < 1 === 1 # F632
+35 + e = x < 1 == 1 # F632
+36 | f = 1 === 1 !== 2 # F632
+ |
+
+F632 [*] Use `==` to compare constant literals
+ --> F632_basedpython.by:36:9
+ |
+34 | def chained(x: object) -> None:
+35 | e = x < 1 === 1 # F632
+36 | f = 1 === 1 !== 2 # F632
+ | ^^^^^^^^^^^^^
+help: Replace `===` with `==`
+ |
+35 | e = x < 1 === 1 # F632
+ - f = 1 === 1 !== 2 # F632
+36 + f = 1 == 1 !== 2 # F632
+ |
+
+F632 [*] Use `!=` to compare constant literals
+ --> F632_basedpython.by:36:9
+ |
+34 | def chained(x: object) -> None:
+35 | e = x < 1 === 1 # F632
+36 | f = 1 === 1 !== 2 # F632
+ | ^^^^^^^^^^^^^
+help: Replace `!==` with `!=`
+ |
+35 | e = x < 1 === 1 # F632
+ - f = 1 === 1 !== 2 # F632
+36 + f = 1 === 1 != 2 # F632
+ |
diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-variable_F841_basedpython.by.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-variable_F841_basedpython.by.snap
index da1004479d..7365f7df87 100644
--- a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-variable_F841_basedpython.by.snap
+++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__unused-variable_F841_basedpython.by.snap
@@ -29,3 +29,27 @@ F841 Local variable `Unmatched` is assigned to but never used
46 | case _:
|
help: Remove assignment to unused variable `Unmatched`
+
+F841 Local variable `k` is assigned to but never used
+ --> F841_basedpython.by:78:21
+ |
+76 | # what python's own capture pattern does, so an unused capture still fires
+77 | def matching_binders(p: Point) -> int:
+78 | if let Point(j, k) := p: # F841 on `k`
+ | ^
+79 | return j
+80 | match p:
+ |
+help: Remove assignment to unused variable `k`
+
+F841 Local variable `m` is assigned to but never used
+ --> F841_basedpython.by:81:23
+ |
+79 | return j
+80 | match p:
+81 | case Point(l, m): # F841 on `m`
+ | ^
+82 | return l
+83 | return 0
+ |
+help: Remove assignment to unused variable `m`
diff --git a/crates/ruff_linter/src/rules/pylint/rules/repeated_equality_comparison.rs b/crates/ruff_linter/src/rules/pylint/rules/repeated_equality_comparison.rs
index 3dc4a2a1a5..a63cdd0d73 100644
--- a/crates/ruff_linter/src/rules/pylint/rules/repeated_equality_comparison.rs
+++ b/crates/ruff_linter/src/rules/pylint/rules/repeated_equality_comparison.rs
@@ -212,6 +212,7 @@ pub(crate) fn repeated_equality_comparison(checker: &Checker, bool_op: &ast::Exp
BoolOp::And => Box::from([CmpOp::NotIn]),
},
comparators: Box::from([comparator]),
+ identity_ops: None,
range: bool_op.range(),
node_index: AtomicNodeIndex::NONE,
})))
diff --git a/crates/ruff_linter/src/rules/pyupgrade/mod.rs b/crates/ruff_linter/src/rules/pyupgrade/mod.rs
index dc0abeeb9c..6f952c11e2 100644
--- a/crates/ruff_linter/src/rules/pyupgrade/mod.rs
+++ b/crates/ruff_linter/src/rules/pyupgrade/mod.rs
@@ -65,6 +65,7 @@ mod tests {
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_0.py"))]
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_1.py"))]
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_2.pyi"))]
+ #[test_case(Rule::QuotedAnnotation, Path::new("UP037_basedpython.by"))]
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_3.py"))]
#[test_case(Rule::RedundantOpenModes, Path::new("UP015.py"))]
#[test_case(Rule::RedundantOpenModes, Path::new("UP015_1.py"))]
diff --git a/crates/ruff_linter/src/rules/pyupgrade/rules/outdated_version_block.rs b/crates/ruff_linter/src/rules/pyupgrade/rules/outdated_version_block.rs
index 10e9cd34f2..acbb3ca041 100644
--- a/crates/ruff_linter/src/rules/pyupgrade/rules/outdated_version_block.rs
+++ b/crates/ruff_linter/src/rules/pyupgrade/rules/outdated_version_block.rs
@@ -97,6 +97,7 @@ pub(crate) fn outdated_version_block(checker: &Checker, stmt_if: &StmtIf) {
comparators,
range: _,
node_index: _,
+ identity_ops: _,
}) = &branch.test
else {
continue;
diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP037_basedpython.by.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP037_basedpython.by.snap
new file mode 100644
index 0000000000..2bacb5d540
--- /dev/null
+++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP037_basedpython.by.snap
@@ -0,0 +1,4 @@
+---
+source: crates/ruff_linter/src/rules/pyupgrade/mod.rs
+---
+
diff --git a/crates/ruff_linter/src/rules/refurb/helpers.rs b/crates/ruff_linter/src/rules/refurb/helpers.rs
index f6375410a3..a48766f3a1 100644
--- a/crates/ruff_linter/src/rules/refurb/helpers.rs
+++ b/crates/ruff_linter/src/rules/refurb/helpers.rs
@@ -72,6 +72,10 @@ pub(super) fn replace_with_identity_check(
left: left.clone().into(),
ops: [op].into(),
comparators: [ast::ExprNoneLiteral::default().into()].into(),
+ // basedpython writes this test as `is None`, whose target is the type
+ // `None`: the same runtime check, and the spelling a reader expects.
+ // recording no `===` is what prints it that way in both languages
+ identity_ops: None,
range: TextRange::default(),
node_index: ruff_python_ast::AtomicNodeIndex::NONE,
});
diff --git a/crates/ruff_linter/src/rules/refurb/rules/single_item_membership_test.rs b/crates/ruff_linter/src/rules/refurb/rules/single_item_membership_test.rs
index feceeb5c89..1b58817e2e 100644
--- a/crates/ruff_linter/src/rules/refurb/rules/single_item_membership_test.rs
+++ b/crates/ruff_linter/src/rules/refurb/rules/single_item_membership_test.rs
@@ -95,6 +95,8 @@ pub(crate) fn single_item_membership_test(
left,
&[membership_test.replacement_op()],
std::slice::from_ref(item),
+ // only `in` / `not in`, which have one spelling
+ None,
expr.into(),
checker.tokens(),
checker.source(),
diff --git a/crates/ruff_linter/src/rules/ruff/mod.rs b/crates/ruff_linter/src/rules/ruff/mod.rs
index 6635c112dd..5770e3ddd9 100644
--- a/crates/ruff_linter/src/rules/ruff/mod.rs
+++ b/crates/ruff_linter/src/rules/ruff/mod.rs
@@ -62,6 +62,7 @@ mod tests {
#[test_case(Rule::UnnecessaryKeyCheck, Path::new("RUF019.py"))]
#[test_case(Rule::NeverUnion, Path::new("RUF020.py"))]
#[test_case(Rule::ParenthesizeChainedOperators, Path::new("RUF021.py"))]
+ #[test_case(Rule::ParenthesizeChainedOperators, Path::new("RUF021_basedpython.by"))]
#[test_case(Rule::UnsortedDunderAll, Path::new("RUF022.py"))]
#[test_case(Rule::UnsortedDunderSlots, Path::new("RUF023.py"))]
#[test_case(Rule::MutableFromkeysValue, Path::new("RUF024.py"))]
diff --git a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_regular_expression.rs b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_regular_expression.rs
index 3342a3bb37..41ee0cd368 100644
--- a/crates/ruff_linter/src/rules/ruff/rules/unnecessary_regular_expression.rs
+++ b/crates/ruff_linter/src/rules/ruff/rules/unnecessary_regular_expression.rs
@@ -334,6 +334,8 @@ impl<'a> ReFunc<'a> {
left: Box::new(left.clone()),
ops: Box::new([op]),
comparators: Box::new([right.clone()]),
+ // only ever `==` / `!=` here, which have one spelling
+ identity_ops: None,
range: TextRange::default(),
node_index: ruff_python_ast::AtomicNodeIndex::NONE,
})
diff --git a/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__parenthesize-chained-operators_RUF021_basedpython.by.snap b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__parenthesize-chained-operators_RUF021_basedpython.by.snap
new file mode 100644
index 0000000000..5c673b2ec4
--- /dev/null
+++ b/crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__parenthesize-chained-operators_RUF021_basedpython.by.snap
@@ -0,0 +1,16 @@
+---
+source: crates/ruff_linter/src/rules/ruff/mod.rs
+---
+RUF021 [*] Parenthesize `a and b` expressions when chaining `and` and `or` together, to make the precedence clear
+ --> RUF021_basedpython.by:21:12
+ |
+19 | # a boolean expression in a value position still wants the parentheses
+20 | def values(a: bool, b: bool, c: bool) -> bool:
+21 | return a and b or c
+ | ^^^^^^^
+help: Parenthesize the `and` subexpression
+ |
+20 | def values(a: bool, b: bool, c: bool) -> bool:
+ - return a and b or c
+21 + return (a and b) or c
+ |
diff --git a/crates/ruff_python_ast/ast.toml b/crates/ruff_python_ast/ast.toml
index 0c6859a902..2f385f33f1 100644
--- a/crates/ruff_python_ast/ast.toml
+++ b/crates/ruff_python_ast/ast.toml
@@ -485,6 +485,20 @@ fields = [
{ name = "left", type = "Expr" },
{ name = "ops", type = "Box<[CmpOp]>" },
{ name = "comparators", type = "Box<[Expr]>" },
+ { name = "identity_ops", type = "Box?", skip_visit = true, doc = """basedpython: which operators were written `===` / `!==`.
+
+basedpython gives the `is` keyword to the type test and spells Python's identity
+comparison `===` / `!==`. Both parse to [`CmpOp::Is`](crate::CmpOp::Is) / [`CmpOp::IsNot`](crate::CmpOp::IsNot) so the
+AST keeps Python's shape, and the spelling is recorded here instead. `None` when
+nothing was written `===` / `!==`, which is every `.py` file — and boxed, because
+paying two more words on every comparison would widen `Expr` itself.
+
+The lexer emits `===` / `!==` for any source that spells them, so a renderer can
+print them back from this field alone without knowing which language it is
+emitting — a `.py` file that contains them was never valid Python anyway. Read it
+through [`ExprCompare::is_identity_operator`], and ask
+[`ExprCompare::is_type_test`] for the other half of the question, which is the
+one that needs the source type.""" },
]
# The fields must be visited simultaneously
custom_source_order = true
diff --git a/crates/ruff_python_ast/src/comparable.rs b/crates/ruff_python_ast/src/comparable.rs
index dca4ec069b..765c7cb2aa 100644
--- a/crates/ruff_python_ast/src/comparable.rs
+++ b/crates/ruff_python_ast/src/comparable.rs
@@ -971,6 +971,10 @@ pub struct ExprCompare<'a> {
left: Box>,
ops: Vec,
comparators: Vec>,
+ /// basedpython: `a is int` and `a === int` share an operator but not a
+ /// meaning, so two comparisons are only equal when they were spelled the
+ /// same way
+ identity_ops: Vec,
}
#[derive(Debug, PartialEq, Eq, Hash)]
@@ -1282,12 +1286,17 @@ impl<'a> From<&'a ast::Expr> for ComparableExpr<'a> {
left,
ops,
comparators,
+ identity_ops,
range: _,
node_index: _,
}) => Self::Compare(ExprCompare {
left: left.into(),
ops: ops.iter().copied().map(Into::into).collect(),
comparators: comparators.iter().map(Into::into).collect(),
+ identity_ops: identity_ops
+ .as_ref()
+ .map(|identity| identity.ops.to_vec())
+ .unwrap_or_default(),
}),
ast::Expr::Call(ast::ExprCall {
func,
diff --git a/crates/ruff_python_ast/src/generated.rs b/crates/ruff_python_ast/src/generated.rs
index 65886f1a10..086f1078ba 100644
--- a/crates/ruff_python_ast/src/generated.rs
+++ b/crates/ruff_python_ast/src/generated.rs
@@ -10440,6 +10440,21 @@ pub struct ExprCompare {
pub left: Box,
pub ops: Box<[crate::CmpOp]>,
pub comparators: Box<[Expr]>,
+ /// basedpython: which operators were written `===` / `!==`.
+ ///
+ /// basedpython gives the `is` keyword to the type test and spells Python's identity
+ /// comparison `===` / `!==`. Both parse to [`CmpOp::Is`](crate::CmpOp::Is) / [`CmpOp::IsNot`](crate::CmpOp::IsNot) so the
+ /// AST keeps Python's shape, and the spelling is recorded here instead. `None` when
+ /// nothing was written `===` / `!==`, which is every `.py` file — and boxed, because
+ /// paying two more words on every comparison would widen `Expr` itself.
+ ///
+ /// The lexer emits `===` / `!==` for any source that spells them, so a renderer can
+ /// print them back from this field alone without knowing which language it is
+ /// emitting — a `.py` file that contains them was never valid Python anyway. Read it
+ /// through [`ExprCompare::is_identity_operator`], and ask
+ /// [`ExprCompare::is_type_test`] for the other half of the question, which is the
+ /// one that needs the source type.
+ pub identity_ops: Option>,
}
/// A call expression whose end offset is derived from its arguments.
diff --git a/crates/ruff_python_ast/src/helpers.rs b/crates/ruff_python_ast/src/helpers.rs
index 9733d556bc..165e1f08ad 100644
--- a/crates/ruff_python_ast/src/helpers.rs
+++ b/crates/ruff_python_ast/src/helpers.rs
@@ -2766,6 +2766,7 @@ pub fn generate_comparison(
left: &Expr,
ops: &[CmpOp],
comparators: &[Expr],
+ identity_ops: Option<&crate::IdentityOperators>,
parent: AnyNodeRef,
tokens: &Tokens,
source: &str,
@@ -2779,7 +2780,14 @@ pub fn generate_comparison(
&source[parenthesized_range(left.into(), parent, tokens).unwrap_or(left.range())],
);
- for (op, comparator) in ops.iter().zip(comparators) {
+ for (index, (op, comparator)) in ops.iter().zip(comparators).enumerate() {
+ // basedpython writes python's identity comparison `===` / `!==` and
+ // gives the `is` keyword to a type test. printing `is` for an operator
+ // the source wrote `===` would rewrite one into the other
+ let identity = identity_ops
+ .and_then(|identity| identity.ops.get(index))
+ .copied()
+ .unwrap_or(false);
// Add the operator.
contents.push_str(match op {
CmpOp::Eq => " == ",
@@ -2790,6 +2798,8 @@ pub fn generate_comparison(
CmpOp::GtE => " >= ",
CmpOp::In => " in ",
CmpOp::NotIn => " not in ",
+ CmpOp::Is if identity => " === ",
+ CmpOp::IsNot if identity => " !== ",
CmpOp::Is => " is ",
CmpOp::IsNot => " is not ",
});
diff --git a/crates/ruff_python_ast/src/node.rs b/crates/ruff_python_ast/src/node.rs
index 929e51c464..34716b388f 100644
--- a/crates/ruff_python_ast/src/node.rs
+++ b/crates/ruff_python_ast/src/node.rs
@@ -96,6 +96,7 @@ impl ast::ExprCompare {
left,
ops,
comparators,
+ identity_ops: _,
range: _,
node_index: _,
} = self;
diff --git a/crates/ruff_python_ast/src/nodes.rs b/crates/ruff_python_ast/src/nodes.rs
index a6adbd4aad..9c1e506098 100644
--- a/crates/ruff_python_ast/src/nodes.rs
+++ b/crates/ruff_python_ast/src/nodes.rs
@@ -2799,6 +2799,83 @@ impl ExprNamed {
}
}
+/// basedpython: which operators of an [`ExprCompare`](crate::ExprCompare) were
+/// written `===` / `!==`, python's identity comparison.
+///
+/// basedpython gives the `is` keyword to the type test and spells identity
+/// `===` / `!==`. Both parse to the same [`CmpOp`](crate::CmpOp), so the AST
+/// keeps this alongside to say which was written.
+///
+/// Boxed behind an `Option` on the node: a comparison written any other way
+/// carries none of this, and paying two words for it on every comparison would
+/// widen `Expr` itself.
+#[derive(Clone, Debug, PartialEq)]
+#[cfg_attr(feature = "get-size", derive(get_size2::GetSize))]
+pub struct IdentityOperators {
+ /// one entry per operator, in the same order as
+ /// [`ExprCompare::ops`](crate::ExprCompare::ops)
+ pub ops: Box<[bool]>,
+}
+
+impl IdentityOperators {
+ /// the side table to store on a node, or `None` when no operator was
+ /// written `===` / `!==`.
+ ///
+ /// `operators` is the comparison's own operator count: an entry past it
+ /// would never be read, and one short of it would answer `false` for an
+ /// operator that was written `===`.
+ pub fn into_stored(ops: Vec, operators: usize) -> Option> {
+ debug_assert!(
+ ops.len() <= operators,
+ "identity spellings outnumber the operators they belong to"
+ );
+ ops.iter().any(|identity| *identity).then(|| {
+ Box::new(Self {
+ ops: ops.into_boxed_slice(),
+ })
+ })
+ }
+}
+
+impl crate::ExprCompare {
+ /// basedpython: whether the operator at `index` was written `===` / `!==`,
+ /// which is Python's identity comparison.
+ ///
+ /// `false` for every operator that is not an identity comparison and for an
+ /// index past the end, so a caller never has to check the encoding or the
+ /// length first. A renderer can print `===` from this answer alone, without
+ /// knowing which language it is emitting: the lexer produces the token for
+ /// any source that spells it, and a `.py` file that does was never valid
+ /// Python.
+ pub fn is_identity_operator(&self, index: usize) -> bool {
+ self.identity_ops
+ .as_ref()
+ .and_then(|identity| identity.ops.get(index))
+ .copied()
+ .unwrap_or(false)
+ }
+
+ /// basedpython: whether the operator at `index` is a *type test* — `is` or
+ /// `is not` spelled with the keyword, which asks whether the left operand
+ /// has the type its right-hand side names.
+ ///
+ /// The source type is required because the same operator means Python's
+ /// identity comparison in a `.py` file, where there is no type test at all.
+ pub fn is_type_test(&self, index: usize, source_type: crate::PySourceType) -> bool {
+ source_type.is_basedpython()
+ && matches!(
+ self.ops.get(index),
+ Some(crate::CmpOp::Is | crate::CmpOp::IsNot)
+ )
+ && !self.is_identity_operator(index)
+ }
+
+ /// basedpython: whether any operator of this comparison is a type test.
+ pub fn has_type_test(&self, source_type: crate::PySourceType) -> bool {
+ (0..self.ops.len()).any(|index| self.is_type_test(index, source_type))
+ }
+}
+
impl ExprList {
pub fn iter(&self) -> std::slice::Iter<'_, Expr> {
self.elts.iter()
@@ -4552,7 +4629,10 @@ mod tests {
assert_eq!(std::mem::size_of::(), 16);
assert_eq!(std::mem::size_of::(), 48);
assert_eq!(std::mem::size_of::(), 64);
- assert_eq!(std::mem::size_of::(), 56);
+ // basedpython: a comparison carries which of its operators were written
+ // `===` / `!==`, boxed so this stays inside `ExprCall`'s width and
+ // `Expr` does not grow
+ assert_eq!(std::mem::size_of::(), 64);
assert_eq!(std::mem::size_of::(), 40);
assert_eq!(std::mem::size_of::(), 56);
assert_eq!(std::mem::size_of::(), 12);
diff --git a/crates/ruff_python_ast/src/visitor.rs b/crates/ruff_python_ast/src/visitor.rs
index caac8b36ee..b22b57ca74 100644
--- a/crates/ruff_python_ast/src/visitor.rs
+++ b/crates/ruff_python_ast/src/visitor.rs
@@ -569,6 +569,7 @@ pub fn walk_expr<'a, V: Visitor<'a> + ?Sized>(visitor: &mut V, expr: &'a Expr) {
left,
ops,
comparators,
+ identity_ops: _,
range: _,
node_index: _,
}) => {
diff --git a/crates/ruff_python_ast/src/visitor/transformer.rs b/crates/ruff_python_ast/src/visitor/transformer.rs
index 9b3221a7af..f189d53375 100644
--- a/crates/ruff_python_ast/src/visitor/transformer.rs
+++ b/crates/ruff_python_ast/src/visitor/transformer.rs
@@ -556,6 +556,7 @@ pub fn walk_expr(visitor: &V, expr: &mut Expr) {
left,
ops,
comparators,
+ identity_ops: _,
range: _,
node_index: _,
}) => {
diff --git a/crates/ruff_python_codegen/src/generator.rs b/crates/ruff_python_codegen/src/generator.rs
index f56a6f497e..887832b328 100644
--- a/crates/ruff_python_codegen/src/generator.rs
+++ b/crates/ruff_python_codegen/src/generator.rs
@@ -1478,17 +1478,26 @@ impl<'a> Generator<'a> {
self.unparse_expr(value, precedence::MAX);
});
}
- Expr::Compare(ast::ExprCompare {
- left,
- ops,
- comparators,
- range: _,
- node_index: _,
- }) => {
+ Expr::Compare(compare) => {
+ let ast::ExprCompare {
+ left,
+ ops,
+ comparators,
+ identity_ops: _,
+ range: _,
+ node_index: _,
+ } = compare;
group_if!(precedence::CMP, {
let new_lvl = precedence::CMP + 1;
self.unparse_expr(left, new_lvl);
- for (op, cmp) in ops.iter().zip(comparators) {
+ for (index, (op, cmp)) in ops.iter().zip(comparators).enumerate() {
+ // basedpython gives the `is` keyword to the type test and
+ // spells python's identity comparison `===` / `!==`. both
+ // parse to the same operator, so printing `is` for one
+ // the source wrote `===` would quietly turn an identity
+ // check into a type test. only basedpython records the
+ // spelling, so this needs no mode of its own
+ let identity = compare.is_identity_operator(index);
let op = match op {
CmpOp::Eq => " == ",
CmpOp::NotEq => " != ",
@@ -1496,6 +1505,8 @@ impl<'a> Generator<'a> {
CmpOp::LtE => " <= ",
CmpOp::Gt => " > ",
CmpOp::GtE => " >= ",
+ CmpOp::Is if identity => " === ",
+ CmpOp::IsNot if identity => " !== ",
CmpOp::Is => " is ",
CmpOp::IsNot => " is not ",
CmpOp::In => " in ",
diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/identity_operators.by b/crates/ruff_python_formatter/resources/test/fixtures/ruff/identity_operators.by
index c10a156483..566905d4c4 100644
--- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/identity_operators.by
+++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/identity_operators.by
@@ -11,8 +11,10 @@ b = xs !== xs
c = xs is list[int]
d = xs is not list[int]
-# chained comparisons mix the spellings freely
-e = xs === xs !== None is list[int] is not list[str]
+# a chain of identity comparisons is ordinary python, and each operator keeps
+# the spelling it was written with. a type test may not join a chain at all —
+# python's chaining rule would ask whether the *class* has the next type
+e = xs === xs !== None
# mixed with value comparisons
f = 1 === 1 == 1
@@ -21,6 +23,16 @@ f = 1 === 1 == 1
g = (xs) === (xs)
h = (xs) !== (xs)
+# a comment between the operands, and a line continuation — the two the old
+# source-gap scan read wrong
+i = (
+ xs
+ # which one is this
+ === xs
+)
+j = xs \
+ === xs
+
# inside a condition
if xs === xs:
pass
diff --git a/crates/ruff_python_formatter/src/expression/binary_like.rs b/crates/ruff_python_formatter/src/expression/binary_like.rs
index ec658172be..77738c02aa 100644
--- a/crates/ruff_python_formatter/src/expression/binary_like.rs
+++ b/crates/ruff_python_formatter/src/expression/binary_like.rs
@@ -8,7 +8,7 @@ use ruff_python_ast::{
Expr, ExprAttribute, ExprBinOp, ExprBoolOp, ExprCompare, ExprUnaryOp, StringLike, UnaryOp,
};
use ruff_python_trivia::{SimpleToken, SimpleTokenKind, SimpleTokenizer, TriviaRanges};
-use ruff_text_size::{Ranged, TextLen, TextRange};
+use ruff_text_size::{Ranged, TextRange};
use crate::comments::{Comments, SourceComment, leading_comments, trailing_comments};
use crate::expression::OperatorPrecedence;
@@ -65,20 +65,16 @@ impl<'a> BinaryLike<'a> {
if let Some((last_expression, middle_expressions)) = compare.comparators.split_last() {
let (last_operator, middle_operators) = compare.ops.split_last().unwrap();
- // the source gap preceding each operator, so a basedpython
- // `===` / `!==` can be told apart from the `is` / `is not` it
- // shares a `CmpOp` with
- let mut gap_start = compare.left.end();
-
- for (operator, expression) in middle_operators.iter().zip(middle_expressions) {
+ for (index, (operator, expression)) in
+ middle_operators.iter().zip(middle_expressions).enumerate()
+ {
parts.push(OperandOrOperator::Operator(Operator {
symbol: OperatorSymbol::Comparator(
*operator,
- TextRange::new(gap_start, expression.start()),
+ compare.is_identity_operator(index),
),
trailing_comments: &[],
}));
- gap_start = expression.end();
rec(Operand::Middle { expression }, comments, trivia, parts);
}
@@ -86,7 +82,7 @@ impl<'a> BinaryLike<'a> {
parts.push(OperandOrOperator::Operator(Operator {
symbol: OperatorSymbol::Comparator(
*last_operator,
- TextRange::new(gap_start, last_expression.start()),
+ compare.is_identity_operator(middle_operators.len()),
),
trailing_comments: &[],
}));
@@ -1022,12 +1018,11 @@ impl Format> for Operator<'_> {
#[derive(Copy, Clone, Debug)]
enum OperatorSymbol {
Binary(ruff_python_ast::Operator),
- /// The comparison operator together with the source gap that precedes it —
- /// the span between the end of the left operand and the start of the right
- /// one. basedpython spells identity as `===` / `!==` but parses both to the
- /// `CmpOp` of `is` / `is not` (whose surface form is instead a parametric
- /// type test), so the spelling can only be recovered from that source.
- Comparator(ruff_python_ast::CmpOp, TextRange),
+ /// The comparison operator, and whether it was written `===` / `!==`.
+ /// basedpython spells python's identity comparison that way and parses it
+ /// to the same `CmpOp` as the `is` keyword, so the operator alone does not
+ /// say which one to print back.
+ Comparator(ruff_python_ast::CmpOp, bool),
Bool(ruff_python_ast::BoolOp),
}
@@ -1045,40 +1040,33 @@ impl OperatorSymbol {
}
}
-/// The source range of a basedpython `===` / `!==` identity operator written in
-/// `gap` — the span between the two operands it joins — or `None` when the
-/// operator is spelled `is` / `is not`, or is not an identity comparison at all.
-fn identity_operator_range(
- context: &PyFormatContext,
+/// The basedpython spelling of an identity comparison — `===` / `!==` — or
+/// `None` when the operator is written `is` / `is not`, or is not an identity
+/// comparison at all.
+///
+/// Only basedpython records the spelling, so a `.py` file always takes the
+/// `None` arm and prints python's `is`.
+fn identity_operator_symbol(
operator: ruff_python_ast::CmpOp,
- gap: TextRange,
-) -> Option {
- let symbol = match operator {
- ruff_python_ast::CmpOp::Is => "===",
- ruff_python_ast::CmpOp::IsNot => "!==",
- _ => return None,
- };
-
- let source = context.source();
- // the operator is the first token in the gap that is neither trivia nor a
- // closing parenthesis of the left operand (`(a) === b`)
- let start = SimpleTokenizer::new(source, gap)
- .skip_trivia()
- .find(|token| token.kind() != SimpleTokenKind::RParen)?
- .start();
-
- source[usize::from(start)..]
- .starts_with(symbol)
- .then(|| TextRange::at(start, symbol.text_len()))
+ identity: bool,
+) -> Option<&'static str> {
+ if !identity {
+ return None;
+ }
+ match operator {
+ ruff_python_ast::CmpOp::Is => Some("==="),
+ ruff_python_ast::CmpOp::IsNot => Some("!=="),
+ _ => None,
+ }
}
impl Format> for OperatorSymbol {
fn fmt(&self, f: &mut Formatter>) -> FormatResult<()> {
match self {
OperatorSymbol::Binary(operator) => operator.format().fmt(f),
- OperatorSymbol::Comparator(operator, gap) => {
- match identity_operator_range(f.context(), *operator, *gap) {
- Some(range) => source_text_slice(range).fmt(f),
+ OperatorSymbol::Comparator(operator, identity) => {
+ match identity_operator_symbol(*operator, *identity) {
+ Some(symbol) => token(symbol).fmt(f),
None => operator.format().fmt(f),
}
}
diff --git a/crates/ruff_python_formatter/src/expression/mod.rs b/crates/ruff_python_formatter/src/expression/mod.rs
index a88e740e26..39f21dfd33 100644
--- a/crates/ruff_python_formatter/src/expression/mod.rs
+++ b/crates/ruff_python_formatter/src/expression/mod.rs
@@ -743,6 +743,7 @@ impl<'input> CanOmitOptionalParenthesesVisitor<'input> {
left: _,
ops,
comparators: _,
+ identity_ops: _,
}) => {
self.update_max_precedence_with_count(
OperatorPrecedence::Comparator,
diff --git a/crates/ruff_python_formatter/tests/snapshots/format@identity_operators.by.snap b/crates/ruff_python_formatter/tests/snapshots/format@identity_operators.by.snap
index 098d728222..7605789ec4 100644
--- a/crates/ruff_python_formatter/tests/snapshots/format@identity_operators.by.snap
+++ b/crates/ruff_python_formatter/tests/snapshots/format@identity_operators.by.snap
@@ -17,8 +17,10 @@ b = xs !== xs
c = xs is list[int]
d = xs is not list[int]
-# chained comparisons mix the spellings freely
-e = xs === xs !== None is list[int] is not list[str]
+# a chain of identity comparisons is ordinary python, and each operator keeps
+# the spelling it was written with. a type test may not join a chain at all —
+# python's chaining rule would ask whether the *class* has the next type
+e = xs === xs !== None
# mixed with value comparisons
f = 1 === 1 == 1
@@ -27,6 +29,16 @@ f = 1 === 1 == 1
g = (xs) === (xs)
h = (xs) !== (xs)
+# a comment between the operands, and a line continuation — the two the old
+# source-gap scan read wrong
+i = (
+ xs
+ # which one is this
+ === xs
+)
+j = xs \
+ === xs
+
# inside a condition
if xs === xs:
pass
@@ -53,8 +65,10 @@ b = xs !== xs
c = xs is list[int]
d = xs is not list[int]
-# chained comparisons mix the spellings freely
-e = xs === xs !== None is list[int] is not list[str]
+# a chain of identity comparisons is ordinary python, and each operator keeps
+# the spelling it was written with. a type test may not join a chain at all —
+# python's chaining rule would ask whether the *class* has the next type
+e = xs === xs !== None
# mixed with value comparisons
f = 1 === 1 == 1
@@ -63,6 +77,15 @@ f = 1 === 1 == 1
g = (xs) === (xs)
h = (xs) !== (xs)
+# a comment between the operands, and a line continuation — the two the old
+# source-gap scan read wrong
+i = (
+ xs
+ # which one is this
+ === xs
+)
+j = xs === xs
+
# inside a condition
if xs === xs:
pass
diff --git a/crates/ruff_python_parser/resources/valid/expressions/identity_compare.py b/crates/ruff_python_parser/resources/valid/expressions/identity_compare.py
index e456a03c87..e460bb77b6 100644
--- a/crates/ruff_python_parser/resources/valid/expressions/identity_compare.py
+++ b/crates/ruff_python_parser/resources/valid/expressions/identity_compare.py
@@ -1,7 +1,10 @@
-# basedpython identity-comparison operators
+# basedpython identity-comparison operators. this fixture parses in python
+# mode, where the lexer still produces the tokens — a `.py` file spelling them
+# was never valid python, and the parser's job here is only to keep the shape
x === y
x !== y
-# chained with other comparisons
-a === b is not c
+# chained with each other. a chain mixing in the `is` keyword is rejected in
+# basedpython mode, and has its own test in `parser::tests`
+a === b !== c
a !== b
diff --git a/crates/ruff_python_parser/src/error.rs b/crates/ruff_python_parser/src/error.rs
index 30f86d7a28..91a33e91ff 100644
--- a/crates/ruff_python_parser/src/error.rs
+++ b/crates/ruff_python_parser/src/error.rs
@@ -139,6 +139,8 @@ pub enum ParseErrorType {
DuplicateTypeParamSeparator(&'static str),
/// basedpython: a bound range `T: Lower..Upper` was missing one of its ends.
IncompleteTypeParamBoundRange,
+ /// basedpython: an `is` type test appeared in a chained comparison.
+ ChainedTypeTest,
/// An unparenthesized named expression was found where it is not allowed.
UnparenthesizedNamedExpression,
@@ -308,6 +310,10 @@ impl std::fmt::Display for ParseErrorType {
"Type parameter list cannot have two `{separator}` separators"
)
}
+ ParseErrorType::ChainedTypeTest => f.write_str(
+ "`is` type test cannot be chained with another comparison; \
+ split it into separate tests joined with `and`",
+ ),
ParseErrorType::IncompleteTypeParamBoundRange => f.write_str(
"Type parameter bound range requires both a lower and an upper bound, as in `T: int..object`",
),
diff --git a/crates/ruff_python_parser/src/parser/expression.rs b/crates/ruff_python_parser/src/parser/expression.rs
index 9d111825ad..ca974a91d0 100644
--- a/crates/ruff_python_parser/src/parser/expression.rs
+++ b/crates/ruff_python_parser/src/parser/expression.rs
@@ -2259,6 +2259,17 @@ impl<'src> Parser<'src> {
op: CmpOp,
context: ExpressionContext,
) -> ast::ExprCompare {
+ // built only once an operator is actually written `===` / `!==`, so an
+ // ordinary comparison — every one in a `.py` file — allocates nothing
+ let mut identity_ops: Vec = Vec::new();
+ let record_identity = |identity_ops: &mut Vec, index: usize, identity: bool| {
+ if identity_ops.is_empty() && !identity {
+ return;
+ }
+ identity_ops.resize(index, false);
+ identity_ops.push(identity);
+ };
+ record_identity(&mut identity_ops, 0, self.at_identity_operator(op));
self.bump_cmp_op(op);
let comparators_snapshot = self.expr_scratch.snapshot();
@@ -2288,19 +2299,58 @@ impl<'src> Parser<'src> {
break;
};
+ record_identity(
+ &mut identity_ops,
+ operators.len(),
+ self.at_identity_operator(next_op),
+ );
self.bump_cmp_op(next_op);
operators.push(next_op);
}
+ let range = self.node_range(start);
+ // a type test's right-hand side is a type expression, and python's
+ // chaining rule makes each operand the *next* comparison's left operand
+ // — so `a is int is str` becomes `a is int and int is str`, where the
+ // class `int` is asked whether it has the type `str`. only a type test
+ // that something follows creates that conflict; a trailing one
+ // (`a < b is None`) hands nothing on, and chains as it always did
+ if self.options.is_basedpython
+ && operators
+ .iter()
+ .enumerate()
+ .take(operators.len().saturating_sub(1))
+ .any(|(index, op)| {
+ matches!(op, CmpOp::Is | CmpOp::IsNot)
+ && !identity_ops.get(index).copied().unwrap_or(false)
+ })
+ {
+ self.add_error(ParseErrorType::ChainedTypeTest, range);
+ }
+ let operator_count = operators.len();
ast::ExprCompare {
left: Box::new(lhs),
ops: operators.into_boxed_slice(),
comparators: self.expr_scratch.take(comparators_snapshot),
- range: self.node_range(start),
+ // the common case — a `.py` file, or basedpython written with the
+ // `is` keyword — stores nothing at all
+ identity_ops: ast::IdentityOperators::into_stored(identity_ops, operator_count),
+ range,
node_index: AtomicNodeIndex::NONE,
}
}
+ /// basedpython: whether the comparison operator the parser is positioned at
+ /// was written `===` / `!==` — python's identity comparison, which the lexer
+ /// emits as its own token and which shares a [`CmpOp`] with the `is` keyword.
+ fn at_identity_operator(&self, op: CmpOp) -> bool {
+ matches!(op, CmpOp::Is | CmpOp::IsNot)
+ && self.at_ts(TokenSet::new([
+ TokenKind::EqEqEqual,
+ TokenKind::BangEqEqual,
+ ]))
+ }
+
/// Parses all kinds of strings and implicitly concatenated strings.
///
/// # Panics
diff --git a/crates/ruff_python_parser/src/parser/helpers.rs b/crates/ruff_python_parser/src/parser/helpers.rs
index e48886b996..05074e6ac9 100644
--- a/crates/ruff_python_parser/src/parser/helpers.rs
+++ b/crates/ruff_python_parser/src/parser/helpers.rs
@@ -44,14 +44,13 @@ pub(super) const fn token_kind_to_cmp_op(
(TokenKind::In, _) => CmpOp::In,
(TokenKind::EqEqual, _) => CmpOp::Eq,
// basedpython: `===` is the identity comparison (Python's `is`).
- // we map it to CmpOp::Is so the AST matches Python semantics; the
- // forward transpile distinguishes it from the basedpython `is`
- // keyword by looking at the source text
+ // we map it to CmpOp::Is so the AST matches Python semantics, and the
+ // parser records the spelling in `ExprCompare::identity_ops` so nothing
+ // downstream has to read it back out of the source
(TokenKind::EqEqEqual, _) => CmpOp::Is,
// basedpython: `!==` is the negated identity comparison (Python's
- // `is not`). mapped to CmpOp::IsNot; the forward transpile
- // distinguishes it from the basedpython `is not` keyword pair by
- // looking at the source text
+ // `is not`). mapped to CmpOp::IsNot, with the spelling recorded the
+ // same way
(TokenKind::BangEqEqual, _) => CmpOp::IsNot,
(TokenKind::NotEqual, _) => CmpOp::NotEq,
(TokenKind::Less, _) => CmpOp::Lt,
diff --git a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__ipython_escape_commands.snap b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__ipython_escape_commands.snap
index b5b94a8af2..99c2c06bcf 100644
--- a/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__ipython_escape_commands.snap
+++ b/crates/ruff_python_parser/src/parser/snapshots/ruff_python_parser__parser__tests__ipython_escape_commands.snap
@@ -194,6 +194,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
),
diff --git a/crates/ruff_python_parser/src/parser/tests.rs b/crates/ruff_python_parser/src/parser/tests.rs
index 82f693be85..c9e126d609 100644
--- a/crates/ruff_python_parser/src/parser/tests.rs
+++ b/crates/ruff_python_parser/src/parser/tests.rs
@@ -39,6 +39,73 @@ fn parse_basedpython_module_with_errors(source: &str) -> Parsed {
.unwrap()
}
+/// the identity spelling recorded for each operator of the single comparison in
+/// `source`, parsed as basedpython
+fn identity_spellings(source: &str) -> Vec {
+ let parsed = parse_basedpython_module(source);
+ let [Stmt::Expr(statement)] = parsed.syntax().body.as_slice() else {
+ panic!("expected a single expression statement: {source}");
+ };
+ let Expr::Compare(compare) = statement.value.as_ref() else {
+ panic!("expected a comparison: {source}");
+ };
+ (0..compare.ops.len())
+ .map(|index| compare.is_identity_operator(index))
+ .collect()
+}
+
+#[test]
+fn identity_operators_are_recorded_per_operator() {
+ // the two spellings share a `CmpOp`, so the parser records which it saw —
+ // and records nothing at all when the answer is "none of them"
+ assert_eq!(identity_spellings("a === b"), vec![true]);
+ assert_eq!(identity_spellings("a !== b"), vec![true]);
+ assert_eq!(identity_spellings("a is b"), vec![false]);
+ assert_eq!(identity_spellings("a == b"), vec![false]);
+ // a spelling recorded for one operator does not shift the others
+ assert_eq!(identity_spellings("a == b === c"), vec![false, true]);
+ assert_eq!(identity_spellings("a === b == c"), vec![true, false]);
+ assert_eq!(
+ identity_spellings("a === b !== c === d"),
+ vec![true, true, true]
+ );
+}
+
+#[test]
+fn a_type_test_may_not_be_chained() {
+ // python chains `a is int is str` into `a is int and int is str`, whose
+ // second half asks whether the class `int` has the type `str`
+ let parsed = parse_basedpython_module_with_errors("a is int is str\n");
+ let errors = parsed.errors();
+ assert!(
+ errors
+ .iter()
+ .any(|error| matches!(error.error, ParseErrorType::ChainedTypeTest)),
+ "expected a chained-type-test error, got {errors:?}"
+ );
+ // the reported range covers the whole comparison, which is what has to change
+ let error = errors
+ .iter()
+ .find(|error| matches!(error.error, ParseErrorType::ChainedTypeTest))
+ .unwrap();
+ assert_eq!(&"a is int is str\n"[error.location], "a is int is str");
+}
+
+#[test]
+fn a_trailing_type_test_still_chains() {
+ // nothing follows it, so no type expression becomes the next comparison's
+ // left operand — the conflict the rejection exists for
+ let parsed = parse_basedpython_module_with_errors("a < b is None\n");
+ assert!(
+ !parsed
+ .errors()
+ .iter()
+ .any(|error| matches!(error.error, ParseErrorType::ChainedTypeTest)),
+ "a trailing type test is not a chained one: {:?}",
+ parsed.errors()
+ );
+}
+
/// The keyword-argument names of the single call statement in `source`, each
/// with the way it was spelled.
fn keyword_names(source: &str) -> Vec<(String, ruff_python_ast::KeywordKey)> {
diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_equals.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_equals.snap
index 7a9edf207f..9b727a1956 100644
--- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_equals.snap
+++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_equals.snap
@@ -49,6 +49,7 @@ expression: suite
},
),
],
+ identity_ops: None,
},
),
debug_text: None,
diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_not_equals.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_not_equals.snap
index 3ab964282e..499e7dd3d5 100644
--- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_not_equals.snap
+++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_fstring_not_equals.snap
@@ -49,6 +49,7 @@ expression: suite
},
),
],
+ identity_ops: None,
},
),
debug_text: None,
diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_equals.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_equals.snap
index c7baa8a513..70f5bae3ff 100644
--- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_equals.snap
+++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_equals.snap
@@ -48,6 +48,7 @@ expression: suite
},
),
],
+ identity_ops: None,
},
),
debug_text: None,
diff --git a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_not_equals.snap b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_not_equals.snap
index bf1180fe07..b8c3e457ae 100644
--- a/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_not_equals.snap
+++ b/crates/ruff_python_parser/src/snapshots/ruff_python_parser__string__tests__parse_tstring_not_equals.snap
@@ -48,6 +48,7 @@ expression: suite
},
),
],
+ identity_ops: None,
},
),
debug_text: None,
diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__invalid_order.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__invalid_order.py.snap
index 17ed357836..cfd2db5e3d 100644
--- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__invalid_order.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__invalid_order.py.snap
@@ -46,6 +46,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -90,6 +91,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__invalid_rhs_expression.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__invalid_rhs_expression.py.snap
index 642c1d4402..5506702509 100644
--- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__invalid_rhs_expression.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__invalid_rhs_expression.py.snap
@@ -76,6 +76,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -117,6 +118,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__missing_rhs_0.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__missing_rhs_0.py.snap
index b40badc953..5b39fbc339 100644
--- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__missing_rhs_0.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__missing_rhs_0.py.snap
@@ -39,6 +39,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__missing_rhs_2.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__missing_rhs_2.py.snap
index a9019dc09e..95295d193a 100644
--- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__missing_rhs_2.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__missing_rhs_2.py.snap
@@ -39,6 +39,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__named_expression.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__named_expression.py.snap
index 7b1a47a58d..691867e8b2 100644
--- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__named_expression.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__named_expression.py.snap
@@ -39,6 +39,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -110,6 +111,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__starred_expression.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__starred_expression.py.snap
index 4b546bf98c..053c2434fa 100644
--- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__starred_expression.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__compare__starred_expression.py.snap
@@ -46,6 +46,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -86,6 +87,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -123,6 +125,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
ctx: Load,
@@ -163,6 +166,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
ctx: Load,
diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__double_star.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__double_star.py.snap
index d8ba224fd8..e15f5a3659 100644
--- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__double_star.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__double_star.py.snap
@@ -442,6 +442,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -486,6 +487,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -530,6 +532,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__star_expression_precedence.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__star_expression_precedence.py.snap
index 52c1ef0432..429b8d5fdf 100644
--- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__star_expression_precedence.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__star_expression_precedence.py.snap
@@ -86,6 +86,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
ctx: Load,
diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__generator.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__generator.py.snap
index 617832fc69..b5ff137f49 100644
--- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__generator.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__generator.py.snap
@@ -90,6 +90,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__tuple_starred_expr.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__tuple_starred_expr.py.snap
index 3dcdb1c7b9..4593ae60d8 100644
--- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__tuple_starred_expr.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__tuple_starred_expr.py.snap
@@ -48,6 +48,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
ctx: Load,
@@ -90,6 +91,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
ctx: Load,
@@ -720,6 +722,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
ctx: Load,
@@ -762,6 +765,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
ctx: Load,
diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__star_expression_precedence.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__star_expression_precedence.py.snap
index 5059784a2b..f4ab2b2c11 100644
--- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__star_expression_precedence.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__star_expression_precedence.py.snap
@@ -85,6 +85,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
ctx: Load,
diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@for_stmt_invalid_target.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@for_stmt_invalid_target.py.snap
index 6da0d01b1f..4597ac6bc8 100644
--- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@for_stmt_invalid_target.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@for_stmt_invalid_target.py.snap
@@ -307,6 +307,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
),
diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@for_stmt_invalid_target_binary_expr.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@for_stmt_invalid_target_binary_expr.py.snap
index ffcc5f43b8..4fcc9fb2a7 100644
--- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@for_stmt_invalid_target_binary_expr.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@for_stmt_invalid_target_binary_expr.py.snap
@@ -40,6 +40,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
pattern: None,
@@ -98,6 +99,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
pattern: None,
diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@for_stmt_invalid_target_in_keyword.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@for_stmt_invalid_target_in_keyword.py.snap
index d5ef96f286..b198947628 100644
--- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@for_stmt_invalid_target_in_keyword.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@for_stmt_invalid_target_in_keyword.py.snap
@@ -56,6 +56,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
],
@@ -125,6 +126,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
arguments: Arguments {
@@ -193,6 +195,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
pattern: None,
@@ -256,6 +259,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
Name(
@@ -336,6 +340,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
Name(
@@ -411,6 +416,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
Name(
diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_assignment_targets.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_assignment_targets.py.snap
index d9762e8efe..477401a8d4 100644
--- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_assignment_targets.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_assignment_targets.py.snap
@@ -853,6 +853,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
],
diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_augmented_assignment_target.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_augmented_assignment_target.py.snap
index 84ed5916fe..6cc5335145 100644
--- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_augmented_assignment_target.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__invalid_augmented_assignment_target.py.snap
@@ -732,6 +732,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
op: Add,
diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@while_stmt_missing_colon.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@while_stmt_missing_colon.py.snap
index 9ad6e62132..1555b0189c 100644
--- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@while_stmt_missing_colon.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@while_stmt_missing_colon.py.snap
@@ -40,6 +40,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
body: [
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@ambiguous_lpar_with_items_binary_expr.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@ambiguous_lpar_with_items_binary_expr.py.snap
index 279f8ac0b9..dac46093f8 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@ambiguous_lpar_with_items_binary_expr.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@ambiguous_lpar_with_items_binary_expr.py.snap
@@ -98,6 +98,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
optional_vars: None,
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__await.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__await.py.snap
index ce65176739..f2057516eb 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__await.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__await.py.snap
@@ -390,6 +390,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__compare.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__compare.py.snap
index b81211f86c..db6cc2c01e 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__compare.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__compare.py.snap
@@ -39,6 +39,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -72,6 +73,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -105,6 +107,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -138,6 +141,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -171,6 +175,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -204,6 +209,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -237,6 +243,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -270,6 +277,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -303,6 +311,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -336,6 +345,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -405,6 +415,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -492,6 +503,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -530,6 +542,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -585,6 +598,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
Name(
@@ -639,6 +653,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -679,6 +694,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -784,6 +800,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary_comprehension.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary_comprehension.py.snap
index f97cfa5d14..f0d0213c9f 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary_comprehension.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__dictionary_comprehension.py.snap
@@ -336,6 +336,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
BoolOp(
@@ -515,6 +516,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
],
@@ -645,6 +647,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
],
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__f_string.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__f_string.py.snap
index f289d34a95..4b981ac072 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__f_string.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__f_string.py.snap
@@ -370,6 +370,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
debug_text: None,
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__generator.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__generator.py.snap
index 3ff8b3044d..1087249e6a 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__generator.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__generator.py.snap
@@ -162,6 +162,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
BoolOp(
@@ -316,6 +317,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
],
@@ -437,6 +439,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
],
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__identity_compare.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__identity_compare.py.snap
index 82a85e4db9..cae10a971b 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__identity_compare.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__identity_compare.py.snap
@@ -8,20 +8,20 @@ input_file: crates/ruff_python_parser/resources/valid/expressions/identity_compa
Module(
ModModule {
node_index: NodeIndex(None),
- range: 0..119,
+ range: 0..410,
body: [
Expr(
StmtExpr {
node_index: NodeIndex(None),
- range: 44..51,
+ range: 234..241,
value: Compare(
ExprCompare {
node_index: NodeIndex(None),
- range: 44..51,
+ range: 234..241,
left: Name(
ExprName {
node_index: NodeIndex(None),
- range: 44..45,
+ range: 234..235,
id: Name("x"),
ctx: Load,
},
@@ -33,12 +33,19 @@ Module(
Name(
ExprName {
node_index: NodeIndex(None),
- range: 50..51,
+ range: 240..241,
id: Name("y"),
ctx: Load,
},
),
],
+ identity_ops: Some(
+ IdentityOperators {
+ ops: [
+ true,
+ ],
+ },
+ ),
},
),
},
@@ -46,15 +53,15 @@ Module(
Expr(
StmtExpr {
node_index: NodeIndex(None),
- range: 52..59,
+ range: 242..249,
value: Compare(
ExprCompare {
node_index: NodeIndex(None),
- range: 52..59,
+ range: 242..249,
left: Name(
ExprName {
node_index: NodeIndex(None),
- range: 52..53,
+ range: 242..243,
id: Name("x"),
ctx: Load,
},
@@ -66,12 +73,19 @@ Module(
Name(
ExprName {
node_index: NodeIndex(None),
- range: 58..59,
+ range: 248..249,
id: Name("y"),
ctx: Load,
},
),
],
+ identity_ops: Some(
+ IdentityOperators {
+ ops: [
+ true,
+ ],
+ },
+ ),
},
),
},
@@ -79,15 +93,15 @@ Module(
Expr(
StmtExpr {
node_index: NodeIndex(None),
- range: 94..110,
+ range: 388..401,
value: Compare(
ExprCompare {
node_index: NodeIndex(None),
- range: 94..110,
+ range: 388..401,
left: Name(
ExprName {
node_index: NodeIndex(None),
- range: 94..95,
+ range: 388..389,
id: Name("a"),
ctx: Load,
},
@@ -100,7 +114,7 @@ Module(
Name(
ExprName {
node_index: NodeIndex(None),
- range: 100..101,
+ range: 394..395,
id: Name("b"),
ctx: Load,
},
@@ -108,12 +122,20 @@ Module(
Name(
ExprName {
node_index: NodeIndex(None),
- range: 109..110,
+ range: 400..401,
id: Name("c"),
ctx: Load,
},
),
],
+ identity_ops: Some(
+ IdentityOperators {
+ ops: [
+ true,
+ true,
+ ],
+ },
+ ),
},
),
},
@@ -121,15 +143,15 @@ Module(
Expr(
StmtExpr {
node_index: NodeIndex(None),
- range: 111..118,
+ range: 402..409,
value: Compare(
ExprCompare {
node_index: NodeIndex(None),
- range: 111..118,
+ range: 402..409,
left: Name(
ExprName {
node_index: NodeIndex(None),
- range: 111..112,
+ range: 402..403,
id: Name("a"),
ctx: Load,
},
@@ -141,12 +163,19 @@ Module(
Name(
ExprName {
node_index: NodeIndex(None),
- range: 117..118,
+ range: 408..409,
id: Name("b"),
ctx: Load,
},
),
],
+ identity_ops: Some(
+ IdentityOperators {
+ ops: [
+ true,
+ ],
+ },
+ ),
},
),
},
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__if.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__if.py.snap
index c321bfbe65..0c796aca94 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__if.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__if.py.snap
@@ -186,6 +186,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
body: BinOp(
@@ -325,6 +326,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
orelse: Name(
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__list.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__list.py.snap
index f39ea8650c..7f4b3f51ae 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__list.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__list.py.snap
@@ -964,6 +964,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
],
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__list_comprehension.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__list_comprehension.py.snap
index b5e3275e91..02ee04f199 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__list_comprehension.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__list_comprehension.py.snap
@@ -228,6 +228,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
BoolOp(
@@ -381,6 +382,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
],
@@ -501,6 +503,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
],
@@ -565,6 +568,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
ifs: [],
@@ -781,6 +785,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
],
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__set_comprehension.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__set_comprehension.py.snap
index c6ba397fb6..7d0e3fa7bc 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__set_comprehension.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__set_comprehension.py.snap
@@ -116,6 +116,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
BoolOp(
@@ -269,6 +270,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
],
@@ -389,6 +391,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
],
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__t_string.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__t_string.py.snap
index dc3f7f79a3..78f3e21ac2 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__t_string.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__t_string.py.snap
@@ -353,6 +353,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
debug_text: None,
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__yield.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__yield.py.snap
index 7613e48055..5c7a19142a 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__yield.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__yield.py.snap
@@ -404,6 +404,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
),
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__yield_from.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__yield_from.py.snap
index 4eb78e54a3..fc81e1c3da 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__yield_from.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__yield_from.py.snap
@@ -330,6 +330,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@for_in_target_valid_expr.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@for_in_target_valid_expr.py.snap
index 788be1a067..a4a71d6e70 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@for_in_target_valid_expr.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@for_in_target_valid_expr.py.snap
@@ -52,6 +52,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
ctx: Store,
@@ -119,6 +120,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
slice: NumberLiteral(
@@ -195,6 +197,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
attr: Identifier {
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_classify_as_identifier_1.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_classify_as_identifier_1.py.snap
index 7eb5caad29..1be7df9bcd 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_classify_as_identifier_1.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_classify_as_identifier_1.py.snap
@@ -39,6 +39,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_classify_as_identifier_2.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_classify_as_identifier_2.py.snap
index 616aeb61bb..3d0d7d6abf 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_classify_as_identifier_2.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@match_classify_as_identifier_2.py.snap
@@ -53,6 +53,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -367,6 +368,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__ambiguous_lpar_with_items.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__ambiguous_lpar_with_items.py.snap
index fc65621d8a..1a3feff163 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__ambiguous_lpar_with_items.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__ambiguous_lpar_with_items.py.snap
@@ -625,6 +625,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
optional_vars: None,
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__assert.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__assert.py.snap
index 23d81b8c5a..a0ad80056d 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__assert.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__assert.py.snap
@@ -41,6 +41,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
msg: None,
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__for.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__for.py.snap
index f57cf2cb45..2aac57fe17 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__for.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__for.py.snap
@@ -272,6 +272,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
body: [
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__if.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__if.py.snap
index 7adfc6f3f9..1cf47f0ccd 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__if.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__if.py.snap
@@ -176,6 +176,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
body: [
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__match.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__match.py.snap
index b3c84a2be2..1f38fe8c7d 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__match.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__match.py.snap
@@ -3366,6 +3366,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
),
@@ -8077,6 +8078,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
),
@@ -9050,6 +9052,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__raise.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__raise.py.snap
index ee2161cd6d..f4fafe203f 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__raise.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__raise.py.snap
@@ -106,6 +106,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
),
@@ -378,6 +379,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
),
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__return.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__return.py.snap
index cf64239bc1..b0af82ddd0 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__return.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__return.py.snap
@@ -195,6 +195,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
),
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__type.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__type.py.snap
index 985fe73d59..a2a5b67daf 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__type.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__type.py.snap
@@ -3201,6 +3201,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
@@ -3345,6 +3346,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
},
diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__while.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__while.py.snap
index fffc87aa97..577ba2b3bc 100644
--- a/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__while.py.snap
+++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@statement__while.py.snap
@@ -75,6 +75,7 @@ Module(
},
),
],
+ identity_ops: None,
},
),
Name(
diff --git a/crates/ruff_python_semantic/src/model.rs b/crates/ruff_python_semantic/src/model.rs
index 369830232e..0aa5ffb3ab 100644
--- a/crates/ruff_python_semantic/src/model.rs
+++ b/crates/ruff_python_semantic/src/model.rs
@@ -892,6 +892,18 @@ impl<'a> SemanticModel<'a> {
self.in_basedpython_file() && self.in_trailing_lambda_block()
}
+ /// True when the model is inside a type expression in a basedpython file,
+ /// where `or` and `and` are the
+ /// [keyword spellings](https://docs.basedpython.org/features/or-and-types)
+ /// of union and intersection.
+ ///
+ /// `A or B` there is the type `A | B`, not a boolean expression whose value
+ /// is `A` whenever `A` is truthy, so a rule that reasons about python's
+ /// boolean operators has nothing to say about it.
+ pub fn in_basedpython_type_expression(&self) -> bool {
+ self.in_basedpython_file() && self.in_type_definition()
+ }
+
/// True when the innermost function scope is a
/// [trailing-lambda](https://docs.basedpython.org/features/trailing-lambdas)
/// block, whose receiver the parser binds outside the source's reach
diff --git a/crates/ty/docs/cli.md b/crates/ty/docs/cli.md
index b01c3d6d6e..aa7953501a 100644
--- a/crates/ty/docs/cli.md
+++ b/crates/ty/docs/cli.md
@@ -76,6 +76,9 @@ over all configuration files.
Check the project from scratch instead of asking a running language server.
+
A server holding this project has already parsed and inferred it, and answers a check out of that in a fraction of the time. It only answers when the two agree about the configuration and there is nothing unsaved, so this is a way to rule the server out rather than a way to get a different answer.
+
BY_NO_PROJECT_SERVER does the same, and also stops a server from listening.
Report what the build read and produced, as <kind> <value> lines.
input <path> for every file the project is made of — what a source distribution has to carry to rebuild into the same thing — and package <name> for every top-level package that came out.
which runtime type-soundness checks to insert: default, all (adds the opt-in parameters entry checks), none, or a comma-separated subset of generic-calls, projections, iterations, assignments, returns, arguments, parameters
diff --git a/crates/ty/docs/environment.md b/crates/ty/docs/environment.md
index 54bead8781..fce846bba4 100644
--- a/crates/ty/docs/environment.md
+++ b/crates/ty/docs/environment.md
@@ -2,6 +2,17 @@
ty defines and respects the following environment variables:
+### `BY_NO_PROJECT_SERVER`
+
+Disable the project server: the language server answering `by` command lines.
+
+A server started for a project holds it parsed and inferred, and a `by check` in that
+project asks the server rather than doing the work again. Set this to `1` to switch
+that off — on a command line, so it checks for itself, and on a server, so it does
+not open the socket that makes it reachable.
+
+Equivalent to the `--no-server` command-line argument.
+
### `TY_CONFIG_FILE`
Path to a `ty.toml` configuration file to use.
diff --git a/crates/ty/src/args.rs b/crates/ty/src/args.rs
index 308fe6e800..838a845903 100644
--- a/crates/ty/src/args.rs
+++ b/crates/ty/src/args.rs
@@ -146,7 +146,7 @@ pub(crate) enum Command {
/// `uv`, which does the packaging.
#[arg(long, conflicts_with_all = ["min_version", "print_manifest"])]
wheels: bool,
- /// Where to write the output [default: `out`, or `dist` with `--wheels`]
+ /// Where to write the output [default: `build`, or `dist` with `--wheels`]
#[arg(short = 'o', long, value_name = "DIR")]
out: Option,
/// Report what the build read and produced, as `` lines.
@@ -196,7 +196,16 @@ pub(crate) enum Command {
#[arg(value_name = "FILE")]
files: Vec,
/// Where to write the generated C and the extension modules.
- #[arg(short = 'o', long, value_name = "DIR", default_value = "out")]
+ ///
+ /// The same directory `by build` writes, and the same flag names it —
+ /// `--output` is kept because it was the only spelling this command took.
+ #[arg(
+ short = 'o',
+ long = "out",
+ visible_alias = "output",
+ value_name = "DIR",
+ default_value = "build"
+ )]
output: PathBuf,
/// Report every function that was not lowered natively, with the reason.
#[arg(long)]
@@ -433,6 +442,17 @@ pub(crate) struct CheckCommand {
#[arg(long, short = 'W')]
pub(crate) watch: bool,
+ /// Check the project from scratch instead of asking a running language server.
+ ///
+ /// A server holding this project has already parsed and inferred it, and answers a
+ /// check out of that in a fraction of the time. It only answers when the two agree
+ /// about the configuration and there is nothing unsaved, so this is a way to rule the
+ /// server out rather than a way to get a different answer.
+ ///
+ /// `BY_NO_PROJECT_SERVER` does the same, and also stops a server from listening.
+ #[arg(long, help_heading = "Global options")]
+ pub(crate) no_server: bool,
+
/// Respect file exclusions via `.gitignore` and other standard ignore files.
/// Use `--no-respect-ignore-files` to disable.
#[arg(
diff --git a/crates/ty/src/by_commands.rs b/crates/ty/src/by_commands.rs
index e0089b50d3..3de90be6a1 100644
--- a/crates/ty/src/by_commands.rs
+++ b/crates/ty/src/by_commands.rs
@@ -846,14 +846,7 @@ pub(crate) fn cmd_build(
let target = config.min_version.to_string();
crate::by_stamps::fill_discovered(&mut config.stamps, &cwd, Some(&target));
- // the output directory is settled before the project is read, because it is
- // the one directory the project must not be read *from*: it holds a copy of
- // every source this build is about to write. canonical, because that is what
- // the paths it is compared against are — creating it first is what makes
- // canonicalizing it possible
- let out = cwd.join(out);
- fs::create_dir_all(&out).with_context(|| format!("could not create {}", out.display()))?;
- let out = fs::canonicalize(&out).unwrap_or(out);
+ let out = settled_output_dir(&cwd, out);
let (db, handles, rebuilder, root) = build_project_db(&cwd, BY_SOURCES, Some(&out))?;
if handles.is_empty() {
@@ -863,7 +856,7 @@ pub(crate) fn cmd_build(
let file_count = handles.len();
let roots = module_roots(&db, &root);
let mut staging = Staging::new(&out);
- // `out/` outlives the build that wrote it — it is what a test runner, a
+ // `build/` outlives the build that wrote it — it is what a test runner, a
// debugger or an editor plugin sees — so the sourcemap goes with it. this is
// the directory where a `.by` really can be saved after the transpile, which
// is what the digests beside the map are for
@@ -889,12 +882,12 @@ pub(crate) fn cmd_build(
},
);
// the sourcemap and the package markers describe the tree that was written,
- // so they are staged whether or not something was reported — an `out/` a
+ // so they are staged whether or not something was reported — a `build/` a
// debugger cannot read is worse than one built from a partial check
stage_verbatim(&db, &root, &roots, &mut staging)?;
stage_by_typed_markers(&db, &mut staging, &roots, &root)?;
write_sourcemap_module(&mut staging, &entries)?;
- // `out/` outlives the build that wrote it and is what a debugger, a test
+ // `build/` outlives the build that wrote it and is what a debugger, a test
// runner or an editor plugin later reads, so it carries the same record a
// `run` tree does. no entry module: a build is not pointed at one
stage_build_record(
@@ -946,6 +939,33 @@ fn print_build_manifest(
Ok(())
}
+/// Where a build's output goes, settled before the project is read.
+///
+/// It is the one directory the project must not be read *from*: it holds a copy
+/// of every source the build is about to write, so a second build that walked it
+/// would take its own output for more of the project. Canonical, because the
+/// paths it is compared against are.
+///
+/// Nothing is created here. An output directory conjured before the command has
+/// anything to put in it outlives every way the command can fail early, and a
+/// project that has just been told it has no sources should not be left holding
+/// an empty `build/` it did not ask for. A directory that does not exist yet
+/// cannot be canonicalized, so its parent is, which answers the same question:
+/// two paths naming this directory compare equal.
+fn settled_output_dir(cwd: &Path, out: &Path) -> PathBuf {
+ let out = cwd.join(out);
+ if let Ok(canonical) = fs::canonicalize(&out) {
+ return canonical;
+ }
+ match (out.parent(), out.file_name()) {
+ (Some(parent), Some(name)) => match fs::canonicalize(parent) {
+ Ok(canonical) => canonical.join(name),
+ Err(_) => out,
+ },
+ _ => out,
+ }
+}
+
// ── compile ─────────────────────────────────────────────────────────────────
/// How `by compile` was invoked.
@@ -990,6 +1010,20 @@ pub(crate) fn cmd_compile(
// to say which, and gets told so rather than told the wrong one
crate::by_stamps::fill_discovered(&mut fallback.stamps, &cwd, None);
options.fallback = Some(fallback);
+ // the python this writes into the tree is `by build`'s python, derived the
+ // way `build` derives it: from the version the *project* declares.
+ //
+ // it must not come from the interpreter the extensions are built against.
+ // the two commands share an output directory, so the module `build` writes
+ // and the module `compile` writes are the same file — and a project that
+ // declares `>=3.9` would get a tree lowered for 3.9 or for 3.13 depending on
+ // which command ran last. `build/` is also where an editable install points,
+ // so the losing half of that is code the project says it supports and no
+ // longer runs on
+ let mut tree_config = version_config(None, &cwd)?;
+ lowering.apply_for_build(&mut tree_config, &cwd)?;
+ let target = tree_config.min_version.to_string();
+ crate::by_stamps::fill_discovered(&mut tree_config.stamps, &cwd, Some(&target));
let sources: Vec = if files.is_empty() {
compilable_files(&cwd)
} else {
@@ -1009,17 +1043,20 @@ pub(crate) fn cmd_compile(
// context that used to sit here misreported a version refusal as a missing header
let toolchain = by_build::Toolchain::probe(&python)?;
- let out_dir = cwd.join(output);
+ let out_dir = settled_output_dir(&cwd, output);
let mut compiled = 0usize;
let mut declined_total = 0usize;
+ // the output tree. `by_build` lays the artefacts out itself, so they are
+ // recorded rather than written through this — but they belong in the same
+ // manifest as everything else, or nothing ever takes a stale one back
+ let mut staging = Staging::new(&out_dir);
// one database for the whole project, so a type imported from a sibling module
// resolves. lowering each file on its own is sound — an unresolved class
// degrades to the object protocol — but it makes every imported type look
// gradual, and `--no-any` would then fail on noise
- // `compile` embeds fallback source produced by the untyped transpile, which
- // takes no db, so the rebuilder the other commands thread through is unused here
- let (db, project, _rebuilder, _root) = build_project_db(&cwd, COMPILABLE_SOURCES, None)?;
+ let (db, project, rebuilder, root) =
+ build_project_db(&cwd, COMPILABLE_SOURCES, Some(&out_dir))?;
// the database holds the whole project so a type imported from a sibling
// resolves, but only the files that were *asked for* are checked and emitted.
@@ -1076,6 +1113,47 @@ pub(crate) fn cmd_compile(
render_diagnostics(&db, &diagnostics)?;
}
+ // `compile` writes a superset of what `build` writes: the whole project as
+ // importable python, and native extensions for the modules that were asked
+ // for. two things follow from it, and neither is optional.
+ //
+ // the tree is a program rather than a heap of artefacts — a module nobody
+ // named still imports, from the python `by build` would have written, so
+ // `by compile one.by` no longer produces a tree that can import `one` and
+ // nothing else written in basedpython.
+ //
+ // and the manifest becomes sound. `finish` deletes what the last run wrote
+ // and this one did not, which is only a correct thing to do when this run
+ // authored the whole tree. while `compile` wrote artefacts alone, it deleted
+ // the sourcemap and build record a `by build` into the same directory had
+ // left — and `by restage` then refused that tree, which took the language
+ // server's single-file re-stage down with it
+ let roots = module_roots(&db, &root);
+ let transpilable: Vec<(PathBuf, ruff_db::files::File)> = project
+ .iter()
+ .filter(|(path, _)| {
+ path.extension()
+ .and_then(OsStr::to_str)
+ .is_some_and(|extension| BY_SOURCES.contains(&extension))
+ })
+ .cloned()
+ .collect();
+ let mut entries: Vec = Vec::new();
+ let mut requirements = by_transforms::RuntimeRequirements::default();
+ let transpiled = render_check_and_transpile(
+ &db,
+ &transpilable,
+ &tree_config,
+ CheckGate::ParseErrorsOnly,
+ &rebuilder,
+ &mut requirements,
+ |emitted| {
+ let relative = transpiled_destination(&roots, &root, emitted.by_path);
+ entries.push(stage_module(&mut staging, &relative, emitted)?);
+ Ok(())
+ },
+ );
+
// what each source will be compiled as, worked out before anything is written:
// two sources that land on the same artefact used to leave only the second, and
// nothing said so
@@ -1109,63 +1187,139 @@ pub(crate) fn cmd_compile(
planned.push((handle, name));
}
- for ((path, file), name) in planned {
- let source = fs::read_to_string(path)
- .with_context(|| format!("could not read {}", path.display()))?;
-
- let program_file = ty_python_semantic::Db::program_file(&db, *file);
- let parsed = ruff_db::parsed::parsed_module(&db, program_file.python_file(&db)).load(&db);
- let model = ty_python_semantic::SemanticModel::new(&db, program_file);
- // a `.py` source needs no transpiling to be its own interpreted fallback
- let mut options = options.clone();
- if path.extension().is_some_and(|x| x == "py") {
- options.language = by_irbuild::Language::Python;
- }
- let mut lowered = by_irbuild::build_module(
- &db,
- &model.program_environment(),
- &model,
- parsed.suite(),
- name,
- options.language,
- );
- // the real path, so a `#line` in the generated C resolves for a debugger
- let absolute = std::fs::canonicalize(path).unwrap_or_else(|_| path.clone());
- lowered.lines = Some(by_ir::function::LineTable::new(
- absolute.display().to_string(),
- &source,
- ));
+ // the tree is finished whatever happens in here, so a run that gives up
+ // half way still leaves a manifest describing what is actually on disk. a
+ // manifest left describing the run before it would have the *next* run prune
+ // against a tree that no longer exists
+ let compiling: anyhow::Result<()> = 'compiling: {
+ for ((path, file), name) in planned {
+ let source = match fs::read_to_string(path)
+ .with_context(|| format!("could not read {}", path.display()))
+ {
+ Ok(source) => source,
+ Err(error) => break 'compiling Err(error),
+ };
- let built = if emit_c_only {
- by_build::emit_lowered(lowered, &source, Some(&toolchain), &out_dir, &options)
- } else {
- by_build::build_lowered(lowered, &source, &toolchain, &out_dir, &options).inspect(
- |built| {
- eprintln!(
- "{} -> {}",
- path.display(),
- built.artifact.extension.display()
- );
- },
- )
+ let program_file = ty_python_semantic::Db::program_file(&db, *file);
+ let parsed =
+ ruff_db::parsed::parsed_module(&db, program_file.python_file(&db)).load(&db);
+ let model = ty_python_semantic::SemanticModel::new(&db, program_file);
+ // a `.py` source needs no transpiling to be its own interpreted fallback
+ let mut options = options.clone();
+ if path.extension().is_some_and(|x| x == "py") {
+ options.language = by_irbuild::Language::Python;
+ }
+ let mut lowered = by_irbuild::build_module(
+ &db,
+ &model.program_environment(),
+ &model,
+ parsed.suite(),
+ name,
+ options.language,
+ );
+ // the real path, so a `#line` in the generated C resolves for a debugger
+ let absolute = std::fs::canonicalize(path).unwrap_or_else(|_| path.clone());
+ lowered.lines = Some(by_ir::function::LineTable::new(
+ absolute.display().to_string(),
+ &source,
+ ));
+
+ let built = match if emit_c_only {
+ by_build::emit_lowered(lowered, &source, Some(&toolchain), &out_dir, &options)
+ } else {
+ by_build::build_lowered(lowered, &source, &toolchain, &out_dir, &options).inspect(
+ |built| {
+ if let Some(extension) = &built.artifact.extension {
+ eprintln!("{} -> {}", path.display(), extension.display());
+ }
+ },
+ )
+ }
+ .with_context(|| format!("could not compile {}", path.display()))
+ {
+ Ok(built) => built,
+ Err(error) => break 'compiling Err(error),
+ };
+
+ // exactly what `by_build` says it wrote — no guessing at names, and no
+ // asking the file system, which cannot tell an artefact this run
+ // produced from one a previous run left
+ for produced in [&built.artifact.source, &built.artifact.header]
+ .into_iter()
+ .chain(built.artifact.extension.as_ref())
+ .chain(built.artifact.annotation.as_ref())
+ {
+ if let Ok(relative) = produced.strip_prefix(&out_dir)
+ && let Err(error) = staging.record(relative)
+ {
+ break 'compiling Err(error);
+ }
+ }
+ if let Some(annotation) = &built.artifact.annotation {
+ eprintln!(" annotated {}", annotation.display());
+ }
+ declined_total += built.declined.len();
+ if verbose {
+ // a decline is the compiler's report on the code it did *not* take,
+ // so it points at that code the way every other diagnostic does
+ let diagnostics: Vec = built
+ .declined
+ .iter()
+ .map(|declined| declined_diagnostic(*file, declined))
+ .collect();
+ if let Err(error) = render_diagnostics(&db, &diagnostics) {
+ break 'compiling Err(error);
+ }
+ }
+ compiled += 1;
}
- .with_context(|| format!("could not compile {}", path.display()))?;
- if let Some(annotation) = &built.artifact.annotation {
- eprintln!(" annotated {}", annotation.display());
+ // the rest of the project: the data a module reads relative to itself,
+ // the hand-written `.py` beside it, the markers and the record that let a
+ // tool read the tree back. the same calls `build` makes, in the same
+ // order, because this is the same tree.
+ //
+ // inside the block with the compile, because each of these can fail and
+ // the tree has to be finished either way — `stage_verbatim` in particular
+ // is where a file the project keeps on top of an artefact is reported,
+ // which is a failure this change deliberately introduced
+ if let Err(error) = stage_verbatim(&db, &root, &roots, &mut staging) {
+ break 'compiling Err(error);
}
- declined_total += built.declined.len();
- if verbose {
- // a decline is the compiler's report on the code it did *not* take, so
- // it points at that code the way every other diagnostic does
- let diagnostics: Vec = built
- .declined
- .iter()
- .map(|declined| declined_diagnostic(*file, declined))
- .collect();
- render_diagnostics(&db, &diagnostics)?;
+ if let Err(error) = stage_by_typed_markers(&db, &mut staging, &roots, &root) {
+ break 'compiling Err(error);
+ }
+ if let Err(error) = write_sourcemap_module(&mut staging, &entries) {
+ break 'compiling Err(error);
}
- compiled += 1;
+ // whether the tree actually *holds* extensions, which is the question a
+ // reader of the record is asking — `--emit-c-only` writes the generated C
+ // and stops, so its tree is as replaceable one file at a time as a
+ // `by build`'s. the generated `.c` beside it is then a description of the
+ // source at the time it was written, and a later re-stage does not
+ // regenerate it; nothing loads it, so the two are allowed to drift
+ let holds_extensions = !emit_c_only && compiled > 0;
+ if let Err(error) = stage_build_record(
+ &mut staging,
+ &BuildRecord::new(&root, &roots, None, holds_extensions, &tree_config),
+ ) {
+ break 'compiling Err(error);
+ }
+ Ok(())
+ };
+
+ // whatever happened above, the manifest is brought into line with what is
+ // actually on disk. a manifest left describing the run before would have the
+ // *next* run prune against a tree that no longer exists, leaving behind files
+ // no source produces — an importable module nobody wrote
+ staging.finish()?;
+
+ compiling?;
+ // the transpile's verdict is answered after the tree is finished, the way
+ // `build` answers it: a diagnostic must not cost the tree, because a `build/`
+ // a debugger cannot read is worse than one built from a partial check
+ if !transpiled? {
+ return Ok(ExitStatus::Failure);
}
eprintln!("\ncompiled {compiled} module(s)");
@@ -1415,7 +1569,7 @@ fn reverse_dir_converting(
/// Forward-transpile every `.by` under `dir` into a `.py` next to it, using one
/// shared project db so cross-module types resolve (the same path as `by
-/// build`, but written in place rather than to `out/`).
+/// build`, but written in place rather than to `build/`).
#[allow(clippy::print_stderr)]
fn forward_dir(dir: &Path, config: &Config) -> anyhow::Result {
let (db, handles, rebuilder, _root) = build_project_db(dir, BY_SOURCES, None)?;
diff --git a/crates/ty/src/by_project_server.rs b/crates/ty/src/by_project_server.rs
new file mode 100644
index 0000000000..b152d0dcac
--- /dev/null
+++ b/crates/ty/src/by_project_server.rs
@@ -0,0 +1,192 @@
+//! Checking a project by asking the language server that is already holding it.
+//!
+//! `by check` builds a project database, resolves an environment, and parses and infers
+//! every file in the project — and then exits, and throws all of it away. An editor with the
+//! language server running has done that work already and has been keeping it current ever
+//! since. So before doing it again, ask.
+//!
+//! What comes back has to be what this process would have produced on its own, which is why
+//! most of what is here is the deciding rather than the asking. The server refuses whenever
+//! it might answer differently — a different build, a different configuration, an unsaved
+//! buffer — and this side refuses for every invocation whose answer is not simply "the
+//! diagnostics for this project": a fix, a watch, a subset of paths.
+//!
+//! The fallback is not a failure mode, it is the normal path. Nothing here is required to
+//! work, and a caller that gets nothing goes on to check for itself.
+
+use std::io::Write;
+
+use ruff_db::Db as _;
+use ruff_db::diagnostic::Severity;
+use ruff_db::system::SystemPath;
+use ty_project::{Db as _, ProjectDatabase};
+use ty_server::project_server;
+use ty_server::project_server::client;
+use ty_server::project_server::protocol;
+use ty_server::project_server::protocol::{Build, CheckRequest, CheckResponse, SeverityLevel};
+
+use crate::printer::Printer;
+use crate::{ExitStatus, exit_status_from_summary, write_summary};
+
+/// Why one `by check` is not a check a server may answer.
+///
+/// A server answers one question — the diagnostics for a whole project, as configured — so
+/// anything that asks a narrower or a different one is ruled out here. Every one of these
+/// would otherwise be a silent change in what the command does.
+#[derive(Debug, Clone, Copy)]
+pub(crate) enum Ineligible {
+ /// The caller passed `--no-server`.
+ ///
+ /// `BY_NO_PROJECT_SERVER` rules it out too, but later and separately: it is read through
+ /// the same [`System`](ruff_db::system::System) the server reads it through, which this
+ /// side does not have until it has a database.
+ Disabled,
+
+ /// The check re-runs on every change, so everything after the first run is warm already.
+ Watch,
+
+ /// The check rewrites the files it reports on, which a server does not do.
+ Fixing,
+
+ /// The check was pointed at part of the project rather than all of it.
+ Paths,
+
+ /// The caller asked about a database that this path never builds.
+ MemoryReport,
+}
+
+impl std::fmt::Display for Ineligible {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(match self {
+ Ineligible::Disabled => "the project server is disabled",
+ Ineligible::Watch => "the check is in watch mode",
+ Ineligible::Fixing => "the check rewrites the files it reports on",
+ Ineligible::Paths => "the check was given paths rather than the whole project",
+ Ineligible::MemoryReport => "a memory report was requested",
+ })
+ }
+}
+
+/// Asks a running server to check the project `db` was built for, and prints what it says.
+///
+/// `None` when there was no answer, for any reason at all. The caller then checks the project
+/// itself, which is what it would have done had none of this existed.
+///
+/// `db` rather than the metadata it came from because the comparison that decides whether the
+/// server may answer is over what this process *resolved* — its search paths, its interpreter,
+/// its merged configuration — and resolving that is the database's job. Building one is cheap
+/// next to checking with it, which is the part this exists to skip.
+pub(crate) fn check(
+ db: &ProjectDatabase,
+ working_directory: &SystemPath,
+ printer: Printer,
+ ineligible: Option,
+) -> Option {
+ if let Some(ineligible) = ineligible {
+ tracing::debug!("Not asking a project server: {ineligible}");
+ return None;
+ }
+
+ if project_server::disabled(db.system()) {
+ tracing::debug!("Not asking a project server: disabled by the environment");
+ return None;
+ }
+
+ // a platform with nowhere for a server to have announced itself
+ let directory = project_server::discovery::default_directory(db.system())?;
+
+ let project = db.project();
+ let options = protocol::configuration(project.metadata(db).to_merged_options().options())
+ .inspect_err(|error| tracing::debug!("Failed to serialize the resolved options: {error}"))
+ .ok()?;
+
+ let response = client::check(
+ &directory,
+ CheckRequest {
+ project_root: project.root(db).to_path_buf(),
+ working_directory: working_directory.to_path_buf(),
+ options,
+ environment: project_server::environment(db),
+ force_exclude: project.force_exclude(db),
+ verbose: project.verbose(db),
+ color: colored::control::SHOULD_COLORIZE.should_colorize(),
+ },
+ &Build::current(ruff_db::program_version()?),
+ )?;
+
+ Some(report(&response, printer))
+}
+
+/// Prints an answer, and says what the command should exit with.
+///
+/// Separate from asking for it because from here on there is no falling back: the answer has
+/// reached the caller's terminal, and checking again would print it twice. A stream that
+/// cannot be written to is the same failure it is on the cold path, and is reported the same
+/// way rather than being turned into "no server answered".
+fn report(response: &CheckResponse, printer: Printer) -> ExitStatus {
+ // said before the diagnostics, and on stderr: a reader piping `--output-format=json`
+ // somewhere is still owed the explanation of why the answer arrived so fast, and is not
+ // owed it in the middle of their json
+ if printer.shows_general_messages() {
+ writeln!(std::io::stderr(), "using project server information").ok();
+ }
+
+ let written = write_answer(response, printer);
+
+ // the project has already been checked and the answer is already partly on its way out,
+ // so a broken stream changes what the caller sees but not what the check found. the cold
+ // path reports the same failure the same way and returns the same status
+ if let Err(error) = written {
+ tracing::warn!("Failed to write the diagnostics: {error}");
+ }
+
+ exit_status_from_summary(
+ response.max_severity.map(severity),
+ response.io_error,
+ response.error_on_warning,
+ )
+}
+
+fn write_answer(response: &CheckResponse, printer: Printer) -> std::io::Result<()> {
+ {
+ let stdout = printer.stream_for_details().lock();
+ if stdout.is_enabled() {
+ let mut stdout = std::io::BufWriter::new(stdout);
+ write!(stdout, "{}", response.rendered)?;
+ stdout.flush()?;
+ }
+ }
+
+ // the cold path warns about both of these, and a caller that cannot tell which path
+ // answered it is a caller for whom the two paths are the same command
+ if response.empty_project {
+ tracing::warn!("No python files found under the given path(s)");
+ }
+ if response.fatal {
+ tracing::warn!(
+ "A fatal error occurred while checking some files. \
+ Not all project files were analyzed. \
+ See the diagnostics list above for details."
+ );
+ }
+
+ // never cancelled: a check the server could not finish comes back as a refusal, and a
+ // refusal never reaches this far
+ write_summary(
+ printer,
+ response.diagnostics,
+ response.human_readable,
+ None,
+ false,
+ )
+ .map_err(std::io::Error::other)
+}
+
+fn severity(level: SeverityLevel) -> Severity {
+ match level {
+ SeverityLevel::Info => Severity::Info,
+ SeverityLevel::Warning => Severity::Warning,
+ SeverityLevel::Error => Severity::Error,
+ SeverityLevel::Fatal => Severity::Fatal,
+ }
+}
diff --git a/crates/ty/src/cli-reference.md b/crates/ty/src/cli-reference.md
index d939c24546..a3ec9abc58 100644
--- a/crates/ty/src/cli-reference.md
+++ b/crates/ty/src/cli-reference.md
@@ -24,8 +24,8 @@ by run main --min-version 3.12
## `by build`
-transpile every `.by` file in the project to `out/`, mirroring the module
-layout — a src-layout project's source root is stripped, so `out/` is
+transpile every `.by` file in the project to `build/`, mirroring the module
+layout — a src-layout project's source root is stripped, so `build/` is
importable as it stands:
```sh
@@ -33,9 +33,9 @@ by build
```
```text
-main.by -> out/main.py
-utils.by -> out/utils.py
-src/package_name/main.by -> out/package_name/main.py
+main.by -> build/main.py
+utils.by -> build/utils.py
+src/package_name/main.by -> build/package_name/main.py
```
generated `.py` files are ordinary Python — run them with any Python tool
diff --git a/crates/ty/src/lib.rs b/crates/ty/src/lib.rs
index c81874bde3..9f1a763d88 100644
--- a/crates/ty/src/lib.rs
+++ b/crates/ty/src/lib.rs
@@ -2,6 +2,7 @@ mod args;
mod by_commands;
mod by_init;
mod by_lowering;
+mod by_project_server;
mod by_source_encoding;
mod by_stamps;
mod by_wheels;
@@ -162,7 +163,7 @@ fn run_command(command: Command) -> anyhow::Result {
by_commands::cmd_build(
min_version.as_deref(),
&lowering,
- out.as_deref().unwrap_or(Path::new("out")),
+ out.as_deref().unwrap_or(Path::new("build")),
print_manifest,
)
}
@@ -362,6 +363,8 @@ fn run_check(args: CheckCommand) -> anyhow::Result {
.map(|path| SystemPath::absolute(path, &cwd))
.collect();
+ let check_paths_given = !check_paths.is_empty();
+
let mode = if args.fix {
MainLoopMode::Fix(FixMode::ApplyFixes)
} else if args.add_ignore {
@@ -394,8 +397,12 @@ fn run_check(args: CheckCommand) -> anyhow::Result {
project_metadata.apply_configuration_files(&system)?;
+ let no_server = args.no_server;
project_metadata.apply_override_options(args.into_options());
+ // the answer this command exists to produce may already exist, in a server that has been
+ // holding this project open. what comes back is only ever the answer this process would
+ // have computed — see `by_project_server` for what that costs and what it refuses
let mut db = ProjectDatabase::fallible(project_metadata, system)?;
// the project's django, which the type checker does not read: its templates are
@@ -413,6 +420,31 @@ fn run_check(args: CheckCommand) -> anyhow::Result {
project.set_included_paths(&mut db, check_paths);
}
+ // the answer this command exists to produce may already exist, in a server that has been
+ // holding this project open. asked here rather than earlier because what decides whether
+ // the server may answer is what *this* database resolved — see `by_project_server`
+ let ineligible = if no_server {
+ Some(by_project_server::Ineligible::Disabled)
+ } else if watch {
+ Some(by_project_server::Ineligible::Watch)
+ } else if !matches!(mode, MainLoopMode::Check) {
+ Some(by_project_server::Ineligible::Fixing)
+ } else if check_paths_given {
+ Some(by_project_server::Ineligible::Paths)
+ } else if memory_report.is_some() {
+ Some(by_project_server::Ineligible::MemoryReport)
+ } else {
+ None
+ };
+
+ if let Some(exit_status) = by_project_server::check(&db, &cwd, printer, ineligible) {
+ return Ok(if exit_zero {
+ ExitStatus::Success
+ } else {
+ exit_status
+ });
+ }
+
// Disabling LRU only assumes that the database is short-lived; unlike freezing below, it does
// not require immutable inputs.
if !watch {
@@ -851,60 +883,89 @@ impl MainLoop {
let terminal_settings = db.project().settings(db).terminal();
let is_human_readable = terminal_settings.output_format.is_human_readable();
- match diagnostics {
- [] if is_human_readable && fixed_diagnostics.is_none_or(|fixed| fixed == 0) => {
- writeln!(
- self.printer.stream_for_success_summary(),
+ {
+ let stdout = self.printer.stream_for_details().lock();
+
+ // Only render diagnostics if they're going to be displayed, since doing
+ // so is expensive.
+ if stdout.is_enabled() {
+ let mut stdout = BufWriter::new(stdout);
+ let display_config = DisplayDiagnosticConfig::new("ty")
+ .format(terminal_settings.output_format.into())
+ .color(colored::control::SHOULD_COLORIZE.should_colorize())
+ .with_cancellation_token(Some(self.cancellation_token.clone()))
+ .context(0);
+
+ write!(
+ stdout,
"{}",
- "All checks passed!".green().bold()
+ DisplayDiagnostics::new(db, &display_config, diagnostics)
)?;
+ stdout.flush()?;
}
- diagnostics => {
- let diagnostics_count = diagnostics.len();
-
- let stdout = self.printer.stream_for_details().lock();
-
- // Only render diagnostics if they're going to be displayed, since doing
- // so is expensive.
- if stdout.is_enabled() {
- let mut stdout = BufWriter::new(stdout);
- let display_config = DisplayDiagnosticConfig::new("ty")
- .format(terminal_settings.output_format.into())
- .color(colored::control::SHOULD_COLORIZE.should_colorize())
- .with_cancellation_token(Some(self.cancellation_token.clone()))
- .context(0);
-
- write!(
- stdout,
- "{}",
- DisplayDiagnostics::new(db, &display_config, diagnostics)
- )?;
- stdout.flush()?;
- }
+ }
- if !self.cancellation_token.is_cancelled() && is_human_readable {
- if let Some(fixed) = fixed_diagnostics {
- let total = fixed + diagnostics_count;
- writeln!(
- self.printer.stream_for_failure_summary(),
- "Found {total} diagnostic{} \
- ({fixed} fixed, {diagnostics_count} remaining).",
- if total == 1 { "" } else { "s" }
- )?;
- } else {
- writeln!(
- self.printer.stream_for_failure_summary(),
- "Found {} diagnostic{}",
- diagnostics_count,
- if diagnostics_count > 1 { "s" } else { "" }
- )?;
- }
- }
- }
+ write_summary(
+ self.printer,
+ diagnostics.len(),
+ is_human_readable,
+ fixed_diagnostics,
+ self.cancellation_token.is_cancelled(),
+ )
+ }
+}
+
+/// Writes the line that says how a check came out, after its diagnostics.
+///
+/// Shared with the [project server](crate::by_project_server) path, which renders its
+/// diagnostics elsewhere but has to summarize them the same way — the wording of "how did
+/// that go" belongs to the command, not to whoever computed the answer.
+///
+/// `cancelled` suppresses the count but not the sentence for a project with nothing wrong
+/// with it: a run interrupted before it finished counting cannot say how many there were, but
+/// a fix run that removed everything and was then interrupted still removed everything.
+pub(crate) fn write_summary(
+ printer: Printer,
+ diagnostics: usize,
+ is_human_readable: bool,
+ fixed_diagnostics: Option,
+ cancelled: bool,
+) -> anyhow::Result<()> {
+ // nothing left and nothing fixed, which is the only outcome with its own sentence
+ if diagnostics == 0 && fixed_diagnostics.is_none_or(|fixed| fixed == 0) {
+ if is_human_readable {
+ writeln!(
+ printer.stream_for_success_summary(),
+ "{}",
+ "All checks passed!".green().bold()
+ )?;
}
+ return Ok(());
+ }
- Ok(())
+ // a check that was interrupted did not finish counting, so it has no total to report
+ if cancelled || !is_human_readable {
+ return Ok(());
}
+
+ if let Some(fixed) = fixed_diagnostics {
+ let total = fixed + diagnostics;
+ writeln!(
+ printer.stream_for_failure_summary(),
+ "Found {total} diagnostic{} \
+ ({fixed} fixed, {diagnostics} remaining).",
+ if total == 1 { "" } else { "s" }
+ )?;
+ } else {
+ writeln!(
+ printer.stream_for_failure_summary(),
+ "Found {} diagnostic{}",
+ diagnostics,
+ if diagnostics > 1 { "s" } else { "" }
+ )?;
+ }
+
+ Ok(())
}
#[derive(Copy, Clone, Debug)]
@@ -949,18 +1010,32 @@ fn exit_status_from_diagnostics(
diagnostics: &[Diagnostic],
terminal_settings: &TerminalSettings,
) -> ExitStatus {
- if diagnostics.is_empty() {
- return ExitStatus::Success;
- }
-
- let mut max_severity = Severity::Info;
+ let mut max_severity = None;
let mut io_error = false;
for diagnostic in diagnostics {
- max_severity = max_severity.max(diagnostic.severity());
+ max_severity = max_severity.max(Some(diagnostic.severity()));
io_error = io_error || matches!(diagnostic.id(), DiagnosticId::Io);
}
+ exit_status_from_summary(max_severity, io_error, terminal_settings.error_on_warning)
+}
+
+/// The status a check exits with, from what it found.
+///
+/// Separate from the diagnostics themselves so that the [project
+/// server](crate::by_project_server) path decides it here too: that path never holds the
+/// diagnostics — they are rendered where they were computed — but the rules for turning them
+/// into an exit code belong to the command either way.
+pub(crate) fn exit_status_from_summary(
+ max_severity: Option,
+ io_error: bool,
+ error_on_warning: bool,
+) -> ExitStatus {
+ let Some(max_severity) = max_severity else {
+ return ExitStatus::Success;
+ };
+
if !max_severity.is_fatal() && io_error {
return ExitStatus::Error;
}
@@ -968,7 +1043,7 @@ fn exit_status_from_diagnostics(
match max_severity {
Severity::Info => ExitStatus::Success,
Severity::Warning => {
- if terminal_settings.error_on_warning {
+ if error_on_warning {
ExitStatus::Failure
} else {
ExitStatus::Success
diff --git a/crates/ty/src/printer.rs b/crates/ty/src/printer.rs
index d72e1ebb5a..842a9492e6 100644
--- a/crates/ty/src/printer.rs
+++ b/crates/ty/src/printer.rs
@@ -102,6 +102,11 @@ impl Printer {
self.stdout_general()
}
+ /// Whether messages that are neither diagnostics nor a summary should be shown.
+ pub(crate) fn shows_general_messages(self) -> bool {
+ self.stdout_general().is_enabled()
+ }
+
pub(crate) fn clear_screen() -> Result<()> {
clearscreen::clear()?;
Ok(())
diff --git a/crates/ty/tests/by_e2e.rs b/crates/ty/tests/by_e2e.rs
index 00993a4658..0fe72c3909 100644
--- a/crates/ty/tests/by_e2e.rs
+++ b/crates/ty/tests/by_e2e.rs
@@ -2,6 +2,7 @@ use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
+use ty_static::EnvVars;
/// a temp directory of this process's own
///
@@ -58,6 +59,398 @@ fn run_transpile(source: &str, extra_args: &[&str]) -> String {
String::from_utf8(output.stdout).unwrap()
}
+/// A project whose program reads a data file sitting beside it.
+///
+/// Written by each `compile` staging test, which then differ only in what they do
+/// to the tree afterwards.
+fn resource_project(name: &str) -> PathBuf {
+ let dir = cli_root().join(name);
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(dir.join("data")).unwrap();
+ fs::write(
+ dir.join("pyproject.toml"),
+ "[project]\nname=\"s\"\nversion=\"0\"\nrequires-python=\">=3.13\"\n",
+ )
+ .unwrap();
+ fs::write(
+ dir.join("data").join("config.json"),
+ "{\"greeting\": \"hi\"}\n",
+ )
+ .unwrap();
+ fs::write(
+ dir.join("helper.py"),
+ "def helper() -> int:\n return 1\n",
+ )
+ .unwrap();
+ fs::write(
+ dir.join("main.by"),
+ "from pathlib import Path\n\n\ndef go() -> str:\n \
+ return (Path(__file__).parent / \"data\" / \"config.json\").read_text()\n",
+ )
+ .unwrap();
+ dir
+}
+
+/// Whether `by` refused because this host's interpreter is below the native floor.
+///
+/// `by compile` probes an interpreter before it does anything, and
+/// `by_build::MINIMUM_PYTHON` refuses one older than 3.11 by name. On a host whose
+/// ambient `python3` is older that is not a failure of the code under test, and a
+/// test that asserted its way through it reported a wall of unrelated noise — so
+/// every `compile` test here skips on it, the way `by_build`'s own suite does.
+/// Pin `PYTHON` to a 3.11+ interpreter to actually run them.
+#[allow(
+ clippy::print_stderr,
+ reason = "skip notices belong on the harness's stderr"
+)]
+fn refused_for_python_version(result: &std::process::Output) -> bool {
+ let refused = String::from_utf8_lossy(&result.stderr)
+ .contains("a native build needs python 3.11 or later");
+ if refused {
+ eprintln!("skipping: this host's python is below the native compilation floor");
+ }
+ refused
+}
+
+/// Run `by compile` in `dir`, or `None` when this host cannot compile natively.
+fn compile_in(dir: &Path) -> Option {
+ let result = Command::new(env!("CARGO_BIN_EXE_by"))
+ .args(["compile", "--emit-c-only"])
+ .current_dir(dir)
+ .output()
+ .expect("failed to spawn by");
+ if refused_for_python_version(&result) {
+ return None;
+ }
+ assert!(
+ result.status.success(),
+ "by exited with error:\n{}",
+ String::from_utf8_lossy(&result.stderr)
+ );
+ Some(result)
+}
+
+#[test]
+fn compile_carries_the_rest_of_the_project_into_the_output_tree() {
+ // a compiled module is only half of a project. `main.by` reads its data file
+ // relative to itself, and the extension's `__file__` is its own place in the
+ // output tree — so a tree holding artefacts and nothing else fails on the
+ // first `open`, with a `FileNotFoundError` naming a path in a directory the
+ // author never wrote anything to. `compile` writes everything `build` writes
+ // and the extensions as well, so a compiled module finds its data exactly
+ // where its interpreted twin would
+ let dir = resource_project("by_cli_compile_resources");
+ if compile_in(&dir).is_none() {
+ return;
+ }
+
+ let out = dir.join("build");
+ assert!(out.join("main.c").exists(), "the module is compiled");
+ assert!(
+ out.join("data").join("config.json").exists(),
+ "a data file lands at the same relative place it had in the source"
+ );
+ assert!(
+ out.join("helper.py").exists(),
+ "a hand-written python module beside the source is carried over too"
+ );
+ assert!(
+ out.join("main.py").exists(),
+ "the `.by` is transpiled too, so the module imports whether or not its \
+ extension was built"
+ );
+ assert!(
+ out.join("_by_sourcemap.py").exists() && out.join("_by_build.json").exists(),
+ "the tree describes itself, the way a `by build` tree does"
+ );
+}
+
+#[test]
+fn compiling_one_module_leaves_the_others_importable() {
+ // the docs offer `by compile app.hot` as "compile one module, leave the rest
+ // interpreted". while `compile` wrote artefacts alone that was not what
+ // happened: the modules nobody named reached the tree as `.by`, which python
+ // cannot import, and the second invocation's `finish` took the first's
+ // artefact back as well — so `compile a` then `compile b` left a tree that
+ // could import neither
+ let dir = resource_project("by_cli_compile_one_of_many");
+ fs::write(dir.join("other.by"), "def other() -> int:\n return 2\n").unwrap();
+
+ let compile_one = |name: &str| -> bool {
+ let result = Command::new(env!("CARGO_BIN_EXE_by"))
+ .args(["compile", "--emit-c-only", name])
+ .current_dir(&dir)
+ .output()
+ .expect("failed to spawn by");
+ if refused_for_python_version(&result) {
+ return false;
+ }
+ assert!(
+ result.status.success(),
+ "by exited with error:\n{}",
+ String::from_utf8_lossy(&result.stderr)
+ );
+ true
+ };
+ let out = dir.join("build");
+
+ if !compile_one("main.by") {
+ return;
+ }
+ assert!(out.join("main.c").exists(), "the named module is compiled");
+ assert!(
+ out.join("other.py").exists(),
+ "a module nobody named still reaches the tree as importable python"
+ );
+
+ assert!(compile_one("other.by"), "the second compile ran");
+ assert!(
+ out.join("other.c").exists(),
+ "the newly named one is compiled"
+ );
+ assert!(
+ out.join("main.py").exists(),
+ "and the previously named one is still importable"
+ );
+}
+
+#[test]
+fn a_compile_leaves_a_build_tree_readable() {
+ // `compile` and `build` write to the same directory by design. while
+ // `compile` wrote artefacts alone it took the sourcemap and the build record
+ // with it, and `by restage` — the language server's single-file re-stage —
+ // then refused the tree for having no `_by_build.json`, so a compile silently
+ // disabled the editor plugin against it
+ let dir = resource_project("by_cli_compile_then_restage");
+ let result = Command::new(env!("CARGO_BIN_EXE_by"))
+ .arg("build")
+ .current_dir(&dir)
+ .output()
+ .expect("failed to spawn by");
+ assert!(result.status.success());
+ if compile_in(&dir).is_none() {
+ return;
+ }
+
+ let restaged = Command::new(env!("CARGO_BIN_EXE_by"))
+ .args(["restage", "build", "main.by"])
+ .current_dir(&dir)
+ .output()
+ .expect("failed to spawn by");
+ let answer = String::from_utf8_lossy(&restaged.stdout);
+ assert!(
+ answer.contains("\"generated\""),
+ "the tree is still one a re-stage can read:\n{answer}"
+ );
+}
+
+#[test]
+fn a_build_with_nothing_to_write_leaves_no_output_directory() {
+ // `by build` created the output directory before it knew whether it had
+ // anything to put in it, so a project with no `.by` files — one whose sources
+ // are all python, say — was left holding an empty `build/` it never asked for
+ let dir = cli_root().join("by_cli_build_no_litter");
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).unwrap();
+ fs::write(
+ dir.join("pyproject.toml"),
+ "[project]\nname=\"s\"\nversion=\"0\"\nrequires-python=\">=3.13\"\n",
+ )
+ .unwrap();
+ fs::write(dir.join("only.py"), "x = 1\n").unwrap();
+
+ let result = Command::new(env!("CARGO_BIN_EXE_by"))
+ .arg("build")
+ .current_dir(&dir)
+ .output()
+ .expect("failed to spawn by");
+ let stderr = String::from_utf8_lossy(&result.stderr);
+ assert!(result.status.success(), "by build failed:\n{stderr}");
+ assert!(
+ stderr.contains("no .by files found"),
+ "the build found nothing to do:\n{stderr}"
+ );
+ assert!(
+ !dir.join("build").exists(),
+ "a build that wrote nothing left a directory behind:\n{stderr}"
+ );
+}
+
+#[test]
+fn a_second_output_directory_is_not_carried_into_the_first() {
+ // a project can have more than one output tree — `by build --out one` beside
+ // `by build --out two`. only the directory *this* run was given is known to
+ // be an output from its arguments; the other is recognised by the
+ // `.by-manifest` it carries, and without that it is carried over as though it
+ // were source, putting a whole copy of one tree inside the other
+ let dir = cli_root().join("by_cli_two_outputs");
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).unwrap();
+ fs::write(
+ dir.join("pyproject.toml"),
+ "[project]\nname=\"s\"\nversion=\"0\"\nrequires-python=\">=3.13\"\n",
+ )
+ .unwrap();
+ fs::write(dir.join("main.by"), "x = 1\n").unwrap();
+ fs::write(dir.join("data.json"), "{}\n").unwrap();
+
+ let build_into = |name: &str| {
+ let result = Command::new(env!("CARGO_BIN_EXE_by"))
+ .args(["build", "--out", name])
+ .current_dir(&dir)
+ .output()
+ .expect("failed to spawn by");
+ assert!(
+ result.status.success(),
+ "by build failed:\n{}",
+ String::from_utf8_lossy(&result.stderr)
+ );
+ };
+ build_into("one");
+ build_into("two");
+
+ assert!(dir.join("two").join("main.py").exists(), "the build ran");
+ assert!(
+ !dir.join("two").join("one").exists(),
+ "the first output tree was copied into the second"
+ );
+}
+
+#[test]
+fn compile_takes_back_what_the_previous_compile_wrote() {
+ // the output tree is a mirror rather than a pile: a resource deleted from the
+ // source is deleted from the tree. without the manifest it would keep being
+ // read, and a wheel built from the same tree would ship it
+ let dir = resource_project("by_cli_compile_stale");
+ if compile_in(&dir).is_none() {
+ return;
+ }
+ let out = dir.join("build");
+ assert!(out.join("data").join("config.json").exists());
+
+ fs::remove_file(dir.join("data").join("config.json")).unwrap();
+ assert!(compile_in(&dir).is_some(), "the second compile ran");
+ assert!(
+ !out.join("data").join("config.json").exists(),
+ "a resource the source no longer has is taken back out of the tree"
+ );
+}
+
+#[test]
+fn a_build_takes_back_the_artifacts_a_compile_left() {
+ // `compile` and `build` write to the same directory by default, and python's
+ // finder prefers an extension to source. an artefact left behind by an
+ // earlier `compile` would therefore go on shadowing the `.py` this `build`
+ // writes in its place — which is why `compile` records what it produced even
+ // though `by_build` is what laid it out
+ let dir = resource_project("by_cli_compile_then_build");
+ if compile_in(&dir).is_none() {
+ return;
+ }
+ let out = dir.join("build");
+ assert!(out.join("main.c").exists());
+
+ let result = Command::new(env!("CARGO_BIN_EXE_by"))
+ .arg("build")
+ .current_dir(&dir)
+ .output()
+ .expect("failed to spawn by");
+ assert!(
+ result.status.success(),
+ "by exited with error:\n{}",
+ String::from_utf8_lossy(&result.stderr)
+ );
+ assert!(out.join("main.py").exists(), "the build wrote the module");
+ assert!(
+ !out.join("main.c").exists(),
+ "what the compile produced is taken back"
+ );
+}
+
+#[test]
+fn compile_refuses_to_carry_a_file_over_an_artifact_it_wrote() {
+ // a project can keep a `main.c` of its own beside `main.by` — and the
+ // compiler writes its generated C under that same name. carrying the
+ // hand-written one over would leave a tree whose generated half is somebody
+ // else's file, with nothing said about it, so it is reported the way two
+ // sources claiming one module are
+ let dir = resource_project("by_cli_compile_artifact_collision");
+ fs::write(dir.join("main.c"), "/* hand-written */\n").unwrap();
+
+ let result = Command::new(env!("CARGO_BIN_EXE_by"))
+ .args(["compile", "--emit-c-only"])
+ .current_dir(&dir)
+ .output()
+ .expect("failed to spawn by");
+ if refused_for_python_version(&result) {
+ return;
+ }
+ let stderr = String::from_utf8_lossy(&result.stderr);
+ assert!(!result.status.success(), "by should have failed:\n{stderr}");
+ assert!(
+ stderr.contains("already wrote an artifact of that name"),
+ "the collision is named:\n{stderr}"
+ );
+ assert!(
+ !fs::read_to_string(dir.join("build").join("main.c"))
+ .unwrap()
+ .contains("hand-written"),
+ "the generated C is left as the compiler wrote it"
+ );
+}
+
+#[test]
+fn compile_does_not_read_the_tree_it_writes() {
+ // the output holds a copy of every resource the build carried over, including
+ // the project's `.py` modules. a second `compile` that walked them would
+ // compile each module twice — once from the source and once from the copy —
+ // and the two would claim the same artifact
+ //
+ // the directory is deliberately *not* one of the names the project walk skips
+ // by default (`build`, `out`, `target`, …). those are turned away whoever
+ // asks, so a tree written to one of them would pass this test even if the
+ // build never told the database where its own output was going.
+ //
+ // two mechanisms keep `generated/` out — the output this run was given, and
+ // the `.by-manifest` any output carries — and either alone is enough here.
+ // the manifest is the only one that covers an output this run was *not*
+ // given, which `a_second_output_directory_is_not_carried_into_the_first`
+ // is for
+ let dir = resource_project("by_cli_compile_not_own_input");
+ let compile = || -> Option {
+ let result = Command::new(env!("CARGO_BIN_EXE_by"))
+ .args(["compile", "--emit-c-only", "-o", "generated"])
+ .current_dir(&dir)
+ .output()
+ .expect("failed to spawn by");
+ if refused_for_python_version(&result) {
+ return None;
+ }
+ assert!(
+ result.status.success(),
+ "by exited with error:\n{}",
+ String::from_utf8_lossy(&result.stderr)
+ );
+ // the count `compile` reports, not the per-artifact lines: `--emit-c-only`
+ // does not print those, so counting them compares nothing to nothing
+ let stderr = String::from_utf8_lossy(&result.stderr);
+ let reported = stderr
+ .lines()
+ .find_map(|line| line.strip_prefix("compiled ")?.strip_suffix(" module(s)"))
+ .and_then(|count| count.parse::().ok());
+ Some(reported.unwrap_or_else(|| panic!("no module count in:\n{stderr}")))
+ };
+ let Some(first) = compile() else {
+ return;
+ };
+ assert!(first > 0, "the first compile compiled something");
+ assert_eq!(
+ Some(first),
+ compile(),
+ "the second compile sees the same sources as the first"
+ );
+}
+
#[test]
fn compile_emits_only_the_files_it_was_given_and_still_resolves_the_others() {
// `by compile a.py` used to compile every source in the project and ignore the
@@ -94,14 +487,18 @@ fn compile_emits_only_the_files_it_was_given_and_still_resolves_the_others() {
)
.unwrap();
- let out = dir.join("out");
+ let out = dir.join("build");
let result = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["compile", "wanted.py", "-o"])
.arg(&out)
.arg("--emit-c-only")
.current_dir(&dir)
.output()
.expect("failed to spawn by");
+ if refused_for_python_version(&result) {
+ return;
+ }
assert!(
result.status.success(),
"by exited with error:\n{}",
@@ -159,12 +556,16 @@ fn compile_writes_each_package_member_at_its_own_place_in_the_output_tree() {
let out = dir.join("o");
let result = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["compile", "-o"])
.arg(&out)
.arg("--emit-c-only")
.current_dir(&dir)
.output()
.expect("failed to spawn by");
+ if refused_for_python_version(&result) {
+ return;
+ }
assert!(
result.status.success(),
"by exited with error:\n{}",
@@ -215,12 +616,16 @@ fn compile_refuses_two_sources_that_would_write_the_same_artifact() {
let out = dir.join("o");
let result = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["compile", "-o"])
.arg(&out)
.arg("--emit-c-only")
.current_dir(&dir)
.output()
.expect("failed to spawn by");
+ if refused_for_python_version(&result) {
+ return;
+ }
assert!(!result.status.success(), "the clash is refused");
let stderr = String::from_utf8_lossy(&result.stderr);
assert!(
@@ -253,12 +658,16 @@ fn compile_declines_a_package_body_whose_package_has_no_importable_name() {
let out = dir.join("o");
let result = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["compile", "-o"])
.arg(&out)
.arg("--emit-c-only")
.current_dir(&dir)
.output()
.expect("failed to spawn by");
+ if refused_for_python_version(&result) {
+ return;
+ }
let stderr = String::from_utf8_lossy(&result.stderr);
// declining one source is not a failed build — the rest of the project is
// compiled, and what was left out is said rather than silently produced
@@ -287,9 +696,10 @@ async def total(s: str, n: int) -> int:
let file = dir.join("sound.by");
std::fs::write(&file, source).unwrap();
- let emitted = |spec: &str| -> String {
+ let emitted = |spec: &str| -> Option {
let out = dir.join(spec);
let status = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["compile"])
.arg(&file)
.arg("-o")
@@ -298,20 +708,26 @@ async def total(s: str, n: int) -> int:
.current_dir(&dir)
.output()
.expect("failed to spawn by");
+ if refused_for_python_version(&status) {
+ return None;
+ }
assert!(
status.status.success(),
"by exited with error:\n{}",
String::from_utf8_lossy(&status.stderr)
);
- std::fs::read_to_string(out.join("sound.c")).expect("the C is readable")
+ Some(std::fs::read_to_string(out.join("sound.c")).expect("the C is readable"))
};
+ let Some(all) = emitted("all") else {
+ return;
+ };
assert!(
- emitted("all").contains("_soundness_check"),
+ all.contains("_soundness_check"),
"`all` puts the entry checks in the fallback"
);
assert!(
- !emitted("none").contains("_soundness_check"),
+ !emitted("none").is_some_and(|none| none.contains("_soundness_check")),
"`none` leaves them out, so the flag is what made the difference"
);
}
@@ -322,6 +738,7 @@ fn run_executes_module() {
fs::write(dir.path().join("main.by"), "print('hello from by run')\n").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -357,6 +774,7 @@ fn run_names_the_by_source_for_file_and_argv() {
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -407,6 +825,7 @@ def main():
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -435,6 +854,7 @@ fn run_enters_a_package_through_its_main_module() {
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "app"])
.current_dir(dir.path())
.output()
@@ -464,6 +884,7 @@ fn run_still_rewrites_traceback_frames_to_by_lines() {
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -509,6 +930,7 @@ main()
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -554,6 +976,7 @@ fn run_checks_a_deeply_nested_expression() {
fs::write(dir.path().join("main.by"), format!("print({terms})\n")).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -575,6 +998,7 @@ fn run_force_unwrap_yields_inner_value() {
fs::write(dir.path().join("main.by"), "x = Some(5)\nprint(x! + 1)\n").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -600,6 +1024,7 @@ fn run_invokes_top_level_main() {
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -624,6 +1049,7 @@ fn run_invokes_async_main_via_asyncio() {
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -648,6 +1074,7 @@ fn run_uses_the_configured_entry_point() {
fs::write(dir.path().join("app.by"), "print('ran the entry point')\n").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.arg("run")
.current_dir(dir.path())
.output()
@@ -677,6 +1104,7 @@ fn run_reads_the_entry_point_from_pyproject() {
fs::write(dir.path().join("pkg/cli.by"), "print('ran pkg.cli')\n").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.arg("run")
.current_dir(dir.path())
.output()
@@ -704,6 +1132,7 @@ fn run_reads_the_entry_point_from_basedpython_toml() {
fs::write(dir.path().join("app.by"), "print('ran the entry point')\n").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.arg("run")
.current_dir(dir.path())
.output()
@@ -733,6 +1162,7 @@ fn run_reads_the_entry_point_from_the_basedpython_section() {
fs::write(dir.path().join("pkg/cli.by"), "print('ran pkg.cli')\n").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.arg("run")
.current_dir(dir.path())
.output()
@@ -757,6 +1187,7 @@ fn run_prefers_an_explicit_module_over_the_configured_entry_point() {
fs::write(dir.path().join("other.by"), "print('explicit')\n").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "other"])
.current_dir(dir.path())
.output()
@@ -783,6 +1214,7 @@ fn run_forwards_arguments_to_the_named_entry_point() {
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "app", "--name", "asdf"])
.current_dir(dir.path())
.output()
@@ -802,6 +1234,7 @@ fn run_without_a_module_or_entry_point_reports_both_ways_out() {
fs::write(dir.path().join("main.by"), "print('unreached')\n").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.arg("run")
.current_dir(dir.path())
.output()
@@ -823,6 +1256,7 @@ fn run_main_with_args(source: &str, args: &[&str]) -> (String, String, i32) {
fs::write(dir.path().join("main.by"), source).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.args(args)
.current_dir(dir.path())
@@ -848,6 +1282,7 @@ fn run_main_stamped(source: &str, stamps: &[&str]) -> (String, String, i32) {
fs::write(dir.path().join("basedpython.toml"), OPT_IN_TO_STAMPS).unwrap();
let mut command = Command::new(env!("CARGO_BIN_EXE_by"));
+ command.env(EnvVars::BY_NO_PROJECT_SERVER, "1");
command.arg("run");
for stamp in stamps {
command.args(["--stamp", stamp]);
@@ -1009,6 +1444,7 @@ def main():
fs::write(dir.path().join("basedpython.toml"), OPT_IN_TO_STAMPS).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "--compiled", "--python"])
.arg(&python)
.args([
@@ -1185,6 +1621,7 @@ Grid()[(1, 2)]
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -1214,6 +1651,7 @@ print(A.__sealed_members__ == (B, C))
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -1247,6 +1685,7 @@ print(shout(\"moon\", greeting=\"good night\"))
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -1288,6 +1727,7 @@ print(\"quiet\".shouty)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -1349,6 +1789,7 @@ print(b not in w)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -1395,6 +1836,7 @@ print(Widget().kind)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -1431,6 +1873,7 @@ extension str:
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -1465,6 +1908,7 @@ print(Holder.value)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -1502,6 +1946,7 @@ print(xs.second())
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -1585,6 +2030,7 @@ print(Color.Green.name)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -1623,6 +2069,7 @@ main()
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -1697,6 +2144,7 @@ print('verified', checked)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -1741,6 +2189,7 @@ boom()
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -1782,6 +2231,7 @@ fn build_skips_a_source_it_cannot_read() {
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["build", "--min-version", "3.12"])
.current_dir(dir.path())
.output()
@@ -1797,11 +2247,11 @@ fn build_skips_a_source_it_cannot_read() {
"the skipped file must be reported:\n{stderr}"
);
assert_eq!(
- fs::read_to_string(dir.path().join("out/good.py")).unwrap(),
+ fs::read_to_string(dir.path().join("build/good.py")).unwrap(),
"x = 1\n"
);
assert!(
- !dir.path().join("out/bad.py").exists(),
+ !dir.path().join("build/bad.py").exists(),
"an unreadable source must not be emitted as an empty module"
);
}
@@ -1887,6 +2337,7 @@ fn transpile_renders_parse_error_with_location() {
fs::write(&by_path, "a b\n").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.arg("transpile")
.arg(&by_path)
.output()
@@ -1943,6 +2394,7 @@ fn transpile_malformed_inputs_never_panic() {
];
for src in inputs {
let mut child = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.arg("transpile")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
@@ -1977,6 +2429,7 @@ fn run_renders_parse_error_and_aborts() {
fs::write(dir.path().join("main.by"), "a b\n").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -2004,6 +2457,7 @@ fn build_renders_parse_error_and_aborts() {
fs::write(dir.path().join("bad.by"), "a b\n").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.arg("build")
.current_dir(dir.path())
.output()
@@ -2016,7 +2470,7 @@ fn build_renders_parse_error_and_aborts() {
"stderr should include invalid-syntax diagnostic:\n{stderr}"
);
assert!(
- !dir.path().join("out").join("bad.py").exists(),
+ !dir.path().join("build").join("bad.py").exists(),
"build should not emit output when parse error present"
);
}
@@ -2035,6 +2489,7 @@ fn run_refuses_to_execute_on_check_errors() {
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "main"])
.current_dir(dir.path())
.output()
@@ -2075,6 +2530,7 @@ fn run_min_version_newer_than_interpreter_errors() {
fs::write(dir.path().join("main.by"), "print(1)\n").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "--min-version", "3.99", "main"])
.env("PYTHON", python)
.current_dir(dir.path())
@@ -2110,6 +2566,7 @@ fn run_honors_explicit_min_version() {
fs::write(dir.path().join("main.by"), "print('versioned')\n").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.args(["run", "--min-version", "3.9", "main"])
.env("PYTHON", python)
.current_dir(dir.path())
@@ -2135,6 +2592,7 @@ fn build_skips_hidden_directories() {
fs::write(hidden.join("junk.by"), "a b\n").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.arg("build")
.current_dir(dir.path())
.output()
@@ -2146,9 +2604,9 @@ fn build_skips_hidden_directories() {
!stderr.contains("junk"),
"hidden-directory file must not be checked:\n{stderr}"
);
- assert!(dir.path().join("out").join("main.py").exists());
+ assert!(dir.path().join("build").join("main.py").exists());
assert!(
- !dir.path().join("out").join(".claude").exists(),
+ !dir.path().join("build").join(".claude").exists(),
"hidden-directory file must not be emitted"
);
}
@@ -2174,6 +2632,7 @@ fn build_writes_what_the_project_exports_into_its_marker() {
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.arg("build")
.current_dir(dir.path())
.output()
@@ -2182,7 +2641,7 @@ fn build_writes_what_the_project_exports_into_its_marker() {
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "by build failed:\n{stderr}");
assert_eq!(
- fs::read_to_string(dir.path().join("out").join("my_lib").join("by.typed")).unwrap(),
+ fs::read_to_string(dir.path().join("build").join("my_lib").join("by.typed")).unwrap(),
"exported-dependencies = [\"numpy\"]\n"
);
}
@@ -2197,6 +2656,7 @@ fn build_writes_a_marker_for_a_project_that_exports_nothing() {
fs::write(package.join("__init__.by"), "").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.arg("build")
.current_dir(dir.path())
.output()
@@ -2204,14 +2664,14 @@ fn build_writes_a_marker_for_a_project_that_exports_nothing() {
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "by build failed:\n{stderr}");
- let marker = dir.path().join("out").join("my_lib").join("by.typed");
- assert!(marker.exists(), "expected out/my_lib/by.typed:\n{stderr}");
+ let marker = dir.path().join("build").join("my_lib").join("by.typed");
+ assert!(marker.exists(), "expected build/my_lib/by.typed:\n{stderr}");
assert_eq!(fs::read_to_string(marker).unwrap(), "");
}
/// a src-layout project's `src/pkg/main.by` is the module `pkg.main`, so the
/// emitted tree has to be rooted at `src` — mirroring the directory instead
-/// emits `out/src/pkg/main.py`, whose module is `src.pkg.main`, a name nothing
+/// emits `build/src/pkg/main.py`, whose module is `src.pkg.main`, a name nothing
/// imports and `run.main` cannot sensibly be set to
#[test]
fn build_mirrors_the_module_tree_not_the_directory_tree() {
@@ -2228,6 +2688,7 @@ fn build_mirrors_the_module_tree_not_the_directory_tree() {
fs::write(package.join("main.by"), "print(\"src layout\")\n").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.arg("build")
.current_dir(dir.path())
.output()
@@ -2235,10 +2696,10 @@ fn build_mirrors_the_module_tree_not_the_directory_tree() {
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "by build failed:\n{stderr}");
- let out = dir.path().join("out");
+ let out = dir.path().join("build");
assert!(
out.join("package_name").join("main.py").exists(),
- "expected out/package_name/main.py:\n{stderr}"
+ "expected build/package_name/main.py:\n{stderr}"
);
assert!(
!out.join("src").exists(),
@@ -2246,7 +2707,7 @@ fn build_mirrors_the_module_tree_not_the_directory_tree() {
);
}
-/// `out/` outlives the build that wrote it — a test runner, a debugger or an
+/// `build/` outlives the build that wrote it — a test runner, a debugger or an
/// editor reads it later — so it is the tree where a `.by` really can be saved
/// after the transpile, and the one that needs the digests to say so
#[test]
@@ -2255,6 +2716,7 @@ fn build_writes_a_sourcemap_beside_the_generated_python() {
fs::write(dir.path().join("main.by"), "print(\"built\")\n").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
.arg("build")
.current_dir(dir.path())
.output()
@@ -2262,7 +2724,7 @@ fn build_writes_a_sourcemap_beside_the_generated_python() {
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "by build failed:\n{stderr}");
- let out = dir.path().join("out");
+ let out = dir.path().join("build");
let map = fs::read_to_string(out.join("_by_sourcemap.py")).expect("sourcemap module");
// read the keys out of the file rather than rebuilding them: the build
@@ -2310,6 +2772,7 @@ fn sourcemap_table_for(source: &str) -> (Vec