Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 6 additions & 6 deletions .config/nextest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ 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
# the transpiler's runtime tests transpile for the interpreter they run on. a few
# programs need a newer python than the one found, or more of it —
# `typing_extensions`, the `regex` package — which a machine often does not have.
# 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)'
filter = 'package(by_transforms) & binary(/_runtime$/)'
success-output = "final"

[profile.ci]
Expand Down
27 changes: 23 additions & 4 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -368,10 +368,12 @@ jobs:
# to answer exactly as that interpreter does, so *which* interpreter is a first-class
# variable and belongs in the workflow rather than in whatever a runner image ships.
#
# the supported set is python 3.13 and 3.14, each in its gil and its free-threaded
# build. `PYTHON_VERSION` is the primary and `cargo test (linux)` runs the whole suite
# against it; this job covers the other three. 3.12 is deliberately absent — it is
# below the project's floor, and it was only ever reached because an image shipped it.
# the supported set is python 3.11 through 3.14, and the free-threaded builds of 3.13
# and 3.14 — free threading starts at 3.13, so 3.11 and 3.12 have gil builds only.
# `PYTHON_VERSION` is the primary and `cargo test (linux)` runs the whole suite against
# it; this job covers the other five. 3.11 is the floor `by_build::MINIMUM_PYTHON`
# states, and 3.11 and 3.12 answer differently from 3.13 in places the backend follows
# per version, so leaving them out would leave those branches untested.
#
# only the version-sensitive targets run here, because only they start an interpreter:
# * the six backend crates, which emit and then load real extension modules
Expand All @@ -394,6 +396,15 @@ jobs:
fail-fast: false
matrix:
include:
# every leg runs every step. a frontend test whose program needs a newer python
# than the leg's says so and skips, by name: a runtime test transpiles for a fixed
# version and runs on an interpreter at least that new, a reified generic is
# refused below 3.12 and its pep 696 defaults below 3.13, and the divergence
# harness lists the sections that need either
- python: "3.11"
backend_filter: "all()"
- python: "3.12"
backend_filter: "all()"
- python: "3.13"
backend_filter: "all()"
# the free-threaded builds run the same tests, minus one that cannot be
Expand Down Expand Up @@ -478,6 +489,14 @@ jobs:
cargo nextest run \
-p by_build -p by_ir -p by_irbuild -p by_opt -p by_codegen_c -p by_rt \
-E "$BACKEND_FILTER"
# python transpiled for a version below the newest takes the typing names that version
# lacks from `typing_extensions` — `override` below 3.12, say — so a program built for
# the leg's version needs it installed, as a project targeting that version would. it
# goes in after the backend has run, which compiles against the interpreter as uv ships
# it. the pin is the one the dev dependencies lock, and uv marks its managed
# interpreters as externally managed, though this one belongs to the job alone
- name: "Install `typing_extensions` into the interpreter"
run: uv pip install --python "$PYTHON" --break-system-packages "typing_extensions==4.16.0"
# a separate invocation on purpose: the backend's differential tests fork processes
# and compile C, and sharing a run with `by_transforms`' ~1800 other tests starves
# them into failures that pass on their own
Expand Down
70 changes: 56 additions & 14 deletions crates/by_build/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,13 +325,37 @@ fn lower(
toolchain: Option<&Toolchain>,
) -> Result<by_ir::function::ModuleIr> {
finish(
by_irbuild::module_from_source(source, module_name, options.lowering()),
by_irbuild::module_from_source_at(
source,
module_name,
options.lowering(),
fallback_config(options, toolchain).min_version,
),
source,
options,
toolchain,
)
}

/// the config the interpreted fallback is transpiled with, and the version both halves of
/// the module are analysed at
///
/// the version has to be the *interpreter's*, because the fallback runs inside the
/// extension at import time — emitting syntax the interpreter cannot parse makes the whole
/// module fail to load, taking every function with it. the compiled half is analysed at
/// the same one: a `sys.version_info` test settles which branch binds a name, and analysed
/// at a newer python the module laid a value out as the branch the interpreter never takes
/// binds it, then refused the value the interpreter's own branch bound
fn fallback_config(options: &Options, toolchain: Option<&Toolchain>) -> by_transforms::Config {
let mut config = options.fallback.clone().unwrap_or_default();
if let Some((major, minor)) = toolchain.and_then(|toolchain| toolchain.version)
&& let Ok(parsed) = format!("{major}.{minor}").parse()
{
config.min_version = parsed;
}
config
}

/// apply the gates, optimize, and attach the interpreted fallback
///
/// split out from [`lower`] because a whole-project build lowers against a real
Expand Down Expand Up @@ -395,25 +419,21 @@ fn finish(
// `JustFloat = float` locally instead of emitting a
// `from ty_extensions import ...` that has no module behind it
//
// the version has to be the *interpreter's*, because this python runs inside
// the extension at import time — emitting syntax the interpreter cannot parse
// makes the whole module fail to load, taking every function with it
// a `.py` source is already what runs: it is its own fallback
// the module is analysed at the interpreter's version, as the compiled half is: a
// lowering that asks what a name is, such as the conversion a `frozendict`
// annotation makes on 3.15, has to be answered by the interpreter's builtins rather
// than by ty's newest python's. a `.py` source is already what runs: it is its own
// fallback
let twin = if options.language == by_irbuild::Language::Python {
source.to_string()
} else {
let mut config = options.fallback.clone().unwrap_or_default();
let mut config = fallback_config(options, toolchain);
// the compiled functions read the module's globals by name, and a deferred
// `from` import is in them only once something has read it
config.defer_from_imports = false;
if let Some((major, minor)) = toolchain.and_then(|toolchain| toolchain.version)
&& let Ok(parsed) = format!("{major}.{minor}").parse()
{
config.min_version = parsed;
}
by_transforms::transpile(source, &config).map_err(|error| {
anyhow::anyhow!("could not transpile for the interpreted fallback: {error}")
})?
by_transforms::transpile_analysed_at(source, &config, config.min_version).map_err(
|error| anyhow::anyhow!("could not transpile for the interpreted fallback: {error}"),
)?
};
// a decorator module init applies to the native definition would otherwise run here
// too, over the twin's — once for each definition rather than once for the name
Expand Down Expand Up @@ -660,4 +680,26 @@ mod tests {
assert!(error.to_string().contains("not well-formed"));
assert!(!dir.exists(), "nothing is written for ill-formed IR");
}

/// `frozendict` is a builtin from 3.15, past ty's newest python, so the conversion its
/// annotation makes is only written where the module is analysed at the interpreter's
/// version. no interpreter is run: the one named is only asked to compile the twin,
/// and an artefact without that runs the source
#[test]
fn the_twin_is_analysed_at_the_interpreter_s_version() {
let toolchain = Toolchain::from_probe(
"by-build-no-such-python",
r#"{"cc": ["cc"], "include": [], "libdir": [], "ext_suffix": ".so", "platform": "linux", "version": "3.15"}"#,
)
.unwrap();
let module = lower(
"a: frozendict[str, str] = {}\n",
"twin_version",
&Options::default(),
Some(&toolchain),
)
.unwrap();
let twin = module.fallback_source.unwrap_or_default();
assert!(twin.contains("frozendict()"), "{twin}");
}
}
Loading
Loading