diff --git a/AGENTS.md b/AGENTS.md index 6536d3123..b4caeaffa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -214,11 +214,19 @@ around one. 1. **The binder never exports a Kotlin namespace under a different name.** A Kotlin fully-qualified name in Python means the original Kotlin (`androidx.compose...` is androidx). The binder must - contain **no** feature that renames `androidx` to `pythonx`, not even as an opt-in or a default - kept "for compatibility" (rule 7). + contain **no** feature that renames `androidx` to `pythonx` - or any package path to any other - + not even as an opt-in or a default kept "for compatibility" (rule 7). + This rule is about **namespaces only**. Converting the names *inside* a Kotlin-named module is a + python-multiplatform feature the maintainer asked for ("파이썬 형태로 인자랑 바꿔주는건 원래 + python-multiplatform 자체에서 지원하는 기능"): every lower-case-first declaration, proxy member and + keyword parameter is also reachable by its snake_case name (`fill_max_width`, + `on_checked_change=`), beside its Kotlin name, which keeps working (SPEC U-12). Do not remove that + conversion in the name of this rule - that mistake was made once (238119b7, reverted by #131). 2. **`pythonx` is a real Python package** (it lives in the separate `pythonx-compose` repository). Its code imports the `androidx` modules and makes them Pythonic. Do not build anything here that - synthesises `pythonx.*` modules or prevents a real on-disk `pythonx` package from loading. + synthesises `pythonx.*` modules or prevents a real on-disk `pythonx` package from loading. (The + snake_case names of rule 1 live on the `androidx.*` modules themselves; they never create a + `pythonx` module.) 3. **No runtime reflection, no dynamic binding.** Kotlin/Native has effectively no reflection and GraalVM native image is closed-world. Upcalls go through a table generated at build time. 4. **Never look up JVM methods by name.** The artifact walker generates Kotlin source and `kotlinc` diff --git a/docs/SPEC.md b/docs/SPEC.md index 9bf5e6299..95e176b45 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -235,14 +235,15 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. `anExtensionIsAMethodOnItsReceiverAndTheChainComposes`) and against the real Compose jars through the artifact walker (`ksp-fixtures/artifact/.../WalkedArtifactComposeModifierTest.kt`); not asserted for KSP-generated proxies. -- **U-8** A function on a Kotlin-named module carries Kotlin's own surface and nothing else: the - Kotlin declaration name, keyword arguments by **Kotlin parameter names**, Kotlin defaults for omitted - parameters, overload sets under the base name, and its signature as public metadata - (`inspect.signature`, `python_multiplatform.describe`; contract in `KotlinSurface.kt`'s KDoc). +- **U-8** A function on a Kotlin-named module carries Kotlin's surface: the Kotlin declaration name, + keyword arguments by **Kotlin parameter names**, Kotlin defaults for omitted parameters, overload sets + under the base name, and its signature as public metadata (`inspect.signature`, + `python_multiplatform.describe`; contract in `KotlinSurface.kt`'s KDoc) - and, beside each Kotlin + name, its Pythonic alias (U-12). `describe(module, name)` describes any bound name, a named constant (`STATIC_GETTER`) included, without evaluating it (#36). A module's `dir()` lists its direct child packages/objects and reading - one as an attribute imports it (#35) — Kotlin names only. No - member or parameter is renamed; the binder creates no `pythonx` module and a real `pythonx` package + one as an attribute imports it (#35) - Kotlin names only (the U-12 aliases are served, not listed). + No namespace is renamed; the binder creates no `pythonx` module and a real `pythonx` package on disk is what `import pythonx` loads. The answer is the same whichever installer (`PythonProxySource`, `PythonxAdapter`) ran first for a table. **A name that is both a function and a 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. kotlin_member_names) -> kotlin_name | (kotlin_name, keyword_map) | None`, asked when no Kotlin member of that name exists — and, for a Kotlin-named member, only when a call passes keywords and only for its `{python_kw: kotlinParam}` keyword map (#34). - The binder renames nothing itself (no resolver: `AttributeError`), and aliases are cached in the - registry, not written on the proxy class (`dir()` stays Kotlin-only). Contract in `KotlinSurface.kt`'s + Resolvers are asked before the binder's own U-12 alias, so a resolver still decides a name it + answers; with none answering, the U-12 alias is served, and a name that is no alias is an + `AttributeError`. Aliases are cached in the registry, not written on the proxy class (`dir()` stays + Kotlin-only). Contract in `KotlinSurface.kt`'s KDoc. `Status: implemented` on desktop — `PM/desktopTest/.../pythonx/MemberResolverTest.kt`, `ksp-fixtures/compose/.../MemberResolverComposeTest.kt`. 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. return type's Kotlin name; a return type with no generated proxy stays a generic owner object. The result's ownership is the same as for any owned result. `Status: implemented` — `ksp-fixtures/app/.../KspClassResultProxyTest.kt` (red until #94 lands). +- **U-12** Pythonic names (#131). On a Kotlin-named module every declaration, proxy member and keyword + parameter is reachable by its Kotlin name **and** by its Pythonic name; the Kotlin name keeps + working. The namespace is never converted: `androidx.compose.material3` is a module under exactly + that name, and no package segment gets an alias (AGENTS §12.1, §12.2). + - **Rule** (pythonx-compose 0.1.0a1's `_reexport.py`, character for character; `python_multiplatform. + python_name`/`snake_case`): a name starting upper-case is unchanged - types, objects, composables, + PascalCase constants (`Checkbox`, `Modifier`, `Alignment.End`). Any other name is snake_case, a run + of capitals counting as one word (`rememberTextFieldState` → `remember_text_field_state`, + `toURLString` → `to_url_string`, `zIndex` → `z_index`). An explicit overload key converts its base + and keeps its `__Types` suffix (`paddingFromBaseline__TextUnit` → `padding_from_baseline__TextUnit`). + A keyword is `snake_case` of its Kotlin parameter name (`onCheckedChange=` → `on_checked_change=`). + - **Scope.** Module functions, overload base names, explicit overload keys, lower-case-first module + constants, functions and constants of a Kotlin `object` (a module here: `Arrangement.spacedBy` → + `Arrangement.spaced_by`), extension methods and properties on a proxy (read and write: + `state.selected_index = 2` writes `selectedIndex`), and the keyword parameters of every function, + method and constructor-shaped function (`TextFieldState(initial_text=...)`). Served by the binding + layer (`PythonxAdapter`) for names and keywords, and by the proxy layer (`PythonProxySource`) alone + for module names and keywords of what it renders. Not converted: classes rendered by KSP + (`PythonProxySource.renderClass`), whose constructors and methods are positional and whose members + keep their Kotlin names. + - **Collisions.** An alias is served only when it is unambiguous in its namespace - one module (its + bound names and child packages), one proxy type (its members and every supertype's), one + declaration's parameters. An alias that is already a Kotlin name there belongs to that Kotlin name + (`foo_bar` is the Kotlin `foo_bar`, never `fooBar`); an alias two Kotlin names map to (`toURL`, + `toUrl` → `to_url`) is not served at all, and both stay reachable by their Kotlin names. Decided + from the table, independent of order. Writing both spellings of one parameter is "two values". + - **Listing.** `dir()` of a module and of a proxy lists Kotlin names only, so each declaration is + listed once; an alias is resolved on a miss and never written onto a proxy class. A layer that + converts `dir()` by the same rule (pythonx-compose 0.1.0a1's `_name_table`) therefore sees no + collision. + - **Metadata.** `inspect.signature` shows each parameter under its Pythonic keyword (the Kotlin name + where no alias is served) - it is the call surface Python tools read; `describe()` keeps the Kotlin + declaration's names in `name` and adds the Pythonic keyword as `python_name`; + `describe(module, alias)` and `describe_member(type, alias)` describe the Kotlin declaration the + alias is served for. + - **Stubs** (B-7) carry the same names. + `Status: implemented` on desktop (tests written first, not yet run at the time of writing) - + `PM/commonTest/.../pythonx/PythonicNameTest.kt`, + `PythonxAdapterTest.kt` (`theNameRuleConvertsKotlinToPythonAndEitherSpellingReachesOneDeclaration`, + `everyBoundNameSurvivesTheRoundTripThroughItsPythonicName`), `PM/desktopTest/.../pythonx/KotlinNamedSurfaceTest.kt`, + `PythonxComposeCompatibilityTest.kt`, `MemberResolverTest.kt`, and the Compose render tests written + with Pythonic names (`ksp-fixtures/compose/.../M3ProofRenderTest.kt`, `CallbackDrivenRenderTest.kt`, + `TextFieldStateRenderTest.kt`). ## 6. Binding prebuilt libraries (Gradle plugin) @@ -346,13 +392,17 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. `Status: implemented` on desktop — `ksp-fixtures/compose/.../PythonContentRenderTest.kt`; the module compiles for Android, nothing runs there yet. - **B-7** The plugin generates `.pyi` stubs for the Kotlin-named modules only, under Kotlin names - (keyword parameters by Kotlin name, `= ...` for a Kotlin default, receiver positional-only). Types are + plus their U-12 Pythonic aliases (`fill_max_width = fillMaxWidth` beside each module `def`, a second + `ClassVar` or `@property` on a receiver's class, a second member of a callable module), parameters + written under the keyword `inspect.signature` shows (the Pythonic one; a stub can name a parameter + once, so a Kotlin keyword type-checks as an error although it runs), `= ...` for a Kotlin default, + receiver positional-only; a class module folded for a case-insensitive filesystem (#44) carries no + aliases. Types are the declared Kotlin types: one stub class per bound type in the module of its own package, `Dp | float` for a value class bound as its primitive, `Callable[...]` for a function type, `| None` for a nullable; an extension function is also a callable attribute of its receiver's class; an overload set is `@overload`ed under its base name in table-key order; a required parameter after a defaulted one is - keyword-only like `inspect.signature`. It emits nothing under `pythonx` and renames nothing; a - Pythonic stub product belongs to pythonx-compose. `Status: partial` — `GP/stubs/PyiRenderingTest.kt`, + keyword-only like `inspect.signature`. It emits nothing under `pythonx` and converts no namespace. `Status: partial` - `GP/stubs/PyiRenderingTest.kt`, `GP/stubs/TypedStubTest.kt`, `GP/stubs/KotlinNamesOnlyStubTest.kt`, `ksp-fixtures/artifact/.../WalkedArtifactStubTest.kt`, `.../StubSignatureAgreesWithRuntimeTest.kt`, `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. Findings where current behaviour is not covered by, or appears to conflict with, `INTENT.md`. Unless marked resolved, nothing here was changed; each needs the maintainer's call. -Items 1–3 were resolved by removing the renaming (2026-10-02): stubs and runtime use Kotlin names -only, and the binder no longer creates a `pythonx` module (U-8, B-7). +Items 1–2 were resolved on 2026-10-02: stubs no longer turn `androidx.*` into `pythonx.*`, and the +binder no longer creates a `pythonx` module (U-8, B-7). Item 3 (member names in snake_case) was +answered by the maintainer the other way: converting member and parameter names is python-multiplatform's +own feature, and only namespaces are never converted. It was removed with items 1–2 by mistake and +restored by #131 (U-12). 4. **`pythonx` adapter machinery lives in this repository** (`PM/commonMain/.../ffi/pythonx/`). INTENT §2.3 places `pythonx` in pythonx-compose. Whether the generic adapter belongs here (as a service diff --git a/docs/design/ecosystem.md b/docs/design/ecosystem.md index fa04ccad2..d22d73f82 100644 --- a/docs/design/ecosystem.md +++ b/docs/design/ecosystem.md @@ -146,7 +146,8 @@ and `pythonx.compose.layout`, and documents signatures like `Text(text, color, f `Button(onclick, …)` — quoted as the notebook writes it; the 2024 library spells that parameter `on_click` (`docs/archive/pythonx-adapter-design.md` §3 measured both). Which Python spelling `pythonx` uses is pythonx-compose's decision: this repository's binder exposes the Kotlin name -`onClick` and renames nothing (`docs/INTENT.md` §2.2) — plus `Column/Row/Spacer/TextField`. It also +`onClick` and its Pythonic alias `on_click` on the Kotlin-named module, and renames no namespace +(`docs/INTENT.md` §2.2, SPEC U-12) - plus `Column/Row/Spacer/TextField`. It also requires `main.App` as a live object, `App.messages.getValue()/setValue()`, and `main.App.update(NewComposable)` for hot-swapping the UI from a Jupyter cell. @@ -684,7 +685,8 @@ static. **`pythonx.*` is ours.** Whatever we wrap or add lives under that prefix. Kotlin fully-qualified names point at the original; `pythonx` points at our Pythonic layer. The two namespaces do not mix. *(2026-10-03: "ours" means the ecosystem's — `pythonx` is a real package in pythonx-compose; this -repository's binder creates no `pythonx` module and renames nothing, `AGENTS.md` §12.1–12.2.)* +repository's binder creates no `pythonx` module and renames no namespace, `AGENTS.md` §12.1–12.2; the +snake_case aliases it serves live on the Kotlin-named modules, SPEC U-12.)* **The wrapping happens in Python, not in Kotlin.** `pythonx.compose.material3` is Python code that uses the generated `androidx.compose.material3` bindings and presents a Pythonic API over them. diff --git a/docs/design/kotlin-extensions-in-python.md b/docs/design/kotlin-extensions-in-python.md index fe2da0d67..51db072a2 100644 --- a/docs/design/kotlin-extensions-in-python.md +++ b/docs/design/kotlin-extensions-in-python.md @@ -26,7 +26,7 @@ in place with what replaced it, because the old measurement is why the replaceme | §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 | | §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) | | §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__` entries (SPEC U-8) | -| §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 | +| §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) | | §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`) | | §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) | | §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: Python cannot attach a method to `int`, so `Int.toDp` and its 19 siblings stay module-level functions on their Kotlin-named module, taking the receiver as the first positional argument. That is a real loss of fidelity and it is confined to 20 functions. (This file first wrote the result as -`pythonx.compose.ui.unit.to_dp(x)`; the binder renames nothing, and a snake_case spelling under -`pythonx` is pythonx-compose's to add.) +`pythonx.compose.ui.unit.to_dp(x)`; the binder renames no namespace -- it serves `to_dp` on +`androidx.compose.ui.unit` itself (SPEC U-12) -- and anything under `pythonx` is pythonx-compose's.) ### 4.2 `Modifier` as one name for both the type and the empty modifier @@ -601,7 +601,8 @@ Kotlin module path, Kotlin parameter names, `= ...` for a Kotlin default, the ex positional-only first parameter, the declared Kotlin types as stub classes (`Modifier`, `Dp | float`, `Callable[...]`), each extension also as a callable attribute of its receiver's class, and `@overload` sets over a base name in table-key order, with the Kotlin signature in the docstring (issue #31). What -stays out is snake_case, `pythonx` and the Pythonic allowlist narrowing, which belong to pythonx-compose. The section +stays out is `pythonx` and the Pythonic allowlist narrowing, which belong to pythonx-compose; the +snake_case aliases the runtime serves are in (SPEC U-12, B-7). The section this replaces — the metaclass stub and the three measured requirements for a Pythonic stub — is in [`docs/archive/kotlin-extensions-in-python-pyi.md`](../archive/kotlin-extensions-in-python-pyi.md). diff --git a/docs/design/pyi-generation-design.md b/docs/design/pyi-generation-design.md index 59eb1732c..12c30efff 100644 --- a/docs/design/pyi-generation-design.md +++ b/docs/design/pyi-generation-design.md @@ -14,7 +14,11 @@ > its receiver's class as well, and states function types, nullability, value classes and overload > sets (§3.1, §3.3–§3.6, §4.1). Everything is still under Kotlin names; the "`Dp | float`, `@overload` > sets, `Modifier` callback-Protocol shape" that the paragraph above says left the plugin are back -> **as Kotlin-name typing**, not as a Pythonic product -- no snake_case, no `pythonx`. +> **as Kotlin-name typing**, not as a Pythonic product -- no `pythonx`. +> +> **Revised again for issue #131.** snake_case is back -- not as a `pythonx` product, but as the +> Pythonic aliases the runtime serves on the Kotlin-named modules themselves (SPEC U-12): parameters +> under their Pythonic keyword, and `alias = kotlinName` beside each module-level name (§3.6). The binder serves Kotlin-named modules (`androidx.compose.foundation.layout`, `junit.runner.Version`, a consumer's own package) that exist only as `sys.modules` entries made at run time @@ -456,8 +460,10 @@ Python's one `int`/`float` and a class that fell back to `Any` cannot tell two K so each overload after the first carries `# type: ignore[overload-cannot-match]` (`TypedStubTest.everyOverloadAfterTheFirstSilencesTheCheckersNeverMatchedDiagnostic`). -Parameters are keyword-capable under their **Kotlin** names (`all`, `fraction`, `alignmentLine`); -nothing is converted to snake_case (`KotlinNamesOnlyStubTest.kotlinNamesAreNeverSnakeCased`). A name +Parameters are keyword-capable under the keyword `inspect.signature` shows: the Pythonic one where an +alias is served (`all`, `fraction`, `alignment_line`), else the Kotlin name; each module-level name also +gets `alias = kotlinName` (issue #131, SPEC U-12; +`KotlinNamesOnlyStubTest.aKotlinNameKeepsItsDefAndGainsItsPythonicAlias`). A name that is not a Python identifier or is a Python keyword, or an unknown name, forces positional-only up to and including that parameter, spelled `__a` (`KotlinNamesOnlyStubTest.aParameterNamedLikeAPythonKeywordForcesAPositionalOnlyPrefix`). A Kotlin @@ -521,7 +527,7 @@ There is none. A stub lives at the Kotlin module path the runtime publishes onto `androidx/compose/foundation/layout/__init__.pyi`, `junit/runner/Version/__init__.pyi` for a static on a class — and nothing is emitted under `pythonx/` (`KotlinNamesOnlyStubTest.theStubLivesUnderTheKotlinModulePathAndNothingLivesUnderPythonx`, -`WalkedArtifactStubTest.theStubsUseKotlinNamesAndNothingIsExportedUnderPythonx`). The `androidx.` → +`WalkedArtifactStubTest.theStubsKeepKotlinNamesAddPythonicAliasesAndExportNothingUnderPythonx`). The `androidx.` → `pythonx.` default rule, the observation that `pythonx.compose.layout` wraps `androidx.compose.foundation.layout`, and the `pythonx-map.toml` manifest are in archive §5. diff --git a/docs/design/upcall.md b/docs/design/upcall.md index aaf9a4076..5471a604a 100644 --- a/docs/design/upcall.md +++ b/docs/design/upcall.md @@ -105,15 +105,17 @@ Every `public` declaration is registered; `@PythonInternal` opts a class or memb (`@PythonAPI`) was rejected (AGENTS.md §12.7; [`binding-policy.md`](binding-policy.md)). This is the premise the tree-shaking decision (§2.6) rests on and must not be re-litigated there. -### 1.6 Kotlin names only +### 1.6 Kotlin namespaces, Kotlin names and their Pythonic aliases -A Kotlin package is importable under its own fully-qualified name and nothing else. Functions on a +A Kotlin package is importable under its own fully-qualified name and nothing else: no namespace is +renamed and no synthetic `pythonx` module exists (INTENT §2.2–2.3, AGENTS.md §12.1–12.2). Functions on a Kotlin-named module take keyword arguments by **Kotlin parameter names**, honour Kotlin defaults for omitted parameters, and expose their signature through `inspect.signature` and `python_multiplatform.describe` (SPEC U-8; contract in the KDoc of -`python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt`). Nothing is -renamed: no snake_case, no synthetic `pythonx` module (INTENT §2.2–2.3, AGENTS.md §12.1–12.2). Making the -surface Pythonic is pythonx-compose's job, built on this metadata. See §4.3. +`python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt`). Beside +every Kotlin name, the declaration, member or keyword is also reachable by its Pythonic (snake_case) +alias when that alias is unambiguous (SPEC U-12, issue #131). This was removed in `238119b7` on a +misreading of §12.1 and restored; §12.1 now says it governs namespaces only. See §4.3. --- @@ -672,8 +674,9 @@ lives at `python_multiplatform.binding`). Its KDoc is the contract (SPEC U-8): - `inspect.signature(fn)` (built lazily via `__wrapped__`) and `python_multiplatform.describe(fn)` give the metadata. The answer is the same whichever installer ran first (stamped epochs). -No member or parameter is renamed and the binder creates no `pythonx` module; a real `pythonx` package on -disk is what `import pythonx` loads. Tests: `desktopTest/.../pythonx/KotlinNamedSurfaceTest.kt`, +No namespace is renamed and the binder creates no `pythonx` module; a real `pythonx` package on +disk is what `import pythonx` loads. Members and keywords also answer to their Pythonic aliases, and the +signature shows the Pythonic keyword (SPEC U-12). Tests: `desktopTest/.../pythonx/KotlinNamedSurfaceTest.kt`, `ksp-fixtures/compose/.../KotlinSignatureMetadataTest.kt`. `.pyi` stubs are generated by the Gradle plugin under Kotlin module paths only (SPEC B-7). diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactCallbackTest.kt b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactCallbackTest.kt index f5c06d447..a37599f4e 100644 --- a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactCallbackTest.kt +++ b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactCallbackTest.kt @@ -87,13 +87,13 @@ class WalkedArtifactCallbackTest { PythonCallables.withScope(PythonCallables.newScope()) { Python3.exec( """ - from kotlin.system import measureTimeMillis + from kotlin.system import measure_time_millis _ran = [] _never = [] _control = lambda: _never.append('should not run') - _elapsed = measureTimeMillis(lambda: _ran.append('ran')) + _elapsed = measure_time_millis(lambda: _ran.append('ran')) assert _ran == ['ran'], 'the Python callable was not invoked: ' + repr(_ran) assert _never == [], 'a callable nobody passed anywhere ran: ' + repr(_never) @@ -120,9 +120,9 @@ class WalkedArtifactCallbackTest { PythonCallables.withScope(PythonCallables.newScope()) { Python3.exec( """ - from kotlin.system import measureTimeMillis + from kotlin.system import measure_time_millis try: - measureTimeMillis(lambda wanted: None) + measure_time_millis(lambda wanted: None) raise AssertionError('a one-argument callable filled a zero-argument slot') except AssertionError: raise @@ -166,7 +166,7 @@ class WalkedArtifactCallbackTest { assert modifierElementCount(_empty) == 0 _clicks = [] - _tappable = clickable(_empty, onClick=lambda: _clicks.append('tap')) + _tappable = clickable(_empty, on_click=lambda: _clicks.append('tap')) assert not isTheEmptyModifier(_tappable._pm_handle), ( 'clickable returned the receiver unchanged' diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactDefaultOmissionTest.kt b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactDefaultOmissionTest.kt index 25adbc2cd..16abfcf44 100644 --- a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactDefaultOmissionTest.kt +++ b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactDefaultOmissionTest.kt @@ -73,7 +73,8 @@ class WalkedArtifactDefaultOmissionTest { /** * The judgement, in Compose's own code: two calls that write **no** optional argument. * - * `fillMaxWidth(m)` and `wrapContentSize(m)` are called with the receiver alone -- + * `fill_max_width(m)` and `wrapContentSize(m)` -- the first by its Pythonic name (issue #131), the + * second by its Kotlin name -- are called with the receiver alone -- * `f(x)`, not `f(x, None, None)` -- and the receiver is the only required parameter either of * them declares. */ @@ -81,14 +82,14 @@ class WalkedArtifactDefaultOmissionTest { fun composeFunctionsAreCalledWithTheirRequiredArgumentsAlone() { Python3.exec( """ - from androidx.compose.foundation.layout import fillMaxWidth, wrapContentSize + from androidx.compose.foundation.layout import fill_max_width, wrapContentSize from fixture.artifact import emptyModifier from fixture.artifact import equalsFillMaxWidth, equalsFillMaxWidthFraction from fixture.artifact import equalsWrapContentSize, equalsWrapContentSizeTopStart - _filled = fillMaxWidth(emptyModifier()) + _filled = fill_max_width(emptyModifier()) assert equalsFillMaxWidth(_filled._pm_handle), ( - 'fillMaxWidth() did not produce Modifier.fillMaxWidth()' + 'fill_max_width() did not produce Modifier.fillMaxWidth()' ) # The controls. `fraction` defaults to 1f, so a body that had passed the sentinel through # as a number would have produced one of these instead. diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt index 4ed7735b2..63e0ad63c 100644 --- a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt +++ b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt @@ -86,16 +86,17 @@ class WalkedArtifactStubTest { /** * The binder never exports a Kotlin namespace under another name (AGENTS.md section 12 rule 1). - * `androidx.compose.foundation.layout` is stubbed under that very path, with Kotlin names and - * Kotlin parameter names; making it Pythonic is the `pythonx-compose` package's job. + * `androidx.compose.foundation.layout` is stubbed under that very path. Inside it (issue #131) + * each declaration keeps its Kotlin `def` and gains its Pythonic alias as an assignment, and a + * parameter is written under the keyword `inspect.signature` shows. * - * Pinned over the whole generated tree: nothing under `pythonx/`, no snake_cased member, and none - * of the Pythonic product's side files (`py.typed`, `_pm_dispatch.json`). The `Protocol` classes - * that type an extension as a method of its receiver's class are typing machinery, not a - * renamed product: they are private (`_Receiver_name`) and carry the Kotlin name unchanged. + * Pinned over the whole generated tree: nothing under `pythonx/`, and none of the old Pythonic + * product's side files (`py.typed`, `_pm_dispatch.json`). The `Protocol` classes that type an + * extension as a method of its receiver's class are typing machinery: they are private + * (`_Receiver_name`) and named after the Kotlin member. */ @Test - fun theStubsUseKotlinNamesAndNothingIsExportedUnderPythonx() { + fun theStubsKeepKotlinNamesAddPythonicAliasesAndExportNothingUnderPythonx() { val paths = generatedPaths() assertEquals(emptyList(), paths.filter { it.startsWith("pythonx/") }, paths.toString()) assertTrue(paths.none { it.endsWith("py.typed") || it.endsWith("_pm_dispatch.json") }, paths.toString()) @@ -106,8 +107,12 @@ class WalkedArtifactStubTest { "-> androidx.compose.ui.Modifier:" in layout, layout.take(3000), ) - assertTrue("fill_max_width" !in layout, "a Kotlin name was exported under another spelling") - assertTrue("alignmentLine: " in layout, "a Kotlin parameter name was renamed: ${layout.take(3000)}") + assertTrue( + Regex("^fill_max_width = fillMaxWidth${'$'}", RegexOption.MULTILINE).containsMatchIn(layout), + "the Pythonic alias of fillMaxWidth is missing", + ) + assertTrue(Regex("^def fill_max_width\\(", RegexOption.MULTILINE).find(layout) == null, "the Kotlin def must stay the Kotlin name") + assertTrue("alignment_line: " in layout, "a parameter is not under its Pythonic keyword: ${layout.take(3000)}") paths.filter { it.endsWith(".pyi") }.forEach { path -> val text = stub(path) diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/CallbackDrivenRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/CallbackDrivenRenderTest.kt index efd432946..e862498b1 100644 --- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/CallbackDrivenRenderTest.kt +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/CallbackDrivenRenderTest.kt @@ -108,7 +108,7 @@ class CallbackDrivenRenderTest { val checked = pixelsOf(CHECKBOX) val moved = differing(unchecked, checked) println( - "compose input: Checkbox click -> onCheckedChange(True), " + + "compose input: Checkbox click -> on_checked_change(True), " + "ink ${inkOf(unchecked)} -> ${inkOf(checked)} px, $moved pixels changed", ) assertTrue( @@ -162,7 +162,7 @@ class CallbackDrivenRenderTest { val after = pixelsOf(SWITCH) val moved = differing(before, after) println( - "compose input: Switch click -> onCheckedChange(True), " + + "compose input: Switch click -> on_checked_change(True), " + "ink ${inkOf(before)} -> ${inkOf(after)} px, $moved pixels changed", ) // Deliberately not an ink comparison: the thumb slides from one end of the track to the @@ -272,7 +272,7 @@ class CallbackDrivenRenderTest { val typed = pixelsOf(TEXT_FIELD, width = FIELD_WIDTH, height = FIELD_HEIGHT) val moved = differing(empty, typed) println( - "compose input: TextField keystroke 'h' -> onValueChange, " + + "compose input: TextField keystroke 'h' -> on_value_change, " + "ink ${inkOf(empty)} -> ${inkOf(typed)} px, $moved pixels changed", ) assertTrue( @@ -445,12 +445,12 @@ class CallbackDrivenRenderTest { * show what the callback wrote. */ val CHECKBOX = """ from androidx.compose.material3 import Checkbox - Checkbox(_cb_state[0], onCheckedChange=_cb_toggle) + Checkbox(_cb_state[0], on_checked_change=_cb_toggle) """.trimIndent() val SWITCH = """ from androidx.compose.material3 import Switch - Switch(_cb_state[0], onCheckedChange=_cb_toggle) + Switch(_cb_state[0], on_checked_change=_cb_toggle) """.trimIndent() /** M3's `TextField` has no minimum-size default anywhere near `Checkbox`'s 48dp -- its own @@ -466,7 +466,7 @@ class CallbackDrivenRenderTest { * what makes a fresh scene show what the callback wrote. */ val TEXT_FIELD = """ from androidx.compose.material3 import TextField - TextField(_tf_state[0], onValueChange=_tf_on_change) + TextField(_tf_state[0], on_value_change=_tf_on_change) """.trimIndent() /** `java.awt.event.KeyEvent`'s constructor requires a non-null source `Component`; nothing diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt index 2650f6007..ae014ac90 100644 --- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt @@ -495,13 +495,13 @@ class ComposableRenderTest { val drawn = inkOf( """ from androidx.compose.material3 import Button, Text - Button(onClick=lambda: None, content=lambda: Text('hi')) + Button(on_click=lambda: None, content=lambda: Text('hi')) """.trimIndent(), ) val empty = inkOf( """ from androidx.compose.material3 import Button - Button(onClick=lambda: None, content=lambda: None) + Button(on_click=lambda: None, content=lambda: None) """.trimIndent(), ) println("compose render: Button(content=Text('hi')) -> $drawn px, empty content -> $empty px") @@ -565,11 +565,11 @@ class ComposableRenderTest { /** * **`ListItem`.** `headlineContent` is the one parameter with no default (also `Function2`, - * arity zero) and it is written under its **Kotlin** parameter name, `headlineContent` -- so - * this also pins that a keyword argument reaches a real multi-word parameter by its Kotlin name, - * not just the single-word ones (`content`, `modifier`) every other test here happens to use. - * (It used to be `headline_content`: the binder snake_cased parameters, which it no longer does; - * a Pythonic spelling is pythonx-compose's to define.) + * arity zero) and it is written under its **snake_cased** keyword, `headline_content` -- so + * this also pins that a keyword argument reaches a real multi-word parameter by its Pythonic name + * (issue #131), not just the single-word ones (`content`, `modifier`) every other test here + * happens to use. The Kotlin spelling `headlineContent=` reaches the same parameter; the empty + * control below is written that way, so both spellings render here. * * The empty control is not zero, unlike `Column`'s or `Row`'s: `ListItem`, like `Button`, always * paints its own container surface (`colors.containerColor` at `tonalElevation`) whether or not @@ -578,11 +578,11 @@ class ComposableRenderTest { * `Column`'s does. */ @Test - fun listItemComposesItsHeadlineContentUnderItsKotlinName() { + fun listItemComposesItsHeadlineContentUnderItsSnakeCasedName() { val drawnPixels = pixelsOf( """ from androidx.compose.material3 import ListItem, Text - ListItem(headlineContent=lambda: Text('hi')) + ListItem(headline_content=lambda: Text('hi')) """.trimIndent(), ) val emptyPixels = pixelsOf( @@ -600,7 +600,7 @@ class ComposableRenderTest { val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet() val emptyColors = emptyPixels.filter { it != BACKGROUND }.toSet() println( - "compose render: ListItem(headlineContent=Text('hi')) -> ${drawnColors.size} distinct colors, " + + "compose render: ListItem(headline_content=Text('hi')) -> ${drawnColors.size} distinct colors, " + "empty -> ${emptyColors.size} distinct colors", ) assertTrue(emptyColors.isNotEmpty(), "a ListItem with no headline must still draw its own container surface") diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/IconRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/IconRenderTest.kt index ea984fb04..ec5c8156c 100644 --- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/IconRenderTest.kt +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/IconRenderTest.kt @@ -112,13 +112,13 @@ class IconRenderTest { fun pythonLoadsAPainterAndIconDrawsIt() { val drawn = inkOf( """ - from androidx.compose.ui.res import painterResource + from androidx.compose.ui.res import painter_resource from androidx.compose.material3 import Icon - Icon(painterResource('$RESOURCE'), contentDescription='a square') + Icon(painter_resource('$RESOURCE'), content_description='a square') """.trimIndent(), ) val blank = inkOf("pass") - println("compose render: Icon(painterResource('$RESOURCE')) -> $drawn px, empty body -> $blank px") + println("compose render: Icon(painter_resource('$RESOURCE')) -> $drawn px, empty body -> $blank px") assertEquals(0, blank, "an empty composition must draw nothing, or the measurement is not measuring") assertEquals(SQUARE, drawn, "Icon did not draw the 24x24 square the resource holds") } diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/KotlinSignatureMetadataTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/KotlinSignatureMetadataTest.kt index ee2b4f920..589f85712 100644 --- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/KotlinSignatureMetadataTest.kt +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/KotlinSignatureMetadataTest.kt @@ -17,7 +17,7 @@ import kotlin.test.assertTrue * * pythonx-compose re-exposes Kotlin-named modules Pythonically by **one rule** applied to every * module it lists, with no per-widget wrapper. That rule needs, for each function: its Kotlin - * parameter names, which have defaults, the overload set behind a base name, which parameters are + * parameter names (and, since issue #131, the Pythonic keyword the binder serves for each), which have defaults, the overload set behind a base name, which parameters are * value classes, and which one is the trailing `@Composable` content lambda. This pins that the * binder publishes exactly that -- through `inspect.signature` and `python_multiplatform.describe` * (`KotlinSurface`'s KDoc is the contract) -- for a real composable (`Checkbox`) and a real @@ -39,7 +39,9 @@ class KotlinSignatureMetadataTest { } /** - * `Checkbox(checked, onCheckedChange, modifier = Modifier, ...)`, as Kotlin declares it. Compose 1.11 + * `Checkbox(checked, onCheckedChange, modifier = Modifier, ...)`, as Kotlin declares it -- and as + * `inspect.signature` shows it to Python since issue #131: `on_checked_change`, the keyword a + * Python caller writes, while `describe` keeps the Kotlin name beside it. Compose 1.11 * has two `Checkbox` overloads (the second adds `checkmarkStroke`/`outlineStroke`), so the base name * answers `(*args, **kwargs)` and the signature lives on the specific overload callable. */ @@ -75,13 +77,14 @@ class KotlinSignatureMetadataTest { 'name': _ksm_d[0]['name'], 'composable': _ksm_d[0]['composable'], 'callback': _ksm_d[0]['parameters'][1]['type'].startswith('kotlin.Function1'), - 'described': [_q['name'] for _q in _ksm_d[0]['parameters']] == + 'described': [_q['python_name'] for _q in _ksm_d[0]['parameters']] == [_p.name for _p in _ksm_params], + 'kotlin_names': [_q['name'] for _q in _ksm_d[0]['parameters']][:2], } """.trimIndent(), ) assertEquals( - "[('checked', False, 'kotlin.Boolean'), ('onCheckedChange', False, " + + "[('checked', False, 'kotlin.Boolean'), ('on_checked_change', False, " + "${eval("repr(_ksm_d[0]['parameters'][1]['type'])")}), " + "('modifier', True, 'androidx.compose.ui.Modifier')]", eval("repr(_ksm['first'])"), @@ -103,7 +106,8 @@ class KotlinSignatureMetadataTest { ) assertEquals("True", eval("_ksm['composable']"), order) assertEquals("True", eval("_ksm['callback']"), order) - assertEquals("True", eval("_ksm['described']"), "$order: describe and inspect.signature must agree") + assertEquals("True", eval("_ksm['described']"), "$order: describe's python_name and inspect.signature must agree") + assertEquals("['checked', 'onCheckedChange']", eval("repr(_ksm['kotlin_names'])"), "$order: describe keeps the Kotlin names") } } diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt index 45967c713..51b6ffb47 100644 --- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt @@ -25,7 +25,7 @@ import kotlin.test.assertTrue * - A scene whose Python body is `pass` or an empty equivalent has none (or fewer). * - Where the component fills the entire scene with its own surface (FAB, TopAppBar, Scaffold, * TabRow), distinct-colour counting is used instead of ink counting — the same approach - * [ComposableRenderTest.listItemComposesItsHeadlineContentUnderItsKotlinName] uses for ListItem. + * [ComposableRenderTest.listItemComposesItsHeadlineContentUnderItsSnakeCasedName] uses for ListItem. * * Components judged here (10 of 27): * 1. HorizontalDivider — leaf, no required param @@ -88,13 +88,13 @@ class M3ProofRenderTest { val selected = inkOf( """ from androidx.compose.material3 import RadioButton - RadioButton(selected=True, onClick=lambda: None) + RadioButton(selected=True, on_click=lambda: None) """.trimIndent(), ) val deselected = inkOf( """ from androidx.compose.material3 import RadioButton - RadioButton(selected=False, onClick=lambda: None) + RadioButton(selected=False, on_click=lambda: None) """.trimIndent(), ) println("compose render: RadioButton selected=$selected px, deselected=$deselected px") @@ -270,14 +270,14 @@ class M3ProofRenderTest { val left = pixelsOf( """ from androidx.compose.material3 import Slider - Slider(0.0, onValueChange=lambda v: None) + Slider(0.0, on_value_change=lambda v: None) """.trimIndent(), width = 200, height = 48, ) val right = pixelsOf( """ from androidx.compose.material3 import Slider - Slider(1.0, onValueChange=lambda v: None) + Slider(1.0, on_value_change=lambda v: None) """.trimIndent(), width = 200, height = 48, ) @@ -338,13 +338,13 @@ class M3ProofRenderTest { val drawnPixels = pixelsOf( """ from androidx.compose.material3 import FloatingActionButton, Text - FloatingActionButton(onClick=lambda: None, content=lambda: Text('hi')) + FloatingActionButton(on_click=lambda: None, content=lambda: Text('hi')) """.trimIndent(), ) val emptyPixels = pixelsOf( """ from androidx.compose.material3 import FloatingActionButton - FloatingActionButton(onClick=lambda: None, content=lambda: None) + FloatingActionButton(on_click=lambda: None, content=lambda: None) """.trimIndent(), ) val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet() @@ -375,7 +375,7 @@ class M3ProofRenderTest { """ from androidx.compose.material3 import TabRow, Tab, Text TabRow( - selectedTabIndex=0, + selected_tab_index=0, tabs=lambda: Tab( selected=True, onClick=lambda: None, @@ -389,7 +389,7 @@ class M3ProofRenderTest { """ from androidx.compose.material3 import TabRow, Tab TabRow( - selectedTabIndex=0, + selected_tab_index=0, tabs=lambda: Tab( selected=True, onClick=lambda: None, @@ -419,13 +419,13 @@ class M3ProofRenderTest { val checked = inkOf( """ from androidx.compose.material3 import Checkbox - Checkbox(checked=True, onCheckedChange=lambda v: None) + Checkbox(checked=True, on_checked_change=lambda v: None) """.trimIndent(), ) val unchecked = inkOf( """ from androidx.compose.material3 import Checkbox - Checkbox(checked=False, onCheckedChange=lambda v: None) + Checkbox(checked=False, on_checked_change=lambda v: None) """.trimIndent(), ) println("compose render: Checkbox checked=$checked px, unchecked=$unchecked px") @@ -450,13 +450,13 @@ class M3ProofRenderTest { val checked = pixelsOf( """ from androidx.compose.material3 import Switch - Switch(checked=True, onCheckedChange=lambda v: None) + Switch(checked=True, on_checked_change=lambda v: None) """.trimIndent(), ) val unchecked = pixelsOf( """ from androidx.compose.material3 import Switch - Switch(checked=False, onCheckedChange=lambda v: None) + Switch(checked=False, on_checked_change=lambda v: None) """.trimIndent(), ) val checkedInk = checked.count { it != BACKGROUND } @@ -568,13 +568,13 @@ class M3ProofRenderTest { val drawnPixels = pixelsOf( """ from androidx.compose.material3 import ExtendedFloatingActionButton, Text - ExtendedFloatingActionButton(onClick=lambda: None, text=lambda: Text('hi'), icon=lambda: None) + ExtendedFloatingActionButton(on_click=lambda: None, text=lambda: Text('hi'), icon=lambda: None) """.trimIndent(), ) val emptyPixels = pixelsOf( """ from androidx.compose.material3 import ExtendedFloatingActionButton - ExtendedFloatingActionButton(onClick=lambda: None, text=lambda: None, icon=lambda: None) + ExtendedFloatingActionButton(on_click=lambda: None, text=lambda: None, icon=lambda: None) """.trimIndent(), ) val drawnColors = drawnPixels.filter { it != BACKGROUND }.toSet() diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MemberResolverComposeTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MemberResolverComposeTest.kt index d02a3b03f..983d935d0 100644 --- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MemberResolverComposeTest.kt +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/MemberResolverComposeTest.kt @@ -36,6 +36,12 @@ class MemberResolverComposeTest { UpcallTable.clear() } + /** + * A name only a resolver knows (`full_width`, which is not the snake_case of any Kotlin name) is an + * `AttributeError` until a resolver answers it, runs Compose's real `fillMaxWidth` while it is + * registered, and is an `AttributeError` again once it is removed. The binder's own alias + * (`fill_max_width`) needs no resolver (issue #131) and is covered by the next test. + */ @Test fun aResolvedSnakeNameRunsComposesFillMaxWidthInARealChain() { Python3.exec( @@ -47,32 +53,53 @@ class MemberResolverComposeTest { _start = padding__Dp(emptyModifier(), 16.0) try: - _start.fill_max_width - raise AssertionError('fill_max_width resolved with no resolver registered') + _start.full_width + raise AssertionError('full_width resolved with no resolver registered') except AttributeError: pass - def _snake_to_camel(type_name, requested, kotlin_names): - head, *rest = requested.split('_') - camel = head + ''.join(p.capitalize() for p in rest) - return camel if camel in kotlin_names else None + def _full_width(type_name, requested, kotlin_names): + return 'fillMaxWidth' if requested == 'full_width' and 'fillMaxWidth' in kotlin_names else None - _b.add_member_resolver(_snake_to_camel) + _b.add_member_resolver(_full_width) try: - _chain = _start.fill_max_width() + _chain = _start.full_width() assert equalsPaddingThenFillMaxWidth(_chain._pm_handle, 16.0), 'chain is not padding(16.dp).fillMaxWidth()' assert not equalsPaddingThenFillMaxWidth(_chain._pm_handle, 17.0), '16dp matched 17dp' assert not equalsFillMaxWidthThenPadding(_chain._pm_handle, 16.0), 'the chain order is not observed' - assert 'fill_max_width' not in dir(_chain), 'the alias leaked into dir()' - assert 'fill_max_width' not in vars(type(_chain)), 'the alias was written onto the proxy class' + assert 'full_width' not in dir(_chain), 'the alias leaked into dir()' + assert 'full_width' not in vars(type(_chain)), 'the alias was written onto the proxy class' finally: - _b.remove_member_resolver(_snake_to_camel) + _b.remove_member_resolver(_full_width) try: - _start.fill_max_width + _start.full_width raise AssertionError('the alias survived removing the resolver') except AttributeError: pass """.trimIndent(), ) } + + /** + * Issue #131 (SPEC U-12): with no resolver registered the binder serves `fill_max_width` itself, as + * the same declaration as `fillMaxWidth`; the alias is never listed and never written onto the class. + */ + @Test + fun theBindersOwnSnakeAliasRunsComposesFillMaxWidthWithNoResolver() { + Python3.exec( + """ + from androidx.compose.foundation.layout import padding__Dp + from fixture.compose import emptyModifier + from fixture.compose import equalsPaddingThenFillMaxWidth, equalsFillMaxWidthThenPadding + + _start = padding__Dp(emptyModifier(), 16.0) + _chain = _start.fill_max_width() + assert equalsPaddingThenFillMaxWidth(_chain._pm_handle, 16.0), 'chain is not padding(16.dp).fillMaxWidth()' + assert not equalsPaddingThenFillMaxWidth(_chain._pm_handle, 17.0), '16dp matched 17dp' + assert not equalsFillMaxWidthThenPadding(_chain._pm_handle, 16.0), 'the chain order is not observed' + assert 'fill_max_width' not in dir(_chain), 'the alias leaked into dir()' + assert 'fill_max_width' not in vars(type(_chain)), 'the alias was written onto the proxy class' + """.trimIndent(), + ) + } } diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt index 2fe9a7c3c..dd485d64f 100644 --- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt @@ -147,7 +147,36 @@ class TextFieldStateRenderTest { } /** - * Made inside a composition by `rememberTextFieldState`, drawn by `TextField(state=...)` in the same + * Issue #131: the Pythonic spellings are the same declarations as the Kotlin ones -- the + * `@Composable` `remember_text_field_state`, the constructor's `initial_text=`/`initial_selection=` + * keywords, and the extensions `clear_text()`/`set_text_and_place_cursor_at_end()` -- and the + * Kotlin spellings keep working beside them. + */ + @Test + fun thePythonicSpellingsAreTheSameDeclarations() { + Python3.exec( + """ + from androidx.compose.foundation.text.input import ( + TextFieldState, rememberTextFieldState, remember_text_field_state, + ) + from androidx.compose.ui.text import TextRange + + assert remember_text_field_state is rememberTextFieldState + _tfs_py = TextFieldState(initial_text='kw', initial_selection=TextRange(0)) + assert _tfs_py.text == 'kw', repr(_tfs_py.text) + _tfs_py.clear_text() + assert _tfs_py.text == '', repr(_tfs_py.text) + _tfs_py.set_text_and_place_cursor_at_end('snake') + assert _tfs_py.text == 'snake', repr(_tfs_py.text) + _tfs_py.setTextAndPlaceCursorAtEnd('camel') + assert _tfs_py.text == 'camel', repr(_tfs_py.text) + """.trimIndent(), + ) + } + + /** + * Made inside a composition by `remember_text_field_state` (the Pythonic spelling of + * `rememberTextFieldState`, issue #131), drawn by `TextField(state=...)` in the same * body, then written from Python **outside** the composition: the state is Compose snapshot state, * so the next frame of the *same* scene shows the new text with no host call and no Python body * re-run being needed. Every value the body saw is the one remembered state (each reads the written @@ -168,7 +197,7 @@ class TextFieldStateRenderTest { """ assert len(_tfs_seen) >= 1, _tfs_seen assert type(_tfs_seen[0].text) is str and _tfs_seen[0].text == 'abc', repr(_tfs_seen[0].text) - _tfs_seen[0].setTextAndPlaceCursorAtEnd('a much longer line of text') + _tfs_seen[0].set_text_and_place_cursor_at_end('a much longer line of text') """.trimIndent(), ) // `TextField(state=...)` does not finish reacting to a state write inside one frame: the @@ -235,9 +264,9 @@ class TextFieldStateRenderTest { const val MAX_FRAMES = 4 val REMEMBERED = """ - from androidx.compose.foundation.text.input import rememberTextFieldState + from androidx.compose.foundation.text.input import remember_text_field_state from androidx.compose.material3 import TextField - _tfs_state = rememberTextFieldState('abc') + _tfs_state = remember_text_field_state('abc') _tfs_seen.append(_tfs_state) TextField(state=_tfs_state) """.trimIndent() diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt index 4cea7f641..c9bfd4536 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt @@ -10,8 +10,27 @@ import python.multiplatform.gradle.model.ValueClassModel * * One `def` per bound declaration, in the module the runtime publishes it onto, under the Kotlin * names. The binder never exports a Kotlin namespace under another name (AGENTS.md §12 rule 1), so - * there is no second, renamed product: a `pythonx` module is a real Python package that imports - * these modules and makes them Pythonic, and its own stubs are its own. + * there is no second, renamed product and nothing under `pythonx`: a `pythonx` module is a real + * Python package that imports these modules, and its own stubs are its own. + * + * ### Pythonic names (issue #131) + * + * The runtime serves every lower-case-first declaration and member under its snake_case alias too, + * and takes each keyword by its Kotlin name or its snake_case one ([pythonName], [snakeCase], + * [pythonicAliases]; the runtime's rule in `KotlinSurface.kt`). The stub says the same: + * + * - **parameters** are written under the keyword `inspect.signature` shows -- the snake_case alias + * where one is served for that declaration, else the Kotlin name. A stub can name a parameter only + * once, and it names it the way the signature does; a Kotlin keyword still works at run time, but a + * checker reports it; + * - **module-level names** keep their Kotlin `def` (or constant), followed by `alias = kotlinName` for + * each alias the runtime serves in that module, so the alias has exactly the Kotlin name's type; + * - **members of a receiver's class** get the alias as a second `ClassVar` of the same `Protocol`, or a + * second `@property` with the same table key in its docstring; + * - members of a callable module (#78) get the alias as a second member. + * + * Not aliased: a class module folded into its parent for a case-insensitive filesystem (#44) -- its + * functions keep their Kotlin names only in the stub, although the runtime serves the alias. * * Returns *relative path to file content*, so nothing here touches a disk and every assertion in * `PyiRenderingTest` and `TypedStubTest` is over text. `PythonStubsTask` is the only thing that writes. @@ -364,7 +383,11 @@ private class StubRenderer(declarations: List) { hasReceiver -> RECEIVER_NAME else -> null } - val writable = parameters.map { d.parameterNamesKnown && isWritableName(it.name, reserved) } + // Issue #131: each parameter under the keyword `inspect.signature` shows at run time -- its + // snake_case alias where one is served for this declaration, else its Kotlin name. The runtime + // accepts both spellings; a stub can name only one. + val shown = pythonicParameterNames(d) + val writable = parameters.indices.map { d.parameterNamesKnown && isWritableName(shown[it], reserved) } // Everything up to and including the last unwritable name is positional-only. val positionalOnlyEnd = writable.indexOfLast { !it } val lastRequired = parameters.indexOfLast { !it.declaresDefault } @@ -383,7 +406,7 @@ private class StubRenderer(declarations: List) { } parameters.forEachIndexed { index, parameter -> if (index == keywordOnlyStart) rendered += "*" - val name = if (writable[index]) parameter.name!! else "__a$index" + val name = if (writable[index]) shown[index]!! else "__a$index" val marked = if (positionalOnlyEnd >= 0) parameter.declaresDefault && index > lastRequired else parameter.declaresDefault rendered += "$name: " + annotate(parameter.type, parameter.boundaryTag, Role.PARAM, ctx) + if (marked) " = ..." else "" if (index == positionalOnlyEnd) rendered += "/" @@ -392,6 +415,20 @@ private class StubRenderer(declarations: List) { return rendered.joinToString(", ") } + /** + * Per parameter, the name `KotlinSurface.signature_of` shows: the snake_case alias the runtime + * serves for it (`keyword_slots`: not another parameter's Kotlin name, not shared by two + * parameters, not a Python keyword), else the Kotlin name; `null` where the name is unknown. + */ + private fun pythonicParameterNames(d: DeclarationModel): List { + val kotlinNames = d.parameters.mapNotNull { it.name } + val aliasOf = pythonicAliases(kotlinNames, ::snakeCase).entries.associate { (alias, kotlin) -> kotlin to alias } + return d.parameters.map { parameter -> + val name = parameter.name ?: return@map null + aliasOf[name]?.takeIf { it !in PYTHON_KEYWORDS } ?: name + } + } + private fun result(d: DeclarationModel, ctx: ModuleOut): String = annotate(d.returnType, d.returnBoundaryTag, Role.RESULT, ctx) @@ -448,6 +485,9 @@ private class StubRenderer(declarations: List) { attributes.getOrPut(leaf) { mutableListOf() } += d if (d.kind != "STATIC_GETTER" && isSuffixed(leaf)) attributes.getOrPut(baseOf(leaf)) { mutableListOf() } += d } + // Issue #131: the module's Pythonic aliases are members too, typed as their Kotlin names. + val namespace = attributes.keys + childSegments("${ctx.module}.$name") + pythonicAliases(namespace).forEach { (alias, kotlin) -> attributes[kotlin]?.let { attributes[alias] = it } } attributes.forEach { (attribute, group) -> if (!isIdentifier(attribute) || attribute == "__call__") { appendLine(" # '$attribute' cannot be written as an attribute here; reach it with getattr(..., '$attribute')") @@ -514,15 +554,35 @@ private class StubRenderer(declarations: List) { } // The base name of an overload set: the binding layer serves it, so the stub says it. val unsuffixedLeaves = sorted.filter { !isSuffixed(leafOf(it)) }.map { leafOf(it) }.toSet() + val overloadBases = mutableSetOf() sorted.filter { it.kind != "STATIC_GETTER" && isSuffixed(leafOf(it)) } .groupBy { baseOf(leafOf(it)) } .toSortedMap() .forEach { (base, group) -> - if (base !in unsuffixedLeaves && base !in companions) ctx.defs += overloadDefs(base, group, ctx) + if (base !in unsuffixedLeaves && base !in companions) { + ctx.defs += overloadDefs(base, group, ctx) + overloadBases += base + } } + // Issue #131: the Pythonic alias of every name written above, as an assignment, so a + // checker gives it exactly the Kotlin name's type. The namespace is the runtime's + // (`_package_aliases`): every bound name of the package and its child packages. + val written = sorted.map { leafOf(it) } + constructors.filter { isSuffixed(leafOf(it)) }.map { leafOf(it) } + + companions + overloadBases + val namespace = entries.flatMap { listOf(leafOf(it), baseOf(leafOf(it))) } + childSegments(module) + ctx.defs += aliasAssignments(pythonicAliases(namespace), written.toSet()) } } + /** The segment directly under [module] of every module below it: its child packages and objects. */ + private fun childSegments(module: String): List = + byModule.keys.filter { it.startsWith("$module.") }.map { it.removePrefix("$module.").substringBefore('.') }.distinct() + + /** `alias = kotlinName` for each alias whose Kotlin name the stub module writes, sorted by alias. */ + private fun aliasAssignments(aliases: Map, written: Set): List = + aliases.filter { (alias, kotlin) -> kotlin in written && kotlin !in PYTHON_KEYWORDS && alias !in PYTHON_KEYWORDS } + .map { (alias, kotlin) -> "$alias = $kotlin" } + /** * A class module that collides case-insensitively with a sibling path (issue #44): its functions * become static members of the class of that name in the parent package's module, nested for the @@ -616,6 +676,10 @@ private class StubRenderer(declarations: List) { attributes.getOrPut(leaf) { mutableListOf() } += d if (isSuffixed(leaf)) attributes.getOrPut(baseOf(leaf)) { mutableListOf() } += d } + // Issue #131: the proxy also serves each member's Pythonic alias, decided over every member + // name the runtime looks up for this type -- its own and every supertype's (`_member_aliases`). + val aliases = pythonicAliases(memberNamesOf(qualifiedName)) + val protocols = mutableMapOf() attributes.forEach { (attribute, group) -> if (!isIdentifier(attribute) || attribute in node.children) return@forEach val protocol = "_" + ref.path.joinToString("_") + "_" + attribute @@ -628,7 +692,9 @@ private class StubRenderer(declarations: List) { } } node.members += "$attribute: $TYPING.ClassVar[$protocol]" + protocols[attribute] = protocol } + val stubbedProperties = mutableMapOf>() properties[qualifiedName].orEmpty().forEach { (name, accessors) -> val (getter, setter) = accessors if (!isIdentifier(name) || name in node.children || name in attributes) { @@ -636,9 +702,22 @@ private class StubRenderer(declarations: List) { return@forEach } node.members += renderProperty(name, getter, setter, ctx) + stubbedProperties[name] = accessors + } + aliases.forEach { (alias, kotlin) -> + if (!isIdentifier(alias) || alias in node.children) return@forEach + protocols[kotlin]?.let { node.members += "$alias: $TYPING.ClassVar[$it]" } + stubbedProperties[kotlin]?.let { (getter, setter) -> node.members += renderProperty(alias, getter, setter, ctx) } } } + /** Every member name the binding layer serves on a proxy of [qualifiedName]: `_member_names`. */ + private fun memberNamesOf(qualifiedName: String): Set = + (listOf(qualifiedName) + supertypes[qualifiedName].orEmpty()).flatMap { type -> + extensionsByReceiver[type].orEmpty().flatMap { listOf(leafOf(it), baseOf(leafOf(it))) } + + properties[type].orEmpty().keys + }.toSet() + private fun renderProperty(name: String, getter: DeclarationModel, setter: DeclarationModel?, ctx: ModuleOut): String = buildString { appendLine("@property") appendLine("def $name(self) -> ${result(getter, ctx)}:") diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PythonNames.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PythonNames.kt new file mode 100644 index 000000000..ecb04e1b1 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PythonNames.kt @@ -0,0 +1,53 @@ +package python.multiplatform.gradle.stubs + +/** + * The binder's Pythonic naming rule (issue #131), mirrored for the stub generator. + * + * The runtime rule lives in `python_multiplatform` (`KotlinSurface.kt`: `snake_case`, `python_name`, + * `pythonic_aliases`), and it is pythonx-compose 0.1.0a1's (`_reexport.py`) character for character. + * This file restates it in Kotlin because the stubs are rendered at build time, with no interpreter; + * `PythonNameRuleTest` pins the two spellings against the same cases. + * + * - [snakeCase]: `fillMaxWidth` -> `fill_max_width`, `toURLString` -> `to_url_string`. + * - [pythonName]: upper-case first is unchanged (types, objects, composables); an explicit overload + * key converts its base and keeps its `__Types` suffix. + * - [pythonicAliases]: an alias is served only when it is not already a Kotlin name of the namespace + * and exactly one Kotlin name maps to it. A namespace (a Kotlin package path) is never converted. + */ + +private val ACRONYM_WORD = Regex("([A-Z]+)([A-Z][a-z])") +private val LOWER_UPPER = Regex("([a-z0-9])([A-Z])") + +/** Python's `_LOWER_UPPER.sub(r'\1_\2', _ACRONYM_WORD.sub(r'\1_\2', name)).lower()`. */ +internal fun snakeCase(kotlinName: String): String = + LOWER_UPPER.replace(ACRONYM_WORD.replace(kotlinName, "$1_$2"), "$1_$2").lowercase() + +/** The one Pythonic spelling of a Kotlin declaration or member name. */ +internal fun pythonName(kotlinName: String): String { + if (kotlinName.isEmpty() || kotlinName[0].isUpperCase()) return kotlinName + val separator = kotlinName.indexOf("__") + if (separator < 0) return snakeCase(kotlinName) + return snakeCase(kotlinName.substring(0, separator)) + kotlinName.substring(separator) +} + +/** Python's `str.isidentifier()` for the ASCII names a Kotlin declaration can carry here. */ +private fun isPythonIdentifier(name: String): Boolean = + name.isNotEmpty() && (name[0] == '_' || name[0].isLetter()) && name.all { it == '_' || it.isLetterOrDigit() } + +/** + * `alias -> kotlin name` for the names of one namespace, ambiguity refused: the runtime's + * `pythonic_aliases`. Independent of the order of [kotlinNames]. + */ +internal fun pythonicAliases(kotlinNames: Collection, rule: (String) -> String = ::pythonName): Map { + val names = kotlinNames.toSet() + val found = sortedMapOf() + val clashed = mutableSetOf() + for (kotlin in names) { + val alias = rule(kotlin) + if (alias == kotlin || alias in names || !isPythonIdentifier(alias)) continue + val other = found[alias] + if (other == null) found[alias] = kotlin else if (other != kotlin) clashed += alias + } + clashed.forEach { found.remove(it) } + return found +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/TextStateBindingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/TextStateBindingTest.kt index 70a4f49d0..c13a41294 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/TextStateBindingTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/TextStateBindingTest.kt @@ -153,7 +153,7 @@ class TextStateBindingTest { entry("$pkg.Editable") val stub = renderKotlinFqnStubs(declarations).getValue("fixture/artifacttextstate/__init__.pyi") assertTrue("class Editable:" in stub, stub) - assertTrue(" def __init__(self, initialText: str = ..., initialSelection: Span = ...) -> None:" in stub, stub) + assertTrue(" def __init__(self, initial_text: str = ..., initial_selection: Span = ...) -> None:" in stub, stub) assertTrue(" @property\n def text(self) -> str:" in stub, stub) assertTrue(" @property\n def draft(self) -> str | None:" in stub, stub) assertTrue(" replaceAll: _t.ClassVar[" in stub, stub) diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/KotlinNamesOnlyStubTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/KotlinNamesOnlyStubTest.kt index 9269df548..b0bbf8404 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/KotlinNamesOnlyStubTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/KotlinNamesOnlyStubTest.kt @@ -9,12 +9,14 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue /** - * AGENTS.md section 12 rule 1: the binder never exports a Kotlin namespace under another name, and has - * no feature that turns `androidx` into `pythonx`. Pythonic renaming is the `pythonx-compose` - * package's job. + * AGENTS.md section 12 rule 1: the binder never exports a Kotlin **namespace** under another name, and + * has no feature that turns `androidx` into `pythonx`. Member and parameter names are another matter + * (issue #131): the runtime serves each lower-case-first name under its snake_case alias as well, and + * the stub says so -- the Kotlin `def` stays, the alias is an assignment beside it, and a parameter is + * written under the keyword `inspect.signature` shows. * * These tests look at everything the generator can emit for a set of androidx declarations, not at - * one function, so a renaming feature added anywhere in the stub pipeline fails them. + * one function, so a namespace-renaming feature added anywhere in the stub pipeline fails them. */ class KotlinNamesOnlyStubTest { @@ -57,13 +59,34 @@ class KotlinNamesOnlyStubTest { assertFalse(files.keys.any { it.endsWith("py.typed") || it.endsWith("_pm_dispatch.json") }, files.keys.toString()) } + /** The Kotlin name is the `def`; its Pythonic alias is the same object, as the runtime serves it. */ @Test - fun kotlinNamesAreNeverSnakeCased() { + fun aKotlinNameKeepsItsDefAndGainsItsPythonicAlias() { val all = everythingEmitted().values.joinToString("\n") assertTrue("def fillMaxWidth(" in all, all) assertTrue("fraction: float" in all, all) - assertFalse("fill_max_width" in all, all) - assertFalse("window_insets" in all, all) + assertTrue(Regex("^fill_max_width = fillMaxWidth${'$'}", RegexOption.MULTILINE).containsMatchIn(all), all) + assertTrue(Regex("^window_insets_padding = windowInsetsPadding${'$'}", RegexOption.MULTILINE).containsMatchIn(all), all) + // A parameter has one name in a stub: the Pythonic keyword. + assertTrue("window_insets: float" in all, all) + assertFalse("windowInsets: float" in all, all) + // The namespace is not converted: the module path is the Kotlin package. + assertFalse(Regex("^def fill_max_width\\(", RegexOption.MULTILINE).containsMatchIn(all), all) + } + + /** + * The collision policy (`pythonicAliases`, the runtime's `pythonic_aliases`): an alias two Kotlin + * names map to is not written, and an alias that is another declaration's Kotlin name belongs to it. + */ + @Test + fun anAmbiguousAliasIsNotWrittenAndAKotlinNameOwnsItsSpelling() { + val text = renderKotlinFqnStubs( + listOf(decl("toURL"), decl("toUrl"), decl("fooBar"), decl("foo_bar")), + ).values.joinToString("\n") + assertFalse(Regex("^to_url = ", RegexOption.MULTILINE).containsMatchIn(text), text) + assertFalse(Regex("^foo_bar = ", RegexOption.MULTILINE).containsMatchIn(text), text) + assertTrue(Regex("^def foo_bar\\(", RegexOption.MULTILINE).containsMatchIn(text), text) + assertTrue(Regex("^def toURL\\(", RegexOption.MULTILINE).containsMatchIn(text), text) } @Test @@ -78,8 +101,8 @@ class KotlinNamesOnlyStubTest { private fun body(vararg d: DeclarationModel) = renderKotlinFqnStubs(d.toList()).getValue("androidx/compose/foundation/layout/__init__.pyi") - /** The receiver is positional-only; declared parameters are positional-or-keyword by their Kotlin - * name, and a defaulted one carries `= ...`. */ + /** The receiver is positional-only; declared parameters are positional-or-keyword by their + * Pythonic keyword (the Kotlin name where they have none), and a defaulted one carries `= ...`. */ @Test fun declaredParametersAreKeywordCapableByTheirKotlinNameAndDefaultsAreMarked() { val b = body(decl("fillMaxWidth", parameters = listOf(param("fraction", default = true)))) @@ -89,7 +112,7 @@ class KotlinNamesOnlyStubTest { @Test fun aRequiredParameterHasNoDefaultMarker() { val b = body(decl("windowInsetsPadding", parameters = listOf(param("windowInsets")))) - assertTrue("def windowInsetsPadding(receiver: androidx.compose.ui.Modifier, /, windowInsets: float) -> androidx.compose.ui.Modifier:" in b, b) + assertTrue("def windowInsetsPadding(receiver: androidx.compose.ui.Modifier, /, window_insets: float) -> androidx.compose.ui.Modifier:" in b, b) } /** `in` is a Python keyword: it cannot be a keyword argument in `.pyi`, so it and everything diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/ObjectStubTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/ObjectStubTest.kt index b12bf797a..def3e79ad 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/ObjectStubTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/ObjectStubTest.kt @@ -85,7 +85,7 @@ class ObjectStubTest { ) val files = renderKotlinFqnStubs(listOf(end, column)) val layout = file(files, pkgLayout) - assertTrue("horizontalAlignment: $alignment.Horizontal = ..." in layout, layout) + assertTrue("horizontal_alignment: $alignment.Horizontal = ..." in layout, layout) assertTrue("import $alignment\n" in layout, layout) } diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/PythonNameRuleTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/PythonNameRuleTest.kt new file mode 100644 index 000000000..3e6028f72 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/PythonNameRuleTest.kt @@ -0,0 +1,46 @@ +package python.multiplatform.gradle.stubs + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The stub generator's copy of the naming rule (issue #131) gives the answers the runtime's gives. + * + * The runtime rule is `python_multiplatform.python_name` (`KotlinSurface.kt`), itself + * pythonx-compose 0.1.0a1's; the expected values below are what that Python function returns, and + * `PythonxAdapterTest.theNameRuleConvertsKotlinToPythonAndEitherSpellingReachesOneDeclaration` pins the + * same cases on the runtime side. + */ +class PythonNameRuleTest { + + @Test + fun theForwardRuleMatchesTheRuntime() { + val cases = mapOf( + "fillMaxWidth" to "fill_max_width", + "zIndex" to "z_index", + "toURLString" to "to_url_string", + "getURL" to "get_url", + "size2Dp" to "size2_dp", + "padding__Dp_Dp" to "padding__Dp_Dp", + "paddingFromBaseline__TextUnit" to "padding_from_baseline__TextUnit", + "rememberTextFieldState" to "remember_text_field_state", + "Modifier" to "Modifier", + "URLHandler" to "URLHandler", + "padding" to "padding", + "foo_bar" to "foo_bar", + ) + cases.forEach { (kotlin, python) -> assertEquals(python, pythonName(kotlin), kotlin) } + assertEquals("on_checked_change", snakeCase("onCheckedChange")) + assertEquals("x_url", snakeCase("xURL")) + } + + @Test + fun anAliasIsServedOnlyWhenItIsUnambiguous() { + assertEquals( + mapOf("fill_max_width" to "fillMaxWidth"), + pythonicAliases(listOf("toURL", "toUrl", "fooBar", "foo_bar", "fillMaxWidth", "Modifier", "padding")), + ) + assertEquals(emptyMap(), pythonicAliases(listOf("xURL", "xUrl"), ::snakeCase)) + assertEquals(emptyMap(), pythonicAliases(listOf("onClick", "on_click"), ::snakeCase)) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/TypedStubTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/TypedStubTest.kt index 10e0a811a..d09e9f0e8 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/TypedStubTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/TypedStubTest.kt @@ -146,7 +146,8 @@ class TypedStubTest { assertTrue(order[0].contains("all: androidx.compose.ui.unit.Dp | float"), order.toString()) assertTrue(order[1].contains("horizontal"), order.toString()) assertTrue(order[2].contains("start"), order.toString()) - assertTrue(order[3].contains("paddingValues"), order.toString()) + // Issue #131: the parameter is written under the keyword `inspect.signature` shows. + assertTrue(order[3].contains("padding_values"), order.toString()) // ... and the explicit table-key spellings are still there, one def each. assertTrue("def padding__Dp(receiver: androidx.compose.ui.Modifier, /, all:" in layout, layout) assertTrue("def padding__PaddingValues(" in layout, layout) @@ -154,7 +155,7 @@ class TypedStubTest { val ui = file(files, pkgUi) val methodOrder = Regex("""class _Modifier_padding\(_t\.Protocol\):\n((?: .*\n)+)""").find(ui)!!.groupValues[1] val calls = Regex("""def __call__\(self, (\w+)""").findAll(methodOrder).map { it.groupValues[1] }.toList() - assertEquals(listOf("all", "horizontal", "start", "paddingValues"), calls, ui) + assertEquals(listOf("all", "horizontal", "start", "padding_values"), calls, ui) } /** Python's `int`/`float` cannot tell two Kotlin overloads apart, so a checker calls every later @@ -245,7 +246,7 @@ class TypedStubTest { ) val body = file(files(checkbox), "androidx.compose.material3") assertTrue( - "def Checkbox(checked: bool, onCheckedChange: _t.Callable[[bool], None] | None, " + + "def Checkbox(checked: bool, on_checked_change: _t.Callable[[bool], None] | None, " + "modifier: androidx.compose.ui.Modifier = ..., *, content: _t.Callable[[], None]) -> None:" in body, body, ) @@ -286,12 +287,24 @@ class TypedStubTest { // ------------------------------------------------------------------------ Kotlin names only + /** + * Issue #131: the Kotlin names stay and the Pythonic aliases are added beside them -- as + * `alias = kotlinName` at module level and as a second `ClassVar` of the same Protocol on the + * receiver -- and nothing is emitted under `pythonx`. + */ @Test - fun theTypedStubsStillCarryOnlyKotlinNames() { + fun theTypedStubsCarryTheKotlinNamesAndThePythonicAliasesAndNothingUnderPythonx() { val all = files(*padding.toTypedArray(), fillMaxWidth) assertEquals(emptyList(), all.keys.filter { it.startsWith("pythonx/") }) val text = all.keys.joinToString("\n") + all.values.joinToString("\n") assertFalse("pythonx" in text, text) - assertFalse("fill_max_width" in text || "padding_values" in text, text) + val layout = file(all, pkgLayout) + assertTrue(Regex("^def fillMaxWidth\\(", RegexOption.MULTILINE).containsMatchIn(layout), layout) + assertTrue(Regex("^fill_max_width = fillMaxWidth${'$'}", RegexOption.MULTILINE).containsMatchIn(layout), layout) + val ui = file(all, pkgUi) + assertTrue(" fillMaxWidth: _t.ClassVar[_Modifier_fillMaxWidth]" in ui, ui) + assertTrue(" fill_max_width: _t.ClassVar[_Modifier_fillMaxWidth]" in ui, ui) + // `padding` has no other spelling: nothing is invented for it. + assertFalse(Regex("^padding_\\w* = ", RegexOption.MULTILINE).containsMatchIn(layout), layout) } } diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt index 4fa114245..8f11a333e 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt @@ -9,13 +9,14 @@ import python.multiplatform.reflection.UpcallTable * The binding layer -- `python_multiplatform.binding`, the hand-written Python that serves Kotlin * declarations on their Kotlin-named modules, and the generated table it reads. * - * **It renames nothing, and it is not `pythonx`.** A Kotlin-named module carries Kotlin's own - * surface: Kotlin declaration names, keyword arguments by Kotlin parameter names, Kotlin defaults, - * overloads, value-class rules and the `$composer` slot (`docs/INTENT.md` §2.2). The Kotlin - * package and class names below (`ffi.pythonx`, `PythonxAdapter`) are historical; the Python module - * this installs is binder-owned, and `pythonx` is a real package in pythonx-compose that builds a - * Pythonic API on top of this one through `python_multiplatform.describe` and `inspect.signature` - * ([KotlinSurface]). + * **It renames no namespace, and it is not `pythonx`.** A Kotlin-named module is served under its + * Kotlin package name only (`docs/INTENT.md` §2.2), with Kotlin defaults, overloads, value-class + * rules and the `$composer` slot. Inside it, every declaration, proxy member and keyword parameter + * answers to its Kotlin name **and** to its Pythonic (snake_case) alias where that alias is + * unambiguous (issue #131; the rule and the policy are [KotlinSurface]'s). The Kotlin package and + * class names below (`ffi.pythonx`, `PythonxAdapter`) are historical; the Python module this + * installs is binder-owned, and `pythonx` is a real package in pythonx-compose that builds on this + * one through `python_multiplatform.describe` and `inspect.signature` ([KotlinSurface]). * * `docs/archive/pythonx-adapter-design.md` §7 draws one line through this whole area: **if it differs per * Kotlin declaration it is generated or resolved at run time; if it is the same rule for every @@ -90,7 +91,7 @@ object PythonxAdapter { * |---|---| * | `_Finder` / `_Loader` | §2.3, the hook a module `__getattr__` cannot replace | * | the module `__getattr__` the loader installs | §4.1, adapted once and then a dict hit | - * | Kotlin names, unchanged | `docs/INTENT.md` §2.2 -- no rule converts a name in either direction | + * | `_package_aliases` / `_member_aliases` | issue #131 -- Kotlin names as keys, Pythonic aliases resolved on a miss; no namespace converted | * | `_Overloads` | `docs/design/kotlin-extensions-in-python.md` §3.1, the dispatcher that has to live here | * | `_proxy_type` / `_Hybrid` | §4.2, an extension as a method on its receiver, both spellings | * | `_coerce`'s allowlist | §4.4, `Dp` yes, a packed value class no | @@ -99,11 +100,11 @@ object PythonxAdapter { # GENERATED-FREE: this file is hand-written Python. See PythonxAdapter.kt. # # `python_multiplatform.binding` -- the binding layer over the Kotlin declarations the upcall - # table exposes. It serves Kotlin-named modules with **Kotlin's own surface**: Kotlin - # declaration names, keyword arguments by Kotlin parameter names, Kotlin defaults, overloads, - # value-class rules and the `${'$'}composer` slot. It renames nothing. Making that surface - # Pythonic is the job of a real Python package built on top of it (pythonx-compose), which - # reads `python_multiplatform.describe` and `inspect.signature` to do it by rule. + # table exposes. It serves Kotlin-named modules: Kotlin declaration names and their Pythonic + # aliases, keyword arguments by Kotlin parameter name or Pythonic alias, Kotlin defaults, + # overloads, value-class rules and the `${'$'}composer` slot. It renames no namespace: a + # Kotlin package is a module under exactly its Kotlin name. A Python package built on top of + # it (pythonx-compose) reads `python_multiplatform.describe` and `inspect.signature`. # # `docs/archive/pythonx-adapter-design.md` §7 draws the line this file lives on: *if it differs per Kotlin # declaration it is generated or resolved at run time; if it is the same rule for every declaration @@ -122,6 +123,10 @@ object PythonxAdapter { import types as _types from python_multiplatform import KOTLIN_DEFAULT, signature_of as _signature_of + # The naming rule (issue #131) lives in the root module, once, so that both installers and the + # stub generator's mirror of it agree; see `KotlinSurface`. + from python_multiplatform import keyword_slots as _keyword_slots + from python_multiplatform import pythonic_aliases as _pythonic_aliases @@ -173,7 +178,7 @@ object PythonxAdapter { 'param_names', 'param_tags', 'param_type_names', 'return_tag', 'return_type_name', 'is_extension', 'receiver_type_name', 'param_has_default', 'handle', 'composer_index', 'changed_slots', 'default_slots', 'return_supertypes', 'row', - 'binding', + 'binding', 'keywords', ) def __init__(self, row): @@ -187,6 +192,9 @@ object PythonxAdapter { # `inspect.signature` read, the same tuple the proxy layer publishes with. self.row = row self.binding = None + # `{keyword: slot}`, Kotlin names and Pythonic aliases (`keyword_slots`), built on the + # first call that passes a keyword rather than for every row at install. + self.keywords = None (self.kotlin_name, self.arity, self.kind, self.is_suspend, self.param_names, self.param_tags, self.param_type_names, self.return_tag, self.return_type_name, self.is_extension, self.receiver_type_name, self.param_has_default) = row @@ -251,6 +259,13 @@ object PythonxAdapter { self.handle = _resolve(self.kotlin_name) return self.handle + def keyword_slot(self, keyword): + '''The slot [keyword] names -- a Kotlin parameter name or its Pythonic alias -- or -1.''' + keywords = self.keywords + if keywords is None: + keywords = self.keywords = _keyword_slots(self.row) + return keywords.get(keyword, -1) + _TABLE = {} # kotlin fqn -> _Decl _MODULES = [] # the Kotlin-named modules this layer has adapted @@ -324,6 +339,7 @@ object PythonxAdapter { _invalidate() _BY_PACKAGE.clear() _BY_RECEIVER.clear() + _ALIASES.clear() _MEMBER_ALIASES.clear() _MEMBER_KEYWORDS.clear() _PACKAGES_SEEN.clear() @@ -436,7 +452,11 @@ object PythonxAdapter { `STATIC_GETTER` read runs Kotlin, so a description has to come from the table and never from the module attribute. An overload set's base name answers every member. ''' - decls = _BY_PACKAGE.get(kotlin_package, {}).get(name) + by_name = _BY_PACKAGE.get(kotlin_package, {}) + decls = by_name.get(name) + if not decls: + kotlin = _package_aliases(kotlin_package).get(name) + decls = by_name.get(kotlin) if kotlin is not None else None if not decls: return None return tuple(decl.row for decl in decls) @@ -444,11 +464,47 @@ object PythonxAdapter { # --------------------------------------------------------------------------- names - # **Kotlin names, unchanged, everywhere.** A Kotlin declaration is reached under its own name - # and takes keyword arguments by its own parameter names. There used to be a forward rule - # here (`fillMaxWidth` -> `fill_max_width`) and its inverse; both are gone, because a binder - # that renames is deciding what a Python API is called, and that is the business of the - # Python package built on top (`docs/INTENT.md` §2.2, §2.3). + # **Kotlin names, and their Pythonic aliases** (issue #131). A Kotlin declaration is reached + # under its own name; a lower-case-first one also under its snake_case name, and each keyword + # parameter under its Kotlin name and its snake_case one. The rule and the ambiguity policy are + # the root module's (`python_multiplatform.python_name`, `pythonic_aliases`; see + # `KotlinSurface`). Every index here stays keyed by the **Kotlin** name: an alias is resolved + # to its Kotlin name on a miss and never becomes a second key, so one declaration has one + # owner, one `_Binding`, one `_Hybrid`. + # + # What is *not* converted: a namespace. `androidx.compose.foundation.layout` is importable as + # exactly that, and no segment of a package path ever gets an alias (`docs/INTENT.md` §2.2). + + _ALIASES = {} # ('package' | 'type', kotlin name) -> {alias: kotlin name}; cleared per table + + + def _package_aliases(kotlin_package): + '''`{alias: kotlin_name}` for the bound names of one Kotlin package. + + The namespace is every bound name there -- base names, explicit `name__Types` keys, + constants -- plus its child packages and objects, which occupy a name without ever being + given an alias. Built once per package per table. + ''' + key = ('package', kotlin_package) + found = _ALIASES.get(key) + if found is None: + names = set(_BY_PACKAGE.get(kotlin_package, {})) + names.update(_CHILDREN.get(kotlin_package, ())) + found = _ALIASES[key] = _pythonic_aliases(names) + return found + + + def _member_aliases(type_name): + '''`{alias: kotlin_name}` for every member a value of [type_name] is served under. + + The namespace is the whole lookup `_member` walks -- the type and every type it is a -- so + an alias that two members of the chain would both claim is not served at all. + ''' + key = ('type', type_name) + found = _ALIASES.get(key) + if found is None: + found = _ALIASES[key] = _pythonic_aliases(_member_names(type_name)) + return found _KOTLIN_PRIMITIVES = frozenset(( 'kotlin.Byte', 'kotlin.Short', 'kotlin.Int', 'kotlin.Long', 'kotlin.Float', 'kotlin.Double', @@ -971,10 +1027,11 @@ object PythonxAdapter { name only when a call to it passes keyword arguments, and then only for its keyword map (the answer must name that same member). `keyword_map` is `{python_kw: kotlinParam}`, applied to that member's calls; keywords it does not name pass through unchanged. - The binder renames nothing by itself: with no resolver an unknown name is an `AttributeError` - and a keyword is a Kotlin parameter name. Answers are cached in this registry, never - written onto the proxy class, so `dir()` of a proxy shows Kotlin names only. Registering - the same `fn` twice is a no-op. + Resolvers are asked before the binder's own Pythonic alias (issue #131): with no resolver + answering, `fill_max_width` is still served as `fillMaxWidth`, and a keyword is a Kotlin + parameter name or its alias. Answers are cached in this registry, never written onto the + proxy class, so `dir()` of a proxy shows Kotlin names only. Registering the same `fn` + twice is a no-op. ''' if not callable(fn): raise TypeError('a member resolver must be callable') @@ -1129,9 +1186,12 @@ object PythonxAdapter { if not _attach(cls, name): resolved = _resolve_member(cls, name) if resolved is None: - raise AttributeError( - 'no Kotlin extension named ' + name + ' on ' + cls._kotlin_type_name - ) + kotlin = _member_aliases(cls._kotlin_type_name).get(name) + if kotlin is None: + raise AttributeError( + 'no Kotlin extension named ' + name + ' on ' + cls._kotlin_type_name + ) + return getattr(cls, kotlin) return _aliased(getattr(cls, resolved[0]), resolved[1]) return getattr(cls, name) @@ -1186,6 +1246,9 @@ object PythonxAdapter { declaration its name stands for, an overload set's base name included. ''' found = _member(type_name, name) + if found is None: + kotlin = _member_aliases(type_name).get(name) + found = _member(type_name, kotlin) if kotlin is not None else None if found is None: return None if isinstance(found, _Property): @@ -1288,9 +1351,13 @@ object PythonxAdapter { if not _attach(type(self), name): resolved = _resolve_member(type(self), name) if resolved is None: - raise AttributeError( - 'no Kotlin extension named ' + name + ' on ' + type(self)._kotlin_type_name - ) + # The binder's own alias, last: a resolver may say otherwise (`KotlinSurface`). + kotlin = _member_aliases(type(self)._kotlin_type_name).get(name) + if kotlin is None: + raise AttributeError( + 'no Kotlin extension named ' + name + ' on ' + type(self)._kotlin_type_name + ) + return getattr(self, kotlin) return _aliased(getattr(self, resolved[0]), resolved[1]) return getattr(self, name) @@ -1301,11 +1368,19 @@ object PythonxAdapter { # from that dict instead of Kotlin (issue #38: `state.value = 7` was "accepted"). # So a write attaches first; `object.__setattr__` then goes through the `property` # (its setter, or Python's own refusal for a `val`). A name no Kotlin member has is - # stored on the instance as before. + # stored on the instance as before -- unless it is the Pythonic alias of one + # (`selected_index = 2` for `selectedIndex`, issue #131), which writes that member. if not name.startswith('_'): cls = type(self) - if name not in cls.__dict__: - _attach(cls, name) + if name not in cls.__dict__ and not _attach(cls, name): + kotlin = _member_aliases(cls._kotlin_type_name).get(name) + if kotlin is not None: + if kotlin not in cls.__dict__: + _attach(cls, kotlin) + # Only a property is written through; an alias of a function is not a + # place to store anything, so it is left to the instance as before. + if isinstance(cls.__dict__.get(kotlin), property): + name = kotlin _set(self, name, value) def __repr__(self): @@ -1706,15 +1781,12 @@ object PythonxAdapter { bit_offset = 1 if decl.is_extension else 0 if kwargs: for key, value in kwargs.items(): - index = -1 - for slot in range(declared): - # `` is deliberately not a Python identifier, so no keyword can name - # it. Every other slot answers to its Kotlin parameter name, exactly. - if decl.param_names[slot] == '': - continue - if decl.param_names[slot] == key: - index = slot - break + # `` is deliberately not a Python identifier, so no keyword can name it, + # and the synthetic slots after `${'$'}composer` answer to none. Every other slot + # answers to its Kotlin parameter name and to its Pythonic alias (`keyword_slots`). + index = decl.keyword_slot(key) + if index >= declared: + index = -1 if index < 0: return _refuse(strict, 'has no parameter named ' + key) if slots[index] is not _NO_MATCH: @@ -1769,11 +1841,8 @@ object PythonxAdapter { if not decl.param_names: return _refuse(strict, 'the producer supplied no parameter names, so it is positional only') for key, value in kwargs.items(): - index = -1 - for slot, name in enumerate(decl.param_names): - if name != '' and name == key: - index = slot - break + # A Kotlin parameter name or its Pythonic alias; never the receiver. + index = decl.keyword_slot(key) if index < 0: return _refuse(strict, 'has no parameter named ' + key) if slots[index] is not _NO_MATCH: @@ -2031,6 +2100,18 @@ object PythonxAdapter { raise AttributeError(name) adapted, cacheable = _adapt(kotlin_package, name) if adapted is None: + kotlin = _package_aliases(kotlin_package).get(name) + if kotlin is not None: + # A Pythonic alias (issue #131): whatever the Kotlin name reads as, through the + # module -- the proxy layer's function, this layer's overload set, a live + # constant -- so the two spellings are one object. Frozen into the module dict + # only when the Kotlin name itself is (a constant is read every time), and + # recorded as this layer's, so a table change drops it with everything else. + adapted = getattr(module, kotlin) + if module.__dict__.get(kotlin) is adapted: + setattr(module, name, adapted) + module._kotlin_adapted[name] = adapted + return adapted raise AttributeError( "module '" + module.__name__ + "' has no attribute '" + name + "' (nothing named that is exposed from Kotlin package " + kotlin_package + ')' diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt index 812ebacad..1ebb5fa17 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/KotlinSurface.kt @@ -8,12 +8,25 @@ import python.multiplatform.reflection.ExposedCallable * ### Why it exists * * A Kotlin-named module (`androidx.compose.material3`, `kotlin.text`, a consumer's own package) - * carries **Kotlin's own surface**: Kotlin declaration names, keyword arguments by **Kotlin - * parameter names**, Kotlin defaults for what a call leaves out. Nothing is renamed -- no - * snake_case, no `pythonx` -- because a Kotlin name in Python means the original Kotlin - * (`docs/INTENT.md` §2.2). Making that surface Pythonic is the business of a real Python package - * built on top of it (pythonx-compose, §2.3), and this module is what such a package reads to do - * it by rule rather than by a wrapper per declaration. + * is importable under its Kotlin package name and nothing else: **a namespace is never renamed**, + * no `pythonx` module is made (`docs/INTENT.md` §2.2, §2.3). Inside it, each declaration is reached + * by its Kotlin name **and** by its Pythonic name, and takes keyword arguments by **either** spelling + * of each parameter (issue #131): + * + * | Kotlin | also reachable as | rule | + * |---|---|---| + * | `rememberTextFieldState` | `remember_text_field_state` | snake_case of a lower-case-first name | + * | `toURLString` | `to_url_string` | a run of capitals is one word | + * | `paddingFromBaseline__TextUnit` | `padding_from_baseline__TextUnit` | an explicit overload key keeps its suffix | + * | `Checkbox`, `Modifier`, `Alignment.End` | (unchanged) | upper-case first: types, objects, composables, constants | + * | `onCheckedChange=` | `on_checked_change=` | a keyword is snake_case of the Kotlin parameter name | + * + * The rule is pythonx-compose 0.1.0a1's (`_reexport.py`'s `snake_case`/`python_name`), so a package + * built on top sees the same spelling it always computed. An alias is served only when it is + * unambiguous in its namespace -- one module, one proxy type (supertypes included), or one + * declaration's parameters: it must not already be a Kotlin name there and no second Kotlin name may + * map to it ([SOURCE]'s `pythonic_aliases`). An ambiguous alias is not served, and every Kotlin name + * involved stays reachable as itself. `dir()` lists Kotlin names only. * * Two installers put functions into those modules -- [PythonProxySource] renders every table entry * eagerly; [python.multiplatform.ffi.pythonx.PythonxAdapter] adapts names lazily, adds overload @@ -28,11 +41,19 @@ import python.multiplatform.reflection.ExposedCallable * | `describe_member(type, name)` | the declaration(s) a member name of a Kotlin type stands for on its proxy -- an extension function or a property, supertypes included -- read from the table | * | `kotlin_function(raw, row)` | what [PythonProxySource] wraps each rendered function in: keyword arguments by Kotlin name, omitted defaults, and the metadata above | * | `signature_of(rows)` | the `inspect.Signature` for one declaration (`(*args, **kwargs)` for an overload set) | + * | `snake_case(name)`, `python_name(name)` | the naming rule below, the one both installers and the stub generator apply | + * | `pythonic_aliases(names)` | `{alias: kotlin_name}` for one namespace, ambiguous aliases left out | + * | `keyword_slots(row)` | `{keyword: slot}` for one declaration: Kotlin parameter names and their aliases | + * | `module_alias_getattr(module)` | the PEP 562 `__getattr__` [PythonProxySource] gives the modules it creates, serving the aliases | * * ### The public contract, for a Pythonic layer * * Every function the binder puts on a Kotlin-named module -- whichever installer put it there -- - * answers `inspect.signature(fn)` with the **Kotlin** parameter names, in declaration order: + * answers `inspect.signature(fn)` with the **Pythonic** keyword of each parameter (its snake_case + * alias, or its Kotlin name where none is served), in declaration order. `describe()` carries both: + * `name` is the Kotlin parameter name, `python_name` the keyword the signature shows. The signature + * is the call surface Python tools read (`help()`, IDEs, `Signature.bind`), so it shows the spelling + * Python code is written in; the description is the Kotlin declaration, so it keeps Kotlin's. * * - an extension receiver is a positional-only parameter named `receiver`; * - a parameter with a Kotlin default has `default is python_multiplatform.KOTLIN_DEFAULT`; @@ -53,8 +74,9 @@ import python.multiplatform.reflection.ExposedCallable * 'composable': True, # has a $composer slot * 'content': None, # name of a trailing @Composable lambda parameter * 'parameters': ( - * {'name': 'checked', 'type': 'kotlin.Boolean', 'tag': 'BOOLEAN', + * {'name': 'checked', 'python_name': 'checked', 'type': 'kotlin.Boolean', 'tag': 'BOOLEAN', * 'has_default': False, 'value_class': False, 'composable_lambda': False}, + * {'name': 'onCheckedChange', 'python_name': 'on_checked_change', ...}, * ...)} * * `python_multiplatform.describe(module, name)` describes **any** bound name of a Kotlin-named @@ -83,6 +105,8 @@ import python.multiplatform.reflection.ExposedCallable * - a name the type does not have is looked up on each type the table says it **is a**, nearest * first -- the same lookup the proxy uses, so a description cannot disagree with the attribute; * - nothing is invoked: no getter runs, no handle is resolved; + * - a member's Pythonic alias (`fill_max_width`) describes the Kotlin member it is served for, and so + * does `describe(module, alias)` for a module name; * - a name served on neither the type nor any supertype, or any name while the binding layer is not * installed (it is what serves members on a proxy), raises `AttributeError`. * @@ -90,9 +114,12 @@ import python.multiplatform.reflection.ExposedCallable * * A Kotlin-named module's `dir()` lists its bound declarations, the receiver types under it, and * its **direct child packages and objects** (an object whose members are bound, `Alignment`, is a - * package here). Each under its Kotlin name. Reading a child as an attribute imports it, so - * `androidx.compose.ui.Alignment.End` works after `import androidx` alone; a name nothing is bound - * under stays an `AttributeError`. + * package here). Each under its Kotlin name only -- the Pythonic aliases are served but not listed, + * so `dir()` names each declaration once (and a layer that converts `dir()` by the same rule, as + * pythonx-compose 0.1.0a1's `_name_table` does, sees no two entries for one name). Reading a child + * as an attribute imports it, so `androidx.compose.ui.Alignment.End` works after `import androidx` + * alone; a name nothing is bound under stays an `AttributeError`. A package segment is a namespace + * and is never given an alias. * * `value_class` is true for a parameter whose marshalling tag is a primitive while its declared * type is not a Kotlin primitive (`Dp`, `Color`, `TextUnit`) -- the only machine-checkable form of @@ -101,7 +128,10 @@ import python.multiplatform.reflection.ExposedCallable * ### Member resolvers: the one hook for names on a proxy * * A proxy the binder returns (`Modifier.padding(16)` is one) serves its Kotlin members under their - * Kotlin names only. A Pythonic package built on the binder can say what else a member may be called: + * Kotlin names and their Pythonic aliases. The order a name is looked up in is fixed: **the Kotlin + * member of that name, then the registered resolvers, then the binder's own snake_case alias** -- so + * a resolver can still decide what a name means, and with none registered the alias is served. A + * Pythonic package built on the binder can say what else a member may be called: * * python_multiplatform.binding.add_member_resolver(fn) * python_multiplatform.binding.remove_member_resolver(fn) @@ -113,20 +143,23 @@ import python.multiplatform.reflection.ExposedCallable * never reaches it. `kotlin_member_names` is a tuple of the Kotlin member names the type has. * - The first resolver (in registration order) returning a name that is in `kotlin_member_names` wins, * and that Kotlin member is served. Any other answer (`None`, an unknown name, a name starting with - * `_`) means "not mine"; with no resolver answering, the result is the usual `AttributeError`. + * `_`) means "not mine"; with no resolver answering, the binder's own alias is served, and a name + * that is no alias either is the usual `AttributeError`. * - **Keyword maps.** An answer may be `(kotlin_name, keyword_map)`, `keyword_map` being * `{python_kw: kotlinParam}`: it is applied when that member is called, and keywords it does not - * name pass through as Kotlin parameter names (an unknown one is still the binding layer's - * `TypeError`, which lists the Kotlin parameters). For a member the proxy *does* have under its + * name pass through unchanged, to be matched as a Kotlin parameter name or its Pythonic alias (an + * unknown one is still the binding layer's `TypeError`). A map that turns `padding_values` into + * `paddingValues` is therefore harmless: the binder would have accepted either. For a member the proxy *does* have under its * Kotlin name (`padding`), resolvers are asked only when a call to it passes keyword arguments, and * only for its keyword map: an answer counts when it is `(that same name, keyword_map)`; a plain * name or `None` means no map. A call with no keywords never asks. A tuple of any other shape is a * `TypeError`. - * - The binder renames nothing itself: with no resolver registered behaviour is exactly the - * Kotlin-names-only behaviour above. + * - With no resolver registered, a proxy serves its Kotlin names and the binder's aliases (issue #131). * - Answers are cached in the resolver registry (cleared when a resolver is added or removed, and * when a table is registered), never written onto a proxy class: `dir()` of a proxy and the class - * `__dict__` show Kotlin names only. Removing a resolver removes its aliases. + * `__dict__` show Kotlin names only. Removing a resolver removes its aliases. The binder's own + * aliases follow the same rule: computed per type, cached beside the table, never written onto the + * class, so reading an alias costs a `__getattr__` miss every time. * - Registering the same function twice is a no-op. Resolvers are process-wide, like the binding layer. * * ### `__signature__` is lazy @@ -162,6 +195,7 @@ object KotlinSurface { import inspect as _inspect import keyword as _keyword + import re as _re import sys as _sys _pm_root = True @@ -226,6 +260,135 @@ object KotlinSurface { return name.startswith('${'$'}') + # ------------------------------------------------------------- Pythonic names (issue #131) + # + # A Kotlin-named module is reachable under its Kotlin package name and nothing else -- a + # namespace is never renamed (`docs/INTENT.md` §2.2). Inside it, every declaration, member and + # keyword parameter is reachable by its Kotlin name **and** by its Pythonic name. The rule is + # the one pythonx-compose 0.1.0a1 uses (`pythonx/compose/_reexport.py`), character for + # character, so a package built on top sees no change of spelling: + # + # - a name that starts upper-case (a type, an object, a `@Composable`, an enum entry, a + # PascalCase constant) is unchanged; + # - any other name is snake_case, a run of capitals counting as one word + # (`fillMaxWidth` -> `fill_max_width`, `toURLString` -> `to_url_string`); + # - an explicit overload key converts its base and keeps its `__Types` suffix + # (`paddingFromBaseline__TextUnit` -> `padding_from_baseline__TextUnit`); + # - a keyword parameter is `snake_case` of its Kotlin name (`onCheckedChange` -> `on_checked_change`). + # + # An alias is served only when it is unambiguous in its namespace (`pythonic_aliases`): when it + # is not already some Kotlin name there, and when no second Kotlin name maps to it. Otherwise + # the Kotlin names are still the only spellings, and nothing is guessed. + + _LOWER_UPPER = _re.compile(r'([a-z0-9])([A-Z])') + _ACRONYM_WORD = _re.compile(r'([A-Z]+)([A-Z][a-z])') + + + def snake_case(kotlin_name): + '''`fillMaxWidth` -> `fill_max_width`, `toURLString` -> `to_url_string`, `zIndex` -> `z_index`.''' + return _LOWER_UPPER.sub(r'\1_\2', _ACRONYM_WORD.sub(r'\1_\2', kotlin_name)).lower() + + + def python_name(kotlin_name): + '''The one Pythonic spelling of a Kotlin declaration or member name. + + Upper-case names keep their Kotlin spelling. An explicit overload key (`padding__Dp`) + converts its base and keeps the type suffix, which names Kotlin types. + ''' + if not kotlin_name or kotlin_name[:1].isupper(): + return kotlin_name + base, sep, suffix = kotlin_name.partition('__') + return snake_case(base) + sep + suffix + + + def pythonic_aliases(kotlin_names, rule=python_name): + '''`{alias: kotlin_name}` for the names of one namespace, ambiguity refused. + + An alias is served when it differs from its Kotlin name, is a Python identifier, is not + itself one of [kotlin_names] (that Kotlin name owns it), and exactly one Kotlin name maps + to it. Two Kotlin names that map to one alias (`toURL` and `toUrl` -> `to_url`) serve no + alias at all: both stay reachable by their Kotlin names only. Independent of order. + ''' + names = set(kotlin_names) + found = {} + clashed = set() + for kotlin in names: + if not isinstance(kotlin, str): + continue + alias = rule(kotlin) + if alias == kotlin or alias in names or not alias.isidentifier(): + continue + other = found.get(alias) + if other is None: + found[alias] = kotlin + elif other != kotlin: + clashed.add(alias) + for alias in clashed: + del found[alias] + return found + + + def _parameter_aliases(names): + '''`{kotlin_parameter: pythonic_keyword}` for one declaration's own parameter names.''' + own = [name for name in names if name != _RECEIVER and not _is_synthetic(name)] + return {kotlin: alias for alias, kotlin in pythonic_aliases(own, snake_case).items()} + + + def keyword_slots(row): + '''`{keyword: slot}` for one table row: each Kotlin parameter name, and its Pythonic alias. + + The receiver and the synthetic Compose slots answer to no keyword. An alias that collides + with another parameter's Kotlin name, or that two parameters share, is not served + (`pythonic_aliases`); those parameters are reached by their Kotlin names. + ''' + names = row[_NAMES] or () + index = {} + for slot, name in enumerate(names): + if name == _RECEIVER or _is_synthetic(name): + continue + index.setdefault(name, slot) + for kotlin, alias in _parameter_aliases(names).items(): + index.setdefault(alias, index[kotlin]) + return index + + + def _module_aliases(module): + '''`{alias: kotlin_name}` for the names the proxy layer published on [module]. + + Recomputed when the proxy layer renders a new table or the module gains a name, so an alias + always reads the module's *current* Kotlin attribute and never a value cached from an + older render. + ''' + names = [name for name in module.__dict__ if not name.startswith('_')] + rows = getattr(type(module), '_pm_kotlin_rows', None) or {} + names.extend(name for name in rows if name not in module.__dict__) + key = (_proxy_epoch, len(names)) + cached = module.__dict__.get('_pm_aliases') + if cached is not None and cached[0] == key: + return cached[1] + aliases = pythonic_aliases(names) + module.__dict__['_pm_aliases'] = (key, aliases) + return aliases + + + def module_alias_getattr(module): + '''The PEP 562 `__getattr__` the proxy layer gives a module it creates: Pythonic aliases. + + Asked only for a name the module does not have. The binding layer, when it adapts the + module, replaces this with its own `__getattr__`, which serves the same aliases from the + table. The alias is never written into the module: every read goes to the Kotlin name. + ''' + def __getattr__(name): + if name.startswith('__') and name.endswith('__'): + raise AttributeError(name) + kotlin = _module_aliases(module).get(name) + if kotlin is None: + raise AttributeError("module '" + module.__name__ + "' has no attribute '" + name + "'") + return getattr(module, kotlin) + + return __getattr__ + + def _is_composable_lambda(type_name): if not type_name or not type_name.startswith(_FUNCTION_PREFIX): return False @@ -243,6 +406,7 @@ object KotlinSurface { tags = row[_TAGS] or () types = row[_TYPES] or () defaults = row[_DEFAULTS] or () + aliases = _parameter_aliases(names) parameters = [] for index in range(row[_ARITY]): name = _field(names, index) @@ -252,6 +416,9 @@ object KotlinSurface { tag = _field(tags, index) parameters.append({ 'name': name, + # The keyword `inspect.signature` shows for it: its snake_case alias, or its Kotlin + # name where no alias is served (issue #131). Both are accepted by a call. + 'python_name': aliases.get(name, name), 'type': type_name, 'tag': tag, 'has_default': bool(_field(defaults, index)), @@ -312,11 +479,16 @@ object KotlinSurface { # is kept on the module's type (`PythonProxySource._pm_kotlin_rows`), and a rendered # function is found by `getattr_static`, which runs no descriptor -- so a constant's # Kotlin getter is never invoked here. - row = (getattr(type(module), '_pm_kotlin_rows', None) or {}).get(name) + kotlin = name + row = (getattr(type(module), '_pm_kotlin_rows', None) or {}).get(kotlin) + if row is None and not name.startswith('_'): + # A Pythonic alias describes the Kotlin declaration it is served for (issue #131). + kotlin = _module_aliases(module).get(name, name) + row = (getattr(type(module), '_pm_kotlin_rows', None) or {}).get(kotlin) if row is not None: rows = (row,) else: - rows = getattr(_inspect.getattr_static(module, name, None), '__kotlin_rows__', None) + rows = getattr(_inspect.getattr_static(module, kotlin, None), '__kotlin_rows__', None) if not rows: raise AttributeError( "module '" + module.__name__ + "' has no bound Kotlin declaration named '" + name + "'" @@ -358,6 +530,10 @@ object KotlinSurface { def signature_of(rows, drop_receiver=False): '''The `inspect.Signature` of one declaration; `(*args, **kwargs)` for anything else. + Each parameter is shown under its **Pythonic** keyword (`on_checked_change`), or under its + Kotlin name where no alias is served (issue #131); a call accepts both spellings, and + `describe` carries both (`name`, `python_name`). + An overload set has no single signature, and a declaration whose Kotlin parameter name Python cannot spell (`in`, `is`, `from` ...) cannot be written as one -- its keyword is still accepted through `**{...}`, and `describe` still carries the true name. @@ -376,7 +552,12 @@ object KotlinSurface { if drop_receiver and params: params = params[1:] return _inspect.Signature(params, return_annotation=_annotation(_declared_return(row[_RTYPE]))) - receiver_name = 'receiver' if 'receiver' not in names else '_receiver' + aliases = _parameter_aliases(names) + shown = {} + for name in names: + alias = aliases.get(name) + shown[name] = alias if alias is not None and not _keyword.iskeyword(alias) else name + receiver_name = 'receiver' if 'receiver' not in shown.values() else '_receiver' kind = P.POSITIONAL_OR_KEYWORD seen_default = False for index, name in enumerate(names): @@ -387,6 +568,7 @@ object KotlinSurface { if not drop_receiver: params.append(P(receiver_name, P.POSITIONAL_ONLY, annotation=annotation)) continue + name = shown[name] if not name.isidentifier() or _keyword.iskeyword(name): return _GENERIC has_default = bool(_field(defaults, index)) @@ -436,8 +618,11 @@ object KotlinSurface { _MISSING = _Missing() - def bind_slots(row, args, kwargs): - '''Positional and Kotlin-named keyword arguments -> one value per slot of [row]. + def bind_slots(row, args, kwargs, keywords=None): + '''Positional and keyword arguments -> one value per slot of [row]. + + A keyword is a Kotlin parameter name or its Pythonic alias ([keywords], `keyword_slots` + when not given); writing both spellings of one parameter is "multiple values". A defaulted slot the call left out (or filled with `KOTLIN_DEFAULT`) becomes `None`: the walked Kotlin body tests `args[i] == null` and takes a call expression that does not @@ -457,13 +642,10 @@ object KotlinSurface { if len(args) > arity: raise TypeError(name + '() takes ' + str(arity) + ' arguments, got ' + str(len(args))) slots = list(args) + [_MISSING] * (arity - len(args)) + if kwargs and keywords is None: + keywords = keyword_slots(row) for key, value in kwargs.items(): - index = -1 - if key != _RECEIVER and not _is_synthetic(key): - for slot, declared in enumerate(names): - if declared == key: - index = slot - break + index = keywords.get(key, -1) if index < 0: raise TypeError(name + "() got an unexpected keyword argument '" + key + "'") if slots[index] is not _MISSING: @@ -492,7 +674,8 @@ object KotlinSurface { answer is the same whichever installer ran first; across tables the newer install wins, and neither layer's handles from an older table are ever used; - otherwise a call that fills every slot positionally goes straight to [raw], and any - other call is mapped onto the slots by Kotlin parameter name first (`bind_slots`). + other call is mapped onto the slots by Kotlin parameter name or Pythonic alias first + (`bind_slots`; the keyword map is built on the first call that passes a keyword). ''' name = row[_NAME] arity = row[_ARITY] @@ -500,6 +683,7 @@ object KotlinSurface { has_defaults = any(row[_DEFAULTS] or ()) modules = _sys.modules default = KOTLIN_DEFAULT + keywords = [] def kotlin(*args, **kwargs): binding = modules.get(BINDING_MODULE) @@ -515,7 +699,9 @@ object KotlinSurface { break else: return raw(*args) - return raw(*bind_slots(row, args, kwargs)) + if kwargs and not keywords: + keywords.append(keyword_slots(row)) + return raw(*bind_slots(row, args, kwargs, keywords[0] if keywords else None)) kotlin.__wrapped__ = _SignatureCarrier(raw, (row,)) kotlin.__kotlin_rows__ = (row,) diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PythonProxySource.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PythonProxySource.kt index b17fd4184..4b941f078 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PythonProxySource.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PythonProxySource.kt @@ -299,6 +299,10 @@ object PythonProxySource { # Kotlin defaults for what a call leaves out, and the signature as public metadata. See # `KotlinSurface`. from python_multiplatform import kotlin_function as _pm_kotlin_function + # Each module this layer creates also answers the Pythonic alias of a Kotlin name it holds + # (`remember_text_field_state` for `rememberTextFieldState`, issue #131), through a PEP 562 + # `__getattr__` that runs only for a name the module does not have. See `KotlinSurface`. + from python_multiplatform import module_alias_getattr as _pm_module_alias_getattr def _pm_settle(_fut, _ok, _payload): @@ -351,6 +355,10 @@ object PythonProxySource { _m = _pm_sys.modules.get(_name) if _m is None: _m = _pm_types.ModuleType(_name) + # Only on a module this layer creates: one the binding layer made already has its + # own `__getattr__`, which serves the same aliases from its table, and a module + # anybody else made is theirs. + _m.__getattr__ = _pm_module_alias_getattr(_m) _pm_sys.modules[_name] = _m if '.' in _name: _parent, _, _leaf = _name.rpartition('.') diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/ComposeShapedFragment.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/ComposeShapedFragment.kt index 4ad98bdcd..c6bc03926 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/ComposeShapedFragment.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/ComposeShapedFragment.kt @@ -32,9 +32,9 @@ import python.multiplatform.reflection.TypeTag * | `padding__Dp_Dp` / `__Dp_Dp_Dp_Dp` again | omitting a defaulted argument, including one in the *middle* of the list | * | `fillMaxHeight` | the same, under a name with no overload set: omission with no dispatcher involved | * | `size__Dp` | the second link of a chain, so the return really is a receiver again | - * | `fillMaxWidth` | a receiver-only extension, reached under its Kotlin name and no other | - * | `zIndex` | a genuine `kotlin.Float` parameter beside `Dp` ones: same tag, different declared type | - * | `toURLString` | an acronym-bearing name, which once tested a snake_case rule and now only has to resolve as itself | + * | `fillMaxWidth` | a receiver-only extension, reached as `fillMaxWidth` and as its Pythonic alias `fill_max_width` | + * | `zIndex` | a genuine `kotlin.Float` parameter beside `Dp` ones: same tag, different declared type; and `z_index`, the case where a one-letter first segment must not be swallowed | + * | `toURLString` | a name the snake -> camel rule **cannot** invert (`to_url_string` -> `toUrlString`); only the forward conversion of the module's own names reaches it | * | `paddingFromBaseline__TextUnit` | the value-class reject list: a packed wrapper must refuse a raw number | * | `emptyModifier` | where a chain starts. Compose has no bound declaration for this; see [EMPTY_MODIFIER] | * | `Arrangement.Start` / `.End` | `kind = STATIC_GETTER`: a value behind a name, read as an attribute and not called | diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonicNameShapedFragment.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonicNameShapedFragment.kt new file mode 100644 index 000000000..eaec28342 --- /dev/null +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonicNameShapedFragment.kt @@ -0,0 +1,156 @@ +package python.multiplatform.ffi.pythonx + +import python.multiplatform.reflection.CallableKind +import python.multiplatform.reflection.ExposedCallable +import python.multiplatform.reflection.FunctionTableFragment +import python.multiplatform.reflection.TypeTag + +/** + * A table built to make the Pythonic naming rule (issue #131) **collide**, backed by Kotlin this + * repository owns. + * + * Every entry is here to say one thing about the alias policy in `KotlinSurface.kt` + * (`pythonic_aliases`): an alias is served only when it is unambiguous in its namespace. + * + * | entry | namespace | what it pins | + * |---|---|---| + * | `toURL` / `toUrl` | module | two Kotlin names, one alias (`to_url`): no alias at all, both Kotlin names work | + * | `fooBar` / `foo_bar` | module | the alias of one is the Kotlin name of the other: `foo_bar` is the Kotlin `foo_bar`, never `fooBar` | + * | `rememberTextFieldState` | module | the ordinary case: `remember_text_field_state`, keywords both ways, a Kotlin default | + * | `clicked(onClick, on_click)` | keywords | `onClick`'s alias is the other parameter's Kotlin name: `on_click=` is that parameter | + * | `pair(xURL, xUrl)` | keywords | two parameters, one alias (`x_url`): neither gets it | + * | `box` / [BOX] | proxy | a receiver type for the member namespace | + * | `ext.labelURL` / `ext.labelUrl` on [BOX] | proxy | two members, one alias (`label_url`): none | + * | `ext.fillMax` on [BOX] | proxy | the ordinary member case: `fill_max` | + * | `Box.selectedIndex` (`var`) | proxy | a property read and written through its alias `selected_index` | + * + * [ProxyOnly] is a second fragment for the proxy layer on its own (`PythonProxySource`), with no + * binding layer: module aliases and keyword aliases have to work there too. + */ +object PythonicNameShapedFragment : FunctionTableFragment { + + override val moduleName: String = "test_pythonic_names" + + const val PACKAGE = "pythonic.names" + + const val BOX = "$PACKAGE.Box" + + private const val STRING = "kotlin.String" + + private const val INT = "kotlin.Int" + + override fun entries(): List = listOf( + stringFunction("toURL") { "toURL:" + it }, + stringFunction("toUrl") { "toUrl:" + it }, + ExposedCallable( + name = "$PACKAGE.fooBar", arity = 0, paramTypes = emptyList(), returnType = TypeTag.STRING, + paramNames = emptyList(), paramTypeNames = emptyList(), returnTypeName = STRING, + ) { "fooBar" }, + ExposedCallable( + name = "$PACKAGE.foo_bar", arity = 0, paramTypes = emptyList(), returnType = TypeTag.STRING, + paramNames = emptyList(), paramTypeNames = emptyList(), returnTypeName = STRING, + ) { "foo_bar" }, + rememberTextFieldState("$PACKAGE.rememberTextFieldState"), + ExposedCallable( + name = "$PACKAGE.clicked", arity = 2, + paramTypes = listOf(TypeTag.INT, TypeTag.INT), returnType = TypeTag.STRING, + paramNames = listOf("onClick", "on_click"), paramTypeNames = listOf(INT, INT), returnTypeName = STRING, + ) { args -> "onClick=${args[0]}, on_click=${args[1]}" }, + ExposedCallable( + name = "$PACKAGE.pair", arity = 2, + paramTypes = listOf(TypeTag.INT, TypeTag.INT), returnType = TypeTag.STRING, + paramNames = listOf("xURL", "xUrl"), paramTypeNames = listOf(INT, INT), returnTypeName = STRING, + ) { args -> "xURL=${args[0]}, xUrl=${args[1]}" }, + ExposedCallable( + name = "$PACKAGE.box", arity = 1, + paramTypes = listOf(TypeTag.STRING), returnType = TypeTag.OBJECT, + paramNames = listOf("label"), paramTypeNames = listOf(STRING), returnTypeName = BOX, + ) { args -> PythonicBox(args[0] as String) }, + ExposedCallable( + name = "$PACKAGE.describeBox", arity = 1, + paramTypes = listOf(TypeTag.OBJECT), returnType = TypeTag.STRING, + paramNames = listOf("box"), paramTypeNames = listOf(BOX), returnTypeName = STRING, + ) { args -> (args[0] as PythonicBox).describe() }, + boxExtension("labelURL") { box, _ -> box.plus("labelURL") }, + boxExtension("labelUrl") { box, _ -> box.plus("labelUrl") }, + ExposedCallable( + name = "$PACKAGE.ext.fillMax", arity = 2, + paramTypes = listOf(TypeTag.OBJECT, TypeTag.FLOAT), returnType = TypeTag.OBJECT, + paramNames = listOf("", "maxFraction"), paramTypeNames = listOf(BOX, "kotlin.Float"), + returnTypeName = BOX, isExtension = true, receiverTypeName = BOX, + paramHasDefault = listOf(false, true), + ) { args -> + (args[0] as PythonicBox).plus(if (args[1] == null) "fillMax()" else "fillMax(${args[1]})") + }, + ExposedCallable( + name = "$BOX.selectedIndex", arity = 0, paramTypes = emptyList(), returnType = TypeTag.INT, + kind = CallableKind.GETTER, paramNames = emptyList(), paramTypeNames = emptyList(), + returnTypeName = INT, receiverTypeName = BOX, + ) { args -> + // TypeTag.INT crosses as a Long (the boundary casts the result to it); a Kotlin Int result + // is the fixture's mistake, not the alias path's. + (args[0] as PythonicBox).selectedIndex.toLong() + }, + ExposedCallable( + name = "$BOX.selectedIndex=", arity = 1, paramTypes = listOf(TypeTag.INT), returnType = TypeTag.UNIT, + kind = CallableKind.SETTER, paramNames = listOf("value"), paramTypeNames = listOf(INT), + returnTypeName = "kotlin.Unit", receiverTypeName = BOX, paramHasDefault = listOf(false), + ) { args -> + (args[0] as PythonicBox).selectedIndex = (args[1] as Number).toInt() + Unit + }, + ) + + /** The proxy layer on its own: one package, rendered by `PythonProxySource` with no binding layer. */ + object ProxyOnly : FunctionTableFragment { + + const val PACKAGE = "pythonic.proxyonly" + + override val moduleName: String = "test_pythonic_names_proxy_only" + + override fun entries(): List = listOf( + rememberTextFieldState("$PACKAGE.rememberTextFieldState"), + ExposedCallable( + name = "$PACKAGE.toURL", arity = 1, paramTypes = listOf(TypeTag.STRING), returnType = TypeTag.STRING, + paramNames = listOf("s"), paramTypeNames = listOf(STRING), returnTypeName = STRING, + ) { args -> "toURL:" + args[0] }, + ExposedCallable( + name = "$PACKAGE.toUrl", arity = 1, paramTypes = listOf(TypeTag.STRING), returnType = TypeTag.STRING, + paramNames = listOf("s"), paramTypeNames = listOf(STRING), returnTypeName = STRING, + ) { args -> "toUrl:" + args[0] }, + ) + } + + /** + * `rememberTextFieldState(initialText: String, maxLength: Int = )`. The body is written + * the way a walked defaulted body is (`ArtifactScanner.presenceBranchedCall`): `null` in the + * defaulted slot means "not written", and the label says so. + */ + private fun rememberTextFieldState(name: String) = ExposedCallable( + name = name, arity = 2, + paramTypes = listOf(TypeTag.STRING, TypeTag.INT), returnType = TypeTag.STRING, + paramNames = listOf("initialText", "maxLength"), paramTypeNames = listOf(STRING, INT), + returnTypeName = STRING, paramHasDefault = listOf(false, true), + ) { args -> "state(${args[0]}" + (if (args[1] == null) "" else ", max=${args[1]}") + ")" } + + private fun stringFunction(leaf: String, body: (String) -> String) = ExposedCallable( + name = "$PACKAGE.$leaf", arity = 1, paramTypes = listOf(TypeTag.STRING), returnType = TypeTag.STRING, + paramNames = listOf("s"), paramTypeNames = listOf(STRING), returnTypeName = STRING, + ) { args -> body(args[0] as String) } + + private fun boxExtension(leaf: String, body: (PythonicBox, List) -> PythonicBox) = ExposedCallable( + name = "$PACKAGE.ext.$leaf", arity = 1, paramTypes = listOf(TypeTag.OBJECT), returnType = TypeTag.OBJECT, + paramNames = listOf(""), paramTypeNames = listOf(BOX), returnTypeName = BOX, + isExtension = true, receiverTypeName = BOX, paramHasDefault = listOf(false), + ) { args -> body(args[0] as PythonicBox, args.drop(1)) } +} + +/** What a `pythonic.names.Box` is for [PythonicNameShapedFragment]: a label, a trail, and one `var`. */ +class PythonicBox(private val label: String, private val trail: List = emptyList()) { + + var selectedIndex: Int = 0 + + fun plus(step: String): PythonicBox = PythonicBox(label, trail + step).also { it.selectedIndex = selectedIndex } + + fun describe(): String = (listOf(label) + trail).joinToString(" -> ") + " [selected=$selectedIndex]" +} diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonicNameTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonicNameTest.kt new file mode 100644 index 000000000..86b11979a --- /dev/null +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonicNameTest.kt @@ -0,0 +1,222 @@ +package python.multiplatform.ffi.pythonx + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.PythonTestFixture +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.publishesProxyEntryPoints +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import python.native.ffi.bindUpcallOrNull +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertTrue + +/** + * Issue #131: on a Kotlin-named module, a declaration, a proxy member and a keyword parameter are + * reachable by their Kotlin names **and** by their Pythonic (snake_case) aliases -- and an alias is + * served only when it is unambiguous ([PythonicNameShapedFragment] is built to collide). + * + * The policy under test (`KotlinSurface.kt`, `pythonic_aliases`): + * + * - an alias that is itself a Kotlin name of the namespace belongs to that Kotlin name; + * - an alias two Kotlin names map to is not served, and both Kotlin names still work; + * - `dir()` and a proxy's class dict list Kotlin names only; + * - `inspect.signature` shows the Pythonic keyword, `describe` both spellings. + * + * Red before #131: every snake_case spelling below is an `AttributeError` or an "unexpected keyword" + * `TypeError`, because `238119b7` removed the conversion. + */ +class PythonicNameTest { + + @AfterTest + fun cleanup() { + UpcallTable.clear() + HandleTable.releaseAll() + } + + /** `remember_text_field_state` is `rememberTextFieldState`; `to_url` is nobody's; `foo_bar` is `foo_bar`'s. */ + @Test + fun aModuleAliasIsServedOnlyWhenItIsUnambiguous() = withBinding { + Python3.exec( + """ + import python_multiplatform as _pn_pm + import pythonic.names as _pn + _pn_r = { + 'same': _pn.remember_text_field_state is _pn.rememberTextFieldState, + 'snake_call': _pn.remember_text_field_state('a', max_length=3), + 'kotlin_call': _pn.rememberTextFieldState('c', maxLength=4), + 'mixed_call': _pn.rememberTextFieldState(initial_text='b'), + 'to_url': hasattr(_pn, 'to_url'), + 'to_urls': [_pn.toURL('x'), _pn.toUrl('x')], + 'foo_bar': _pn.foo_bar(), + 'fooBar': _pn.fooBar(), + 'dir_snake': [_n for _n in dir(_pn) if _n in ('remember_text_field_state', 'describe_box', 'to_url')], + 'described': _pn_pm.describe(_pn, 'remember_text_field_state')['name'], + } + """.trimIndent(), + ) + assertEquals("True", eval("_pn_r['same']"), "an alias must be the Kotlin name's own object") + assertEquals("state(a, max=3)", eval("_pn_r['snake_call']")) + assertEquals("state(c, max=4)", eval("_pn_r['kotlin_call']")) + assertEquals("state(b)", eval("_pn_r['mixed_call']"), "an omitted Kotlin default must stay omitted") + assertEquals("False", eval("_pn_r['to_url']"), "two Kotlin names map to to_url: neither may own it") + assertEquals("['toURL:x', 'toUrl:x']", eval("repr(_pn_r['to_urls'])")) + assertEquals("foo_bar", eval("_pn_r['foo_bar']"), "the Kotlin name foo_bar owns its spelling") + assertEquals("fooBar", eval("_pn_r['fooBar']")) + assertEquals("[]", eval("repr(_pn_r['dir_snake'])"), "dir() lists Kotlin names only") + assertEquals("pythonic.names.rememberTextFieldState", eval("_pn_r['described']")) + } + + /** `on_click=` is the parameter Kotlin calls `on_click`; `x_url=` names neither of two parameters. */ + @Test + fun aKeywordAliasIsServedOnlyWhenItIsUnambiguous() = withBinding { + Python3.exec( + """ + import inspect as _pn_inspect + import python_multiplatform as _pn_pm + import pythonic.names as _pn + _pn_r = { + 'clicked': _pn.clicked(onClick=1, on_click=2), + 'clicked_pos': _pn.clicked(1, on_click=2), + 'clicked_sig': list(_pn_inspect.signature(_pn.clicked).parameters), + 'pair': _pn.pair(xURL=1, xUrl=2), + 'pair_sig': list(_pn_inspect.signature(_pn.pair).parameters), + 'remember_sig': [ + (_p.name, _p.default is _pn_pm.KOTLIN_DEFAULT) + for _p in _pn_inspect.signature(_pn.rememberTextFieldState).parameters.values() + ], + 'remember_described': [ + (_q['name'], _q['python_name']) + for _q in _pn_pm.describe(_pn.rememberTextFieldState)[0]['parameters'] + ], + } + for _pn_label, _pn_call in ( + ('x_url', lambda: _pn.pair(1, x_url=2)), + ('both', lambda: _pn.rememberTextFieldState('a', maxLength=1, max_length=2)), + ): + try: + _pn_call() + _pn_r[_pn_label] = 'accepted' + except TypeError as _pn_e: + _pn_r[_pn_label] = 'TypeError' + """.trimIndent(), + ) + assertEquals("onClick=1, on_click=2", eval("_pn_r['clicked']")) + assertEquals("onClick=1, on_click=2", eval("_pn_r['clicked_pos']")) + assertEquals("['onClick', 'on_click']", eval("repr(_pn_r['clicked_sig'])"), "no alias may shadow a Kotlin name") + assertEquals("xURL=1, xUrl=2", eval("_pn_r['pair']")) + assertEquals("['xURL', 'xUrl']", eval("repr(_pn_r['pair_sig'])")) + assertEquals("TypeError", eval("_pn_r['x_url']"), "x_url would name two parameters") + assertEquals("TypeError", eval("_pn_r['both']"), "two spellings of one parameter are two values") + assertEquals( + "[('initial_text', False), ('max_length', True)]", + eval("repr(_pn_r['remember_sig'])"), + "inspect.signature shows the Pythonic keyword and keeps the Kotlin default", + ) + assertEquals( + "[('initialText', 'initial_text'), ('maxLength', 'max_length')]", + eval("repr(_pn_r['remember_described'])"), + ) + } + + /** `fill_max` is `fillMax`; `label_url` is nobody's; `selected_index` reads and writes the `var`. */ + @Test + fun aMemberAliasIsServedOnlyWhenItIsUnambiguous() = withBinding { + Python3.exec( + """ + import python_multiplatform as _pn_pm + from pythonic.names import box, describe_box + _pn_box = box('b') + _pn_r = { + 'fill': describe_box(_pn_box.fill_max(max_fraction=0.5).fillMax()), + 'label_url': hasattr(_pn_box, 'label_url'), + 'labels': describe_box(_pn_box.labelURL().labelUrl()), + 'member_described': [_d['name'] for _d in _pn_pm.describe_member('pythonic.names.Box', 'fill_max')], + } + _pn_box.selected_index = 4 + _pn_r['read_alias'] = _pn_box.selected_index + _pn_r['read_kotlin'] = _pn_box.selectedIndex + _pn_r['kotlin_side'] = describe_box(_pn_box) + _pn_r['class_dict'] = [_n for _n in ('fill_max', 'selected_index') if _n in vars(type(_pn_box))] + _pn_r['instance_dict'] = [_n for _n in ('selected_index',) if _n in _pn_box.__dict__] + """.trimIndent(), + ) + assertEquals("b -> fillMax(0.5) -> fillMax() [selected=0]", eval("_pn_r['fill']")) + assertEquals("False", eval("_pn_r['label_url']"), "two members map to label_url: neither may own it") + assertEquals("b -> labelURL -> labelUrl [selected=0]", eval("_pn_r['labels']")) + assertEquals("['pythonic.names.ext.fillMax']", eval("repr(_pn_r['member_described'])")) + assertEquals("4", eval("_pn_r['read_alias']")) + assertEquals("4", eval("_pn_r['read_kotlin']")) + assertEquals("b [selected=4]", eval("_pn_r['kotlin_side']"), "the alias write has to reach Kotlin's setter") + assertEquals("[]", eval("repr(_pn_r['class_dict'])"), "an alias is never written onto the proxy class") + assertEquals("[]", eval("repr(_pn_r['instance_dict'])"), "an alias write must not land in the instance dict") + } + + /** + * The proxy layer on its own (`PythonProxySource`, no binding layer): module aliases through the + * PEP 562 `__getattr__` it gives the modules it creates, and keyword aliases through + * `python_multiplatform.kotlin_function`. The collision policy is the same. + */ + @Test + fun theProxyLayerAloneServesTheSameAliases() = PythonTestFixture.withInterpreter { + UpcallTable.install(listOf(PythonicNameShapedFragment.ProxyOnly)) + assertTrue(bindUpcallOrNull("pythonic.proxyonly.toURL"), "the fixture table is not installed") + if (!publishesProxyEntryPoints) { + assertFails { PythonProxySource.install() } + return@withInterpreter + } + Python3.exec( + """ + import sys as _pn_sys + _pn_saved = _pn_sys.modules.pop('python_multiplatform.binding', None) + for _pn_n in [_n for _n in _pn_sys.modules if _n == 'pythonic.proxyonly' or _n.startswith('pythonic.proxyonly.')]: + del _pn_sys.modules[_pn_n] + """.trimIndent(), + ) + try { + PythonProxySource.install() + Python3.exec( + """ + import pythonic.proxyonly as _pn + _pn_r = { + 'same': _pn.remember_text_field_state is _pn.rememberTextFieldState, + 'snake_call': _pn.remember_text_field_state('a', max_length=3), + 'kotlin_kw': _pn.rememberTextFieldState(initialText='b'), + 'to_url': hasattr(_pn, 'to_url'), + 'cached': 'remember_text_field_state' in vars(_pn), + } + """.trimIndent(), + ) + } finally { + Python3.exec( + "if _pn_saved is not None:\n" + + " _pn_sys.modules['python_multiplatform.binding'] = _pn_saved", + ) + } + assertEquals("True", eval("_pn_r['same']")) + assertEquals("state(a, max=3)", eval("_pn_r['snake_call']")) + assertEquals("state(b)", eval("_pn_r['kotlin_kw']")) + assertEquals("False", eval("_pn_r['to_url']")) + assertEquals("False", eval("_pn_r['cached']"), "the proxy layer reads an alias through the Kotlin name every time") + } + + /** Installs [PythonicNameShapedFragment] and the binding layer, as `PythonxAdapterTest.withAdapter` does. */ + private inline fun withBinding(block: () -> Unit) = PythonTestFixture.withInterpreter { + UpcallTable.install(listOf(PythonicNameShapedFragment)) + assertTrue(bindUpcallOrNull("${PythonicNameShapedFragment.PACKAGE}.box"), "the fixture table is not installed") + if (!publishesProxyEntryPoints) { + val refusal = assertFails { PythonxAdapter.install() } + assertTrue( + refusal.message?.contains("raw upcall entry points are not bound") == true, + "a target with no proxy bootstrap must fail the adapter's own guard: $refusal", + ) + return@withInterpreter + } + PythonxAdapter.install() + block() + } + + private fun eval(expression: String): String = PythonTestFixture.eval(expression).toString() +} diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapterTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapterTest.kt index 81fadf8f8..bb4f06098 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapterTest.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapterTest.kt @@ -147,42 +147,111 @@ class PythonxAdapterTest { } /** - * A Kotlin name is reached as itself, and no renamed spelling exists. + * §3's rule, forwards, on the names the fixture carries -- restored by issue #131 after + * `238119b7` removed it. * - * This used to pin a snake_case rule in both directions (`fillMaxWidth` <-> `fill_max_width`) - * and the index that made `toURLString` reachable as `to_url_string`. The binder renames - * nothing now (`docs/INTENT.md` §2.2): Pythonic spellings are the business of a Python package - * built on top, so the rule and its inverse are gone, and asking for the snake_case spelling is - * an ordinary `AttributeError`. + * The rule is pythonx-compose 0.1.0a1's: a lower-case-first name is snake_case with a run of + * capitals as one word, an upper-case-first name is unchanged, and an explicit overload key keeps + * its suffix. There is still no reverse rule: `toURLString` is reachable as `to_url_string` because + * the forward conversion of the module's own Kotlin names is what is looked up, and a reverse rule + * would have produced `toUrlString`, a declaration nobody wrote. */ @Test - fun aKotlinNameIsReachedAsItselfAndNoRenamedSpellingExists() = withAdapter { + fun theNameRuleConvertsKotlinToPythonAndEitherSpellingReachesOneDeclaration() = withAdapter { Python3.exec( """ + import python_multiplatform as _pm import python_multiplatform.binding as _pm_binding import androidx.compose.foundation.layout as _layout import androidx.compose.ui.draw as _draw import androidx.compose.ui.util as _util _px = { - 'kotlin': [ - _n for _m, _n in ( - (_layout, 'fillMaxWidth'), (_draw, 'zIndex'), (_layout, 'padding__Dp_Dp'), - (_util, 'toURLString'), - ) if getattr(_m, _n, None) is not None + 'fill': _pm.python_name('fillMaxWidth'), + 'z': _pm.python_name('zIndex'), + 'overload': _pm.python_name('padding__Dp_Dp'), + 'suffixed': _pm.python_name('paddingFromBaseline__TextUnit'), + 'type': _pm.python_name('Modifier'), + 'acronym': _pm.python_name('toURLString'), + 'keyword': _pm.snake_case('paddingValues'), + 'same': [ + _k for _m, _k, _s in ( + (_layout, 'fillMaxWidth', 'fill_max_width'), (_draw, 'zIndex', 'z_index'), + (_util, 'toURLString', 'to_url_string'), + (_layout, 'paddingFromBaseline__TextUnit', 'padding_from_baseline__TextUnit'), + (_layout, 'paddingValuesOf', 'padding_values_of'), + ) if getattr(_m, _s) is getattr(_m, _k) ], - 'snake': [ - _n for _m, _n in ( - (_layout, 'fill_max_width'), (_draw, 'z_index'), (_util, 'to_url_string'), - ) if hasattr(_m, _n) - ], - 'rule': [_n for _n in ('to_python_name', 'to_kotlin_name') if hasattr(_pm_binding, _n)], + 'url': _util.to_url_string('x'), + 'no_reverse': hasattr(_util, 'toUrlString'), + 'rule_in_binding': [_n for _n in ('to_python_name', 'to_kotlin_name') if hasattr(_pm_binding, _n)], } """.trimIndent(), ) - assertEquals("['fillMaxWidth', 'zIndex', 'padding__Dp_Dp', 'toURLString']", eval("repr(_px['kotlin'])")) - assertEquals("[]", eval("repr(_px['snake'])"), "no snake_case spelling may resolve") - assertEquals("[]", eval("repr(_px['rule'])"), "the binder must carry no renaming rule") + assertEquals("fill_max_width", eval("_px['fill']")) + assertEquals("z_index", eval("_px['z']")) + // The overload suffix is a list of Kotlin type names and stays PascalCase; only the base + // name is a function name. + assertEquals("padding__Dp_Dp", eval("_px['overload']")) + assertEquals("padding_from_baseline__TextUnit", eval("_px['suffixed']")) + assertEquals("Modifier", eval("_px['type']")) + assertEquals("to_url_string", eval("_px['acronym']")) + assertEquals("padding_values", eval("_px['keyword']")) + assertEquals( + "['fillMaxWidth', 'zIndex', 'toURLString', 'paddingFromBaseline__TextUnit', 'paddingValuesOf']", + eval("repr(_px['same'])"), + "an alias must be the very object its Kotlin name is", + ) + assertEquals("url:x", eval("_px['url']")) + assertEquals("False", eval("_px['no_reverse']"), "a reverse rule would invent toUrlString") + // The rule is the root module's, once; the binding layer carries no second copy of it. + assertEquals("[]", eval("repr(_px['rule_in_binding'])")) + } + + /** + * §3's invariant, stated as the design states it -- *every name the `.pyi` generator emits must + * resolve through the adapter* -- restored by issue #131. + * + * The generator writes `python_name(kotlin)` beside every Kotlin name, so this converts every + * entry in the table forwards and requires the module to answer with the very object the Kotlin + * name gives -- including `toURLString`, which a reverse rule provably cannot produce (above). + * Counted, so an empty failure list means something. + */ + @Test + fun everyBoundNameSurvivesTheRoundTripThroughItsPythonicName() = withAdapter { + Python3.exec( + """ + import importlib as _importlib + import python_multiplatform as _pm + import python_multiplatform.binding as _pm_binding + _px = {'checked': 0, 'aliased': 0, 'bad': []} + for _kotlin in _pm_binding.bound_names(): + _package, _, _leaf = _kotlin.rpartition('.') + _python = _pm.python_name(_leaf) + _px['checked'] += 1 + _module = _importlib.import_module(_package) + try: + _by_kotlin = getattr(_module, _leaf) + _by_python = getattr(_module, _python) + except Exception as _e: + _px['bad'].append(_kotlin + ' -> ' + _python + ': ' + type(_e).__name__ + ': ' + str(_e)) + continue + if _python != _leaf: + _px['aliased'] += 1 + # A constant is read afresh on every access, so two reads are two proxies. + if callable(_by_kotlin) and _by_python is not _by_kotlin: + _px['bad'].append(_kotlin + ' -> ' + _python + ': a different object') + _px['bad'] = ', '.join(_px['bad']) + """.trimIndent(), + ) + + assertEquals("", eval("_px['bad']"), "a stub name that the adapter cannot resolve") + assertEquals( + UpcallTable.entries().size.toString(), + eval("_px['checked']"), + "every entry has to be checked, or the empty failure list means nothing", + ) + assertTrue(eval("_px['aliased']").toInt() >= 8, "too few entries had an alias to check: ${eval("_px['aliased']")}") } /** @@ -275,6 +344,39 @@ class PythonxAdapterTest { ) } + /** + * The same dispatch with the **Pythonic** keyword (issue #131, the spelling `238119b7` removed): + * `padding_values=` names only `padding__PaddingValues`'s `paddingValues`, so it is the keyword, + * not the type, that selects -- and the Kotlin keyword `paddingValues=` selects the same overload. + * Writing both spellings of one parameter is "two values", not a silent pick. + */ + @Test + fun anOverloadSetDispatchesOnPythonicKeywordNames() = withAdapter { + Python3.exec( + """ + from androidx.compose.foundation.layout import padding, padding_values_of + from androidx.compose.ui import empty_modifier, describe_modifier + _px = { + 'snake': describe_modifier(padding(empty_modifier(), padding_values=padding_values_of(2))), + 'kotlin': describe_modifier(padding(empty_modifier(), paddingValues=padding_values_of(3))), + } + try: + padding(empty_modifier(), paddingValues=padding_values_of(1), padding_values=padding_values_of(1)) + _px['both'] = 'call succeeded' + except TypeError as e: + _px['both'] = str(e) + """.trimIndent(), + ) + + assertEquals("padding(pv(2.0))", eval("_px['snake']")) + assertEquals("padding(pv(3.0))", eval("_px['kotlin']")) + assertTrue(eval("_px['both']") != "call succeeded", "two spellings of one parameter were both accepted") + assertEquals( + listOf("padding__PaddingValues", "padding__PaddingValues"), + ComposeShapedFragment.calls, + ) + } + /** * What the dispatcher does when it cannot decide, and what it does when the caller has already * decided. @@ -348,6 +450,38 @@ class PythonxAdapterTest { ) } + /** + * The same chain under the Pythonic method names (issue #131): `fill_max_width` and `z_index` + * are the Kotlin members `fillMaxWidth` and `zIndex`, from the class object and from an instance, + * with a snake_case keyword (`z_index=` is `zIndex`'s own parameter `zIndex`). The proxy class + * keeps Kotlin names only: an alias is resolved on each read and never written onto it. + */ + @Test + fun anExtensionIsReachedOnItsReceiverByItsPythonicNameToo() = withAdapter { + Python3.exec( + """ + from androidx.compose.ui import Modifier, describe_modifier + _from_class = Modifier.fill_max_width().z_index(2) + _from_instance = Modifier.empty().padding(16).fill_max_width().z_index(z_index=3) + _px = { + 'class': describe_modifier(_from_class), + 'instance': describe_modifier(_from_instance), + 'alias_in_class_dict': [_n for _n in ('fill_max_width', 'z_index') if _n in vars(type(_from_class))], + 'alias_in_dir': [_n for _n in ('fill_max_width', 'z_index') if _n in dir(_from_class)], + } + """.trimIndent(), + ) + + assertEquals("fillMaxWidth -> zIndex(2.0)", eval("_px['class']")) + assertEquals("padding(16.0) -> fillMaxWidth -> zIndex(3.0)", eval("_px['instance']")) + assertEquals("[]", eval("repr(_px['alias_in_class_dict'])")) + assertEquals("[]", eval("repr(_px['alias_in_dir'])")) + assertEquals( + listOf("fillMaxWidth", "zIndex", "padding__Dp", "fillMaxWidth", "zIndex"), + ComposeShapedFragment.calls, + ) + } + /** * The negative half of the test above, and the reason to trust it. * diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxCallableTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxCallableTest.kt index 7c7492c30..3b342a6e9 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxCallableTest.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxCallableTest.kt @@ -363,7 +363,7 @@ class PythonxCallableTest { """ from androidx.compose.material3 import Slider _px_seen = [] - Slider(onValueChange=lambda v: _px_seen.append(v)) + Slider(on_value_change=lambda v: _px_seen.append(v)) """.trimIndent(), ) @@ -597,8 +597,8 @@ class PythonxCallableTest { val refusal = assertFails { Python3.exec( """ - from androidx.compose.material3 import rememberSheetState - rememberSheetState(confirmValueChange=lambda v: True) + from androidx.compose.material3 import remember_sheet_state + remember_sheet_state(confirm_value_change=lambda v: True) """.trimIndent(), ) } diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxComposableTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxComposableTest.kt index 2ff8dddee..65fb0fe36 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxComposableTest.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxComposableTest.kt @@ -102,7 +102,7 @@ class PythonxComposableTest { Text('hi') Text('hi', modifier=None) Text('hi', color=3) - Text('hi', fontSize=4) + Text('hi', font_size=4) Text('hi', color=3, fontSize=4) """.trimIndent(), ) @@ -149,7 +149,7 @@ class PythonxComposableTest { """ from androidx.compose.foundation.layout import stubRowScope _row = stubRowScope() - _row.NavigationBarItem(selected=True, onClick=lambda: None) + _row.NavigationBarItem(selected=True, on_click=lambda: None) _row.NavigationBarItem(selected=True, onClick=lambda: None, enabled=False) """.trimIndent(), ) diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxDefaultsTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxDefaultsTest.kt index 80c0c550d..578d6ea87 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxDefaultsTest.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxDefaultsTest.kt @@ -72,7 +72,7 @@ class PythonxDefaultsTest { """ from androidx.compose.ui import Modifier, describeModifier _px = { - 'omitted': describeModifier(Modifier.fillMaxHeight()), + 'omitted': describeModifier(Modifier.fill_max_height()), 'given': describeModifier(Modifier.fillMaxHeight(0.5)), } """.trimIndent(), diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/upcall/PythonProxySourceTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/upcall/PythonProxySourceTest.kt index bb22a1dce..4105d343f 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/upcall/PythonProxySourceTest.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/upcall/PythonProxySourceTest.kt @@ -380,10 +380,26 @@ class PythonProxySourceTest { // 551-587 ns per read on desktop against 6-14 ns for the descriptor that replaced it. A // hook reintroduced here would be a fifty-fold regression on every top-level `val` read, // and it would be invisible -- both shapes are correct. + // + // Issue #131 adds one hook, and it is the exception that keeps the rule: the PEP 562 + // `__getattr__` of `KotlinSurface.module_alias_getattr` runs only for a name the module does + // NOT have (a Pythonic alias), so a real Kotlin name is still found by the descriptor first + // and never reaches it. It lives in the delivered `KotlinSurface` half; the proxy half, which + // renders every real attribute, must define no hook of its own. + val proxyHalf = PythonProxySource.support.removePrefix(python.multiplatform.ffi.upcall.KotlinSurface.DELIVERY) assertFalse( - PythonProxySource.support.contains("def __getattr__("), + proxyHalf.contains("def __getattr__("), "a module attribute must be answered by a data descriptor, not by the fallback hook", ) + assertEquals( + 1, + Regex("def __getattr__\\(").findAll(python.multiplatform.ffi.upcall.KotlinSurface.DELIVERY).count(), + "the only fallback hook is the alias one in module_alias_getattr", + ) + assertTrue( + python.multiplatform.ffi.upcall.KotlinSurface.DELIVERY.contains("def module_alias_getattr(module):"), + "the single hook must be the alias hook", + ) assertFalse( PythonProxySource.support.contains("def __setattr__("), "a data descriptor answers the write half too; a __setattr__ hook means one is missing", diff --git a/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/KotlinNamedSurfaceTest.kt b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/KotlinNamedSurfaceTest.kt index 3cf8906a4..bfaac20c2 100644 --- a/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/KotlinNamedSurfaceTest.kt +++ b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/KotlinNamedSurfaceTest.kt @@ -18,11 +18,12 @@ import kotlin.test.assertTrue * * `docs/INTENT.md` §2.2 and §2.3, as executable statements: * - * - A Kotlin declaration is reached under its Kotlin name, takes keyword arguments by its **Kotlin - * parameter names** and leaves an omitted defaulted parameter to Kotlin's own default. Nothing is - * snake_cased: `fillMaxWidth` is `fillMaxWidth`, and `fill_max_width` does not exist. + * - A Kotlin package is a module under its Kotlin name and nothing else: no namespace is renamed. + * - Inside it, a Kotlin declaration is reached under its Kotlin name **and** its Pythonic alias + * (`fillMaxWidth` / `fill_max_width`, issue #131), takes keyword arguments by either spelling of a + * parameter, and leaves an omitted defaulted parameter to Kotlin's own default. * - `pythonx` is a real package in pythonx-compose. The binder neither creates a `pythonx` module - * nor prevents one on disk from loading. + * nor prevents one on disk from loading -- and such a package can build on the aliases. * - Every function the binder puts on a Kotlin-named module carries its signature as public metadata * (`inspect.signature`, and `python_multiplatform.describe`) so a Pythonic layer can build on it * by rule rather than by a wrapper per declaration. @@ -123,46 +124,97 @@ class KotlinNamedSurfaceTest { assertTrue("horizontal_padding" in eval("_kns['unknown']"), eval("_kns['unknown']")) } - /** No snake_case anywhere: not as a member, not as a keyword. */ + /** + * Issue #131: a Kotlin name and its Pythonic alias are one declaration, and a keyword is accepted + * by either spelling -- whichever installer ran. (`238119b7` had made the snake_case member an + * `AttributeError` and the snake_case keyword a `TypeError`; this is the reversal of exactly + * those two assertions.) + */ @Test - fun aKotlinNameIsNotSnakeCased() = withBothInstalled { + fun aKotlinNameAndItsPythonicAliasAreOneDeclaration() = withBothInstalled { Python3.exec( """ + import python_multiplatform as _kns_pm import androidx.compose.foundation.layout as _kns_layout - from androidx.compose.ui import emptyModifier, describeModifier - _kns = {'kotlin': describeModifier(_kns_layout.fillMaxWidth(emptyModifier()))} - try: - _kns_layout.fill_max_width - _kns['snake_member'] = 'resolved' - except AttributeError: - _kns['snake_member'] = 'AttributeError' - try: - _kns_layout.padding__Dp_Dp(emptyModifier(), vertical=1.0) - _kns['kotlin_keyword'] = 'accepted' - except TypeError as _kns_e: - _kns['kotlin_keyword'] = str(_kns_e) - try: - _kns_layout.padding__PaddingValues(emptyModifier(), padding_values=None) - _kns['snake_keyword'] = 'accepted' - except TypeError as _kns_e: - _kns['snake_keyword'] = 'TypeError' - import python_multiplatform.binding as _kns_binding - _kns['renamers'] = [ - _n for _n in ('to_python_name', 'to_kotlin_name') if hasattr(_kns_binding, _n) - ] + from androidx.compose.ui import emptyModifier, describeModifier, empty_modifier, describe_modifier + _kns = { + 'kotlin': describeModifier(_kns_layout.fillMaxWidth(emptyModifier())), + 'snake': describe_modifier(_kns_layout.fill_max_width(empty_modifier())), + 'same': _kns_layout.fill_max_width is _kns_layout.fillMaxWidth, + 'kotlin_keyword': describeModifier(_kns_layout.padding__Dp_Dp(emptyModifier(), vertical=1.0)), + 'snake_keyword': describeModifier(_kns_layout.padding__PaddingValues( + emptyModifier(), padding_values=_kns_layout.padding_values_of(2))), + 'kotlin_keyword_pv': describeModifier(_kns_layout.padding__PaddingValues( + emptyModifier(), paddingValues=_kns_layout.paddingValuesOf(3))), + 'rule': _kns_pm.python_name('rememberTextFieldState'), + 'dir': [_n for _n in dir(_kns_layout) if _n in ('fill_max_width', 'padding_values_of')], + } """.trimIndent(), ) assertEquals("fillMaxWidth", eval("_kns['kotlin']")) - assertEquals("AttributeError", eval("_kns['snake_member']")) - assertEquals("accepted", eval("_kns['kotlin_keyword']")) - assertEquals("TypeError", eval("_kns['snake_keyword']")) - assertEquals("[]", eval("repr(_kns['renamers'])")) + assertEquals("fillMaxWidth", eval("_kns['snake']")) + assertEquals("True", eval("_kns['same']")) + assertEquals("padding(v=1.0)", eval("_kns['kotlin_keyword']")) + assertEquals("padding(pv(2.0))", eval("_kns['snake_keyword']")) + assertEquals("padding(pv(3.0))", eval("_kns['kotlin_keyword_pv']")) + assertEquals("remember_text_field_state", eval("_kns['rule']")) + assertEquals("[]", eval("repr(_kns['dir'])"), "dir() lists each declaration once, by its Kotlin name") + } + + /** + * AGENTS.md §12.2 with the aliases in place: a real `pythonx` package on disk loads, and its own + * code reaches the Kotlin-named module by the Pythonic names -- the namespace it imports is + * `androidx.*`, untouched, and the binder has made no `pythonx` module of its own. + */ + @Test + fun aRealPythonxPackageOnDiskBuildsOnThePythonicNames() = PythonTestFixture.withInterpreter { + installAdapter() + PythonProxySource.install() + Python3.exec( + """ + import os as _kns_os, sys as _kns_sys + _kns = { + 'occupied': sorted( + _n for _n in _kns_sys.modules if _n == 'pythonx' or _n.startswith('pythonx.') + ), + } + _kns_root = _kns_os.path.abspath( + _kns_os.path.join('build', 'tmp', 'kotlin-named-surface-pythonic', str(_kns_os.getpid())) + ) + _kns_os.makedirs(_kns_os.path.join(_kns_root, 'pythonx'), exist_ok=True) + with open(_kns_os.path.join(_kns_root, 'pythonx', '__init__.py'), 'w') as _kns_f: + _kns_f.write( + "from androidx.compose.foundation.layout import fill_max_width\n" + "from androidx.compose.ui import empty_modifier, describe_modifier\n" + "MARK = 'on-disk'\n" + "def full_width():\n" + " return describe_modifier(fill_max_width(empty_modifier()))\n" + ) + _kns_sys.path.insert(0, _kns_root) + try: + import pythonx as _kns_px + _kns['mark'] = getattr(_kns_px, 'MARK', None) + _kns['from_disk'] = (getattr(_kns_px, '__file__', None) or '').startswith(_kns_root) + _kns['works'] = _kns_px.full_width() + finally: + _kns_sys.path.remove(_kns_root) + if getattr(_kns_sys.modules.get('pythonx'), 'MARK', None) == 'on-disk': + del _kns_sys.modules['pythonx'] + import shutil as _kns_shutil + _kns_shutil.rmtree(_kns_root, ignore_errors=True) + """.trimIndent(), + ) + assertEquals("[]", eval("repr(_kns['occupied'])"), "the binder must not create any pythonx module") + assertEquals("on-disk", eval("_kns['mark']")) + assertEquals("True", eval("_kns['from_disk']")) + assertEquals("fillMaxWidth", eval("_kns['works']")) } /** - * The public metadata contract a Pythonic layer builds on: `inspect.signature` shows the Kotlin - * parameter names with defaults marked, and `python_multiplatform.describe` gives the overload - * set with per-parameter Kotlin types. + * The public metadata contract a Pythonic layer builds on: `inspect.signature` shows each + * parameter's Pythonic keyword (the Kotlin name where it has no alias) with defaults marked, and + * `python_multiplatform.describe` gives the overload set with per-parameter Kotlin names, Pythonic + * names and Kotlin types. * * Asserted for the proxy-rendered function and for the binding layer's own, in both install * orders, so the answer cannot depend on who got there first. @@ -190,6 +242,11 @@ class KotlinNamedSurfaceTest { for _p in _kns_sig.parameters.values() ], 'annotation': _kns_sig.parameters['vertical'].annotation, + 'pythonic': list(_kns_inspect.signature(_kns_layout.padding__PaddingValues).parameters), + 'both_names': [ + (_q['name'], _q['python_name']) + for _q in _kns_pm.describe(_kns_layout.padding__PaddingValues)[0]['parameters'] + ], 'one': [ (_d['name'], [(_q['name'], _q['type'], _q['has_default']) for _q in _d['parameters']], _d['receiver']) for _d in _kns_pm.describe(_kns_layout.padding__Dp_Dp) @@ -206,6 +263,9 @@ class KotlinNamedSurfaceTest { order, ) assertEquals("androidx.compose.ui.unit.Dp", eval("_kns['annotation']"), order) + // Issue #131: the signature shows the Pythonic keyword, `describe` both spellings. + assertEquals("['receiver', 'padding_values']", eval("repr(_kns['pythonic'])"), order) + assertEquals("[('paddingValues', 'padding_values')]", eval("repr(_kns['both_names'])"), order) assertEquals( "[('androidx.compose.foundation.layout.padding__Dp_Dp', " + "[('horizontal', 'androidx.compose.ui.unit.Dp', True), ('vertical', 'androidx.compose.ui.unit.Dp', True)], " + diff --git a/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/MemberResolverTest.kt b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/MemberResolverTest.kt index 0bac2e693..dab2f3677 100644 --- a/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/MemberResolverTest.kt +++ b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/MemberResolverTest.kt @@ -14,11 +14,13 @@ import kotlin.test.assertTrue /** * `python_multiplatform.binding.add_member_resolver` (issue #17): the one public point where a * Pythonic package built on the binder (pythonx-compose) can say what a Kotlin proxy's member is - * called, without the binder renaming anything itself. + * called. Since issue #131 the binder also serves its own Pythonic alias (`fill_max_width`), asked + * **after** the resolvers, so a resolver still decides what a name means wherever it answers. * * Contract under test: * - * - With no resolver registered, an unknown name on a proxy is an `AttributeError`, exactly as before. + * - With no resolver registered, a snake_case alias is the binder's own (issue #131), and a name that + * is no alias either is an `AttributeError`. * - `fn(kotlin_type_name, requested_name, kotlin_member_names) -> kotlin_name | None` is asked only * when no Kotlin member of the requested name exists; the Kotlin member it names is served. * - The binder's proxy classes are not written to: `dir()` and the class `__dict__` show Kotlin names only. @@ -41,22 +43,28 @@ class MemberResolverTest { HandleTable.releaseAll() } + /** Issue #131: with no resolver, the binder's own alias serves the snake_case name; nonsense stays an error. */ @Test - fun withNoResolverASnakeNameIsAnAttributeError() = withAdapter { + fun withNoResolverASnakeNameIsTheBindersOwnAlias() = withAdapter { Python3.exec( """ - from androidx.compose.ui import Modifier + from androidx.compose.ui import Modifier, describeModifier _mr = {} for _label, _target in (('instance', Modifier.padding(16)), ('class', Modifier)): try: - _target.fill_max_width - _mr[_label] = 'resolved' + _mr[_label] = describeModifier(_target.fill_max_width()) except AttributeError: _mr[_label] = 'AttributeError' + try: + Modifier.padding(16).fill_max_nothing + _mr['nonsense'] = 'resolved' + except AttributeError: + _mr['nonsense'] = 'AttributeError' """.trimIndent(), ) - assertEquals("AttributeError", eval("_mr['instance']")) - assertEquals("AttributeError", eval("_mr['class']")) + assertEquals("padding(16.0) -> fillMaxWidth", eval("_mr['instance']")) + assertEquals("fillMaxWidth", eval("_mr['class']")) + assertEquals("AttributeError", eval("_mr['nonsense']")) } @Test @@ -175,17 +183,17 @@ class MemberResolverTest { assertEquals("True", eval("_mr['class_dict_same']")) assertEquals("True", eval("_mr['dir_same']")) } - // Removing the only resolver takes the alias with it: nothing was left on the class. + // Removing the only resolver takes its alias with it: nothing was left on the class. The name + // still resolves -- now as the binder's own alias (issue #131) -- and that one is not written + // onto the class either. Python3.exec( """ - try: - Modifier.padding(1).fill_max_width - _mr = {'after': 'still resolved'} - except AttributeError: - _mr = {'after': 'AttributeError'} + _mr = {'after': 'still resolved' if callable(Modifier.padding(1).fill_max_width) else 'not callable'} + _mr['alias_in_class'] = 'fill_max_width' in vars(type(Modifier.padding(1))) """.trimIndent(), ) - assertEquals("AttributeError", eval("_mr['after']")) + assertEquals("still resolved", eval("_mr['after']")) + assertEquals("False", eval("_mr['alias_in_class']")) } // ------------------------------------------------------------------------------- keyword maps (#34) @@ -257,21 +265,23 @@ class MemberResolverTest { } } - /** No resolver: a Python-spelled keyword on a member is refused exactly as before. */ + /** No resolver: a snake_case keyword on a member is the binder's own keyword alias (issue #131). */ @Test - fun withNoResolverAKeywordIsNotMapped() = withAdapter { + fun withNoResolverASnakeKeywordIsTheBindersOwnAlias() = withAdapter { Python3.exec( """ - from androidx.compose.ui import Modifier + from androidx.compose.ui import Modifier, describeModifier from androidx.compose.foundation.layout import paddingValuesOf + _mr = {'outcome': describeModifier(Modifier.padding(1).padding(padding_values=paddingValuesOf(4)))} try: - Modifier.padding(1).padding(padding_values=paddingValuesOf(4)) - _mr = {'outcome': 'accepted'} + Modifier.padding(1).padding(padding_valuez=paddingValuesOf(4)) + _mr['typo'] = 'accepted' except TypeError: - _mr = {'outcome': 'TypeError'} + _mr['typo'] = 'TypeError' """.trimIndent(), ) - assertEquals("TypeError", eval("_mr['outcome']")) + assertEquals("padding(1.0) -> padding(pv(4.0))", eval("_mr['outcome']")) + assertEquals("TypeError", eval("_mr['typo']")) } // ------------------------------------------------------------------------------- helpers diff --git a/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/PythonxComposeCompatibilityTest.kt b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/PythonxComposeCompatibilityTest.kt new file mode 100644 index 000000000..1b303078f --- /dev/null +++ b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/PythonxComposeCompatibilityTest.kt @@ -0,0 +1,219 @@ +package python.multiplatform.ffi.pythonx + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.PythonTestFixture +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.UpcallTable +import python.native.ffi.bindUpcallOrNull +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * pythonx-compose 0.1.0a1 against the binder after issue #131. + * + * pythonx-compose ships its own name-conversion layer (`pythonx/compose/_reexport.py`) and will drop + * it once the binder converts names itself. Until it does, the two run together, and this pins that + * they do not fight: + * + * - its member resolver (`member_name`) is asked **before** the binder's own alias, so on a proxy it + * still answers every snake_case name it answered before, with the same Kotlin member; + * - its keyword maps (`{snake: kotlinParam}`) reach the binder as Kotlin names, which the binder takes + * as it always did -- no double conversion, and writing both spellings is still "two values"; + * - its `_name_table(kotlin_module)` converts `dir()` by the same rule and **raises** when two entries + * map to one name, so the binder's `dir()` must list each declaration once, by its Kotlin name. A + * `dir()` that also listed `fill_max_width` would make every pythonx module fail; + * - its `PythonicFunction` snake_cases `inspect.signature`'s names, which is a no-op on names the + * binder already shows snake_cased. + * + * The functions below are copied from pythonx-compose 0.1.0a1 (`_reexport.py`: `snake_case`, + * `python_name`, `_method_keywords`, `member_name`, `_name_table`, `PythonicFunction`), trimmed of + * docstrings and of the manifest, which this test does not need. They are a pin of that release's + * behaviour, not a dependency: the file lives in another repository. + */ +class PythonxComposeCompatibilityTest { + + @BeforeTest + fun install() { + check(PythonTestFixture.available) { + "CPython could not be initialized (${PythonTestFixture.failureReason})" + } + UpcallTable.install(listOf(ComposeShapedFragment)) + ComposeShapedFragment.calls.clear() + assertTrue(bindUpcallOrNull(ComposeShapedFragment.EMPTY_MODIFIER), "the fixture table is not installed") + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + HandleTable.releaseAll() + } + + @Test + fun theReleasedResolverStillDecidesAProxyNameAndItsKeywords() = withPythonx { + Python3.exec( + """ + from androidx.compose.ui import Modifier, describeModifier + from androidx.compose.foundation.layout import paddingValuesOf + _pxc_calls.clear() + _pxc = { + 'chain': describeModifier(Modifier.padding(16).fill_max_width().z_index(2)), + 'asked': sorted(set(_c[1] for _c in _pxc_calls)), + 'keyword': describeModifier(Modifier.padding(1).padding(padding_values=paddingValuesOf(4))), + 'kotlin_keyword': describeModifier(Modifier.padding(1).padding(paddingValues=paddingValuesOf(5))), + } + try: + Modifier.padding(1).padding(padding_values=paddingValuesOf(4), paddingValues=paddingValuesOf(4)) + _pxc['both'] = 'accepted' + except TypeError: + _pxc['both'] = 'TypeError' + """.trimIndent(), + ) + assertEquals("padding(16.0) -> fillMaxWidth -> zIndex(2.0)", eval("_pxc['chain']")) + val asked = eval("repr(_pxc['asked'])") + assertTrue("'fill_max_width'" in asked && "'z_index'" in asked, "the released resolver was not asked first: $asked") + assertEquals("padding(1.0) -> padding(pv(4.0))", eval("_pxc['keyword']")) + assertEquals("padding(1.0) -> padding(pv(5.0))", eval("_pxc['kotlin_keyword']")) + assertEquals("TypeError", eval("_pxc['both']")) + } + + @Test + fun theReleasedNameTableStillReadsTheBindersDirWithoutACollision() = withPythonx { + Python3.exec( + """ + import inspect as _pxc_inspect + import androidx.compose.foundation.layout as _pxc_layout + import androidx.compose.ui as _pxc_ui + _pxc = {} + try: + _pxc_table = _pxc_name_table(_pxc_layout) + _pxc['table'] = sorted((_k, _v) for _k, _v in _pxc_table.items() if _k != _v) + _pxc_name_table(_pxc_ui) + _pxc['raised'] = None + except AttributeError as _pxc_e: + _pxc['raised'] = str(_pxc_e) + _pxc_fn = _PxcPythonicFunction(_pxc_layout.padding__PaddingValues, 'padding__PaddingValues') + _pxc['signature'] = list(_pxc_inspect.signature(_pxc_fn).parameters) + _pxc['call'] = _pxc_ui.describeModifier( + _pxc_fn(_pxc_ui.emptyModifier(), padding_values=_pxc_layout.paddingValuesOf(6))) + """.trimIndent(), + ) + assertEquals("None", eval("repr(_pxc['raised'])"), "pythonx's _name_table must not see two names for one declaration") + assertTrue("('fill_max_width', 'fillMaxWidth')" in eval("repr(_pxc['table'])"), eval("repr(_pxc['table'])")) + assertEquals("['receiver', 'padding_values']", eval("repr(_pxc['signature'])")) + assertEquals("padding(pv(6.0))", eval("_pxc['call']")) + } + + /** Installs both binder layers and registers pythonx-compose 0.1.0a1's resolver, recording its questions. */ + private inline fun withPythonx(block: () -> Unit) = PythonTestFixture.withInterpreter { + PythonxAdapter.install(COMPOSE_SHAPED_RAW_VALUE_CLASSES) + Python3.exec( + "import python_multiplatform.binding as _pm_binding\n" + + "_pm_binding.register_empty('androidx.compose.ui.Modifier', '${ComposeShapedFragment.EMPTY_MODIFIER}')", + ) + PythonProxySource.install() + Python3.exec(RELEASED_REEXPORT) + try { + block() + } finally { + Python3.exec("_pm_binding.remove_member_resolver(_pxc_member_name)") + } + } + + private fun eval(expression: String): String = PythonTestFixture.eval(expression).toString() + + private companion object { + /** pythonx-compose 0.1.0a1's rule and resolver, as released; see the class KDoc. */ + val RELEASED_REEXPORT = """ + import inspect as _pxc_inspect_mod + import re as _pxc_re + import sys as _pxc_sys + import python_multiplatform.binding as _pm_binding + + _PXC_LOWER_UPPER = _pxc_re.compile(r"([a-z0-9])([A-Z])") + _PXC_ACRONYM_WORD = _pxc_re.compile(r"([A-Z]+)([A-Z][a-z])") + + def _pxc_snake_case(kotlin_name): + return _PXC_LOWER_UPPER.sub(r"\1_\2", _PXC_ACRONYM_WORD.sub(r"\1_\2", kotlin_name)).lower() + + def _pxc_python_name(kotlin_name): + if kotlin_name[:1].isupper(): + return kotlin_name + base, sep, suffix = kotlin_name.partition("__") + return _pxc_snake_case(base) + sep + suffix + + def _pxc_method_keywords(kotlin_type_name, kotlin_member_name): + describe_member = getattr(_pxc_sys.modules.get("python_multiplatform"), "describe_member", None) + if describe_member is None: + return {} + keywords = {} + try: + declarations = describe_member(kotlin_type_name, kotlin_member_name) + except (TypeError, AttributeError): + declarations = () + for declaration in declarations: + for parameter in declaration.get("parameters", ()): + kotlin_parameter = parameter.get("name") + if kotlin_parameter and _pxc_snake_case(kotlin_parameter) != kotlin_parameter: + keywords.setdefault(_pxc_snake_case(kotlin_parameter), kotlin_parameter) + return keywords + + _pxc_calls = [] + + def _pxc_member_name(kotlin_type_name, requested, kotlin_member_names): + _pxc_calls.append((kotlin_type_name, requested)) + if requested in kotlin_member_names: + target = requested + else: + matches = [name for name in kotlin_member_names if _pxc_python_name(name) == requested] + target = matches[0] if len(matches) == 1 else None + if target is None: + return None + keywords = _pxc_method_keywords(kotlin_type_name, target) + return (target, keywords) if keywords else target + + def _pxc_name_table(kotlin): + table = {} + for kotlin_name in dir(kotlin): + if kotlin_name.startswith("_"): + continue + pythonic = _pxc_python_name(kotlin_name) + other = table.get(pythonic) + if other is not None and other != kotlin_name: + raise AttributeError( + repr(pythonic) + " would name both " + repr(other) + " and " + repr(kotlin_name) + ) + table[pythonic] = kotlin_name + return table + + class _PxcPythonicFunction: + def __init__(self, kotlin_fn, name): + self._kotlin = kotlin_fn + self.__name__ = name + keywords = {} + for row in _pxc_sys.modules["python_multiplatform"].describe(kotlin_fn): + for parameter in row.get("parameters", ()): + keywords.setdefault(_pxc_snake_case(parameter["name"]), parameter["name"]) + self._keywords = keywords + + @property + def __signature__(self): + kotlin_signature = _pxc_inspect_mod.signature(self._kotlin) + return kotlin_signature.replace(parameters=[ + parameter if parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD) + else parameter.replace(name=_pxc_snake_case(parameter.name)) + for parameter in kotlin_signature.parameters.values() + ]) + + def __call__(self, *args, **kwargs): + if kwargs: + kwargs = {self._keywords.get(key, key): value for key, value in kwargs.items()} + return self._kotlin(*args, **kwargs) + + _pm_binding.add_member_resolver(_pxc_member_name) + """.trimIndent() + } +} diff --git a/tools/stubs/consumer.py b/tools/stubs/consumer.py index 3111280c6..99e4e61d8 100644 --- a/tools/stubs/consumer.py +++ b/tools/stubs/consumer.py @@ -1,15 +1,29 @@ -"""A small consumer of the generated Kotlin-name stubs, type-checked by `check-stubs.sh` (issue #31). +"""A small consumer of the generated Kotlin-named stubs, type-checked by `check-stubs.sh` (issue #31). -It is the code a user writes against the binder -- Kotlin names, Kotlin parameter names -- and it -must type-check against the stubs generated for the real Compose jars: a Modifier chain started from -the class and continued from an instance, `Checkbox`, `Text`, and a container with its trailing -`content` lambda. The last four lines are **meant** to be rejected; they carry `# type: ignore[...]` -and mypy runs with `warn_unused_ignores`, so a stub that stopped rejecting one fails the check too. +It is the code a user writes against the binder, and it must type-check against the stubs generated +for the real Compose jars: a Modifier chain started from the class and continued from an instance, +`Checkbox`, `Text`, and a container with its trailing `content` lambda. + +Names: a declaration is reachable by its Kotlin name and by its Pythonic (snake_case) alias, and the +stubs carry both (issue #131), so both spellings are written below. Keywords: a stub can name a +parameter only once, and it names it the way `inspect.signature` does -- by the Pythonic keyword +(`on_checked_change`). The Kotlin keyword (`onCheckedChange=`) still works at run time; a checker +reports it, so it is not written here. + +The last four lines are **meant** to be rejected; they carry `# type: ignore[...]` and mypy runs +with `warn_unused_ignores`, so a stub that stopped rejecting one fails the check too. """ from typing import assert_type -from androidx.compose.foundation.layout import Arrangement, Column, Row, fillMaxWidth, padding__Dp +from androidx.compose.foundation.layout import ( + Arrangement, + Column, + Row, + fill_max_width, + fillMaxWidth, + padding__Dp, +) from androidx.compose.material.icons import Icons from androidx.compose.material3 import Checkbox, Icon, Text from androidx.compose.ui import Modifier @@ -17,13 +31,18 @@ # A chain started from the class, the way Kotlin writes `Modifier.padding(16.dp)`. chain: Modifier = Modifier.fillMaxWidth().padding(16.0).size(24.0) -# ... and continued from an instance, and with Kotlin parameter names. +# ... and continued from an instance, with keyword arguments. more: Modifier = chain.padding(horizontal=8.0, vertical=4.0).padding(start=1.0, bottom=2.0) # The explicit table-key spelling, as an attribute and as a module function. explicit: Modifier = chain.padding__Dp(4.0) by_function: Modifier = padding__Dp(chain, 4.0) by_extension: Modifier = fillMaxWidth(chain, fraction=0.5) +# Issue #131: the Pythonic alias of a module function and of a method is the same declaration. +by_alias: Modifier = fill_max_width(chain, fraction=0.5) +assert_type(fill_max_width(chain), Modifier) +pythonic_chain: Modifier = Modifier.fill_max_width().padding(16.0).fill_max_width(0.5) + checked = True @@ -33,7 +52,7 @@ def on_change(value: bool) -> None: Checkbox(checked, on_change, modifier=more, enabled=True) -Checkbox(checked=False, onCheckedChange=None) +Checkbox(checked=False, on_checked_change=None) Text("hello", modifier=Modifier.padding(8.0)) @@ -44,18 +63,18 @@ def content() -> None: Column(modifier=chain, content=lambda scope: None) Row(content=lambda scope: None) # `SpaceBetween` is a `HorizontalOrVertical`: a `Vertical` slot and a `Horizontal` slot both take it (#71). -Column(verticalArrangement=Arrangement.SpaceBetween, content=lambda scope: None) -Row(horizontalArrangement=Arrangement.SpaceBetween, content=lambda scope: None) +Column(vertical_arrangement=Arrangement.SpaceBetween, content=lambda scope: None) +Row(horizontal_arrangement=Arrangement.SpaceBetween, content=lambda scope: None) # An extension property on a type nested in an object (issue #68): `Icons.Default` is `Icons.Filled`. # `assert_type`, not an annotation: an `Any` (the stub before #53/#67) would satisfy an annotation. assert_type(Icons.Default.Add, ImageVector) -Icon(Icons.Default.Add, contentDescription=None) +Icon(Icons.Default.Add, content_description=None) # Rejected on purpose. (`padding("sixteen")` is not among them: one `padding` overload takes a # `PaddingValues`, a type the stubs cannot name -- `PaddingValues(...)` the factory owns the name -- so # it is `Any` and accepts anything.) -Modifier.fillMaxWidth("wide") # type: ignore[arg-type] +Modifier.fill_max_width("wide") # type: ignore[arg-type] Checkbox("not a bool", on_change) # type: ignore[call-overload] Checkbox(checked, on_change, enabled="yes") # type: ignore[call-overload] Column(chain) # type: ignore[call-arg]