Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
862f455
native: answer equality, tagged ints, exceptions, with blocks and fie…
KotlinIsland Sep 10, 2026
e95be1f
native: make compiled generators, coroutines and async generators beh…
KotlinIsland Sep 14, 2026
0096d2e
native: follow a rebound module function and python's recursion limit
KotlinIsland Sep 14, 2026
0275608
native: guard loops once on the way in, from an analysis of what can …
KotlinIsland Sep 14, 2026
422ec1b
native: fill a class's dunder slots and instance surface as python does
KotlinIsland Sep 14, 2026
ecd8cc2
native: speed up generators, reference lifetimes and numeric loops
KotlinIsland Sep 15, 2026
9c19af2
native: publish compiled methods and classes with python's surface an…
KotlinIsland Sep 17, 2026
847c50b
native: outline cold recursion, prove narrowings and fix reference leaks
KotlinIsland Sep 17, 2026
ea90321
native-bench: stage only a leg's own source, scope cdiff per definiti…
KotlinIsland Sep 14, 2026
7038736
ty: fix statement expression regions, overload checks and gradual bases
KotlinIsland Sep 14, 2026
c965fff
transpiler: keep the author's source, refuse lost edits, and make rep…
KotlinIsland Sep 17, 2026
e4b79a4
transpiler: hold a .by file to its target python, and spell what the …
KotlinIsland Sep 23, 2026
d4676f0
transpiler: write every lowering-spelled name through one fresh-name …
KotlinIsland Sep 23, 2026
aaff328
transpiler: report visibility collisions, type `?` after a type test,…
KotlinIsland Sep 24, 2026
888e4d5
native: keep a bool or int subclass as itself, ask in-place methods, …
KotlinIsland Sep 23, 2026
06c7b16
native: compile marked methods, follow class rebinding and patching, …
KotlinIsland Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .config/nextest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ serial = { max-threads = 1 }
filter = 'binary(file_watching)'
test-group = 'serial'

# the runtime tests that need something of their interpreter — an old version
# with `typing_extensions`, a new one, the `regex` package — run on one found to
# have it, which a machine often does not. each says which interpreter it ran
# on, or that it found none and ran nothing, and a passing test's output is
# otherwise hidden, so a skip would read as a pass
[[profile.default.overrides]]
filter = 'binary(polyfill_runtime) | binary(grapheme_runtime) | binary(conversion_runtime) | binary(lazy_import_runtime) | binary(fstring_debug_runtime) | binary(tstring_expression_runtime) | binary(visibility_runtime) | binary(data_class_runtime) | binary(builtin_names_runtime) | binary(inferred_annotation_runtime) | binary(forward_reference_runtime) | binary(imported_extension_runtime) | binary(imported_conversion_class_runtime)'
success-output = "final"

[profile.ci]
# Print out output for failing tests as soon as they fail, and also at the end
# of the run (for easy scrollability).
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,10 @@ jobs:
# The build backend decides what the staged `pyproject.toml` says and where
# a dynamic version comes from. Neither is reachable from the Rust tests.
- run: uv run --no-project --with pytest pytest scripts/test_build_backend.py
# the header scoper decides which modules a `by.h` change can reach, and a
# narrowing that is wrong hides a module from every behavioural sweep over the
# native compiler. nothing in the rust tests runs it
- run: uv run --no-project --with pytest pytest scripts/native-sweeps/test_headerscope.py
# Lint/format/type-check py-fuzzer
# (dogfooding with ty is done in a separate job)
- run: uv run --directory=./python/py-fuzzer mypy
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/basedpython/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

130 changes: 126 additions & 4 deletions crates/by_build/src/annotate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
//! the "why not" half is the point. a compiler whose failures are silent is a
//! compiler nobody can tune against.

use std::collections::HashSet;
use std::fmt::Write;

use by_ir::function::{Function, ModuleIr};
Expand All @@ -17,17 +18,28 @@ use by_ir::print::print_function;
pub(crate) fn report(module: &ModuleIr) -> String {
let mut out = format!("# {}\n", module.name.dotted());

let native: Vec<&Function> = module.all_functions().collect();
// a function that compiled and is never installed is counted where it runs, and
// that is the interpreted definition
let unreached: HashSet<String> = module
.unreached()
.into_iter()
.map(|unreached| unreached.name)
.collect();
let native: Vec<&Function> = module
.all_functions()
.filter(|function| !unreached.contains(&function.qualified_name()))
.collect();
let left = crate::left_interpreted(module);
let _ = writeln!(
out,
"\n{} compiled, {} left interpreted\n",
native.len(),
module.declined.len()
left.len()
);

if !module.declined.is_empty() {
if !left.is_empty() {
out.push_str("## left to the interpreted definition\n\n");
for declined in &module.declined {
for declined in &left {
let _ = writeln!(out, "- {}: {}", declined.name, declined.reason);
}
out.push('\n');
Expand Down Expand Up @@ -278,6 +290,116 @@ def total(xs: list[float], k: float) -> float:
assert!(!text.contains("numeric promotion cost"), "{text}");
}

#[test]
fn a_method_whose_class_installs_the_body_answer_is_counted_as_interpreted() {
// the class holds what the interpreted body's `@mark` handed back, so neither the
// compiled `get` nor the closure only it makes ever runs
let module = lowered(
"\
def mark(f):
return f

class Box:
@mark
def get(self, x: int) -> int:
def add(k: int) -> int:
return k + x

return add(1)

def plain(self) -> int:
return 1
",
);
let text = report(&module);
assert!(text.contains("2 compiled, 2 left interpreted"), "{text}");
assert!(
text.contains("- Box.get: decorated in the class body"),
"{text}"
);
assert!(
text.contains("- Box$get$env.add: only `Box.get` reaches this"),
"{text}"
);
assert!(!text.contains("### Box.get"), "{text}");
assert!(text.contains("### Box.plain"), "{text}");
}

#[test]
fn a_method_the_class_body_binds_again_is_reported_for_that() {
// the class holds what `get = other` left, and the report says so rather than
// blaming a decorator the method does not have
let module = lowered(
"\
def other(self) -> int:
return 2

class Box:
def get(self) -> int:
return 1

get = other
",
);
let text = report(&module);
assert!(text.contains("1 compiled, 1 left interpreted"), "{text}");
assert!(
text.contains(
"- Box.get: its name is bound again in the class body, so the class holds what that binding left and this one is never installed"
),
"{text}"
);
}

#[test]
fn a_property_on_a_class_its_metaclass_builds_is_counted_as_interpreted() {
// `Number` writes a metaclass, so `Real`'s type spec is refused and `ABCMeta` is
// called instead — with the `property` the interpreted body built, which is what
// the class keeps. `Plain` stands on nothing of the kind, and its own is compiled
let mut module = by_irbuild::module_from_source(
"\
from abc import ABCMeta


class Number(metaclass=ABCMeta):
pass


class Real(Number):
@property
def real(self) -> int:
return 1

def plain(self) -> int:
return 2


class Root:
pass


class Plain(Root):
@property
def real(self) -> int:
return 1
",
"app",
by_irbuild::Language::Python,
);
by_opt::optimize(&mut module).expect("the pipeline verifies");
let text = report(&module);
assert!(
text.contains(
"- Real.real$get: a half of a property on a class built by calling its metaclass"
),
"{text}"
);
assert!(!text.contains("### Real.real$get"), "{text}");
assert!(text.contains("### Real.plain"), "{text}");
assert!(text.contains("### Plain.real$get"), "{text}");
assert!(!text.contains("- Plain.real$get"), "{text}");
}

#[test]
fn a_module_with_nothing_compiled_still_reports() {
let mut module = ModuleIr::new("app");
Expand Down
81 changes: 71 additions & 10 deletions crates/by_build/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ pub fn build_source(
artifact.annotation = write_annotation(&module, out_dir, options)?;
Ok(Built {
artifact,
declined: module.declined,
declined: left_interpreted(&module),
})
}

Expand All @@ -76,7 +76,7 @@ pub fn build_lowered(
artifact.annotation = write_annotation(&module, out_dir, options)?;
Ok(Built {
artifact,
declined: module.declined,
declined: left_interpreted(&module),
})
}

Expand Down Expand Up @@ -138,7 +138,7 @@ fn emit_verified(module: &ModuleIr, out_dir: &Path, options: &Options) -> Result
extension: None,
annotation: write_annotation(module, out_dir, options)?,
},
declined: module.declined.clone(),
declined: left_interpreted(module),
})
}

Expand Down Expand Up @@ -219,6 +219,22 @@ pub struct Options {
/// figure becomes an upper bound without anyone noticing. the check runs once per
/// module at import rather than per call
pub verify_install: bool,
/// count compiled calls against python's recursion limit, as well as against the
/// stack.
///
/// on by default, so a compiled recursion raises `RecursionError` at the depth the
/// interpreted one does. off, a recursion is stopped only where the stack would run
/// out, which saves the count on every call inside a cycle of compiled calls and
/// leaves `sys.setrecursionlimit` unobserved by them. neither setting can crash
pub follow_recursion_limit: bool,
/// have a compiled call to a function the module defines go straight to its native
/// entry, without asking whether the name still holds that function.
///
/// off by default, so a module function rebound, deleted or patched from outside —
/// `mock.patch("mod.fn")` — or given another `__code__` or other defaults reaches the
/// module's own compiled callers as it reaches python's. on, they go on calling the
/// function the module was compiled with, which saves the question on every such call
pub bind_functions_early: bool,
}

impl Default for Options {
Expand All @@ -231,6 +247,8 @@ impl Default for Options {
fallback: None,
recheck_licences: false,
verify_install: true,
follow_recursion_limit: true,
bind_functions_early: false,
}
}
}
Expand All @@ -240,7 +258,16 @@ impl Options {
pub fn lowering(&self) -> by_irbuild::LowerOptions {
by_irbuild::LowerOptions {
language: self.language,
// the checks the interpreted fallback is transpiled with, and a `.py` source
// is its own fallback
soundness: match self.language {
by_irbuild::Language::BasedPython => {
self.fallback.clone().unwrap_or_default().soundness
}
by_irbuild::Language::Python => by_transforms::SoundnessPositions::none(),
},
recheck_licences: self.recheck_licences,
bind_functions_early: self.bind_functions_early,
}
}
}
Expand All @@ -261,6 +288,29 @@ fn write_annotation(
Ok(Some(path))
}

/// every function an import of `module` runs from its interpreted definition: each one
/// that declined, and each one that compiled and is never installed — see
/// [`ModuleIr::unreached`](by_ir::function::ModuleIr::unreached)
pub(crate) fn left_interpreted(
module: &by_ir::function::ModuleIr,
) -> Vec<by_ir::function::Declined> {
module
.declined
.iter()
.cloned()
.chain(
module
.unreached()
.into_iter()
.map(|unreached| by_ir::function::Declined {
name: unreached.name,
reason: unreached.reason,
range: unreached.range,
}),
)
.collect()
}

fn render_declines<'a>(declines: impl Iterator<Item = &'a by_ir::function::Declined>) -> String {
declines
.map(|declined| format!(" {}: {}", declined.name, declined.reason))
Expand Down Expand Up @@ -308,13 +358,17 @@ fn finish(
}

module.verify_install = options.verify_install;

if options.require_native && !module.declined.is_empty() {
bail!(
"`require-native` is on and {} function(s) were left interpreted:\n{}",
module.declined.len(),
render_declines(module.declined.iter())
);
module.follow_recursion_limit = options.follow_recursion_limit;

if options.require_native {
let left = left_interpreted(&module);
if !left.is_empty() {
bail!(
"`require-native` is on and {} function(s) were left interpreted:\n{}",
left.len(),
render_declines(left.iter())
);
}
}
if options.no_any && !module.gradual.is_empty() {
let places = module
Expand Down Expand Up @@ -363,6 +417,12 @@ fn finish(
// too, over the twin's — once for each definition rather than once for the name
let twin = by_irbuild::without_init_decorators(&twin, &module)
.map_err(|error| anyhow::anyhow!("could not prepare the interpreted fallback: {error}"))?;
// and a class init reads against what its statement bound has that statement record it
let twin = by_irbuild::with_bound_records(&twin, &module)
.map_err(|error| anyhow::anyhow!("could not prepare the interpreted fallback: {error}"))?;
// a nested function's annotations are the twin's, evaluated by python, so they are
// written from the twin's text
by_irbuild::annotations::write_factories(&mut module, &twin);
// and the same program compiled, so that importing the artefact does not have to
// parse it all over again. it is asked for after every rewrite above, because what
// gets compiled has to be exactly what would otherwise be run
Expand Down Expand Up @@ -590,6 +650,7 @@ mod tests {
fallback_code: None,
shims: None,
verify_install: true,
follow_recursion_limit: true,
};
let dir = std::env::temp_dir().join("by_build_refuses_test");
let _ = fs::remove_dir_all(&dir);
Expand Down
Loading
Loading