Skip to content

Commit 452cc80

Browse files
authored
Merge pull request #138 from thisisthepy/feat/restore-pythonic-names
Feat: Restore the binder's Pythonic names (snake_case members and keywords)
2 parents 970be90 + 0db0857 commit 452cc80

38 files changed

Lines changed: 1770 additions & 306 deletions

‎AGENTS.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,19 @@ around one.
214214

215215
1. **The binder never exports a Kotlin namespace under a different name.** A Kotlin fully-qualified
216216
name in Python means the original Kotlin (`androidx.compose...` is androidx). The binder must
217-
contain **no** feature that renames `androidx` to `pythonx`, not even as an opt-in or a default
218-
kept "for compatibility" (rule 7).
217+
contain **no** feature that renames `androidx` to `pythonx` - or any package path to any other -
218+
not even as an opt-in or a default kept "for compatibility" (rule 7).
219+
This rule is about **namespaces only**. Converting the names *inside* a Kotlin-named module is a
220+
python-multiplatform feature the maintainer asked for ("파이썬 형태로 인자랑 바꿔주는건 원래
221+
python-multiplatform 자체에서 지원하는 기능"): every lower-case-first declaration, proxy member and
222+
keyword parameter is also reachable by its snake_case name (`fill_max_width`,
223+
`on_checked_change=`), beside its Kotlin name, which keeps working (SPEC U-12). Do not remove that
224+
conversion in the name of this rule - that mistake was made once (238119b7, reverted by #131).
219225
2. **`pythonx` is a real Python package** (it lives in the separate `pythonx-compose` repository).
220226
Its code imports the `androidx` modules and makes them Pythonic. Do not build anything here that
221-
synthesises `pythonx.*` modules or prevents a real on-disk `pythonx` package from loading.
227+
synthesises `pythonx.*` modules or prevents a real on-disk `pythonx` package from loading. (The
228+
snake_case names of rule 1 live on the `androidx.*` modules themselves; they never create a
229+
`pythonx` module.)
222230
3. **No runtime reflection, no dynamic binding.** Kotlin/Native has effectively no reflection and
223231
GraalVM native image is closed-world. Upcalls go through a table generated at build time.
224232
4. **Never look up JVM methods by name.** The artifact walker generates Kotlin source and `kotlinc`

‎docs/SPEC.md‎

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -235,14 +235,15 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
235235
`anExtensionIsAMethodOnItsReceiverAndTheChainComposes`) and against the real Compose jars through
236236
the artifact walker (`ksp-fixtures/artifact/.../WalkedArtifactComposeModifierTest.kt`); not
237237
asserted for KSP-generated proxies.
238-
- **U-8** A function on a Kotlin-named module carries Kotlin's own surface and nothing else: the
239-
Kotlin declaration name, keyword arguments by **Kotlin parameter names**, Kotlin defaults for omitted
240-
parameters, overload sets under the base name, and its signature as public metadata
241-
(`inspect.signature`, `python_multiplatform.describe`; contract in `KotlinSurface.kt`'s KDoc).
238+
- **U-8** A function on a Kotlin-named module carries Kotlin's surface: the Kotlin declaration name,
239+
keyword arguments by **Kotlin parameter names**, Kotlin defaults for omitted parameters, overload sets
240+
under the base name, and its signature as public metadata (`inspect.signature`,
241+
`python_multiplatform.describe`; contract in `KotlinSurface.kt`'s KDoc) - and, beside each Kotlin
242+
name, its Pythonic alias (U-12).
242243
`describe(module, name)` describes any bound name, a named constant (`STATIC_GETTER`) included,
243244
without evaluating it (#36). A module's `dir()` lists its direct child packages/objects and reading
244-
one as an attribute imports it (#35) — Kotlin names only. No
245-
member or parameter is renamed; the binder creates no `pythonx` module and a real `pythonx` package
245+
one as an attribute imports it (#35) - Kotlin names only (the U-12 aliases are served, not listed).
246+
No namespace is renamed; the binder creates no `pythonx` module and a real `pythonx` package
246247
on disk is what `import pythonx` loads. The answer is the same whichever installer
247248
(`PythonProxySource`, `PythonxAdapter`) ran first for a table. **A name that is both a function and a
248249
module is callable** (#78): Kotlin has `TextRange(2)` (a top-level factory) and `TextRange.Zero` (a
@@ -257,8 +258,10 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
257258
kotlin_member_names) -> kotlin_name | (kotlin_name, keyword_map) | None`, asked when no Kotlin member
258259
of that name exists — and, for a Kotlin-named member, only when a call passes keywords and only for
259260
its `{python_kw: kotlinParam}` keyword map (#34).
260-
The binder renames nothing itself (no resolver: `AttributeError`), and aliases are cached in the
261-
registry, not written on the proxy class (`dir()` stays Kotlin-only). Contract in `KotlinSurface.kt`'s
261+
Resolvers are asked before the binder's own U-12 alias, so a resolver still decides a name it
262+
answers; with none answering, the U-12 alias is served, and a name that is no alias is an
263+
`AttributeError`. Aliases are cached in the registry, not written on the proxy class (`dir()` stays
264+
Kotlin-only). Contract in `KotlinSurface.kt`'s
262265
KDoc. `Status: implemented` on desktop — `PM/desktopTest/.../pythonx/MemberResolverTest.kt`,
263266
`ksp-fixtures/compose/.../MemberResolverComposeTest.kt`.
264267
A resolver (or anything else) reads a member's declaration rows from the receiver type alone with
@@ -289,6 +292,49 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
289292
return type's Kotlin name; a return type with no generated proxy stays a generic owner object. The
290293
result's ownership is the same as for any owned result. `Status: implemented` —
291294
`ksp-fixtures/app/.../KspClassResultProxyTest.kt` (red until #94 lands).
295+
- **U-12** Pythonic names (#131). On a Kotlin-named module every declaration, proxy member and keyword
296+
parameter is reachable by its Kotlin name **and** by its Pythonic name; the Kotlin name keeps
297+
working. The namespace is never converted: `androidx.compose.material3` is a module under exactly
298+
that name, and no package segment gets an alias (AGENTS §12.1, §12.2).
299+
- **Rule** (pythonx-compose 0.1.0a1's `_reexport.py`, character for character; `python_multiplatform.
300+
python_name`/`snake_case`): a name starting upper-case is unchanged - types, objects, composables,
301+
PascalCase constants (`Checkbox`, `Modifier`, `Alignment.End`). Any other name is snake_case, a run
302+
of capitals counting as one word (`rememberTextFieldState` → `remember_text_field_state`,
303+
`toURLString` → `to_url_string`, `zIndex` → `z_index`). An explicit overload key converts its base
304+
and keeps its `__Types` suffix (`paddingFromBaseline__TextUnit` → `padding_from_baseline__TextUnit`).
305+
A keyword is `snake_case` of its Kotlin parameter name (`onCheckedChange=` → `on_checked_change=`).
306+
- **Scope.** Module functions, overload base names, explicit overload keys, lower-case-first module
307+
constants, functions and constants of a Kotlin `object` (a module here: `Arrangement.spacedBy` →
308+
`Arrangement.spaced_by`), extension methods and properties on a proxy (read and write:
309+
`state.selected_index = 2` writes `selectedIndex`), and the keyword parameters of every function,
310+
method and constructor-shaped function (`TextFieldState(initial_text=...)`). Served by the binding
311+
layer (`PythonxAdapter`) for names and keywords, and by the proxy layer (`PythonProxySource`) alone
312+
for module names and keywords of what it renders. Not converted: classes rendered by KSP
313+
(`PythonProxySource.renderClass`), whose constructors and methods are positional and whose members
314+
keep their Kotlin names.
315+
- **Collisions.** An alias is served only when it is unambiguous in its namespace - one module (its
316+
bound names and child packages), one proxy type (its members and every supertype's), one
317+
declaration's parameters. An alias that is already a Kotlin name there belongs to that Kotlin name
318+
(`foo_bar` is the Kotlin `foo_bar`, never `fooBar`); an alias two Kotlin names map to (`toURL`,
319+
`toUrl` → `to_url`) is not served at all, and both stay reachable by their Kotlin names. Decided
320+
from the table, independent of order. Writing both spellings of one parameter is "two values".
321+
- **Listing.** `dir()` of a module and of a proxy lists Kotlin names only, so each declaration is
322+
listed once; an alias is resolved on a miss and never written onto a proxy class. A layer that
323+
converts `dir()` by the same rule (pythonx-compose 0.1.0a1's `_name_table`) therefore sees no
324+
collision.
325+
- **Metadata.** `inspect.signature` shows each parameter under its Pythonic keyword (the Kotlin name
326+
where no alias is served) - it is the call surface Python tools read; `describe()` keeps the Kotlin
327+
declaration's names in `name` and adds the Pythonic keyword as `python_name`;
328+
`describe(module, alias)` and `describe_member(type, alias)` describe the Kotlin declaration the
329+
alias is served for.
330+
- **Stubs** (B-7) carry the same names.
331+
`Status: implemented` on desktop (tests written first, not yet run at the time of writing) -
332+
`PM/commonTest/.../pythonx/PythonicNameTest.kt`,
333+
`PythonxAdapterTest.kt` (`theNameRuleConvertsKotlinToPythonAndEitherSpellingReachesOneDeclaration`,
334+
`everyBoundNameSurvivesTheRoundTripThroughItsPythonicName`), `PM/desktopTest/.../pythonx/KotlinNamedSurfaceTest.kt`,
335+
`PythonxComposeCompatibilityTest.kt`, `MemberResolverTest.kt`, and the Compose render tests written
336+
with Pythonic names (`ksp-fixtures/compose/.../M3ProofRenderTest.kt`, `CallbackDrivenRenderTest.kt`,
337+
`TextFieldStateRenderTest.kt`).
292338

293339
## 6. Binding prebuilt libraries (Gradle plugin)
294340

@@ -346,13 +392,17 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
346392
`Status: implemented` on desktop — `ksp-fixtures/compose/.../PythonContentRenderTest.kt`; the module
347393
compiles for Android, nothing runs there yet.
348394
- **B-7** The plugin generates `.pyi` stubs for the Kotlin-named modules only, under Kotlin names
349-
(keyword parameters by Kotlin name, `= ...` for a Kotlin default, receiver positional-only). Types are
395+
plus their U-12 Pythonic aliases (`fill_max_width = fillMaxWidth` beside each module `def`, a second
396+
`ClassVar` or `@property` on a receiver's class, a second member of a callable module), parameters
397+
written under the keyword `inspect.signature` shows (the Pythonic one; a stub can name a parameter
398+
once, so a Kotlin keyword type-checks as an error although it runs), `= ...` for a Kotlin default,
399+
receiver positional-only; a class module folded for a case-insensitive filesystem (#44) carries no
400+
aliases. Types are
350401
the declared Kotlin types: one stub class per bound type in the module of its own package, `Dp | float`
351402
for a value class bound as its primitive, `Callable[...]` for a function type, `| None` for a nullable;
352403
an extension function is also a callable attribute of its receiver's class; an overload set is
353404
`@overload`ed under its base name in table-key order; a required parameter after a defaulted one is
354-
keyword-only like `inspect.signature`. It emits nothing under `pythonx` and renames nothing; a
355-
Pythonic stub product belongs to pythonx-compose. `Status: partial` — `GP/stubs/PyiRenderingTest.kt`,
405+
keyword-only like `inspect.signature`. It emits nothing under `pythonx` and converts no namespace. `Status: partial` - `GP/stubs/PyiRenderingTest.kt`,
356406
`GP/stubs/TypedStubTest.kt`, `GP/stubs/KotlinNamesOnlyStubTest.kt`,
357407
`ksp-fixtures/artifact/.../WalkedArtifactStubTest.kt`, `.../StubSignatureAgreesWithRuntimeTest.kt`,
358408
`GP/stubs/CompanionFactoryStubTest.kt` (#78: a name that is both a function and a Kotlin-named
@@ -520,8 +570,11 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
520570
Findings where current behaviour is not covered by, or appears to conflict with, `INTENT.md`.
521571
Unless marked resolved, nothing here was changed; each needs the maintainer's call.
522572

523-
Items 1–3 were resolved by removing the renaming (2026-10-02): stubs and runtime use Kotlin names
524-
only, and the binder no longer creates a `pythonx` module (U-8, B-7).
573+
Items 1–2 were resolved on 2026-10-02: stubs no longer turn `androidx.*` into `pythonx.*`, and the
574+
binder no longer creates a `pythonx` module (U-8, B-7). Item 3 (member names in snake_case) was
575+
answered by the maintainer the other way: converting member and parameter names is python-multiplatform's
576+
own feature, and only namespaces are never converted. It was removed with items 1–2 by mistake and
577+
restored by #131 (U-12).
525578

526579
4. **`pythonx` adapter machinery lives in this repository** (`PM/commonMain/.../ffi/pythonx/`). INTENT
527580
§2.3 places `pythonx` in pythonx-compose. Whether the generic adapter belongs here (as a service

‎docs/design/ecosystem.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ and `pythonx.compose.layout`, and documents signatures like `Text(text, color, f
146146
`Button(onclick, …)` — quoted as the notebook writes it; the 2024 library spells that parameter
147147
`on_click` (`docs/archive/pythonx-adapter-design.md` §3 measured both). Which Python spelling
148148
`pythonx` uses is pythonx-compose's decision: this repository's binder exposes the Kotlin name
149-
`onClick` and renames nothing (`docs/INTENT.md` §2.2) — plus `Column/Row/Spacer/TextField`. It also
149+
`onClick` and its Pythonic alias `on_click` on the Kotlin-named module, and renames no namespace
150+
(`docs/INTENT.md` §2.2, SPEC U-12) - plus `Column/Row/Spacer/TextField`. It also
150151
requires `main.App` as a live object, `App.messages.getValue()/setValue()`, and
151152
`main.App.update(NewComposable)` for hot-swapping the UI from a Jupyter cell.
152153

@@ -684,7 +685,8 @@ static.
684685
**`pythonx.*` is ours.** Whatever we wrap or add lives under that prefix. Kotlin fully-qualified
685686
names point at the original; `pythonx` points at our Pythonic layer. The two namespaces do not mix.
686687
*(2026-10-03: "ours" means the ecosystem's — `pythonx` is a real package in pythonx-compose; this
687-
repository's binder creates no `pythonx` module and renames nothing, `AGENTS.md` §12.1–12.2.)*
688+
repository's binder creates no `pythonx` module and renames no namespace, `AGENTS.md` §12.1–12.2; the
689+
snake_case aliases it serves live on the Kotlin-named modules, SPEC U-12.)*
688690

689691
**The wrapping happens in Python, not in Kotlin.** `pythonx.compose.material3` is Python code that
690692
uses the generated `androidx.compose.material3` bindings and presents a Pythonic API over them.

‎docs/design/kotlin-extensions-in-python.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ in place with what replaced it, because the old measurement is why the replaceme
2626
| §1, the Compose version | 1.6.11 | the measurements here were taken on 1.6.11 and are kept as such; the build now pins Compose Multiplatform **1.11.1** (`gradle/libs.versions.toml`, `compose-plugin`), with material3 1.9.0 |
2727
| §3.2, 43 `Modifier` extensions declined for a function-typed parameter | "a real boundary limit" | function-typed slots bind now (SPEC B-5); `ArtifactScanner`'s KDoc records **157 `Modifier` extensions bound and 8 declined** after `functionSlotOrNull` (not re-measured for this revision) |
2828
| §3.1/§6, where the overload dispatcher lives | "in `pythonx`" | in **this repository's** binding layer, `python_multiplatform.binding` (`_Overloads` in `PythonxAdapter.kt`), which serves the base name (`padding`) and selects among the `name__<types>` entries (SPEC U-8) |
29-
| §4.1, primitive receivers as `pythonx.compose.ui.unit.to_dp(x)` | snake_case, under `pythonx` | the binder serves Kotlin names only (`docs/INTENT.md` §2.2): such an extension is a module-level function on its Kotlin-named module, receiver first. A Pythonic spelling is pythonx-compose's |
29+
| §4.1, primitive receivers as `pythonx.compose.ui.unit.to_dp(x)` | under `pythonx` | the binder renames no namespace (`docs/INTENT.md` §2.2): such an extension is a module-level function on its Kotlin-named module, receiver first, reachable as `toDp` and as its Pythonic alias `to_dp` (SPEC U-12) |
3030
| §4.4, the allowlist | decided, not built | built at run time in the binding layer: `_VALUE_CLASS_ALLOWLIST` starts **empty** and pythonx-compose fills it through `allow_raw_primitive` (`PythonxAdapter.kt`) |
3131
| §4.5, `.pyi` with `@overload` and `Dp \| float` | proposed for this plugin | the plugin emits Kotlin-named stubs with boundary types only (SPEC B-7, `docs/design/pyi-generation-design.md`); the Pythonic stub is pythonx-compose's. §4.5 is replaced below; the original is in [`docs/archive/kotlin-extensions-in-python-pyi.md`](../archive/kotlin-extensions-in-python-pyi.md) |
3232
| §4.6, composables | "remain blocked" | callable from Python inside a composition (SPEC B-6); see §4.6's update |
@@ -475,8 +475,8 @@ Not every receiver can take a method, and the split was counted:
475475
Python cannot attach a method to `int`, so `Int.toDp` and its 19 siblings stay module-level
476476
functions on their Kotlin-named module, taking the receiver as the first positional argument. That is
477477
a real loss of fidelity and it is confined to 20 functions. (This file first wrote the result as
478-
`pythonx.compose.ui.unit.to_dp(x)`; the binder renames nothing, and a snake_case spelling under
479-
`pythonx` is pythonx-compose's to add.)
478+
`pythonx.compose.ui.unit.to_dp(x)`; the binder renames no namespace -- it serves `to_dp` on
479+
`androidx.compose.ui.unit` itself (SPEC U-12) -- and anything under `pythonx` is pythonx-compose's.)
480480

481481
### 4.2 `Modifier` as one name for both the type and the empty modifier
482482

@@ -601,7 +601,8 @@ Kotlin module path, Kotlin parameter names, `= ...` for a Kotlin default, the ex
601601
positional-only first parameter, the declared Kotlin types as stub classes (`Modifier`, `Dp | float`,
602602
`Callable[...]`), each extension also as a callable attribute of its receiver's class, and `@overload`
603603
sets over a base name in table-key order, with the Kotlin signature in the docstring (issue #31). What
604-
stays out is snake_case, `pythonx` and the Pythonic allowlist narrowing, which belong to pythonx-compose. The section
604+
stays out is `pythonx` and the Pythonic allowlist narrowing, which belong to pythonx-compose; the
605+
snake_case aliases the runtime serves are in (SPEC U-12, B-7). The section
605606
this replaces — the metaclass stub and the three measured requirements for a Pythonic stub — is in
606607
[`docs/archive/kotlin-extensions-in-python-pyi.md`](../archive/kotlin-extensions-in-python-pyi.md).
607608

0 commit comments

Comments
 (0)