Skip to content

Features - #199

Merged
KotlinIsland merged 16 commits into
mainfrom
features
Sep 4, 2026
Merged

KotlinIsland merged 16 commits into
mainfrom
features

Conversation

@KotlinIsland

Copy link
Copy Markdown
Owner

No description provided.

KotlinIsland and others added 7 commits September 4, 2026 20:10
typing `"hello {na` now offers the names in scope. taking one writes the `f`
that makes the string an f-string and the brace that closes the field, so
`"hello {na` becomes `f"hello {name}"`. the wasm playground builds the same
edits, so a name completed there arrives with its `f` too.

a docstring, a type expression, a `str.format` template and a `match` pattern
are left alone, since an f-string means something else, or nothing legal, in
each of those places. a docstring is the whole of its statement rather than
merely the nearest one that opens a body, so `print("hi {na")` at the top of a
function is still offered the conversion.

the offer is made whenever the cursor sits after a `{` and a name: brace runs
are counted for parity so `{{{na` opens a field, and the replaced range covers
the whole name so completing inside `{na|me}` does not write `{nameme}`. when
nothing matches, the collection is given up rather than preempting the
completions the string would otherwise get.

Co-Authored-By: Claude Opus 5 <[email protected]>
`Asdf.na` and `mod.As` offered nothing when the root name was unbound.
auto-import only ever fired on a bare identifier, so past a dot the only
unimported root that resolved was one of the handful of common aliases.

the root of the chain is now looked up in the auto-import index by exact name,
and the members reached through it are offered carrying the import that would
bind it. a submodule is reached too: `all_symbols` matches a module against the
whole of its dotted name, which no import binds, so the binding question is
asked instead and the walk falls back to a submodule where member lookup fails.
the import written is always a from-import, since an existing `import mod` made
the file offer less than one with no imports at all.

a query that matches everything bails, the way auto-import does in a scope, so a
bare `mod.` does not offer every member of every symbol spelled `mod`, and
candidates are deduped on the resolved root type, so three modules re-exporting
one `Mapping` contribute one copy of its members rather than three told apart
only by an import edit the list does not show.

Co-Authored-By: Claude Opus 5 <[email protected]>
goto-definition on an extension member only worked when the member was
called: a call resolves through the call's own dispatch target and landed on
the extension's function that way, while a bare `xs.second` had nothing at
all — and a property, which can never be a callee, therefore never resolved.
find-references shared the same path, so rename edited only the `def` and
silently broke every call site.

`definitions_for_attribute` now takes the two fallbacks inference takes when
ordinary member lookup comes up undefined: an applicable `extension` member,
then an implicit-receiver callable. an inline protocol's members answer too —
the type is structural and has no declaration of its own, so the annotation
the receiver was declared with is what goto points at, and its interface is
consulted directly for completions rather than a class it does not have.

Co-Authored-By: Claude Opus 5 <[email protected]>
the scope map is an interval map over node indices that merges consecutive
same-scope entries, so a node with no entry of its own is only answered for
when some recorded expression in the same scope sits either side of it. a
`match` subject happens to precede its patterns and covered them by accident;
`let (a, b) := v` writes the pattern *before* the value, and a `let (m, n): T`
parameter binder has no neighbouring expression at all.

both therefore fell outside every interval, and every service that starts by
asking which scope a name is in answered nothing at the binder — so rename
rewrote the uses and left the binder naming something that no longer existed.

Co-Authored-By: Claude Opus 5 <[email protected]>
`extension Widget:` declares no class — its name is a reference to one. goto
answered with the header itself, so f12 there went nowhere and
find-references on a class could not list its extensions, which is the thing
worth asking in a codebase that uses them. renaming the class left the header
behind.

the outline listed it as a bare second `Widget` of kind Class too, so a
120-file project with one extension per class returned twice the symbols the
equivalent python does, half of them naming a class that does not exist.

Co-Authored-By: Claude Opus 5 <[email protected]>
nothing in scope binds `Red` in `c: Color = Red` — it is reached through the
expected type — so the scope walk had nothing to answer with even though
hover already resolved it to `Color.Red`. a bare `case Red:` was worse than
silent: it answered that the pattern defines the name, which is the reading
context-sensitive resolution exists to override.

Co-Authored-By: Claude Opus 5 <[email protected]>
`case Red:` on an `enum class` variant drew F841 and N806 while `by check`
passed on the same file: python spells a member match as a capture, so the
binding exists, and the rules that read a capture as a variable took it at face
value. a name matching no variant is still an ordinary capture and still
reported.

a `context` parameter is no longer reported unused either, by the linter or by
ty. it is filled implicitly at a call site that leaves it out and forwarded
implicitly to any nested call that wants one, and that forward happens during
inference rather than as a name load the use-def map can see — so the function
that "does not use" it is usually the one forwarding it.

Co-Authored-By: Claude Opus 5 <[email protected]>
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

ecosystem check

Linter (stable)

✅ ecosystem check detected no linter changes.

Linter (preview)

✅ ecosystem check detected no linter changes.

Formatter (stable)

✅ ecosystem check detected no format changes.

Formatter (preview)

✅ ecosystem check detected no format changes.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

by ecosystem round-trip

base: 678f1986da63aa3e11b6ba3a9b3256b1c815671a (merge base) → head: 199/merge

regressions: 1, changed: 2, improvements: 0, error changes: 0 (across 25638 files in 148 projects)

⚠️ 13 project(s) fail to round-trip on both base and head, so this check says nothing about them.

❌ regressions (built on base, now fails)

pandas —
build: killed: timed out after 900s

ℹ️ changed round-trip output

bokeh — _by_sourcemap.py
--- base/_by_sourcemap.py
+++ head/_by_sourcemap.py
@@ -2591,5 +2591,5 @@
     "/tmp/tmphmsip_v4/bokeh/out/tests/unit/bokeh/colors/test_groups.py": {"by": "sha256:509b7df388e7010a457259ffc27c1043ff62710707a516b98a3f48db7e9376d6", "py": "sha256:dae1a6f9fae7f4bbf69ffaf0f4019a5f664ede82d9ec94fbd678e9b58246f8cb"},
     "/tmp/tmphmsip_v4/bokeh/out/tests/unit/bokeh/colors/test_named.py": {"by": "sha256:6eda4232623c4259aa0ad948999f8a40a16ee1946b700c80067161f9447fb14f", "py": "sha256:7ae81249a2582d6b3f14d2173b56406f00207383150a46773be064cd55969663"},
-    "/tmp/tmphmsip_v4/bokeh/out/tests/unit/bokeh/colors/test_util__colors.py": {"by": "sha256:a74160fe3f76fa71d78fa4eb1cb62684afdbeaa6a0dfa7fdb63b73deb37d66c2", "py": "sha256:9fbef817cd7e0cc3f2627aca3bf660a80eb93dbb9f1abc2ee48f97fb4fd6eaf2"},
+    "/tmp/tmphmsip_v4/bokeh/out/tests/unit/bokeh/colors/test_util__colors.py": {"by": "sha256:a74160fe3f76fa71d78fa4eb1cb62684afdbeaa6a0dfa7fdb63b73deb37d66c2", "py": "sha256:ea991ef796f672e49a14b2870ff2bb572722122838e5abe8b34b87f2d99335eb"},
     "/tmp/tmphmsip_v4/bokeh/out/tests/unit/bokeh/command/subcommands/_dummy_auth.py": {"by": "sha256:b7b3bf990cf0d9d59983fd0e82e96ef4b66c5a5ee053d5793bfefcc2ab548048", "py": "sha256:b7b3bf990cf0d9d59983fd0e82e96ef4b66c5a5ee053d5793bfefcc2ab548048"},
     "/tmp/tmphmsip_v4/bokeh/out/tests/unit/bokeh/command/subcommands/_util_subcommands.py": {"by": "sha256:8f1e57aa012c27414989d3145038a0d9f863c07fdfe6aab2747bbf27f9a00dc3", "py": "sha256:42dc27e991aeaee37a47f12e4f12a93528e7dc507d4c665820ca3bd650fd15c9"},
bokeh — tests/unit/bokeh/colors/test_util__colors.py
--- base/tests/unit/bokeh/colors/test_util__colors.py
+++ head/tests/unit/bokeh/colors/test_util__colors.py
@@ -138,5 +138,5 @@
     def test_getitem_bad(self) -> None:
         with pytest.raises(ValueError):
-            _TestGroup[10.2]
+            _TestGroup[float]
         with pytest.raises(ValueError):
             _TestGroup[Literal[1],]

💥 fails to round-trip (unchanged from base)

  • alerta: build: killed: timed out after 900s
  • apprise: build: killed: exceeded 11.7365GB memory budget
  • cryptography: build: killed: exceeded 11.7358GB memory budget
  • jax: build: killed: timed out after 900s
  • manticore: reverse: killed: exceeded 11.7358GB memory budget
  • meson: build: killed: exceeded 11.7358GB memory budget
  • more-itertools: reverse: killed: exceeded 11.7395GB memory budget
  • openlibrary: reverse: killed: timed out after 900s
  • scikit-learn: build: killed: exceeded 11.7358GB memory budget
  • scipy: build: killed: exceeded 11.7365GB memory budget
  • setuptools: reverse: killed: exceeded 11.7365GB memory budget
  • sympy: reverse: thread '' (4920) has overflowed its stack
  • xarray: build: killed: exceeded 11.7358GB memory budget

⏭️ skipped

  • AutoSplit: setup failed: setup_primer_project.py failed for 'AutoSplit': Cloning into '/tmp/tmphvuiaqs7/AutoSplit'... Using CPython 3.12.3 interpreter at: /home/runner/work/_temp/setup-uv-cache/environments-v2/…
  • CPython (Argument Clinic): skipped: 2330 .py files exceeds --max-project-py-files (2000); by build would not fit in the runner's memory
  • CPython (cases_generator): skipped: 2330 .py files exceeds --max-project-py-files (2000); by build would not fit in the runner's memory
  • CPython (peg_generator): skipped: 2330 .py files exceeds --max-project-py-files (2000); by build would not fit in the runner's memory
  • core: skipped: 18631 .py files exceeds --max-project-py-files (2000); by build would not fit in the runner's memory
  • dd-trace-py: skipped: 2976 .py files exceeds --max-project-py-files (2000); by build would not fit in the runner's memory
  • ecosystem-analyzer: setup failed: setup_primer_project.py failed for 'ecosystem-analyzer': Cloning into '/tmp/tmpti_b_odh/ecosystem-analyzer'... Using CPython 3.12.3 interpreter at: /home/runner/work/_temp/setup-uv-cach…
  • pylint: skipped: 2427 .py files exceeds --max-project-py-files (2000); by build would not fit in the runner's memory
  • rotki: skipped: 2568 .py files exceeds --max-project-py-files (2000); by build would not fit in the runner's memory
  • spack: skipped: known to spike past the runner's memory during by build (faster than the watchdog can react)
  • streamlit: setup failed: setup_primer_project.py failed for 'streamlit': Cloning into '/tmp/tmpvoe5y8ik/streamlit'... Updating files: 26% (2629/9875) Updating files: 27% (2667/9875) Updating files: 28% (2765/98…
  • zulip: skipped: 2023 .py files exceeds --max-project-py-files (2000); by build would not fit in the runner's memory

KotlinIsland and others added 9 commits September 5, 2026 01:30
`extension`, `let`, `var` and `init` were offered nowhere. the only
basedpython-aware keyword table held the *compound* forms, and the list beside
it is python's keywords verbatim — so a basedpython keyword was offered
exactly when it happened to head a compound form. the two are now one table of
statement openers.

a callable completion now writes its parentheses and leaves the caret between
them. the machinery was already there and gated behind a setting upstream ty
defaults off; basedpython defaults it on. a completion that writes its own
text is only wrapped when that text is a plain name, so an
`override def greet(self) -> str:` template is left alone.

`extension_members` asked whether an extension applies once per *member name*
rather than once per extension, and `resolve_extension_member` re-walked every
extension in the file each time. 300 single-method extension blocks cost 80ms
to complete one builtin member, and blocks extending an unrelated type cost
the same as ones extending the receiver. the receiver check is now hoisted out
of the member loop.

Co-Authored-By: Claude Opus 5 <[email protected]>
hovering `age` in `var age: int = 0` answered nothing. the construct lowers to
a `def` ranged over the accessor *below* the declaration, carrying the name the
author wrote on the line above — so the name sits outside its own function, and
no node containing that offset exists for the covering-node walk to descend
into. the enclosing class body is where the `def` is, so that is searched when
the ordinary walk comes up with nothing. the answer is the `def`, which is what
python's own `@property` declaration answers with.

widening the getter to span the whole construct would have been the tidier
story — a node whose range excludes its children's is the real oddity here —
but the semantic-token walk relies on that range to order the construct's
pieces, and both accessor orderings have to keep working. not worth it for a
navigation gap.

goto on a property *use* also offered the same place twice: a property is one
declaration in the source and several `def`s in the tree, each carrying the
same name range. definitions that resolve to one location are now one entry.
python's own getter/setter pair sit at two different `def`s and stay two.

Co-Authored-By: Claude Opus 5 <[email protected]>
…untime errors in a `.by` file

two problems with `type Title = f"the {Name}"` over `type Name = "foo" | "bar"`.

the alias body reported a false `unsupported-operator`: the `|` arm of a type
expression infers both operands as *values* as well, to catch `int | "bytes"`
blowing up at import time on an old target. that probe reads a `.by` source that
never runs — the transpiler lowers the whole type expression first, so
`"foo" | "bar"` is emitted as `Literal["foo", "bar"]` — so it is now gated on
standard python, the same way the `&` intersection arm already was.

and the template accepted anything: the constructor normalizes by reading each
hole's own shape, and a `Type::TypeAlias` answers none of those questions for the
type it names, so the union never distributed and the hole stood for any string
at all. holes are now resolved through their aliases first, which makes
`f"the {Name}"` the `"the foo" | "the bar"` that spelling the union inline
already gave.

one step is not enough: a union written in a type expression keeps its arms'
aliases, so one step off `type Outer = Inner | "baz"` lands on a union whose
first arm is still an alias. holes are followed through the arms of a union as
well, remembering which unions are already open so that `type Loop = Loop | "q"`
ends, and the nested pattern splice moves after distribution, since a hole is
only ever a single pattern once the union it was written as has been split into
branches.

Co-Authored-By: Claude Opus 5 <[email protected]>
…terals` option

`a: int | 3.5` passed `by check` and then raised `TypeError` at import: the
transpiler left a float literal in a type position exactly as written, because
PEP 586 has no `Literal[...]` for one and wrapping it would emit something every
checker rejects. a bare `a: 3.5` evaluates harmlessly, which is why it went
unnoticed — only the `|` makes it fail.

there are two defensible answers and the project now picks between them.
`lowering.float-literals = "nominal"`, the default, writes the type the literal
is one of, so `1.5` becomes `float` and `2j` becomes `complex`: precision is lost
and every checker reading the output accepts it. `"literal"` keeps the literal,
writing `Literal[1.5]` — `typing` does not check what it is handed, so it runs,
but a checker reports the argument as invalid.

either way the annotation is something python can evaluate, which is what the
`.by` gate on the `|` runtime-error probe assumes. the setting rides in the build
record alongside the other lowering choices, so a re-stage reproduces the bytes
the build wrote.

Co-Authored-By: Claude Opus 5 <[email protected]>
the rules a module's api has to answer are written in source, in the package
`__init__`, and spelled relative to it with a leading dot

Co-Authored-By: Claude Opus 5 <[email protected]>
`implements Backend` obliges the module that writes it to answer the protocol,
and the check runs in that module rather than wherever something happens to
assign it. a `for` clause in a package's `__init__` imposes the same obligation
on the modules its patterns name, so a plugin directory is enforced whether or
not anything imports it — a module finds the rules imposed on it by walking its
ancestor packages, which is what keeps the error in the file whose author can
fix it.

the obligation is about the surface importers see, so a module with both an
implementation and a stub is checked once, against the stub that shadows it,
and a declaration written in a file its stub shadows is an error naming the
stub.

the declaration parses to the synthetic `__implements__` marker call, so the
interfaces stay ordinary loads and ruff's binder needs no teaching. it erases at
transpile time. a specialization is kept rather than dropped, trailing tokens
are a syntax error, and a pattern that will not resolve is reported and skipped
rather than poisoning the rule it is written in.

the feature is experimental and off by default — a project opts in by name:

    # basedpython.toml
    [experimental]
    module-api = true

a declaration written while the feature is off is reported, with the opt-in in
the help, rather than ignored: an obligation nothing checks is the exact failure
this feature exists to remove. the parser still parses a declaration and the
transpiler still erases one, so a project that turns the feature off after
writing `implements` does not emit python carrying a call to a name that does
not exist at runtime.

`[experimental]` is its own options group, and `ExperimentalSettings` is its own
struct reached without a file: an analysis setting says how to read code that is
already understood, while this says whether a language feature exists, so
`[[overrides]]` cannot vary it.

Co-Authored-By: Claude Opus 5 <[email protected]>
`build:` declares the values a build settles when it produces the artifact —
GIT_SHA, GIT_SHA_SHORT, GIT_BRANCH, GIT_TAG, GIT_DIRTY, BUILT_AT,
PYTHON_VERSION — and each is read as `build.NAME` at the type it declares. a
stamp with no default that the build cannot supply fails the transpile instead
of shipping a wrong value, which is the whole point of declaring it.

the block parses to a class carrying a synthetic `build_def` marker, the way
`extension` and `enum class` already do, so an attribute read needs no
resolution rule of its own and ty needs one line.

the values are settled at the command layer and handed to the transpiler as
config; the pipeline never goes looking for them. asking git inside a transform
would make the emitted python a function of the working tree as well as the
source, and a re-staged file would then disagree with the tree around it about
what commit it is. the stamps ride in `_by_build.json` so a re-stage reproduces
them, and `by build --wheels` settles them once for the whole release rather
than once per wheel — all but PYTHON_VERSION, which each wheel settles itself.

`--stamp NAME=VALUE` supplies one explicitly and beats discovery, which is what
a CI job that knows its own commit wants. SOURCE_DATE_EPOCH replaces the clock
for BUILT_AT so a reproducible build stays reproducible.

there is deliberately no VERSION stamp — a wheel already carries its version and
a baked copy can disagree with it — and no reverse transform, since the emitted
class is indistinguishable from a hand-written one and reversing would discard
the values.

the feature is experimental and off by default — a project opts in by name:

    # basedpython.toml
    [experimental]
    build-stamps = true

the gate is a check rather than a parse or lowering rule. the block still parses
and still lowers with the feature off, because a program that reads
`build.GIT_SHA` has to keep working when a project turns the feature off — which
is exactly why a block written while it is off is reported rather than ignored:
nothing at the point of use would say the value was never settled. a stamp with
no default would then fail the transpile, and one with a default would quietly
stand for that default in an artifact that claims to know what commit it came
from.

the check walks the whole file rather than its module body, since the lowering
fills a block in wherever it is written, and a class def answers for the range
of the keyword its synthetic marker stands for — the class name the parser
synthesizes spans nothing, so it is not a span a diagnostic can point at.

Co-Authored-By: Claude Opus 5 <[email protected]>
`--soundness`, `--runtime-raises-checks` and `--no-unique-loop-bindings` stopped
at the outer command, so `by build --wheels --soundness none` was accepted and
then produced wheels with the default checks in them. they now travel to the
inner builds in an environment variable, the way the stamps already do, as one
json struct rather than two adjacent strings that could be swapped with nothing
to catch it.

every field of the handed-down message defaults, so a message written by a `by`
that predates an option is still readable — otherwise the first added option
would have made the whole message unparseable and sent every build back to its
defaults, silently.

only `by build` reads the message. `apply` is shared by `run`, `compile` and
`transpile`, none of which a release ever runs, and these options change what
the emitted python does — a variable left in the environment must not re-lower
an unrelated transpile.

the arguments are destructured on the way in and the settled options on the way
out, so a lowering flag added later and forgotten here does not compile. both
specs are parsed before any `git` discovery runs, which also refuses a bad one
before `uv` is looked for.

Co-Authored-By: Claude Opus 5 <[email protected]>
`import "data/config.json" as config` reads the file at check time and binds it
as a value whose shape is the document's own, so `config.server.port` is an
`int` the checker knows about and a key that is not there is an error rather
than a `KeyError` at runtime.

the document is rendered as a python file of its own rather than served as the
importing module's contents: a position in the python is not a position in the
json, so every consumer that takes a file and a range — a diagnostic above all —
would otherwise hold two halves of different things, and a range past the end of
the document is a panic rather than a wrong answer.

a key that would collide with the rendering's own names is refused: the two
renderings name their classes after the binding, which differs between the
checker and the transpiler, and a class body resolves a bare name to its own
attributes first, so a document key spelled `_by_...` could give a value at
runtime where the checker said a class. names beginning with `_by_` join `Final`
in what a document cannot be read through, and the report names the path the
unusable key sits at.

yaml is read from the parser's event stream against a budget charged as the tree
is built, and again for every copy an alias makes. loading a tree and measuring
it afterwards aborted the process on a document nested past the stack, and let a
two-hundred byte file name more values than there is memory to hold.

the path stays a string everywhere it is written out: the code generator emits
the literal rather than a bare name, so a pass that re-renders the statement no
longer writes `import data/config.json as config`; the formatter leaves a path
holding an escape alone; and the highlighter paints it as the string it is,
binding a variable rather than a namespace.

Co-Authored-By: Claude Opus 5 <[email protected]>
@KotlinIsland
KotlinIsland merged commit ccd6add into main Sep 4, 2026
61 of 65 checks passed
@KotlinIsland
KotlinIsland deleted the features branch September 4, 2026 16:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant