diff --git a/.github/workflows/stubs.yml b/.github/workflows/stubs.yml new file mode 100644 index 000000000..fa96d92da --- /dev/null +++ b/.github/workflows/stubs.yml @@ -0,0 +1,110 @@ +name: Kotlin-name stubs + +# Issue #32: pythonx-compose consumes the binder's Kotlin-name `.pyi` stubs for the real Compose jars +# but cannot run this repository's Gradle build. This job runs the stub task over the Compose version the +# build resolves (`compose-plugin` in gradle/libs.versions.toml) and publishes the result. +# +# - every push to develop and every pull request: the stubs are generated, type-checked against +# tools/stubs/consumer.py with mypy (issue #31), and uploaded as the workflow artifact `kotlin-stubs`; +# - every `v*` tag: the same stubs are also attached to the tag's GitHub release as `kotlin-stubs.zip`. + +on: + push: + branches: [ "develop" ] + tags: [ "v*" ] + pull_request: + branches: [ "develop" ] + paths: + - "python-multiplatform-gradle-plugin/**" + - "ksp-fixtures/compose/**" + - "tools/stubs/**" + - "gradle/libs.versions.toml" + - ".github/workflows/stubs.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: stubs-${{ github.ref }} + cancel-in-progress: true + +jobs: + stubs: + name: Generate stubs + # ubuntu, not macos: two Kotlin names can differ only by case (`graphics.Shadow` the class module and + # `graphics.shadow` the package), which are two paths here and one on a case-insensitive filesystem. + runs-on: ubuntu-latest + permissions: + contents: write # attaching the stubs to a tag's release + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'zulu' + cache: 'gradle' + + - name: Setup Python (for mypy) + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Generate the stubs over the Compose jars + run: ./gradlew :ksp-fixtures:compose:generatePythonStubs --console=plain + + - name: Type-check a consumer against them (mypy) + run: tools/stubs/check-stubs.sh ksp-fixtures/compose/build/generated/pythonStubs/desktopMain + + - name: Assemble the artifact + run: | + set -euo pipefail + compose_version="$(sed -nE 's/^compose-plugin = "([^"]+)".*/\1/p' gradle/libs.versions.toml)" + test -n "$compose_version" + out=build/kotlin-stubs + rm -rf "$out" + mkdir -p "$out" + cp -R ksp-fixtures/compose/build/generated/pythonStubs/desktopMain/. "$out/" + cat > "$out/README.md" <\`, or the + IDE's "stub path" / interpreter paths). It is checked with mypy against + \`tools/stubs/consumer.py\` in the commit above. + README + (cd build && zip -qr kotlin-stubs.zip kotlin-stubs) + + - name: Upload the stubs + uses: actions/upload-artifact@v4 + with: + name: kotlin-stubs + path: build/kotlin-stubs + if-no-files-found: error + + - name: Attach the stubs to the tag's release + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 \ + || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "Kotlin-name stubs for the Compose version this tag builds against." + gh release upload "$GITHUB_REF_NAME" build/kotlin-stubs.zip --clobber diff --git a/docs/SPEC.md b/docs/SPEC.md index 1be46730c..42096ef6c 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -212,10 +212,22 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. `CallbackDrivenRenderTest.kt`, pointer/drag render tests), `GP/artifact/ComposableBindingTest.kt`; `planned` on Android, iOS and wasm. - **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). It emits - nothing under `pythonx` and renames nothing; a Pythonic stub product belongs to pythonx-compose. - `Status: partial` — `GP/stubs/PyiRenderingTest.kt`, `GP/stubs/KotlinNamesOnlyStubTest.kt`, - `ksp-fixtures/artifact/.../WalkedArtifactStubTest.kt`; handle-returning stubs are not wrapped. + (keyword parameters by Kotlin name, `= ...` for a Kotlin default, receiver positional-only). 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`, + `GP/stubs/TypedStubTest.kt`, `GP/stubs/KotlinNamesOnlyStubTest.kt`, + `ksp-fixtures/artifact/.../WalkedArtifactStubTest.kt`, `.../StubSignatureAgreesWithRuntimeTest.kt`, + `tools/stubs/check-stubs.sh` (mypy over the Compose stubs). A class whose name is also a function in + its module is `Any`. +- **B-8** CI generates the stubs over the Compose version the build resolves and publishes them + (`.github/workflows/stubs.yml`): workflow artifact `kotlin-stubs` on every push to `develop`, with a + README naming the Compose version and the commit, and `kotlin-stubs.zip` on every `v*` tag's release. + `Status: partial` — the workflow could not be run where it was written; its YAML parses and its + assemble step was executed locally. ## 7. Threading and builds diff --git a/docs/design/kotlin-extensions-in-python.md b/docs/design/kotlin-extensions-in-python.md index e73849eb2..fe2da0d67 100644 --- a/docs/design/kotlin-extensions-in-python.md +++ b/docs/design/kotlin-extensions-in-python.md @@ -598,9 +598,10 @@ Generation belongs to the Gradle plugin (`AGENTS.md` §12.5), and it reads the s the binder, so the stub and the binding cannot drift. **What it emits is the Kotlin surface, not a Pythonic one** (SPEC B-7, `docs/design/pyi-generation-design.md`): one `def` per table key under the Kotlin module path, Kotlin parameter names, `= ...` for a Kotlin default, the extension receiver as a -positional-only first parameter, and boundary-type annotations (`Dp` → `float`, `Modifier` → `int`) -with the Kotlin signature in the docstring. Extension-as-method, `@overload` sets over a base name and -`Dp | float` are what a Pythonic stub would say; that stub belongs to pythonx-compose. The section +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 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 1ffede955..bd618aba2 100644 --- a/docs/design/pyi-generation-design.md +++ b/docs/design/pyi-generation-design.md @@ -8,6 +8,13 @@ > [`docs/archive/pyi-generation-pythonic-stubs.md`](../archive/pyi-generation-pythonic-stubs.md); > that product belongs to pythonx-compose. Section numbers are kept so that the code's KDoc > references still resolve; sections that moved say so where they used to be. +> +> **Revised again for issue #31 (typed stubs).** The stub no longer annotates every object as `int`: +> it names the declared Kotlin type with a stub class per bound type, puts each extension function on +> 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`. 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 @@ -300,41 +307,49 @@ is being fetched and then dropped, one function before it would be used. ## 3. Kotlin → Python type mapping -### 3.1 The table — boundary types, with the Kotlin signature in the docstring +### 3.1 The table — the declared Kotlin type, a stub class per bound type -**Current.** A stub describes the module the runtime actually publishes, and that module accepts and -returns what `UpcallTrampoline` marshals. So every annotation is the parameter's or return's -**boundary tag** (`python.multiplatform.reflection.TypeTag`), mapped by `boundaryAnnotation` in -`PyiRendering.kt`, and the declared Kotlin signature goes into the docstring, where a reader wants it -and a checker cannot act on it: +**Current (issue #31).** A stub says what the Kotlin declaration says, wherever the runtime serves +that type. `renderKotlinFqnStubs` (`PyiRendering.kt`) maps each `KotlinTypeModel` with its boundary +tag as the tiebreaker, and the Kotlin signature stays in the docstring: - def padding__Dp(receiver: int, /, all: float) -> int: + def padding__Dp(receiver: androidx.compose.ui.Modifier, /, all: androidx.compose.ui.unit.Dp | float) -> androidx.compose.ui.Modifier: """Kotlin: androidx.compose.ui.Modifier.padding(all: androidx.compose.ui.unit.Dp): androidx.compose.ui.Modifier""" ... -(the exact text `WalkedArtifactStubTest.theKotlinFqnStubsDescribeTheModulesTheRuntimePublishes` -asserts against the real Compose jars). - -| boundary tag | `.pyi` | Kotlin declarations that cross as it | +| Kotlin | `.pyi` | why | |---|---|---| -| `BOOLEAN` | `bool` | `Boolean` | -| `INT` | `int` | `Byte`, `Short`, `Int`, `Long` (the boundary carries every one as `Long`; Python has one integer type) | -| `FLOAT` | `float` | `Float`, `Double` | -| `STRING` | `str` | `String`, `String?` | -| `BYTES` | `bytes` | `ByteArray`, `ByteArray?` | -| `UNIT` | `None` | `Unit`, return position only | -| `OBJECT` | `int` | any object: it crosses as a `HandleTable` integer (`UpcallTrampoline.marshalResult`), so `int` is the truth about the raw module. Function-typed slots also cross as `OBJECT` (§3.5) | -| none of the above | `object` | defensive default in `boundaryAnnotation`; a declined declaration is not stubbed at all | - -A value class crosses as its underlying primitive's tag when its constructor (parameter side) or its -property (return side) is public — `Dp` is `FLOAT` and is annotated `float` — and otherwise falls -through to an object handle (§3.4). - -**Not emitted:** `list[T]`, `dict[K, V]`, `Callable[...]`, `T | None`, `typing.Never`, or a stub name -for an object type. Each of those would describe the declared Kotlin type rather than what the raw -module accepts, and saying `-> Modifier` where Python receives an `int` is the lie §7's first item -warns about. The declared-Kotlin-type table that the removed Pythonic product implemented -(`PythonTypes.kt`) is in the archive file, §3.1. +| `Boolean` | `bool` | | +| `Byte` `Short` `Int` `Long` | `int` | the boundary carries every one as `Long`; Python has one integer type | +| `Float` `Double` | `float` | | +| `String`, `Char` | `str` | | +| `ByteArray` | `bytes` | | +| `Unit` | `None` | | +| `Any` | `typing.Any` | | +| any other class or interface | **a stub class in the module of its own Kotlin package** (`class Modifier` in `androidx/compose/ui/__init__.pyi`), referenced as `androidx.compose.ui.Modifier` from other modules and bare from its own | the value crosses as a `HandleTable` integer and the binding layer wraps it in a proxy class named for the declared type (`PythonxAdapter._wrap`). A nested class is nested in its outer class's stub | +| a class that extends another | `class BitmapPainter(Painter)` | the nearest supertype the scanner read off the jar (`DeclarationModel.returnSupertypes`, the same list `_is_a` accepts a value for); only for types some bound declaration returns | +| value class bound as its primitive (`Dp`) | `Dp \| float` as a parameter, `float` as a result | a result is the raw number (`_wrap` only wraps `OBJECT`); the raw module accepts the number, and the binding layer's allowlist (`allow_raw_primitive`) decides at run time, which no stub can know | +| value class the binder cannot open (`TextUnit`) | its own class | it crosses as an object handle | +| `(A, B) -> R` | `Callable[[A, B], R]` | `kotlin.FunctionN`'s type arguments; `@Composable` and a receiver are not expressible and not faked | +| `T?` | `T \| None` | `KotlinTypeModel.isNullable` | +| type arguments of a non-function class | dropped (`State` is `State`) | the proxy class is one per Kotlin class | + +`typing` is imported as `_t` (`import typing as _t`) because a Kotlin declaration may be called `Any` +or `Callable`; other modules are imported whole (`import androidx.compose.ui`) and referenced +qualified, so a class never collides with a name the importing module defines. + +**A class whose name is also a function in its module falls back to `typing.Any`.** Compose has +`PaddingValues(...)` the factory and `PaddingValues` the interface, `Color(...)` and `Color`, `Dp(...)` +and `Dp`. One Python module cannot define both, and the runtime resolves the name to the function +(`_adapt` asks `_BY_PACKAGE` first), so the type is not reachable there either. In Compose 1.11.1 this +turns `PaddingValues`, `TextStyle`, `Color`, `AnnotatedString` and `Dp` itself into `Any` (`Dp` then +collapses to its `float`). It is the honest loss of this design, not a bug to chase: the alternatives +are a differently named class (a renamed spelling the binder does not have) or a private side module. + +**Case-colliding module paths** (`androidx.compose.ui.graphics.Shadow`, a class module, and +`...graphics.shadow`, a package) are two files on Linux and one on macOS and Windows. +`PythonStubsTask` warns (`caseCollidingPaths`); generate and publish from a case-sensitive +filesystem, which is why the CI job (`.github/workflows/stubs.yml`) runs on ubuntu. ### 3.2 Java declarations have no types to map @@ -364,65 +379,74 @@ not hidden. ### 3.3 Nullability -**Current.** The binder's gate moved since this section was first written: a nullable **object** -binds (the handle is already nullable at the boundary; `UpcallTrampoline.toKotlin` maps Python -`None` to `null`), and `String?` and `ByteArray?` bind; a nullable numeric or `Boolean` primitive and -a nullable value class stay declined (`resolveKotlinBoundary` and `nullablePrimitiveBoundaryTypeOf` -in `KotlinMetadata.kt`). The stub does **not** mark nullability: a `String?` parameter is annotated -`str`, and the `?` appears only in the docstring's Kotlin signature. Marking it `str | None` is -mechanical once wanted — `KotlinTypeModel.isNullable` carries the fact — and is listed in §7. +**Current.** The binder's gate: a nullable **object** binds (the handle is already nullable at the +boundary; `UpcallTrampoline.toKotlin` maps Python `None` to `null`), and `String?` and `ByteArray?` +bind; a nullable numeric or `Boolean` primitive and a nullable value class stay declined +(`resolveKotlinBoundary` and `nullablePrimitiveBoundaryTypeOf` in `KotlinMetadata.kt`). The stub marks +every nullable it renders `T | None`, for parameters and results alike +(`TypedStubTest.aFunctionTypeIsCallableAndANullableTypeIsOptional`). ### 3.4 Value classes -**Current.** A value-class parameter is annotated with its underlying boundary type when the binder -binds it as that primitive (`Dp` → `float`, because `Dp`'s constructor is public and it wraps a -`Float`). A value class whose wrapper cannot be opened from outside its module (`TextUnit`, every -`packedValue` class) falls through to the object handle and is annotated `int`. `Color`'s public -constructor takes `ULong`, which `kotlinPrimitiveBoundaryTypeOf` has no entry for. +**Current.** A value class the binder binds as its primitive (`Dp`: public constructor, wraps a +`Float`) is `Dp | float` as a parameter and the raw `float` as a result; the class `Dp` is stubbed in +`androidx.compose.ui.unit` with a docstring saying what it wraps. A value class whose wrapper cannot be +opened from outside its module (`TextUnit`, every `packedValue` class) crosses as an object handle and +is an ordinary class in parameters and results. `Color`'s public constructor takes `ULong`, which +`kotlinPrimitiveBoundaryTypeOf` has no entry for, so it is an object handle too. The raw-primitive **allowlist** (`docs/design/kotlin-extensions-in-python.md` §4.4: `Dp` yes, `TextUnit` -and `Color` no) is not a stub concern here. It is enforced at run time by the binding layer, where it -starts **empty** and is filled by pythonx-compose through `allow_raw_primitive` -(`PythonxAdapter.kt`). The stub describes the raw module, which accepts the underlying primitive for -any value class it binds that way. How the allowlist shows up in a *Pythonic* stub (`Dp | float` -versus the proxy alone, with the mypy evidence) is archive §3.4. +and `Color` no) is not a stub concern. It is enforced at run time by the binding layer, where it starts +**empty** and is filled by pythonx-compose through `allow_raw_primitive` (`PythonxAdapter.kt`), so the +stub states the widest thing the raw module accepts (`Dp | float`) and the layer above narrows it. +The mypy evidence for the asymmetry (`Text(fontSize=16)` rejected, `padding(16)` accepted) is archive +§3.4; it needs the allowlist in the stub, which belongs to the Pythonic layer. ### 3.5 Function types, and Compose's `content` **Current.** A function-typed parameter crosses as `OBJECT` (`ArtifactScanner.functionSlotOrNull`): -the runtime turns a Python callable into a Kotlin `FunctionN` and hands the raw boundary a handle. The -stub therefore annotates it `int`, with the declared `kotlin.FunctionN<...>` in the docstring; no -`Callable[...]` is emitted. - -Two things the current product does that the original design asked for: +the runtime turns a Python callable into a Kotlin `FunctionN`. The stub says `Callable[[A], R]` from +`kotlin.FunctionN`'s type arguments (arguments the callback *receives* are rendered as results, the +callback's own result as a parameter); `Unit` is `None`. `@Composable` on a lambda and a function type +with a receiver (`ColumnScope.() -> Unit`, which the metadata spells as a leading argument) are not +expressible and are not faked. The Kotlin type stays in the docstring. - **The synthetic parameters are not stubbed.** Parameters come from `KmFunction` metadata, so `$composer`, `$changed` and `$default` never appear (`docs/design/kotlin-extensions-in-python.md` - §4.6: metadata arity is the correct source). This follows from `DeclarationModel`'s construction; - no stub test asserts it for a composable. -- **A required parameter after a defaulted one is not marked optional.** Python rejects a required - parameter after one with a default, and Compose's trailing `content` is exactly that, so a - default is marked `= ...` only when no required parameter follows it - (`KotlinNamesOnlyStubTest.aDefaultBeforeARequiredParameterIsNotMarkedSoTheStubStaysSyntacticallyValid`, - `WalkedArtifactStubTest.noGeneratedDefPutsARequiredParameterAfterADefaultedOne`). - -What it does not do: make `content` keyword-only. The run-time signature does -(`inspect.signature` on a binder-made function makes "required after optional" keyword-only, -`KotlinSurface.kt`), so stub and run-time signature differ there; §7. + §4.6: metadata arity is the correct source). +- **A required parameter after a defaulted one is keyword-only**, exactly as `inspect.signature` on a + binder-made callable reports it (`KotlinSurface.kt`): `def Column(modifier: Modifier = ..., + verticalArrangement: Any = ..., horizontalAlignment: Any = ..., *, content: Callable[[ColumnScope], + None])`, and the defaults stay marked. The one exception is a declaration with an unwritable + parameter name, where a positional-only prefix is already in play and a default is marked only when + no required parameter follows it. `StubSignatureAgreesWithRuntimeTest` compares name, kind, default + and type against `inspect.signature` for every stubbed function of three real modules. **Suspend functions are not stubbed** (`renderKotlinFqnStubs` filters `isSuspend`), although the runtime makes them awaitable (SPEC U-5). §7. ### 3.6 Overloads -**Current.** The binder no longer drops an ambiguous name: `ArtifactScanner.disambiguateOverloads` -gives every member of an overload set its own table key, `name__` (`padding__Dp`, -`padding__Dp_Dp`, `padding__Dp_Dp_Dp_Dp`, `padding__PaddingValues`; a group of one keeps the bare -name; `docs/design/kotlin-extensions-in-python.md` §3.1). The stub emits **one `def` per table key** -and nothing else — `WalkedArtifactStubTest.everyStubbedFunctionIsATableKeyAndEveryTableKeyIsStubbed` -asserts the two sets are equal. The base name of an overload set (`padding`), which the binding layer -dispatches at run time (`_Overloads` in `PythonxAdapter.kt`, `(*args, **kwargs)` signature per -`KotlinSurface.kt`), has no stub; no `@overload` is emitted. +**Current.** `ArtifactScanner.disambiguateOverloads` gives every member of an overload set its own +table key, `name__` (`padding__Dp`, `padding__Dp_Dp`, `padding__Dp_Dp_Dp_Dp`, +`padding__PaddingValues`; a group of one keeps the bare name; +`docs/design/kotlin-extensions-in-python.md` §3.1). The stub emits **one `def` per table key**, and +also the **base name** the binding layer serves (`padding`, `_Overloads` in `PythonxAdapter.kt`) as an +`@overload` set **in table-key order**: the artefact table is sorted by name (`ArtifactScanner.scanJar`), +the binding layer indexes candidates in that order and `_Overloads` tries them in it, and a checker +takes the first match, so the stub's order and the dispatcher's are one order. For `padding` that is +`all`, `horizontal/vertical`, `start/top/end/bottom`, `paddingValues`. A base name is stubbed whenever a +key has an overload suffix, even for a group of one (the runtime serves it), and not when a declaration +already has that exact name. + +`WalkedArtifactStubTest.everyStubbedFunctionIsATableKeyAndEveryTableKeyIsStubbed` asserts both +directions: every stubbed `def` is a table key or the base name of one, and every key is stubbed. + +Python's one `int`/`float` and a class that fell back to `Any` cannot tell two Kotlin overloads apart +(`lerp(Int, Int, Float)` and `lerp(Float, Float, Float)`), so mypy reports every later overload as +`overload-cannot-match`. The runtime dispatcher tells them apart and the explicit spelling reaches each, +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 @@ -431,12 +455,6 @@ to and including that parameter, spelled `__a` (`KotlinNamesOnlyStubTest.aParameterNamedLikeAPythonKeywordForcesAPositionalOnlyPrefix`). A Kotlin default is `= ...`, because metadata carries `declaresDefaultValue` but not the value. -The mypy measurements of four `padding` overloads as `@overload`s, the overlap behaviour (mypy takes -the first match silently) and the judged rule "deterministic, arity-ascending, same order as the -dispatcher" belong to a Pythonic stub and are in archive §3.6. - ---- - ## 4. Extension functions ### 4.1 Placement (unchanged from `docs/design/kotlin-extensions-in-python.md` §4.1) @@ -451,12 +469,28 @@ dispatcher" belong to a Pythonic stub and are in archive §3.6. Nothing measured here disturbs that. Chaining is free because each of the 177 returns `Modifier`, and the mypy run in archive §4.4 confirms `Modifier.size(4).padding(2)` type-checks as `Modifier` in a Pythonic stub. -> **Current:** this placement is what a Pythonic layer does with extensions, and the counts are a -> property of Compose. The stub product here places **every** extension function as a module-level -> `def` in the module of its declaring package, with the receiver as the first positional-only -> parameter named `receiver` (`def fillMaxWidth(receiver: int, /, fraction: float = ...) -> int:`, -> `WalkedArtifactStubTest`). The method form on the receiver's proxy (SPEC U-7) exists only at run -> time in the binding layer and is not stubbed. +> **Current (issue #31):** every extension function stays a module-level `def` in the module of its +> declaring package, with the receiver as the first positional-only parameter named `receiver` +> (`def fillMaxWidth(receiver: androidx.compose.ui.Modifier, /, fraction: float = ...) -> +> androidx.compose.ui.Modifier:`), **and** an extension whose receiver is a class is also a **callable +> attribute of that receiver's stub class**, because the runtime attaches it there (`PythonxAdapter._attach`, +> SPEC U-7) under both the Kotlin name and every `name__Types` key: +> +> class _Modifier_padding(_t.Protocol): +> @_t.overload +> def __call__(self, all: float) -> Modifier: ... +> ... +> class Modifier: +> padding: _t.ClassVar[_Modifier_padding] +> padding__Dp: _t.ClassVar[_Modifier_padding__Dp] +> +> A plain method cannot say both `Modifier.padding(16)` and `m.padding(16)` (the first binds `16` to +> `self`); a `ClassVar` of a `Protocol` with `__call__` can, and is the shape archive §4.4 measured +> with mypy. The receivers that stay module-level only are the ones the runtime cannot attach to: +> Kotlin primitives and `String`, function types, and value classes (never a proxy instance). The +> protocol classes are private (`_Receiver_name`), carry the Kotlin name unchanged and exist only for a +> type checker. `Modifier.padding(...)` at run time needs `register_empty` for `Modifier`; the stub does +> not know whether a consumer called it. ### 4.2–4.4 Moved @@ -489,7 +523,7 @@ Of the two products the original §5.3 named, only the first remains: | product | namespace | content | |---|---|---| -| Kotlin-FQN stubs | `androidx.compose.material3`, `junit.runner`, … | 1:1 with the table keys `PythonProxySource` publishes into `sys.modules`; Kotlin names, boundary-type annotations, the Kotlin signature in each docstring, no extension-as-method | +| Kotlin-FQN stubs | `androidx.compose.material3`, `junit.runner`, … | 1:1 with the table keys `PythonProxySource` publishes into `sys.modules`; Kotlin names, declared-type annotations with a stub class per bound type, extension functions also as callable attributes of their receiver's class, the Kotlin signature in each docstring | It is the direct descendant of PyREPL's output and the only thing an IDE can see for those modules, since they exist only as runtime `sys.modules` entries. The Pythonic stubs a user of `pythonx` @@ -558,14 +592,12 @@ merges an overlap. ## 7. Open -- **What to stub for a `TypeTag.OBJECT` return.** `PythonProxySource`'s KDoc records that such a - value crosses as a bare handle integer, not as an instance of the class rendered for it, which is - why the stub says `-> int` (§3.1). A stub saying `-> Modifier` needs the handle-to-proxy wrapping - at the raw module, which is not done (SPEC B-7: "handle-returning stubs are not wrapped"). -- **Nullability** (§3.3) is carried by the model and not rendered. +- **A class whose name is a function in its module is `Any`** (§3.1): `PaddingValues`, `TextStyle`, + `Color`, `AnnotatedString`, `Dp` in Compose 1.11.1. +- **Supertypes are the nearest one only**, and only for types a bound declaration returns (§3.1); an + interface a class implements besides its first listed supertype is not in the stub. +- **Type arguments of a non-function class are dropped** (§3.1). - **Suspend functions** (§3.5) are bound and awaitable but not stubbed. -- **Overload base names** (§3.6) are dispatched at run time and not stubbed. -- **`content` keyword-only** (§3.5): the run-time signature makes it keyword-only, the stub does not. - **The consumer's own KSP-bound declarations** are not stubbed: KSP does not produce `DeclarationModel` (§2.2), although §2.3 records it would need to read nothing new. - **Generic declarations.** `BindingPolicy` rejects them, so they are not stubbed; if the binder ever @@ -576,5 +608,16 @@ merges an overlap. is chosen. - **IDE pick-up** (§6.2): registering `build/generated/pythonStubs//` with an interpreter or IDE is per-environment and none of it is implemented or verified; PyCharm was never tested - (§0). + (§0). Only mypy has seen the typed shape (§8). - **commonMain/platform overlap** (§6.3) is not implemented. + +## 8. Checked with mypy, and published + +- `tools/stubs/check-stubs.sh ` runs `mypy --strict` over `tools/stubs/consumer.py` (a + `Modifier` chain from the class and from an instance, `Checkbox`, `Text`, `Column` with its keyword + `content`, and four lines that must be rejected) against stubs generated for the real Compose jars + (`:ksp-fixtures:compose:generatePythonStubs`, which `ksp-fixtures/compose` now enables). Errors inside + the stubs are reported as well, not only in the consumer. +- `.github/workflows/stubs.yml` runs the stub task and that check on every push to `develop`, uploads + the stubs as the workflow artifact `kotlin-stubs` (with a README naming the Compose version and the + producing commit) and attaches `kotlin-stubs.zip` to the release of every `v*` tag (issue #32). diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/StubSignatureAgreesWithRuntimeTest.kt b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/StubSignatureAgreesWithRuntimeTest.kt new file mode 100644 index 000000000..2169d808d --- /dev/null +++ b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/StubSignatureAgreesWithRuntimeTest.kt @@ -0,0 +1,180 @@ +package fixture.artifact + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.upcall.PythonProxySource +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.FunctionTable +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Issue #31's second completion criterion: the stub's signature for a declaration is the one + * `inspect.signature` answers at run time. + * + * The stubs are the ones the **build** generated (`generatePythonStubs`, over the real Compose jars + * this module resolves), and the runtime is the real one: the same table installed into a real + * interpreter through `PythonProxySource`, whose published functions carry `__wrapped__` carriers that + * `KotlinSurface` builds `inspect.Signature`s from. Both are read **inside Python** -- the stub with + * `ast`, the runtime with `inspect` -- and compared declaration by declaration over every stubbed + * function in the modules below, not a hand-picked sample, so a rule changed on one side only fails + * here. + * + * What is compared, per parameter: its name, its kind (positional-only, positional-or-keyword, + * keyword-only), whether it has a default, and its type. The type is where the two sides speak + * different dialects -- the runtime says the declared Kotlin name as a string + * (`'androidx.compose.ui.unit.Dp'`), the stub says a Python annotation -- so the comparison is the + * mapping the stub documents: a Kotlin primitive is its Python builtin, a function type is a + * `Callable`, and any other class is that class's stub, named by its fully qualified name. + * + * Skipped, and counted so that the skip cannot grow silently: a declaration whose runtime signature is + * the generic `(*args, **kwargs)` (a Kotlin parameter name Python cannot spell, or a Java class + * compiled without `-parameters` -- the stub is positional-only there, `__a`, and the runtime + * has no names to compare). + */ +class StubSignatureAgreesWithRuntimeTest { + + @BeforeTest + fun installBothProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + private val stubDir: String + get() = System.getProperty("python.multiplatform.stubDir") + ?: error("the stubDir system property is not set; see build.gradle.kts") + + @Test + fun everyStubbedSignatureMatchesInspectSignatureAtRunTime() { + Python3.exec( + """ + import ast, importlib, inspect + + STUBS = r'$stubDir' + MODULES = [ + 'androidx.compose.foundation.layout', + 'androidx.compose.foundation', + 'kotlin.text', + ] + BUILTIN = { + 'kotlin.Boolean': 'bool', 'kotlin.Byte': 'int', 'kotlin.Short': 'int', 'kotlin.Int': 'int', + 'kotlin.Long': 'int', 'kotlin.Float': 'float', 'kotlin.Double': 'float', 'kotlin.String': 'str', + 'kotlin.Char': 'str', 'kotlin.ByteArray': 'bytes', 'kotlin.Unit': 'None', 'kotlin.Any': '_t.Any', + } + + TAG = {'INT': 'int', 'FLOAT': 'float', 'BOOLEAN': 'bool', 'STRING': 'str', 'BYTES': 'bytes', 'UNIT': 'None'} + + def annotation_agrees(runtime, stub_node, tag): + if stub_node is None: + return runtime in ('', None, inspect.Parameter.empty) + stub = ast.unparse(stub_node) + parts = [part for part in stub.split(' | ') if part != 'None'] or ['None'] + if runtime is inspect.Parameter.empty or not runtime: + return False + runtime = runtime.rstrip('?') + if runtime.startswith('kotlin.Function') or runtime.startswith('@Composable'): + return stub.startswith('_t.Callable') + if runtime in BUILTIN: + return stub == BUILTIN[runtime] or parts == [BUILTIN[runtime]] or parts[0].endswith('Any') + if tag in TAG: + # a value class bound as its primitive: the raw number crosses (`Dp | float`, or `float` + # where the class name is taken by a function in its module) + return TAG[tag] in parts + # an object: the stub class named by its FQN (or bare inside its own module), or `Any` + # where a function in its module owns the name -- never an `int` handle + simple = runtime.rsplit('.', 1)[-1] + return parts[0].endswith('Any') or runtime in stub or parts[0] == simple + + def stub_parameters(fn): + a = fn.args + positional = a.posonlyargs + a.args + defaults = [None] * (len(positional) - len(a.defaults)) + list(a.defaults) + out = [(p.arg, 'POSITIONAL_ONLY' if i < len(a.posonlyargs) else 'POSITIONAL_OR_KEYWORD', + d is not None, p.annotation) for i, (p, d) in enumerate(zip(positional, defaults))] + for p, d in zip(a.kwonlyargs, a.kw_defaults): + out.append((p.arg, 'KEYWORD_ONLY', d is not None, p.annotation)) + return out + + compared = 0 + skipped = [] + problems = [] + for module_name in MODULES: + path = STUBS + '/' + module_name.replace('.', '/') + '/__init__.pyi' + tree = ast.parse(open(path).read()) + module = importlib.import_module(module_name) + for node in tree.body: + if not isinstance(node, ast.FunctionDef): + continue + if any(isinstance(d, ast.Attribute) and d.attr == 'overload' for d in node.decorator_list): + continue # an overload set's base name has no single signature: `(*args, **kwargs)` + runtime_fn = getattr(module, node.name, None) + if runtime_fn is None: + problems.append(module_name + '.' + node.name + ': stubbed but not on the module') + continue + try: + signature = inspect.signature(runtime_fn) + except (TypeError, ValueError): + skipped.append(module_name + '.' + node.name) + continue + runtime_parameters = list(signature.parameters.values()) + row = runtime_fn.__kotlin_rows__[0] + row_names, row_tags = row[4] or (), row[5] or () + + def tag_of(parameter_name): + key = '' if parameter_name in ('receiver', '_receiver') and '' in row_names else parameter_name + return row_tags[row_names.index(key)] if key in row_names else None + if any(p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD) for p in runtime_parameters): + skipped.append(module_name + '.' + node.name) + continue + stub = stub_parameters(node) + label = module_name + '.' + node.name + if [p.name for p in runtime_parameters] != [s[0] for s in stub]: + # a Java-sourced or keyword-named declaration: the stub is positional-only `__a` + if all(s[0].startswith('__a') for s in stub[:len(runtime_parameters)]) or any(s[0].startswith('__a') for s in stub): + skipped.append(label) + continue + problems.append(label + ': names ' + repr([p.name for p in runtime_parameters]) + ' vs stub ' + repr([s[0] for s in stub])) + continue + for p, (name, kind, has_default, ann) in zip(runtime_parameters, stub): + if p.kind.name != kind: + problems.append(label + '.' + name + ': kind ' + p.kind.name + ' vs stub ' + kind) + if (p.default is not p.empty) != has_default: + problems.append(label + '.' + name + ': default ' + repr(p.default) + ' vs stub ' + repr(has_default)) + if not annotation_agrees(p.annotation, ann, tag_of(p.name)): + problems.append(label + '.' + name + ': type ' + repr(p.annotation) + ' vs stub ' + (ast.unparse(ann) if ann else 'none')) + if not annotation_agrees(signature.return_annotation, node.returns, row[7]): + problems.append(label + ' -> ' + repr(signature.return_annotation) + ' vs stub ' + ast.unparse(node.returns)) + compared += 1 + + assert compared >= 30, 'only ' + str(compared) + ' signatures were compared; skipped ' + repr(skipped[:10]) + assert not problems, str(len(problems)) + ' stub/runtime disagreements, first: ' + repr(problems[:12]) + print('compared', compared, 'skipped', len(skipped)) + """.trimIndent(), + ) + } + + @Test + fun theComparisonCanFailBecauseTheStubDirIsReallyRead() { + // A comparison that cannot fail is not one (AGENTS.md rule 8): the stub it reads must be the + // generated one, so a path with no stub in it has to raise rather than compare nothing. + val raised = runCatching { + Python3.exec( + """ + open(r'$stubDir/androidx/compose/foundation/layout/no_such_stub.pyi').read() + """.trimIndent(), + ) + }.isFailure + assertTrue(raised) + } +} 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 375ee9d57..a7d25cc77 100644 --- a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt +++ b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt @@ -58,15 +58,21 @@ class WalkedArtifactStubTest { * The Kotlin-FQN product, against the package `docs/design/kotlin-extensions-in-python.md` §3 measured * at zero bound declarations. * - * The annotations are the **boundary's**: `Dp` marshals as a raw float and `Modifier` as a - * `HandleTable` integer, which is what `androidx.compose.foundation.layout` -- a module that - * exists only as a `sys.modules` entry `PythonProxySource` creates -- actually accepts and - * returns. §7 lists the handle-to-proxy wrapping that would make it `Modifier` as not yet done. + * The annotations are the **declared Kotlin types** (issue #31): a `Modifier` receiver and result, + * and `Dp | float` for the value class the binder binds as its primitive. */ @Test fun theKotlinFqnStubsDescribeTheModulesTheRuntimePublishes() { val layout = stub("androidx/compose/foundation/layout/__init__.pyi") - assertTrue("def padding__Dp(receiver: int, /, all: float) -> int:" in layout, layout.take(2000)) + assertTrue( + "def padding__Dp(receiver: androidx.compose.ui.Modifier, /, all: androidx.compose.ui.unit.Dp | float) " + + "-> androidx.compose.ui.Modifier:" in layout, + layout.take(2000), + ) + // The class is stubbed in the module of its own package, with the extension as an attribute. + val ui = stub("androidx/compose/ui/__init__.pyi") + assertTrue("class Modifier:" in ui, ui.take(2000)) + assertTrue("padding__Dp: _t.ClassVar[_Modifier_padding__Dp]" in ui, ui.take(4000)) assertTrue( "\"\"\"Kotlin: androidx.compose.ui.Modifier.padding(all: androidx.compose.ui.unit.Dp): " + "androidx.compose.ui.Modifier\"\"\"" in layout, @@ -84,7 +90,9 @@ class WalkedArtifactStubTest { * Kotlin parameter names; making it Pythonic is the `pythonx-compose` package's job. * * 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`). + * 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. */ @Test fun theStubsUseKotlinNamesAndNothingIsExportedUnderPythonx() { @@ -93,36 +101,63 @@ class WalkedArtifactStubTest { assertTrue(paths.none { it.endsWith("py.typed") || it.endsWith("_pm_dispatch.json") }, paths.toString()) val layout = stub("androidx/compose/foundation/layout/__init__.pyi") - assertTrue("def fillMaxWidth(receiver: int, /, fraction: float = ...) -> int:" in layout, layout.take(3000)) + assertTrue( + "def fillMaxWidth(receiver: androidx.compose.ui.Modifier, /, fraction: float = ...) " + + "-> 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: int" in layout, "a Kotlin parameter name was renamed: ${layout.take(3000)}") + assertTrue("alignmentLine: " in layout, "a Kotlin parameter name was renamed: ${layout.take(3000)}") paths.filter { it.endsWith(".pyi") }.forEach { path -> val text = stub(path) assertTrue("pythonx" !in text, "$path mentions pythonx") - assertTrue("class _" !in text && "Protocol" !in text, "$path carries the Pythonic Protocol shape") + assertTrue( + Regex("""class _(?!\w+_\w+\(_t\.Protocol\))""").find(text) == null, + "$path carries a private class that is not a receiver-method protocol", + ) } } /** * The runtime resolves a Kotlin default when a defaulted parameter is omitted, so the stub * marks it `= ...`; a parameter with no default stays required. Python rejects a required - * parameter after a defaulted one, so for the stub to be valid no `name: type` without `= ...` may - * follow one with it. Checked textually, since no Python parser runs in this module. + * parameter after a defaulted one **unless it is keyword-only**, which is exactly what + * `inspect.signature` reports for such a parameter (`KotlinSurface.kt`), so the stub writes `*` + * before it. Checked textually, since no Python parser runs in this module -- and bracket-aware, + * because a `Callable[[A, B], R]` annotation has commas of its own. */ @Test - fun noGeneratedDefPutsARequiredParameterAfterADefaultedOne() { + fun noGeneratedDefPutsARequiredPositionalParameterAfterADefaultedOne() { generatedPaths().filter { it.endsWith(".pyi") }.forEach { path -> - Regex("""^def [A-Za-z0-9_]+\((.*)\) -> """, RegexOption.MULTILINE).findAll(stub(path)).forEach { match -> + Regex("""^(?:def [A-Za-z0-9_]+| def __call__)\((.*)\) -> """, RegexOption.MULTILINE).findAll(stub(path)).forEach { match -> var sawDefault = false - match.groupValues[1].split(", ").filter { it != "/" && it != "*" }.forEach { parameter -> - if ("= ..." in parameter) sawDefault = true - else assertTrue(!sawDefault, "$path: required parameter after a default in ${match.value}") + var keywordOnly = false + splitTopLevel(match.groupValues[1]).filter { it != "/" }.forEach { parameter -> + if (parameter == "*") keywordOnly = true + else if ("= ..." in parameter) sawDefault = true + else assertTrue(!sawDefault || keywordOnly, "$path: required positional parameter after a default in ${match.value}") } } } } + private fun splitTopLevel(parameters: String): List { + val out = mutableListOf() + var depth = 0 + val current = StringBuilder() + for (c in parameters) { + when { + c == '[' -> { depth++; current.append(c) } + c == ']' -> { depth--; current.append(c) } + c == ',' && depth == 0 -> { out += current.toString().trim(); current.clear() } + else -> current.append(c) + } + } + if (current.isNotBlank()) out += current.toString().trim() + return out + } + /** * The invariant the whole product rests on, stated in both directions. * @@ -157,7 +192,11 @@ class WalkedArtifactStubTest { } .toSet() - assertEquals(emptySet(), stubbed - installed, "stubbed but not callable") + // The base name of an overload set (`padding` for `padding__Dp`, ...) is served by the binding + // layer's dispatcher and is stubbed as `@overload`s; it is not itself a table key. + val overloadBases = installed.map { it.substringBeforeLast('.') + "." + it.substringAfterLast('.').substringBefore("__") } + .filter { it !in installed }.toSet() + assertEquals(emptySet(), stubbed - installed - overloadBases, "stubbed but not callable") assertEquals(emptySet(), installed - stubbed, "callable but not stubbed") } } diff --git a/ksp-fixtures/compose/build.gradle.kts b/ksp-fixtures/compose/build.gradle.kts index 4a8581fc9..5d89fd6a7 100644 --- a/ksp-fixtures/compose/build.gradle.kts +++ b/ksp-fixtures/compose/build.gradle.kts @@ -94,7 +94,10 @@ pythonBindings { "androidx.compose.ui", ), ) - generateStubs.set(false) + // Stubs on: `generatePythonStubs` over the real Compose jars is what `tools/stubs/check-stubs.sh` (the mypy + // check) and `.github/workflows/stubs.yml` (the published artifact) both run. Nothing depends on the + // task, so the render tests do not pay for it. + generateStubs.set(true) } /** diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactRendering.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactRendering.kt index e5326a644..0eb66027c 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactRendering.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactRendering.kt @@ -146,7 +146,7 @@ internal fun artifactFragmentObjectName(coordinates: String): String = * that evidence -- it is set by exactly one path, the one that emits the mask parameter -- so the * rule stays "derived from the body", with two bodies to derive from rather than one. */ -private fun omittableSlotsOf(entry: ArtifactCallable): List = +internal fun omittableSlotsOf(entry: ArtifactCallable): List = if ("== null" in entry.lambdaBody || entry.thunk != null) entry.paramHasDefault else entry.paramHasDefault.map { false } /** diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt index 7d3e81d05..dbe45fe38 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt @@ -228,6 +228,26 @@ internal object ArtifactScanner { // Defaults first: [disambiguateOverloads] renames entries, and the sibling test needs the // Kotlin name they still share. return assignThunkIndices(disambiguateOverloads(applyDefaultOmission(dropCollidingConstructors(entries)))) + // The ancestry the binding carries is the model's too (issue #31): copied at the end, from the + // callable that was actually bound, so the two cannot be read differently. + .map { candidate -> + val callable = candidate.callable ?: return@map candidate + // ... and so is what the binding lets Python leave out. The model's `declaresDefault` is + // Kotlin's own view until [applyDefaultOmission] narrows it, and that pass returns a + // candidate untouched when nothing is omittable (past [MAX_OMITTABLE_PARAMETERS], an + // unwritable name, an ambiguous sibling): the binding then requires every argument + // while the stub would still offer `= ...`. One view, taken from the binding. + val omittable = omittableSlotsOf(callable) + val receiverIndex = if (callable.receiverTypeName != null) 1 else 0 + candidate.copy( + declaration = candidate.declaration.copy( + returnSupertypes = callable.returnSupertypes, + parameters = candidate.declaration.parameters.mapIndexed { index, parameter -> + parameter.copy(declaresDefault = omittable.getOrElse(index + receiverIndex) { false }) + }, + ), + ) + } } /** diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/model/StubModel.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/model/StubModel.kt index 25efbe6fb..7e5bccacd 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/model/StubModel.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/model/StubModel.kt @@ -81,6 +81,14 @@ internal data class DeclarationModel( val isComposable: Boolean = false, val isSuspend: Boolean = false, val kind: String = "FUNCTION", + /** + * What the declared return type **is a**, nearest first, as qualified Kotlin names -- the + * ancestry `ArtifactCallable.returnSupertypes` carries into the binding layer, kept here so a + * stub class can extend the class a value of it may fill (`BitmapPainter` fills a `Painter` + * slot). Empty for a type nothing could read an ancestry for, which is the truth rather than a + * gap to fill. + */ + val returnSupertypes: List = emptyList(), ) : Serializable /** @param name `null` when the producer could not read one (§3.2); never a synthesised `arg0`. */ 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 f153ef06f..3bfd0e88d 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 @@ -1,7 +1,8 @@ package python.multiplatform.gradle.stubs import python.multiplatform.gradle.model.DeclarationModel -import python.multiplatform.gradle.model.DeclaredParameter +import python.multiplatform.gradle.model.KotlinTypeModel +import python.multiplatform.gradle.model.ValueClassModel /** * The stub product: `docs/design/pyi-generation-design.md` §5.3's Kotlin-FQN stubs, rendered from the @@ -13,95 +14,53 @@ import python.multiplatform.gradle.model.DeclaredParameter * these modules and makes them Pythonic, and its own stubs are its own. * * Returns *relative path to file content*, so nothing here touches a disk and every assertion in - * `PyiRenderingTest` is over text. `PythonStubsTask` is the only thing that writes. - */ - -private const val HEADER = "# GENERATED by python-multiplatform-gradle-plugin. Do not edit." - -/** - * The 1:1 product: one `def` per `UpcallTable` key, in the module the runtime publishes it onto. + * `PyiRenderingTest` and `TypedStubTest` is over text. `PythonStubsTask` is the only thing that writes. + * + * ### What a stub says about a type (issue #31) + * + * The declared Kotlin type, wherever the binder serves one: + * + * | Kotlin | stub | why | + * |---|---|---| + * | `Int` `Long` `Float` `Boolean` `String` `ByteArray` `Unit` | `int` `float` `bool` `str` `bytes` `None` | what crosses | + * | any other class or interface | a stub class **in the module of its own Kotlin package** | `UpcallTrampoline` hands Python a handle and the binding layer wraps it in a proxy class named for the declared type | + * | value class the binder binds as its primitive (`Dp`) | `Dp \| float` as a parameter, `float` as a result | a result is the raw number (`_wrap` only wraps `OBJECT`); the raw module accepts the number for a parameter, and the binding layer's allowlist (`allow_raw_primitive`) decides at run time, which no stub can know | + * | value class the binder cannot open (`TextUnit`) | its own stub class | it crosses as an object handle | + * | `(A, B) -> R` | `Callable[[A, B], R]` | `kotlin.FunctionN`'s type arguments | + * | `T?` | `T \| None` | `KotlinTypeModel.isNullable` | + * + * A class whose name is also a function in its module (`PaddingValues(...)` the factory and + * `PaddingValues` the interface) cannot be both in one Python module, and the runtime resolves the + * name to the function, so such a type is annotated `Any`. * - * ### The types are the boundary's, not the declaration's + * ### Extension functions are also methods * - * `PythonProxySource.renderOne` publishes `setattr(_pm_module('androidx.compose.foundation.layout'), - * 'padding__Dp', f)` where `f` is `def _pm_f_7(a0, a1)`, and `UpcallTrampoline` marshals a `Dp` - * argument as a raw float and a `Modifier` return as a `HandleTable` integer. So this product - * annotates `float` and `int`: that is what the module accepts and returns, and - * `docs/design/pyi-generation-design.md` §7 lists the handle-to-proxy wrapping that would make it - * `Modifier` as *not yet done*. Saying `-> Modifier` here would be the exact lie that section warns - * about. The Kotlin signature goes in the docstring, which is where a reader wants it and where no - * checker can act on it. + * The runtime attaches an extension to the receiver's proxy class (`PythonxAdapter._attach`), reached + * as `m.padding(...)` and `Modifier.padding(...)` alike. A plain method cannot say both -- the + * second binds the argument to `self` -- so each extension is a **callable attribute** of the + * receiver's stub class whose type is a `Protocol` with `__call__` (a measured shape: archive + * `pyi-generation-pythonic-stubs.md` §4.4). Both the Kotlin name (an overload set, `@overload`ed) and + * each table-key spelling (`padding__Dp`) are attributes, because the runtime indexes both. + * The module-level `def`s stay, with the receiver as the first positional-only parameter. * - * ### Parameters - -The runtime accepts keyword arguments by the **Kotlin parameter names** and honours Kotlin defaults -(an omitted defaulted parameter takes the Kotlin default). So the extension receiver stays -positional-only (`receiver: ..., /`) and every declared parameter is positional-or-keyword under its -Kotlin name, with `= ...` when the declaration has a default. Two cases cannot be written as a -keyword in `.pyi` syntax: a name that is not a Python identifier or is a Python keyword (`in`, `is`, -`from`), and an unknown name (a Java class without `-parameters`, `parameterNamesKnown == false`). -Those parameters -- and every parameter before one, to keep the stub valid -- are positional-only, -the unwritable one spelled `__a`. No renamed spelling is invented. + * ### Overloads + * + * An overload set is `@overload`ed under its base name, **in table-key order**: the artefact table is + * sorted by name (`ArtifactScanner.scanJar`), the binding layer indexes its candidates in that order + * and `_Overloads` tries them in it, and a checker takes the first match. */ -internal fun renderKotlinFqnStubs(declarations: List): Map { - val byModule = declarations - .filter { it.bindingName != null && !it.isSuspend } - .groupBy { it.bindingName!!.substringBeforeLast('.') } - return byModule.toSortedMap().mapValues { (_, entries) -> - buildString { - appendLine(HEADER) - appendLine() - entries.sortedBy { it.bindingName }.forEach { declaration -> - appendLine(renderKotlinFqnDef(declaration)) - appendLine() - } - } - }.mapKeys { (module, _) -> module.replace('.', '/') + "/__init__.pyi" } -} -private fun renderKotlinFqnDef(declaration: DeclarationModel): String { - val leaf = declaration.bindingName!!.substringAfterLast('.') - if (declaration.kind == "STATIC_GETTER") { - return buildString { - appendLine("$leaf: ${boundaryAnnotation(declaration.returnBoundaryTag)}") - append("\"\"\"Kotlin: ${kotlinSignatureOf(declaration)}\"\"\"") - } - } - val signature = renderParameters(declaration) - return buildString { - appendLine("def $leaf$signature -> ${boundaryAnnotation(declaration.returnBoundaryTag)}:") - appendLine(" \"\"\"Kotlin: ${kotlinSignatureOf(declaration)}\"\"\"") - append(" ...") - } -} +private const val HEADER = "# GENERATED by python-multiplatform-gradle-plugin. Do not edit." -/** - * `python.multiplatform.reflection.TypeTag` to the Python type that actually crosses. - * - * `OBJECT` is `int` because it is: `UpcallTrampoline.marshalResult` puts the Kotlin object in - * `HandleTable` and hands Python the resulting integer, and `PythonProxySource`'s KDoc records that - * such a handle "is the caller's to release". - */ -private fun boundaryAnnotation(tag: String?): String = when (tag) { - "INT", "OBJECT" -> "int" - "FLOAT" -> "float" - "BOOLEAN" -> "bool" - "STRING" -> "str" - "BYTES" -> "bytes" - "UNIT" -> "None" - else -> "object" -} +/** The module alias every stub imports `typing` as: a Kotlin declaration may be called `Any`. */ +private const val TYPING = "_t" -/** What the docstring says: the declaration as Kotlin wrote it, which is the information the - * annotations above deliberately do not carry. */ -private fun kotlinSignatureOf(declaration: DeclarationModel): String { - val owner = declaration.receiver?.qualifiedName ?: declaration.owner - val parameters = declaration.parameters.joinToString(", ") { parameter -> - val type = parameter.type.qualifiedName + if (parameter.type.isNullable) "?" else "" - if (declaration.parameterNamesKnown && parameter.name != null) "${parameter.name}: $type" else type - } - return "$owner.${declaration.simpleName}($parameters): ${declaration.returnType.qualifiedName}" -} +private const val ANY = "$TYPING.Any" + +private const val RECEIVER_NAME = "receiver" + +internal fun renderKotlinFqnStubs(declarations: List): Map = + StubRenderer(declarations.filter { it.bindingName != null && !it.isSuspend }).render() /** Python's hard keywords (`keyword.kwlist`): none of these can be a parameter name. */ private val PYTHON_KEYWORDS = setOf( @@ -111,41 +70,368 @@ private val PYTHON_KEYWORDS = setOf( "try", "while", "with", "yield", ) -private fun isWritableName(name: String?, receiverPresent: Boolean): Boolean = - name != null && - name !in PYTHON_KEYWORDS && - !(receiverPresent && name == RECEIVER_NAME) && +private fun isIdentifier(name: String): Boolean = + name.isNotEmpty() && name !in PYTHON_KEYWORDS && (name[0] == '_' || name[0].isLetter()) && name.all { it == '_' || it.isLetterOrDigit() } -private const val RECEIVER_NAME = "receiver" +private fun isWritableName(name: String?, reserved: String?): Boolean = + name != null && name != reserved && isIdentifier(name) + +/** What the docstring says: the declaration as Kotlin wrote it, which is the information the + * annotations do not always carry (a type that fell back to `Any`, a function type's receiver). */ +private fun kotlinSignatureOf(declaration: DeclarationModel): String { + val owner = declaration.receiver?.qualifiedName ?: declaration.owner + val parameters = declaration.parameters.joinToString(", ") { parameter -> + val type = parameter.type.qualifiedName + if (parameter.type.isNullable) "?" else "" + if (declaration.parameterNamesKnown && parameter.name != null) "${parameter.name}: $type" else type + } + return "$owner.${declaration.simpleName}($parameters): ${declaration.returnType.qualifiedName}" +} + +private fun builtinOf(qualifiedName: String): String? = when (qualifiedName) { + "kotlin.Boolean" -> "bool" + "kotlin.Byte", "kotlin.Short", "kotlin.Int", "kotlin.Long" -> "int" + "kotlin.Float", "kotlin.Double" -> "float" + "kotlin.String", "kotlin.Char" -> "str" + "kotlin.ByteArray" -> "bytes" + "kotlin.Unit" -> "None" + "kotlin.Any" -> ANY + "kotlin.Nothing" -> "$TYPING.NoReturn" + else -> null +} + +/** `python.multiplatform.reflection.TypeTag` to the Python type that actually crosses. */ +private fun builtinOfTag(tag: String?): String? = when (tag) { + "INT" -> "int" + "FLOAT" -> "float" + "BOOLEAN" -> "bool" + "STRING" -> "str" + "BYTES" -> "bytes" + "UNIT" -> "None" + else -> null +} + +private val FUNCTION_TYPE = Regex("""kotlin\.Function\d+""") + +/** A Kotlin class's qualified name split into its package and its (possibly nested) class path. */ +private class ClassRef(val pkg: String, val path: List) { + val top: String get() = path[0] +} /** - * `(receiver: T, /, a: A, b: B = ...)`, or `(__a0: A, x: B, /)` when a name cannot be written. + * `androidx.compose.ui.Modifier.Companion` -> package `androidx.compose.ui`, path `Modifier.Companion`. * - * Python rejects a parameter without a default after one with a default, and Kotlin allows it - * (`padding(start = 0.dp, top: Dp)`), so a default is marked only when no required parameter - * follows it. The stub then never promises an omission the syntax cannot express. + * Kotlin's qualified name does not say where the package ends, so this uses the convention every + * JetBrains and AndroidX package follows: package segments are lower case, a class starts upper + * case. A name with no upper-case segment is taken to be a package plus one lower-case class. */ -private fun renderParameters(declaration: DeclarationModel): String { - val hasReceiver = declaration.receiver != null - val parameters = declaration.parameters - val writable = parameters.map { declaration.parameterNamesKnown && isWritableName(it.name, hasReceiver) } - // Everything up to and including the last unwritable name is positional-only. - val positionalOnlyEnd = writable.indexOfLast { !it } - val lastRequired = parameters.indexOfLast { !it.declaresDefault } - - val rendered = mutableListOf() - // The extension receiver is the runtime's first positional argument and has no Kotlin name. - if (hasReceiver) rendered += "$RECEIVER_NAME: " + boundaryAnnotation(declaration.receiverBoundaryTag) - if (hasReceiver && positionalOnlyEnd < 0 && parameters.isNotEmpty()) rendered += "/" - parameters.forEachIndexed { index, parameter -> - val name = if (writable[index]) parameter.name!! else "__a$index" - val default = if (parameter.declaresDefault && index > lastRequired) " = ..." else "" - rendered += "$name: " + boundaryAnnotation(parameter.boundaryTag) + default - if (index == positionalOnlyEnd) rendered += "/" - } - if (rendered.isEmpty()) return "()" - if (parameters.isEmpty() && hasReceiver) rendered += "/" - return "(" + rendered.joinToString(", ") + ")" +private fun classRefOf(qualifiedName: String): ClassRef? { + val segments = qualifiedName.split('.') + val firstClass = segments.indexOfFirst { it.isNotEmpty() && it[0].isUpperCase() } + val split = if (firstClass < 0) segments.size - 1 else firstClass + if (split <= 0) return null + return ClassRef(segments.take(split).joinToString("."), segments.drop(split)) } + +private enum class Role { PARAM, RESULT } + +private class StubRenderer(private val bound: List) { + + // ------------------------------------------------------------------------ what is taken + + private fun moduleOf(d: DeclarationModel) = d.bindingName!!.substringBeforeLast('.') + private fun leafOf(d: DeclarationModel) = d.bindingName!!.substringAfterLast('.') + + /** Mirrors `_Decl`: `base, _, suffix = leaf.partition('__')`, and the runtime indexes both. */ + private fun isSuffixed(leaf: String): Boolean { + val base = leaf.substringBefore("__") + return "__" in leaf && base.isNotEmpty() && leaf.length > base.length + 2 + } + + private fun baseOf(leaf: String): String = leaf.substringBefore("__") + + private val byModule: Map> = + bound.groupBy { moduleOf(it) }.toSortedMap() + + /** Every name a module defines at the top level, which a class stub must not take. */ + private val takenNames: Map> = byModule.mapValues { (_, entries) -> + entries.flatMap { d -> leafOf(d).let { leaf -> if (isSuffixed(leaf)) listOf(leaf, baseOf(leaf)) else listOf(leaf) } }.toSet() + } + + private val moduleNames: Set = byModule.keys + + private val supertypes: Map> = bound + .filter { it.returnSupertypes.isNotEmpty() } + .groupBy { it.returnType.qualifiedName } + .mapValues { (_, entries) -> entries.first().returnSupertypes } + + // ----------------------------------------------------------------------- per-module output + + private class ClassNode(val name: String) { + val children = sortedMapOf() + var qualifiedName: String? = null + var bases: String? = null + var doc: String? = null + val members = mutableListOf() + } + + private inner class ModuleOut(val module: String) { + val imports = sortedSetOf() + val protocols = mutableListOf() + val classes = sortedMapOf() + val defs = mutableListOf() + + fun node(path: List): ClassNode { + var current = classes.getOrPut(path[0]) { ClassNode(path[0]) } + path.drop(1).forEach { current = current.children.getOrPut(it) { ClassNode(it) } } + return current + } + } + + private val modules = sortedMapOf() + private fun out(module: String): ModuleOut = modules.getOrPut(module) { ModuleOut(module) } + + /** Every class a signature mentioned, and the value class it wraps when it is one. */ + private val classes = sortedMapOf() + + // ------------------------------------------------------------------------------ types + + private fun annotate(type: KotlinTypeModel, tag: String?, role: Role, ctx: ModuleOut): String { + val core = core(type, tag, role, ctx) + return if (type.isNullable && core != "None") "$core | None" else core + } + + private fun argument(type: KotlinTypeModel?, role: Role, ctx: ModuleOut): String = + if (type == null) ANY else annotate(type, null, role, ctx) + + private fun core(type: KotlinTypeModel, tag: String?, role: Role, ctx: ModuleOut): String { + if (FUNCTION_TYPE.matches(type.qualifiedName)) { + if (type.arguments.isEmpty()) return "$TYPING.Callable[..., $ANY]" + val parameters = type.arguments.dropLast(1).joinToString(", ") { argument(it, Role.RESULT, ctx) } + return "$TYPING.Callable[[$parameters], ${argument(type.arguments.last(), Role.PARAM, ctx)}]" + } + val valueClass = type.valueClass + if (valueClass != null && tag != null && tag != "OBJECT") { + // Bound as its primitive: the raw number is what comes back, and what the raw module takes. + val raw = builtinOfTag(tag) ?: builtinOf(valueClass.underlying.qualifiedName) ?: ANY + if (role == Role.RESULT) return raw + val ref = classReference(type, ctx) + return if (ref == ANY) raw else "$ref | $raw" + } + builtinOf(type.qualifiedName)?.let { return it } + if (tag != null && tag != "OBJECT") builtinOfTag(tag)?.let { return it } + return classReference(type, ctx) + } + + /** The spelling of a class stub from [ctx]'s module, registering it so that it gets emitted. */ + private fun classReference(type: KotlinTypeModel, ctx: ModuleOut): String = + classReference(type.qualifiedName, type.valueClass, ctx) + + private fun classReference(qualifiedName: String, valueClass: ValueClassModel?, ctx: ModuleOut): String { + val ref = classRefOf(qualifiedName) ?: return ANY + if (ref.top in takenNames[ref.pkg].orEmpty() || "${ref.pkg}.${ref.top}" in moduleNames) return ANY + if (qualifiedName !in classes || classes[qualifiedName] == null) classes[qualifiedName] = valueClass ?: classes[qualifiedName] + val path = ref.path.joinToString(".") + if (ref.pkg == ctx.module) return path + ctx.imports += ref.pkg + return "${ref.pkg}.$path" + } + + // --------------------------------------------------------------------------- parameters + + private fun parameters(d: DeclarationModel, ctx: ModuleOut, method: Boolean): String { + val hasReceiver = !method && d.receiver != null + val parameters = d.parameters + val reserved = when { + method -> "self" + hasReceiver -> RECEIVER_NAME + else -> null + } + val writable = parameters.map { d.parameterNamesKnown && isWritableName(it.name, reserved) } + // Everything up to and including the last unwritable name is positional-only. + val positionalOnlyEnd = writable.indexOfLast { !it } + val lastRequired = parameters.indexOfLast { !it.declaresDefault } + // `inspect.signature` on the binder's callable makes a required parameter that follows a + // defaulted one keyword-only (`KotlinSurface.kt`), and so does the stub -- except where a + // positional-only prefix is already in play, where the old rule (mark a default only when no + // required parameter follows it) keeps the prefix valid. + val keywordOnlyStart = if (positionalOnlyEnd >= 0) -1 else + parameters.indices.firstOrNull { index -> !parameters[index].declaresDefault && parameters.take(index).any { it.declaresDefault } } ?: -1 + + val rendered = mutableListOf() + if (method) rendered += "self" + if (hasReceiver) { + rendered += "$RECEIVER_NAME: " + annotate(d.receiver!!, d.receiverBoundaryTag, Role.PARAM, ctx) + if (positionalOnlyEnd < 0 && parameters.isNotEmpty()) rendered += "/" + } + parameters.forEachIndexed { index, parameter -> + if (index == keywordOnlyStart) rendered += "*" + val name = if (writable[index]) parameter.name!! 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 += "/" + } + if (parameters.isEmpty() && hasReceiver) rendered += "/" + return rendered.joinToString(", ") + } + + private fun result(d: DeclarationModel, ctx: ModuleOut): String = + annotate(d.returnType, d.returnBoundaryTag, Role.RESULT, ctx) + + // ---------------------------------------------------------------------- module level + + private fun renderDef(d: DeclarationModel, ctx: ModuleOut): String { + val leaf = leafOf(d) + if (d.kind == "STATIC_GETTER") { + return buildString { + appendLine("$leaf: ${result(d, ctx)}") + append("\"\"\"Kotlin: ${kotlinSignatureOf(d)}\"\"\"") + } + } + return buildString { + appendLine("def $leaf(${parameters(d, ctx, method = false)}) -> ${result(d, ctx)}:") + appendLine(" \"\"\"Kotlin: ${kotlinSignatureOf(d)}\"\"\"") + append(" ...") + } + } + + private fun overloadDefs(name: String, group: List, ctx: ModuleOut): String = + group.withIndex().joinToString("\n") { (index, d) -> + val signature = "def $name(${parameters(d, ctx, method = false)}) -> ${result(d, ctx)}: ..." + shadowed(index) + if (group.size > 1) "@$TYPING.overload\n$signature" else signature + } + + /** + * Python's one `int`/`float` and a class that fell back to `Any` cannot tell two Kotlin overloads + * apart (`lerp(Int, Int, Float)` against `lerp(Float, Float, Float)`), so a checker reports every + * later one as "will never be matched". The runtime dispatcher *does* tell them apart, and the + * explicit `name__Types` spelling reaches each one, so the diagnostic is silenced where it is a + * statement about Python's types and not about the declaration. + */ + private fun shadowed(index: Int): String = if (index > 0) " # type: ignore[overload-cannot-match]" else "" + + private fun renderModuleDefs() { + byModule.forEach { (module, entries) -> + val ctx = out(module) + val sorted = entries.sortedBy { it.bindingName } + sorted.forEach { ctx.defs += renderDef(it, ctx) } + // 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() + sorted.filter { it.kind != "STATIC_GETTER" && isSuffixed(leafOf(it)) } + .groupBy { baseOf(leafOf(it)) } + .toSortedMap() + .forEach { (base, group) -> + if (base !in unsuffixedLeaves) ctx.defs += overloadDefs(base, group, ctx) + } + } + } + + // --------------------------------------------------------------------------- classes + + /** The class a declaration's extension receiver names, when the runtime attaches it as a method. */ + private fun methodReceiver(d: DeclarationModel): String? { + val receiver = d.receiver ?: return null + if (builtinOf(receiver.qualifiedName) != null || FUNCTION_TYPE.matches(receiver.qualifiedName)) return null + val tag = d.receiverBoundaryTag + if (tag != null && tag != "OBJECT") return null + if (tag == null && receiver.valueClass != null) return null + return receiver.qualifiedName + } + + private val extensionsByReceiver: Map> = bound + .filter { it.kind != "STATIC_GETTER" } + .mapNotNull { d -> methodReceiver(d)?.let { it to d } } + .groupBy({ it.first }, { it.second }) + + private fun renderClass(qualifiedName: String) { + val ref = classRefOf(qualifiedName) ?: return + val ctx = out(ref.pkg) + val node = ctx.node(ref.path) + node.qualifiedName = qualifiedName + val valueClass = classes[qualifiedName] + node.doc = if (valueClass != null) { + "Kotlin value class $qualifiedName over ${valueClass.underlying.qualifiedName}. As a parameter the raw " + + "primitive is also accepted (the binding layer's allowlist decides, at run time); a result is the raw primitive." + } else { + "Kotlin: $qualifiedName" + } + supertypes[qualifiedName] + ?.firstOrNull { it != qualifiedName && it != "kotlin.Any" && !it.startsWith("java.lang.") && classRefOf(it) != null } + ?.let { base -> + val reference = classReference(base, null, ctx) + if (reference != ANY) node.bases = reference + } + + val attributes = sortedMapOf>() + extensionsByReceiver[qualifiedName].orEmpty().sortedBy { it.bindingName }.forEach { d -> + val leaf = leafOf(d) + attributes.getOrPut(leaf) { mutableListOf() } += d + if (isSuffixed(leaf)) attributes.getOrPut(baseOf(leaf)) { mutableListOf() } += d + } + attributes.forEach { (attribute, group) -> + if (!isIdentifier(attribute) || attribute in node.children) return@forEach + val protocol = "_" + ref.path.joinToString("_") + "_" + attribute + ctx.protocols += buildString { + appendLine("class $protocol($TYPING.Protocol):") + group.forEachIndexed { index, d -> + if (group.size > 1) appendLine(" @$TYPING.overload") + append(" def __call__(${parameters(d, ctx, method = true)}) -> ${result(d, ctx)}: ..." + shadowed(index)) + if (index != group.lastIndex) appendLine() + } + } + node.members += "$attribute: $TYPING.ClassVar[$protocol]" + } + } + + private fun renderClasses() { + val done = mutableSetOf() + while (true) { + val next = classes.keys.firstOrNull { it !in done } ?: break + done += next + renderClass(next) + } + } + + private fun renderNode(node: ClassNode, indent: String, into: StringBuilder) { + into.append(indent).append("class ").append(node.name) + node.bases?.let { into.append('(').append(it).append(')') } + into.appendLine(":") + into.append(indent).append(" \"\"\"").append(node.doc ?: "Kotlin: container of nested classes").appendLine("\"\"\"") + node.members.forEach { into.append(indent).append(" ").appendLine(it) } + node.children.values.forEach { renderNode(it, "$indent ", into) } + } + + // ---------------------------------------------------------------------------- assembly + + fun render(): Map { + renderModuleDefs() + renderClasses() + return modules.filterValues { it.defs.isNotEmpty() || it.classes.isNotEmpty() } + .mapValues { (_, ctx) -> + buildString { + appendLine(HEADER) + appendLine() + appendLine("import typing as $TYPING") + ctx.imports.forEach { appendLine("import $it") } + appendLine() + ctx.protocols.forEach { appendLine(it); appendLine() } + ctx.classes.values.forEach { node -> + renderNode(node, "", this) + appendLine() + } + ctx.defs.forEach { appendLine(it); appendLine() } + } + } + .mapKeys { (module, _) -> module.replace('.', '/') + "/__init__.pyi" } + } +} + +/** + * Groups of stub paths that name one file on a case-insensitive filesystem. A Kotlin package and a + * class may differ only by case (`graphics.shadow`, `graphics.Shadow`), which is legal on the JVM and + * in Python and not on macOS or Windows, where `PythonStubsTask` would otherwise overwrite one with + * the other without a word. + */ +internal fun caseCollidingPaths(paths: Collection): List> = + paths.groupBy { it.lowercase() }.values.filter { it.size > 1 }.map { it.sorted() } diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PythonStubsTask.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PythonStubsTask.kt index 8cd7c724b..fd57601ba 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PythonStubsTask.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PythonStubsTask.kt @@ -101,6 +101,12 @@ abstract class PythonStubsTask : DefaultTask() { } val files = renderKotlinFqnStubs(declarations) + caseCollidingPaths(files.keys).forEach { group -> + logger.warn( + "python-multiplatform: stub paths differ only by case and name one file on a case-insensitive " + + "filesystem (macOS, Windows); the last one written wins: $group", + ) + } files.forEach { (path, content) -> val file = destination.resolve(path) file.parentFile.mkdirs() diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/DefaultOmissionTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/DefaultOmissionTest.kt index b74cc18fc..514c92ec3 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/DefaultOmissionTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/DefaultOmissionTest.kt @@ -172,6 +172,33 @@ class DefaultOmissionTest { * is all `false`, so `pythonx` requires every argument and the `.pyi` writes no `= ...`. The * declaration is still bound and still callable; what it loses is omission. */ + /** + * The stub's side of the same rule: the declaration model says `declaresDefault` for exactly the + * slots the **binding** lets Python leave out, so a stub never promises an omission the runtime + * refuses. Before this was pinned the model kept Kotlin's own view for a declaration the cap (or a + * name the walker cannot write) left all-required, and `combinedClickable`'s stub offered nine + * defaults that `inspect.signature` at run time requires (`StubSignatureAgreesWithRuntimeTest`). + */ + @Test + fun theDeclarationModelMarksADefaultOnlyWhereTheBindingAllowsOmission() { + val declarations = ArtifactScanner.scanDeclarations(fixtureClasses, includePrefixes = listOf("fixture.artifactdefaults")) + fun defaults(name: String) = declarations.single { it.bindingName == "fixture.artifactdefaults.$name" }.parameters.map { it.declaresDefault } + assertEquals(List(7) { false }, defaults("wide")) + assertEquals(List(6) { true }, defaults("sixWide")) + assertEquals(listOf(false, true), defaults("greet")) + // And for every bound declaration, receiver slot included, the two views are one view. + val bound = walk().associateBy { it.name } + declarations.filter { it.bindingName != null }.forEach { declaration -> + val callable = bound.getValue(declaration.bindingName!!) + val offset = if (callable.receiverTypeName != null) 1 else 0 + assertEquals( + declaration.parameters.indices.map { index -> callable.paramHasDefault.getOrElse(index + offset) { false } }, + declaration.parameters.map { it.declaresDefault }, + declaration.bindingName, + ) + } + } + @Test fun aDeclarationWiderThanTheCapKeepsEveryParameterRequired() { val wide = entry("wide") diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ReturnSupertypeTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ReturnSupertypeTest.kt index d09f950b9..f6c9d3979 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ReturnSupertypeTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ReturnSupertypeTest.kt @@ -92,6 +92,23 @@ class ReturnSupertypeTest { ) } + /** + * The same ancestry on the declaration model, which is what the stub generator reads: a stub class + * extends what the binding layer's `_is_a` would accept it for (issue #31). One walk, so the two + * cannot disagree. + */ + @Test + fun theDeclarationModelCarriesTheSameAncestryAsTheBinding() { + val graphics = jarUnder("org.jetbrains.compose.ui", "ui-graphics-desktop") ?: return + val declarations = ArtifactScanner.scanDeclarations( + graphics, + includePrefixes = listOf("androidx.compose.ui.graphics"), + classpath = composeClasspath(), + ) + val painter = declarations.first { it.bindingName == "androidx.compose.ui.graphics.painter.BitmapPainter" } + assertEquals(listOf("androidx.compose.ui.graphics.painter.Painter"), painter.returnSupertypes) + } + /** * A return that does **not** cross as a handle carries no ancestry, because nothing could read * one: a `Dp` result is a raw `FLOAT` with no identity to hang it on, and a `Boolean` even less. 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 2f918a5df..9269df548 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 @@ -83,13 +83,13 @@ class KotlinNamesOnlyStubTest { @Test fun declaredParametersAreKeywordCapableByTheirKotlinNameAndDefaultsAreMarked() { val b = body(decl("fillMaxWidth", parameters = listOf(param("fraction", default = true)))) - assertTrue("def fillMaxWidth(receiver: int, /, fraction: float = ...) -> int:" in b, b) + assertTrue("def fillMaxWidth(receiver: androidx.compose.ui.Modifier, /, fraction: float = ...) -> androidx.compose.ui.Modifier:" in b, b) } @Test fun aRequiredParameterHasNoDefaultMarker() { val b = body(decl("windowInsetsPadding", parameters = listOf(param("windowInsets")))) - assertTrue("def windowInsetsPadding(receiver: int, /, windowInsets: float) -> int:" in b, b) + assertTrue("def windowInsetsPadding(receiver: androidx.compose.ui.Modifier, /, windowInsets: 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 @@ -100,7 +100,7 @@ class KotlinNamesOnlyStubTest { val b = body( decl("scan", parameters = listOf(param("first"), param("in"), param("last", default = true))), ) - assertTrue("def scan(receiver: int, first: float, __a1: float, /, last: float = ...) -> int:" in b, b) + assertTrue("def scan(receiver: androidx.compose.ui.Modifier, first: float, __a1: float, /, last: float = ...) -> androidx.compose.ui.Modifier:" in b, b) assertFalse("in_" in b, b) } @@ -109,14 +109,15 @@ class KotlinNamesOnlyStubTest { val b = body( decl("assertEquals", parameters = listOf(param(null), param(null))).copy(parameterNamesKnown = false), ) - assertTrue("def assertEquals(receiver: int, __a0: float, __a1: float, /) -> int:" in b, b) + assertTrue("def assertEquals(receiver: androidx.compose.ui.Modifier, __a0: float, __a1: float, /) -> androidx.compose.ui.Modifier:" in b, b) } - /** Python rejects a non-default parameter after a default one, so a Kotlin default that precedes - * a required parameter cannot be marked; the stub stays valid and the parameter stays named. */ + /** Python rejects a non-default parameter after a default one, and `inspect.signature` on the + * binder's own callable makes such a parameter keyword-only (`KotlinSurface.kt`): so does the + * stub, and the default stays marked. */ @Test - fun aDefaultBeforeARequiredParameterIsNotMarkedSoTheStubStaysSyntacticallyValid() { + fun aRequiredParameterAfterADefaultIsKeywordOnlyLikeTheRuntimeSignature() { val b = body(decl("mix", parameters = listOf(param("a", default = true), param("b")))) - assertTrue("def mix(receiver: int, /, a: float, b: float) -> int:" in b, b) + assertTrue("def mix(receiver: androidx.compose.ui.Modifier, /, a: float = ..., *, b: float) -> androidx.compose.ui.Modifier:" in b, b) } } diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/PyiRenderingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/PyiRenderingTest.kt index b137a73f9..e1efbc675 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/PyiRenderingTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/PyiRenderingTest.kt @@ -70,7 +70,9 @@ class PyiRenderingTest { val body = files.getValue("androidx/compose/foundation/layout/__init__.pyi") assertTrue("def padding__Dp(" in body, body) assertTrue("def padding__PaddingValues(" in body, body) - assertFalse("def padding(" in body, "the bare name is not a table key: $body") + // The base name of an overload set is served by the binding layer's dispatcher, so it is + // stubbed as `@overload`s beside the explicit table-key spellings (`TypedStubTest`). + assertTrue("@_t.overload\ndef padding(" in body, body) } /** @@ -85,9 +87,9 @@ class PyiRenderingTest { @Test fun kotlinFqnParametersAreKeywordCapableAndCarryTheBoundarysOwnTypes() { val body = renderKotlinFqnStubs(paddingOverloads).getValue("androidx/compose/foundation/layout/__init__.pyi") - assertTrue("def padding__Dp(receiver: int, /, all: float) -> int:" in body, body) + assertTrue("def padding__Dp(receiver: androidx.compose.ui.Modifier, /, all: androidx.compose.ui.unit.Dp | float) -> androidx.compose.ui.Modifier:" in body, body) // `padding(start, top, end, bottom)` has four defaulted parameters. - assertTrue("def padding__Dp_Dp_Dp_Dp(receiver: int, /, start: float = ..., top: float = ..., end: float = ..., bottom: float = ...) -> int:" in body, body) + assertTrue("def padding__Dp_Dp_Dp_Dp(receiver: androidx.compose.ui.Modifier, /, start: androidx.compose.ui.unit.Dp | float = ..., top: androidx.compose.ui.unit.Dp | float = ..., end: androidx.compose.ui.unit.Dp | float = ..., bottom: androidx.compose.ui.unit.Dp | float = ...) -> androidx.compose.ui.Modifier:" in body, body) assertTrue( "\"\"\"Kotlin: androidx.compose.ui.Modifier.padding(all: androidx.compose.ui.unit.Dp): " + "androidx.compose.ui.Modifier\"\"\"" in body, 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 new file mode 100644 index 000000000..10e0a811a --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/TypedStubTest.kt @@ -0,0 +1,297 @@ +package python.multiplatform.gradle.stubs + +import python.multiplatform.gradle.model.DeclarationModel +import python.multiplatform.gradle.model.DeclaredParameter +import python.multiplatform.gradle.model.KotlinTypeModel +import python.multiplatform.gradle.model.ValueClassModel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Issue #31: the stub names the Kotlin type a declaration says, instead of the `int` handle the + * boundary carries. + * + * What is pinned here, all over generated text: + * + * - one stub class per bound Kotlin type, in the module of its own Kotlin package, and used in every + * annotation (`Modifier`, not `int`); + * - an extension function is also reachable from its receiver's stub class, under both the Kotlin + * name and the overload-suffixed table key, the way the runtime attaches it + * (`PythonxAdapter._attach`); + * - a value class over a primitive is `Dp | float` as a parameter and the raw primitive as a + * result, because that is what crosses; a value class the binder cannot open is a plain class; + * - a Kotlin function type is `Callable[...]`, a nullable type `| None`; + * - an overload set is `@overload`ed under its base name **in table-key order**, which is the order + * the binder's `_Overloads` tries candidates in (`ArtifactScanner.scanJar` sorts by table key); + * - a required parameter after a defaulted one is keyword-only, as `inspect.signature` says + * (`KotlinSurface.kt`). + */ +class TypedStubTest { + + private val pkgUi = "androidx.compose.ui" + private val pkgLayout = "androidx.compose.foundation.layout" + private val pkgUnit = "androidx.compose.ui.unit" + + private val modifier = KotlinTypeModel("$pkgUi.Modifier") + private val float = KotlinTypeModel("kotlin.Float") + private val dp = KotlinTypeModel( + "$pkgUnit.Dp", + valueClass = ValueClassModel(float, constructorIsPublic = true, propertyIsPublic = true), + ) + private val textUnit = KotlinTypeModel( + "$pkgUnit.TextUnit", + valueClass = ValueClassModel(KotlinTypeModel("kotlin.Long"), constructorIsPublic = false, propertyIsPublic = false), + ) + private val paddingValues = KotlinTypeModel("$pkgLayout.PaddingValues") + private val unit = KotlinTypeModel("kotlin.Unit") + + private fun p(name: String, type: KotlinTypeModel, tag: String, default: Boolean = false) = + DeclaredParameter(name, type, default, tag) + + private fun ext( + name: String, + binding: String = "$pkgLayout.$name", + owner: String = pkgLayout, + vararg params: DeclaredParameter, + ) = DeclarationModel( + simpleName = name, + owner = owner, + ownerIsClass = false, + receiver = modifier, + receiverBoundaryTag = "OBJECT", + parameters = params.toList(), + returnType = modifier, + returnBoundaryTag = "OBJECT", + bindingName = binding, + ) + + private val padding = listOf( + ext("padding", "$pkgLayout.padding__Dp_Dp_Dp_Dp", + params = arrayOf(p("start", dp, "FLOAT", true), p("top", dp, "FLOAT", true), p("end", dp, "FLOAT", true), p("bottom", dp, "FLOAT", true))), + ext("padding", "$pkgLayout.padding__Dp", params = arrayOf(p("all", dp, "FLOAT"))), + ext("padding", "$pkgLayout.padding__PaddingValues", params = arrayOf(p("paddingValues", paddingValues, "OBJECT"))), + ext("padding", "$pkgLayout.padding__Dp_Dp", params = arrayOf(p("horizontal", dp, "FLOAT", true), p("vertical", dp, "FLOAT", true))), + ) + + private val fillMaxWidth = ext("fillMaxWidth", params = arrayOf(p("fraction", float, "FLOAT", true))) + + private fun files(vararg d: DeclarationModel) = renderKotlinFqnStubs(d.toList()) + private fun file(files: Map, module: String) = + files.getValue(module.replace('.', '/') + "/__init__.pyi") + + // ------------------------------------------------------------------ classes in annotations + + @Test + fun receiverAndReturnAreTheKotlinClassNotAnInt() { + val layout = file(files(fillMaxWidth), pkgLayout) + assertTrue( + "def fillMaxWidth(receiver: androidx.compose.ui.Modifier, /, fraction: float = ...) -> androidx.compose.ui.Modifier:" in layout, + layout, + ) + assertFalse(": int" in layout || "-> int" in layout, layout) + assertTrue("import androidx.compose.ui\n" in layout, layout) + } + + @Test + fun theClassIsStubbedInTheModuleOfItsOwnPackage() { + val files = files(fillMaxWidth) + val ui = file(files, pkgUi) + assertTrue("class Modifier:" in ui, ui) + assertTrue("\"\"\"Kotlin: androidx.compose.ui.Modifier\"\"\"" in ui, ui) + // A class is never stubbed in the module of the function that merely mentions it. + assertFalse("class Modifier" in file(files, pkgLayout)) + } + + @Test + fun aTypeFromTheSameModuleIsReferencedBare() { + val ui = file(files(fillMaxWidth), pkgUi) + // The extension is attached to its receiver's class, which lives in `androidx.compose.ui`. + assertTrue("-> Modifier" in ui, ui) + assertFalse("androidx.compose.ui.Modifier" in ui.substringAfter("class _Modifier_fillMaxWidth").substringBefore("class Modifier:"), ui) + } + + // ------------------------------------------------------- extension functions as methods + + @Test + fun anExtensionIsAlsoAnAttributeOfItsReceiversClass() { + val ui = file(files(fillMaxWidth), pkgUi) + assertTrue("class _Modifier_fillMaxWidth(_t.Protocol):" in ui, ui) + assertTrue("def __call__(self, fraction: float = ...) -> Modifier: ..." in ui, ui) + assertTrue(" fillMaxWidth: _t.ClassVar[_Modifier_fillMaxWidth]" in ui, ui) + // Callable both as `Modifier.fillMaxWidth(...)` and `m.fillMaxWidth(...)`: the attribute is a + // callable object, not a function that would bind `self`. + assertFalse("def fillMaxWidth(self" in ui, ui) + } + + @Test + fun theTableKeySpellingIsAlsoAnAttribute() { + val ui = file(files(*padding.toTypedArray()), pkgUi) + assertTrue("padding__Dp: _t.ClassVar[_Modifier_padding__Dp]" in ui, ui) + assertTrue("padding__PaddingValues: _t.ClassVar[_Modifier_padding__PaddingValues]" in ui, ui) + assertTrue("padding: _t.ClassVar[_Modifier_padding]" in ui, ui) + } + + // ----------------------------------------------------------------------------- overloads + + /** The binder tries candidates in table-key order (the artefact table is sorted by name), and a + * checker takes the first match, so the stub's order has to be the same one. */ + @Test + fun anOverloadSetIsOverloadedUnderItsBaseNameInTableKeyOrder() { + val files = files(*padding.toTypedArray()) + val layout = file(files, pkgLayout) + val order = Regex("""@_t\.overload\ndef padding\((.*)\) -> """).findAll(layout).map { it.groupValues[1] }.toList() + assertEquals(4, order.size, layout) + 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()) + // ... 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) + + 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) + } + + /** Python's `int`/`float` cannot tell two Kotlin overloads apart, so a checker calls every later + * one unreachable; the diagnostic is silenced on those, and never on the first. */ + @Test + fun everyOverloadAfterTheFirstSilencesTheCheckersNeverMatchedDiagnostic() { + val layout = file(files(*padding.toTypedArray()), pkgLayout) + val defs = layout.lines().filter { it.startsWith("def padding(") } + assertEquals(4, defs.size, layout) + assertFalse("type: ignore" in defs[0], defs[0]) + assertTrue(defs.drop(1).all { it.endsWith(" # type: ignore[overload-cannot-match]") }, defs.toString()) + val ui = file(files(*padding.toTypedArray()), pkgUi) + val calls = ui.substringAfter("class _Modifier_padding(").substringBefore("class _Modifier_padding__Dp").lines().filter { "def __call__" in it } + assertEquals(4, calls.size, ui) + assertFalse("type: ignore" in calls[0], calls[0]) + assertTrue(calls.drop(1).all { "type: ignore[overload-cannot-match]" in it }, calls.toString()) + } + + /** `androidx.compose.ui.graphics.Shadow` (a class module) and `...graphics.shadow` (a package) are + * two paths on a case-sensitive filesystem and one on macOS or Windows, where the later write + * silently replaces the earlier. */ + @Test + fun pathsThatDifferOnlyByCaseAreReported() { + val paths = listOf( + "androidx/compose/ui/graphics/Shadow/__init__.pyi", + "androidx/compose/ui/graphics/shadow/__init__.pyi", + "androidx/compose/ui/graphics/__init__.pyi", + ) + assertEquals( + listOf(listOf("androidx/compose/ui/graphics/Shadow/__init__.pyi", "androidx/compose/ui/graphics/shadow/__init__.pyi")), + caseCollidingPaths(paths), + ) + assertEquals(emptyList(), caseCollidingPaths(paths.drop(1).take(1) + paths.drop(2))) + } + + @Test + fun aSingleDeclarationUnderAnOverloadSuffixStillGetsItsBaseName() { + val only = listOf(padding[1]) + val layout = file(files(*only.toTypedArray()), pkgLayout) + assertTrue("def padding(receiver: androidx.compose.ui.Modifier, /, all:" in layout, layout) + assertFalse("@_t.overload" in layout, "one declaration is not an overload set: $layout") + } + + // ----------------------------------------------------------------------------- value classes + + @Test + fun aValueClassParameterIsTheClassOrItsRawPrimitiveAndItsResultIsTheRawPrimitive() { + val takes = ext("padding", "$pkgLayout.padding__Dp", params = arrayOf(p("all", dp, "FLOAT"))) + val returns = DeclarationModel( + simpleName = "defaultPadding", owner = pkgLayout, ownerIsClass = false, receiver = null, + parameters = emptyList(), returnType = dp, returnBoundaryTag = "FLOAT", bindingName = "$pkgLayout.defaultPadding", + ) + val files = files(takes, returns) + val layout = file(files, pkgLayout) + assertTrue("all: androidx.compose.ui.unit.Dp | float) ->" in layout, layout) + assertTrue("def defaultPadding() -> float:" in layout, layout) + val units = file(files, pkgUnit) + assertTrue("class Dp:" in units, units) + assertTrue("value class" in units && "kotlin.Float" in units, units) + } + + @Test + fun aValueClassTheBinderCannotOpenIsAnOrdinaryClass() { + val takes = DeclarationModel( + simpleName = "fontSize", owner = pkgLayout, ownerIsClass = false, receiver = null, + parameters = listOf(p("size", textUnit, "OBJECT")), returnType = unit, returnBoundaryTag = "UNIT", + bindingName = "$pkgLayout.fontSize", + ) + val layout = file(files(takes), pkgLayout) + assertTrue("def fontSize(size: androidx.compose.ui.unit.TextUnit) -> None:" in layout, layout) + } + + // ------------------------------------------------------------------ function types, nullable + + @Test + fun aFunctionTypeIsCallableAndANullableTypeIsOptional() { + val function1 = KotlinTypeModel("kotlin.Function1", arguments = listOf(KotlinTypeModel("kotlin.Boolean"), unit)) + val function0 = KotlinTypeModel("kotlin.Function0", arguments = listOf(unit)) + val checkbox = DeclarationModel( + simpleName = "Checkbox", owner = "androidx.compose.material3", ownerIsClass = false, receiver = null, + parameters = listOf( + p("checked", KotlinTypeModel("kotlin.Boolean"), "BOOLEAN"), + p("onCheckedChange", function1.copy(isNullable = true), "OBJECT"), + p("modifier", modifier, "OBJECT", default = true), + p("content", function0, "OBJECT"), + ), + returnType = unit, returnBoundaryTag = "UNIT", bindingName = "androidx.compose.material3.Checkbox", + ) + val body = file(files(checkbox), "androidx.compose.material3") + assertTrue( + "def Checkbox(checked: bool, onCheckedChange: _t.Callable[[bool], None] | None, " + + "modifier: androidx.compose.ui.Modifier = ..., *, content: _t.Callable[[], None]) -> None:" in body, + body, + ) + } + + // ------------------------------------------------------------------------------ inheritance + + @Test + fun aClassExtendsItsNearestSupertypeWhenTheScannerKnowsOne() { + val painter = KotlinTypeModel("androidx.compose.ui.graphics.painter.Painter") + val bitmapPainter = KotlinTypeModel("androidx.compose.ui.graphics.painter.BitmapPainter") + val make = DeclarationModel( + simpleName = "loadPainter", owner = "androidx.compose.ui.graphics.painter", ownerIsClass = false, receiver = null, + parameters = emptyList(), returnType = bitmapPainter, returnBoundaryTag = "OBJECT", + bindingName = "androidx.compose.ui.graphics.painter.loadPainter", + returnSupertypes = listOf(painter.qualifiedName), + ) + val body = file(files(make), "androidx.compose.ui.graphics.painter") + assertTrue("class BitmapPainter(Painter):" in body, body) + assertTrue("class Painter:" in body, body) + } + + // ---------------------------------------------------------------------- name collisions + + /** A Kotlin factory function and the class it makes share a name (`PaddingValues(...)`). One + * Python module cannot define both, and the runtime resolves that name to the function. */ + @Test + fun aClassWhoseNameIsAFunctionInItsModuleFallsBackToAny() { + val factory = DeclarationModel( + simpleName = "PaddingValues", owner = pkgLayout, ownerIsClass = false, receiver = null, + parameters = listOf(p("all", dp, "FLOAT")), returnType = paddingValues, returnBoundaryTag = "OBJECT", + bindingName = "$pkgLayout.PaddingValues", + ) + val layout = file(files(factory), pkgLayout) + assertTrue("def PaddingValues(all: androidx.compose.ui.unit.Dp | float) -> _t.Any:" in layout, layout) + assertFalse("class PaddingValues" in layout, layout) + } + + // ------------------------------------------------------------------------ Kotlin names only + + @Test + fun theTypedStubsStillCarryOnlyKotlinNames() { + 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) + } +} diff --git a/tools/stubs/check-stubs.sh b/tools/stubs/check-stubs.sh new file mode 100755 index 000000000..b8c1c41d3 --- /dev/null +++ b/tools/stubs/check-stubs.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Type-checks tools/stubs/consumer.py against a directory of generated Kotlin-name stubs (issue #31). +# +# tools/stubs/check-stubs.sh [venv-dir] +# +# is what `generatePythonStubs` wrote, e.g. +# ksp-fixtures/compose/build/generated/pythonStubs/desktopMain +# The venv defaults to /.tmp/stubs-venv (git-ignored) and is created on first use. +set -euo pipefail + +root="$(cd "$(dirname "$0")/../.." && pwd)" +stubs="$(cd "${1:?usage: check-stubs.sh [venv-dir]}" && pwd)" +venv="${2:-$root/.tmp/stubs-venv}" + +if [ ! -x "$venv/bin/mypy" ]; then + python3 -m venv "$venv" + "$venv/bin/pip" install --quiet mypy +fi + +# tools/stubs/mypy.ini: `strict`, and `warn_unused_ignores` for the consumer so that the lines it +# expects to be rejected fail the check when they stop being. Errors inside the stubs themselves are +# reported too: MYPYPATH modules are checked, not silenced. +MYPYPATH="$stubs" "$venv/bin/mypy" \ + --config-file "$root/tools/stubs/mypy.ini" \ + --cache-dir "$root/.tmp/mypy-cache" \ + "$root/tools/stubs/consumer.py" diff --git a/tools/stubs/consumer.py b/tools/stubs/consumer.py new file mode 100644 index 000000000..c2d94cb62 --- /dev/null +++ b/tools/stubs/consumer.py @@ -0,0 +1,49 @@ +"""A small consumer of the generated Kotlin-name 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. +""" + +from androidx.compose.foundation.layout import Column, Row, fillMaxWidth, padding__Dp +from androidx.compose.material3 import Checkbox, Text +from androidx.compose.ui import Modifier + +# 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. +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) + +checked = True + + +def on_change(value: bool) -> None: + global checked + checked = value + + +Checkbox(checked, on_change, modifier=more, enabled=True) +Checkbox(checked=False, onCheckedChange=None) +Text("hello", modifier=Modifier.padding(8.0)) + + +def content() -> None: + Text("inside") + + +Column(modifier=chain, content=lambda scope: None) +Row(content=lambda scope: 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] +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] diff --git a/tools/stubs/mypy.ini b/tools/stubs/mypy.ini new file mode 100644 index 000000000..732ab2106 --- /dev/null +++ b/tools/stubs/mypy.ini @@ -0,0 +1,11 @@ +[mypy] +python_version = 3.13 +strict = True +# A `# type: ignore` in the consumer that no longer ignores anything means a stub stopped rejecting a +# line it should reject, so it fails the check ... +warn_unused_ignores = True + +# ... but the generated stubs carry their own, deliberate ignores (`overload-cannot-match` on the +# overloads Python's types cannot tell apart), and not every one is needed for every Compose version. +[mypy-androidx.*,kotlin.*,java.*,javax.*,org.*,junit.*,fixture.*] +warn_unused_ignores = False