Skip to content
Merged
16 changes: 16 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,22 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
either plugin application order. Without that configuration nothing happens and none is created.
`Status: implemented`, `GP/TypedPythonStubsWiringTest.kt` (a `ProjectBuilder` project; the toolchain
side that consumes the configuration is toolchain#23 and is not exercised here).
- **B-10** pythonx-compose's `UI.ipynb` scenarios run end to end on desktop (#26), in the spellings
pythonx-compose decided: with the pythonx-compose **wheel** installed (a path or version input; no
wheel fails every scenario by name unless `-PnotebookE2e.skip=true` disables them) and the host
drawing `PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root")` (B-8): `import main` draws the root it
declares; the notebook reads and writes the state the screen shows and the screen follows within four
frames; a cell that redeclares the root with `@app` puts it on screen within four frames with no
update call, and disabling the replacement wiring (a stub `@app`, or a host handed the root once)
leaves the screen unchanged; each practice cell (Text, Button, Card, Icon, Column, Row, Arrangement,
Spacer) draws exactly what the same composables draw from Kotlin; `TextField(state=...)` takes a
Hangul input-method composition with no Python function started and the root not rerun, holds the
composing range in Compose, and the notebook reads the committed text; a write from Python outside
the composition reaches the screen within four frames. Notebook content with no decided or Kotlin
counterpart is listed, not imitated. `Status: implemented` on desktop: `ksp-fixtures/notebook-e2e/` (20 tests, and a
README with the cell map, the wheel commands and the gaps), 20 passed, 0 failed against the
pythonx-compose `develop` wheel on 2026-10-04 (after #146, #168 and pythonx-compose #88). No CI
workflow runs the module yet.

## 7. Threading and builds

Expand Down
106 changes: 106 additions & 0 deletions ksp-fixtures/notebook-e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# `:ksp-fixtures:notebook-e2e`: pythonx-compose's `UI.ipynb`, end to end

Issue #26. A desktop host draws a Python-declared root through `python-multiplatform-compose`
(`PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root")`, #18), with the **pythonx-compose wheel
installed**, and runs the notebook's scenarios against it. Each scenario is its own test, so a
regression fails under the notebook cell's number.

The specification is the user's notebook, `pythonx-compose/UI.ipynb` (read in place, never copied).
Where pythonx-compose has since decided a different spelling (its `docs/INTENT.md` §5 and
`docs/SPEC.md`), the tests use the decided one: `@app` and an `app_root` state instead of
`main.App.update(...)`; `.value` instead of `getValue()`/`setValue()`; Kotlin parameter names in
snake_case; `Color(0xFF...)`; `DefaultIcons.Add` without parentheses;
`TextField(state=..., modifier=Modifier.padding(8))`.

## Building the wheel

Build the wheel with uv from a clean copy of pythonx-compose's `develop`, so nothing is written into
that checkout:

```bash
mkdir -p .tmp/pxc-src
git -C ../pythonx-compose archive origin/develop | tar -x -C .tmp/pxc-src
(cd .tmp/pxc-src && uv build --wheel --out-dir ../pythonx-compose-dist)
```

The result is `.tmp/pythonx-compose-dist/pythonx_compose-<version>-py3-none-any.whl`.

## Running

```bash
JAVA_HOME=<JDK 21> ./gradlew :ksp-fixtures:notebook-e2e:desktopTest --rerun \
-PpythonxComposeWheel=.tmp/pythonx-compose-dist --console=plain > .tmp/notebook-e2e.log 2>&1; echo "EXIT=$?"
```

| Input | Gradle property | Environment |
|---|---|---|
| the wheel: a `.whl` file, or a directory holding `pythonx_compose-*.whl` (relative paths are from the repository root) | `-PpythonxComposeWheel=<path>` | `PYTHONX_COMPOSE_WHEEL` |
| the version to pick from that directory (or to require of the file) | `-PpythonxComposeVersion=<version>` | `PYTHONX_COMPOSE_VERSION` |
| do not run the scenarios | `-PnotebookE2e.skip=true` | `NOTEBOOK_E2E_SKIP=true` |

**Without a wheel the tests fail, by name, with a message that says how to give one.** They do not
skip. Only `-PnotebookE2e.skip=true` disables `desktopTest`, and the build log then says that the
scenarios did not run. Which of the two CI wants is CI's decision; no workflow runs this module yet.

"Installing" is unpacking the pure-Python wheel into `build/pythonx-compose/site-packages`
(`installPythonxComposeWheel`), which is all installing a pure-Python wheel amounts to; the embedded
interpreter has no installer of its own. The tests then require `pythonx.compose.__file__` to be
inside that directory, so a stray source checkout on `sys.path` cannot stand in for the wheel.

## Scenarios

| Notebook cells | Test | What must hold |
|---|---|---|
| 5 | `NotebookRootTest.cell05_theImportsResolveFromTheInstalledWheel` | the cell's imports (decided spellings) resolve |
| 6–7 | `…cell06to07_importingMainDrawsTheAppItDeclares` | `import main` declares the root; the host draws exactly `Text(INITIAL)`; an idle frame does not rerun it |
| 9–13 | `…cell09to13_theNotebookAndTheScreenShareOneState` | the notebook reads the shown state, writes it, the screen follows; restoring restores the screen |
| 15–16 | `…cell15to16_redeclaringTheRootChangesTheScreenWithNoUpdateCall` | a cell redeclaring the root with `@app` puts it on screen within 4 frames, pixel-equal to Kotlin |
| (negative) | `…negative_withAppStubbedOutTheRedeclarationDoesNotReachTheScreen` | with `@app` stubbed to not write `app_root`, the same observation reports no change |
| (negative) | `…negative_aHostHandedTheRootOnceDoesNotFollowARedeclaration` | with the host given `main.App` instead of the state, the same observation reports no change |
| 19, 20 | `NotebookPracticeTest.cell19_text`, `cell20_textWithAFontSize` | red text; at 30 sp |
| 22, 23 | `…cell22_anEmptyBlackButton`, `cell23_aButtonHoldingText` | black `Button`, empty and with text |
| 25 | `…cell25_aCard` | red `Card` with white text |
| 28, 29, 30 | `…cell28_defaultIconsAddIsAnImageVector`, `cell29_anIcon`, `cell30_anIconInsideAButton` | `DefaultIcons.Add` is an `ImageVector`; `Icon` draws it, alone and in a button |
| 35, 36, 38 | `…cell35_aColumnOfTwoButtons`, `cell36_aColumnAlignedToTheEnd`, `cell38_aRow` | `Column`, `Column(horizontal_alignment=Alignment.Horizontal.End)`, `Row` |
| 37 (signature) | `…cell37_aRowWithAnArrangement` | `Row(horizontal_arrangement=Arrangement.Center)` |
| 40 | `…cell40_aCardSpacedBySpacers` | `Card`/`Column`/`Row` spaced by `Spacer(modifier=Modifier.padding(...))` |
| 31–33 | `NotebookTextFieldTest.cell32to33_aTextFieldKeepsInputMethodCompositionInComposeAndTheNotebookReadsTheResult` | see below |

Every practice cell is compared **pixel for pixel** with the same composables drawn from Kotlin, so a
pass means the screen shows what the cell declared.

### The text field (cells 31–33, pythonx-compose #10)

`TextField(state=remember_text_field_state(""))` is focused by a click (keyboard focus traversal if
the click does not do it), then receives a Hangul input-method sequence (composing ㅎ, 하, 한, then
committing 한) with a frame after each event. While it types, a `sys.monitoring` counter of every
Python function started (`PY_START`, all threads) must stay at zero and the root must not rerun.
Kotlin's `TextFieldState` must hold the composing text with composing range `0..1` at each step and
`한` with no composing range after the commit; the notebook then reads `fields[-1].text == "한"`.
Finally `set_text_and_place_cursor_at_end("x")` from outside the composition must reach the screen
within 4 frames.

**How the input method is driven.** `ImageComposeScene` (Compose Multiplatform 1.11.1) cannot take
input-method composition: it builds its scene with a `PlatformContext.Empty` delegate that never
hands out the text field's `PlatformTextInputMethodRequest`, has no parameter to replace it, and its
public input API is `sendKeyEvent` (key presses) and pointer events. `InputMethodScene` therefore
builds the same `CanvasLayersComposeScene` (`@InternalComposeUiApi`) with its own `PlatformContext`
that records the request, and delivers each event the way the desktop window's
`InputMethodSession.inputMethodTextChanged` does in 1.11.1: one `request.editText { commitText(committed, 1);
if (composing.isNotEmpty()) setComposingText(composing, 1) }`. Not exercised: the AWT layer above it
(decoding an `InputMethodEvent` into those two strings), which needs a window.

## Gaps: notebook content not expressed here

| Notebook | Why it is not in a test |
|---|---|
| cells 2–3 (`print`, numpy) | not UI |
| cell 5: `remember_saveable`, `DefaultCoroutineScope`, `MainCoroutineScope` | undecided in pythonx-compose (INTENT §4.1, SPEC §8); nothing provides them |
| cell 5/25/35/…: lower-case `modifier`, `modifier=modifier` | INTENT §5.4 replaces it with `modifier=Modifier`. The binder has no conversion for a proxy **class** passed as an argument (`Modifier.empty()` is used only for the class-spelling *method call*, `PythonxAdapter.kt` `_BoundMember`), so `Card(modifier=Modifier)` is not expected to work; the argument is left out, which is the same Kotlin default (`Modifier`). Not run to confirm. |
| cell 22/23/30/35: `corner_radius=` on `Button`; cell 25/40: on `Card` | Kotlin's counterpart is `shape=RoundedCornerShape(...)` from `androidx.compose.foundation.shape`, which this fixture does not walk (its package list is `:ksp-fixtures:compose`'s). Adding `"androidx.compose.foundation.shape"` to `artifactIncludePackages` is the next step; not tried. |
| cell 22/23/25/…: `color=` on `Button`/`Card` | written as Kotlin's `colors=ButtonColors(...)` / `CardColors(...)` constructors. `ButtonDefaults.buttonColors(...)`, the usual Kotlin spelling, is a `@Composable` object function, which the walker declines (SPEC B-1). |
| cell 27: `help(DefaultIcons)` | prints documentation; nothing to assert on screen |
| cells 42, 48: empty; "채팅 화면 디자인하기" | no content |
| cells 44–47: `from ws import client` | a websocket client outside pythonx-compose; marked "재 구현 필요" in the notebook |
| cells 51–56: Llama 3.1 streaming into the UI | needs a model and `main.run_llama3`; the state half (writing `.value` from a loop) is cell 12's mechanism |
| cell 54: `onclick=lambda: {...}` driving a state change | a click from Python reaching a Kotlin callback is `:ksp-fixtures:compose`'s `CallbackDrivenRenderTest`; not repeated here |
182 changes: 182 additions & 0 deletions ksp-fixtures/notebook-e2e/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

/**
* Issue #26: pythonx-compose's `UI.ipynb` scenarios, run end to end against a real host.
*
* The host is `python-multiplatform-compose`'s `PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root")`
* (#18). The Python side is the **installed pythonx-compose wheel**, not a copy of its sources and
* not a stand-in: every `pythonx.compose.*` import in the tests must resolve to the files the wheel
* unpacked (the tests check `__file__`). The wheel is an input:
*
* | Input | Gradle property | Environment |
* |---|---|---|
* | a `.whl` file, or a directory holding `pythonx_compose-*.whl` | `-PpythonxComposeWheel=<path>` | `PYTHONX_COMPOSE_WHEEL` |
* | which version to pick from that directory (or to require of the file) | `-PpythonxComposeVersion=<v>` | `PYTHONX_COMPOSE_VERSION` |
* | deliberately not running the scenarios | `-PnotebookE2e.skip=true` | `NOTEBOOK_E2E_SKIP=true` |
*
* **No wheel is a failure, not a skip.** Without an input every test fails by name with the message
* below; only the explicit opt-out disables `desktopTest`, and it says so in the build log. CI decides
* which of the two it wants.
*
* "Installing" a pure-Python wheel is unpacking it onto `sys.path`, which is what
* `pip install --target` does for one; the embedded interpreter has no `pip` of its own to run.
* [installPythonxComposeWheel] unpacks it into `build/pythonx-compose/site-packages`.
*
* The rest -- the Compose compiler plugin, the walked packages, the JVM flags -- is
* `:ksp-fixtures:compose`'s configuration, kept identical where it can be so that a difference in
* result is a difference in what the notebook asks for, not in the build.
*/
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.jetpack.compose)
alias(libs.plugins.compose.compiler)
id("io.github.thisisthepy.python.multiplatform.bindings")
}

kotlin {
jvm("desktop") {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}

sourceSets {
val commonMain by getting {
dependencies {
implementation(projects.pythonMultiplatform)
}
}
val desktopMain by getting {
dependencies {
implementation(projects.pythonMultiplatformCompose)
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(libs.compose.material.icons.core)
implementation(compose.desktop.currentOs)
}
}
val desktopTest by getting {
dependencies {
implementation(libs.kotlin.test)
}
}
}
}

pythonBindings {
role.set("app")
processor.set(projects.pythonMultiplatformKsp)

artifactConfiguration.set("desktopCompileClasspath")
artifactSourceSet.set("desktopMain")
// The same list as `:ksp-fixtures:compose` (its build file says what each entry is for). Every
// `pythonx.compose.*` module the notebook imports maps onto one of these Kotlin packages
// (pythonx-compose's `pythonx-map.toml`); a package not walked here is a name pythonx cannot
// resolve, which is a fixture gap, not a pythonx one.
artifactIncludePackages.set(
listOf(
"androidx.compose.material3",
"androidx.compose.foundation.layout",
"androidx.compose.ui.graphics",
"androidx.compose.ui.res",
"androidx.compose.ui",
"androidx.compose.material.icons",
"androidx.compose.runtime.SnapshotStateKt",
"androidx.compose.runtime.State",
"androidx.compose.runtime.MutableState",
"androidx.compose.foundation.text.input.TextFieldState",
"androidx.compose.foundation.text.input.TextFieldStateKt",
),
)
}

// ---- The pythonx-compose wheel ---------------------------------------------------------------------

val wheelInput: String? = providers.gradleProperty("pythonxComposeWheel")
.orElse(providers.environmentVariable("PYTHONX_COMPOSE_WHEEL")).orNull?.takeIf { it.isNotBlank() }
val wheelVersion: String? = providers.gradleProperty("pythonxComposeVersion")
.orElse(providers.environmentVariable("PYTHONX_COMPOSE_VERSION")).orNull?.takeIf { it.isNotBlank() }
val notebookSkipped: Boolean = providers.gradleProperty("notebookE2e.skip")
.orElse(providers.environmentVariable("NOTEBOOK_E2E_SKIP")).orNull?.toBoolean() == true

/** The wheel to install, or why there is none. Exactly one of the two is non-null. */
fun resolveWheel(): Pair<File?, String?> {
val howTo = "Build it (ksp-fixtures/notebook-e2e/README.md) and pass -PpythonxComposeWheel=<file.whl or directory> " +
"(or PYTHONX_COMPOSE_WHEEL); to skip the notebook scenarios deliberately pass -PnotebookE2e.skip=true."
val input = wheelInput
?: return null to "No pythonx-compose wheel was given, so the UI.ipynb scenarios cannot run. $howTo"
val given = rootProject.file(input)
if (!given.exists()) return null to "The pythonx-compose wheel input does not exist: $given. $howTo"
val versionPart = wheelVersion?.let { "pythonx_compose-$it-" }
fun isWheel(f: File) = f.isFile && f.name.startsWith("pythonx_compose-") && f.name.endsWith(".whl")
if (given.isDirectory) {
val candidates = given.listFiles().orEmpty().filter(::isWheel)
.filter { versionPart == null || it.name.startsWith(versionPart) }
.sortedBy { it.name }
return when (candidates.size) {
1 -> candidates.single() to null
0 -> null to "No pythonx_compose-${wheelVersion ?: "*"}-*.whl in $given. $howTo"
else -> null to "Several pythonx-compose wheels in $given (${candidates.joinToString { it.name }}); " +
"choose one with -PpythonxComposeVersion=<version> or pass the file itself."
}
}
if (!isWheel(given)) return null to "$given is not a pythonx_compose-*.whl file. $howTo"
if (versionPart != null && !given.name.startsWith(versionPart)) {
return null to "$given is not version $wheelVersion (-PpythonxComposeVersion)."
}
return given to null
}

val resolvedWheel = resolveWheel()
val pythonxComposeWheel: File? = resolvedWheel.first
val pythonxComposeWheelError: String? = resolvedWheel.second
val pythonxComposeSitePackages = layout.buildDirectory.dir("pythonx-compose/site-packages")

val installPythonxComposeWheel by tasks.registering(Sync::class) {
group = "verification"
description = "Unpacks the pythonx-compose wheel (-PpythonxComposeWheel) where the notebook E2E tests put it on sys.path."
val wheel = pythonxComposeWheel
enabled = wheel != null
if (wheel != null) from(zipTree(wheel))
into(pythonxComposeSitePackages)
}

if (notebookSkipped) {
logger.lifecycle(
"notebook-e2e: desktopTest is DISABLED by -PnotebookE2e.skip=true -- the UI.ipynb scenarios did not run.",
)
}

/**
* The same JVM `:ksp-fixtures:compose`'s tests need: JDK 21 with the preview FFM API, a
* `java.library.path` for the extracted `libpython`, and a headless AWT.
*/
tasks.named<Test>("desktopTest") {
javaLauncher.set(
javaToolchains.launcherFor {
languageVersion.set(JavaLanguageVersion.of(21))
},
)
jvmArgs("--enable-preview", "-Djava.library.path=.")
systemProperty("java.awt.headless", "true")

// The application module the notebook imports as `main`.
val appDir = layout.projectDirectory.dir("src/desktopTest/python")
inputs.dir(appDir)
systemProperty("notebookE2e.appDir", appDir.asFile.absolutePath)

val wheel = pythonxComposeWheel
if (wheel != null) {
dependsOn(installPythonxComposeWheel)
inputs.file(wheel)
systemProperty("notebookE2e.wheel", wheel.absolutePath)
systemProperty("notebookE2e.sitePackages", pythonxComposeSitePackages.get().asFile.absolutePath)
} else {
systemProperty("notebookE2e.wheelError", checkNotNull(pythonxComposeWheelError))
}
enabled = !notebookSkipped
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package fixture.notebook

import androidx.compose.ui.Modifier

/**
* The one line of Kotlin pythonx-compose asks an application for: where an empty `Modifier` comes from.
*
* `Modifier` as a Kotlin expression is `Modifier.Companion`, an object, and the walker binds functions,
* so the empty modifier every `Modifier.padding(8)` chain starts from has no bound name of its own.
* pythonx-compose's `pythonx/compose/ui/modifier.py` says what to do about it: supply a zero-argument
* function returning `Modifier` and register its name with `install(...)`. KSP binds this one as
* `fixture.notebook.emptyModifier`; `NotebookHost` registers it.
*/
fun emptyModifier(): Modifier = Modifier
Loading
Loading