Skip to content

Commit 4706f11

Browse files
committed
Merge remote-tracking branch 'origin/develop' into work/include-provider
2 parents ac4525b + d6d3978 commit 4706f11

27 files changed

Lines changed: 1645 additions & 136 deletions

File tree

‎.github/workflows/stubs.yml‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,6 @@ concurrency:
3232
jobs:
3333
stubs:
3434
name: Generate stubs
35-
# ubuntu, not macos: two Kotlin names can differ only by case (`graphics.Shadow` the class module and
36-
# `graphics.shadow` the package), which are two paths here and one on a case-insensitive filesystem.
3735
runs-on: ubuntu-latest
3836
permissions:
3937
contents: write # attaching the stubs to a tag's release
@@ -108,3 +106,44 @@ jobs:
108106
gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 \
109107
|| gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "Kotlin-name stubs for the Compose version this tag builds against."
110108
gh release upload "$GITHUB_REF_NAME" build/kotlin-stubs.zip --clobber
109+
110+
# Issue #44: the stub layout is case-unique, so the stubs generate, extract and type-check on a
111+
# case-insensitive filesystem too (`graphics.Shadow` the class and `graphics.shadow` the package).
112+
stubs-macos:
113+
name: Generate stubs (macOS, case-insensitive)
114+
runs-on: macos-latest
115+
steps:
116+
- name: Checkout
117+
uses: actions/checkout@v4
118+
119+
- name: Setup Java
120+
uses: actions/setup-java@v4
121+
with:
122+
java-version: '21'
123+
distribution: 'zulu'
124+
cache: 'gradle'
125+
126+
- name: Setup Python (for mypy)
127+
uses: actions/setup-python@v5
128+
with:
129+
python-version: '3.13'
130+
131+
- name: Generate the stubs over the Compose jars
132+
run: ./gradlew :ksp-fixtures:compose:generatePythonStubs --console=plain
133+
134+
- name: No two stub paths are equal under case folding
135+
run: |
136+
python3 - ksp-fixtures/compose/build/generated/pythonStubs/desktopMain <<'PY'
137+
import os, sys
138+
seen = {}
139+
for root, dirs, files in os.walk(sys.argv[1]):
140+
for name in dirs + files:
141+
rel = os.path.relpath(os.path.join(root, name), sys.argv[1])
142+
seen.setdefault(rel.casefold(), []).append(rel)
143+
clashes = [v for v in seen.values() if len(v) > 1]
144+
print("clashes:", clashes)
145+
sys.exit(1 if clashes else 0)
146+
PY
147+
148+
- name: Type-check a consumer against them (mypy)
149+
run: tools/stubs/check-stubs.sh ksp-fixtures/compose/build/generated/pythonStubs/desktopMain

‎docs/SPEC.md‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,14 +173,19 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
173173
- **U-8** A function on a Kotlin-named module carries Kotlin's own surface and nothing else: the
174174
Kotlin declaration name, keyword arguments by **Kotlin parameter names**, Kotlin defaults for omitted
175175
parameters, overload sets under the base name, and its signature as public metadata
176-
(`inspect.signature`, `python_multiplatform.describe`; contract in `KotlinSurface.kt`'s KDoc). No
176+
(`inspect.signature`, `python_multiplatform.describe`; contract in `KotlinSurface.kt`'s KDoc).
177+
`describe(module, name)` describes any bound name, a named constant (`STATIC_GETTER`) included,
178+
without evaluating it (#36). A module's `dir()` lists its direct child packages/objects and reading
179+
one as an attribute imports it (#35) — Kotlin names only. No
177180
member or parameter is renamed; the binder creates no `pythonx` module and a real `pythonx` package
178181
on disk is what `import pythonx` loads. The answer is the same whichever installer
179182
(`PythonProxySource`, `PythonxAdapter`) ran first for a table. `Status: implemented` on desktop —
180-
`PM/desktopTest/.../pythonx/KotlinNamedSurfaceTest.kt`, `ksp-fixtures/compose/.../KotlinSignatureMetadataTest.kt`.
183+
`PM/desktopTest/.../pythonx/KotlinNamedSurfaceTest.kt`, `BinderNamespaceTest.kt`, `ksp-fixtures/compose/.../KotlinSignatureMetadataTest.kt`.
181184
- **U-9** A Pythonic package can serve extra member names on a Kotlin proxy through one hook,
182185
`python_multiplatform.binding.add_member_resolver(fn)`, `fn(kotlin_type_name, requested_name,
183-
kotlin_member_names) -> kotlin_name | None`, asked only when no Kotlin member of that name exists.
186+
kotlin_member_names) -> kotlin_name | (kotlin_name, keyword_map) | None`, asked when no Kotlin member
187+
of that name exists — and, for a Kotlin-named member, only when a call passes keywords and only for
188+
its `{python_kw: kotlinParam}` keyword map (#34).
184189
The binder renames nothing itself (no resolver: `AttributeError`), and aliases are cached in the
185190
registry, not written on the proxy class (`dir()` stays Kotlin-only). Contract in `KotlinSurface.kt`'s
186191
KDoc. `Status: implemented` on desktop — `PM/desktopTest/.../pythonx/MemberResolverTest.kt`,
@@ -211,6 +216,15 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
211216
`ksp-fixtures/compose/src/desktopTest/` (`ComposableRenderTest.kt`, `M3ProofRenderTest.kt`,
212217
`CallbackDrivenRenderTest.kt`, pointer/drag render tests), `GP/artifact/ComposableBindingTest.kt`;
213218
`planned` on Android, iOS and wasm.
219+
- **B-8** A host draws a Python-declared application root with
220+
`python.multiplatform.compose.PythonContent(root: PyObject)` or `PythonContent(module, attribute)`,
221+
from the `python-multiplatform-compose` module (`python-multiplatform` itself does not depend on
222+
Compose). The root is a Python callable, or a Compose `State` that Python holds whose value is that
223+
callable. The state is read inside the composition, so a Python write into it replaces the root on
224+
the next frame with no host call; Python callables a root passed into composables are released when
225+
that root is replaced or the composition is disposed. The entry point names no library.
226+
`Status: implemented` on desktop — `ksp-fixtures/compose/.../PythonContentRenderTest.kt`; the module
227+
compiles for Android, nothing runs there yet.
214228
- **B-7** The plugin generates `.pyi` stubs for the Kotlin-named modules only, under Kotlin names
215229
(keyword parameters by Kotlin name, `= ...` for a Kotlin default, receiver positional-only). Types are
216230
the declared Kotlin types: one stub class per bound type in the module of its own package, `Dp | float`
@@ -262,6 +276,12 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
262276
- **N-3** Binding Kotlin/Native klib declarations at run time (B-2). `Status: planned`.
263277
- **N-4** Linux and Windows desktop runs in CI (P-2). `Status: planned`.
264278
- **N-5** Native-image upcall verification as an automated test (U-6). `Status: planned`.
279+
- **N-6** Compose state created and written from Python through the binder:
280+
`androidx.compose.runtime.mutableStateOf(x)` callable, and `.value` of the returned `MutableState`
281+
readable and writable on its proxy. Neither is bound today: the walker emits only `FUNCTION` and
282+
`STATIC_GETTER` entries (no instance members), and declines every declaration whose signature
283+
mentions a type parameter. B-8's test writes its root state through a fixture-local KSP-bound
284+
setter instead. `Status: planned`.
265285

266286
---
267287

‎docs/design/pyi-generation-design.md‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -346,10 +346,18 @@ turns `PaddingValues`, `TextStyle`, `Color`, `AnnotatedString` and `Dp` itself i
346346
collapses to its `float`). It is the honest loss of this design, not a bug to chase: the alternatives
347347
are a differently named class (a renamed spelling the binder does not have) or a private side module.
348348

349-
**Case-colliding module paths** (`androidx.compose.ui.graphics.Shadow`, a class module, and
350-
`...graphics.shadow`, a package) are two files on Linux and one on macOS and Windows.
351-
`PythonStubsTask` warns (`caseCollidingPaths`); generate and publish from a case-sensitive
352-
filesystem, which is why the CI job (`.github/workflows/stubs.yml`) runs on ubuntu.
349+
**Stub paths are case-unique** (issue #44). `androidx.compose.ui.graphics.Shadow` (a class the runtime
350+
exposes as a module) and `...graphics.shadow` (a package) would be one directory on macOS and Windows.
351+
The rule: a class module whose path equals a sibling's under case folding (directories above a module
352+
count) is emitted in its parent package's `__init__.pyi` -- as the class `Shadow`, its functions as
353+
`@staticmethod`s, nested class modules as nested classes -- and the package keeps `shadow/__init__.pyi`.
354+
In each colliding group the all-lower-case spelling is the package and keeps its path. A class module
355+
without a collision still gets its own `<Class>/__init__.pyi`. The runtime import path is unchanged; only
356+
the stub layout moves, and `from androidx.compose.ui.graphics import Shadow` resolves to the class.
357+
If the parent package already defines a function of the same name the class cannot sit beside it, and
358+
nothing is emitted for that module (the runtime resolves the name to the function too).
359+
`StubCaseLayoutTest` asserts no two emitted paths are equal under `lowercase()`;
360+
`PythonStubsTask` still warns (`caseCollidingPaths`) as a backstop, and the CI job runs on macOS as well.
353361

354362
### 3.2 Java declarations have no types to map
355363

‎gradle/libs.versions.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ androidx-lifecycle = "2.10.0"
1212
androidx-material = "1.12.0"
1313
androidx-test-junit = "1.2.1"
1414
compose-plugin = "1.11.1"
15+
# JetBrains stopped publishing material-icons with Compose after 1.7.3 (Maven Central: newest is 1.7.3);
16+
# it is the version Compose Multiplatform 1.11.x points to, resolved against 1.11.1's ui/ui-graphics.
17+
compose-material-icons = "1.7.3"
1518
graalvm-buildtools = "0.10.6"
1619
junit = "4.13.2"
1720
kotlin = "2.4.20-Beta2"
@@ -20,6 +23,7 @@ kotlinx-coroutines = "1.8.1"
2023
ksp = "2.3.11"
2124

2225
[libraries]
26+
compose-material-icons-core = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "compose-material-icons" }
2327
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
2428
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
2529
junit = { group = "junit", name = "junit", version.ref = "junit" }

‎ksp-fixtures/compose/build.gradle.kts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,13 @@ kotlin {
4545
}
4646
val desktopMain by getting {
4747
dependencies {
48+
implementation(projects.pythonMultiplatformCompose)
4849
implementation(compose.runtime)
4950
implementation(compose.foundation)
5051
implementation(compose.material3)
5152
implementation(compose.ui)
53+
// `Icons.Default.Add` and friends (issue #37): the one place an `ImageVector` is built.
54+
implementation(libs.compose.material.icons.core)
5255
implementation(compose.desktop.currentOs)
5356
}
5457
}
@@ -92,6 +95,8 @@ pythonBindings {
9295
"androidx.compose.ui.graphics",
9396
"androidx.compose.ui.res",
9497
"androidx.compose.ui",
98+
// 5. `material.icons` -- `Icons.Default` and the icon properties behind it (issue #37).
99+
"androidx.compose.material.icons",
95100
),
96101
)
97102
// Stubs on: `generatePythonStubs` over the real Compose jars is what `tools/stubs/check-stubs.sh` (the mypy

‎ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposed.kt‎

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
package fixture.compose
22

3-
import androidx.compose.runtime.currentComposer
43
import androidx.compose.runtime.remember
54
import androidx.compose.ui.Modifier
65
import androidx.compose.ui.composed
6+
import python.multiplatform.compose.withPythonComposer
77
import python.multiplatform.ffi.PyObject
88
import python.multiplatform.ffi.Python3
99
import python.multiplatform.ffi.types.basic.PyInt
@@ -53,8 +53,9 @@ import python.native.ffi.PyLong_AsLongLong
5353
*
5454
* ### The composer, and the receiver, cross the same way [PythonComposition]'s does
5555
*
56-
* [currentComposer] is rooted and pushed onto `pythonx`'s composer stack (`pythonx.push_composer`) for
57-
* the duration of the call, so [factory] may itself call a bound composable (the same contract
56+
* The composer is rooted and pushed onto the binding layer's composer stack for the duration of the
57+
* call by `python-multiplatform-compose`'s `withPythonComposer` -- the same helper [PythonComposition]
58+
* uses -- so [factory] may itself call a bound composable (the same contract
5859
* [PythonComposition] documents). `this` -- the `Modifier` chain [factory] receives as its receiver,
5960
* matching `@Composable Modifier.() -> Modifier` -- is rooted too and passed as a bare handle
6061
* (`int`), which `PythonxAdapter._coerce`'s OBJECT branch already accepts as a caller-selected
@@ -76,11 +77,11 @@ fun pythonComposed(modifier: Modifier, factory: PyObject): Modifier = modifier.c
7677
slot.invocations++
7778
ComposedInvocationSlot.lastCreated = slot
7879

79-
val composer = currentComposer
80-
val composerRef = HandleTable.register(composer)
81-
val receiverRef = HandleTable.register(this)
82-
try {
83-
Python3.exec("import python_multiplatform.binding as _pm_binding\n_pm_binding.push_composer(${composerRef.raw})")
80+
val receiver = this
81+
// The receiver is rooted *inside* the composer call: Compose forbids a try/finally around a
82+
// composable invocation, so the release cannot wrap `withPythonComposer` from outside.
83+
withPythonComposer {
84+
val receiverRef = HandleTable.register(receiver)
8485
try {
8586
val receiverHandle = PyInt.from(receiverRef.raw)
8687
try {
@@ -101,19 +102,16 @@ fun pythonComposed(modifier: Modifier, factory: PyObject): Modifier = modifier.c
101102
} else {
102103
Python3.withPython { PyLong_AsLongLong(result.pointer) }
103104
}
104-
(HandleTable.resolveRaw(raw) as? Modifier) ?: this
105+
(HandleTable.resolveRaw(raw) as? Modifier) ?: receiver
105106
} finally {
106107
result.close()
107108
}
108109
} finally {
109110
receiverHandle.close()
110111
}
111112
} finally {
112-
Python3.exec("import python_multiplatform.binding as _pm_binding\n_pm_binding.pop_composer()")
113+
HandleTable.release(receiverRef)
113114
}
114-
} finally {
115-
HandleTable.release(receiverRef)
116-
HandleTable.release(composerRef)
117115
}
118116
}
119117

0 commit comments

Comments
 (0)