From 2012252d5e5ea51b3e0674225437dabf2e695a10 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:41:24 +0900 Subject: [PATCH 1/2] WIP #309 --- docs/SPEC.md | 6 ++ .../fixture/compose/ComposableRenderTest.kt | 91 +++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/docs/SPEC.md b/docs/SPEC.md index c4315b0d9..15b7d2bf9 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -453,6 +453,12 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. - **B-3** Default arguments can be omitted from Python (a call per subset of defaulted parameters, ambiguous omissions refused). `Status: implemented`, `GP/artifact/DefaultOmissionTest.kt`, `python-multiplatform-gradle-plugin/fixtures/artifact/.../WalkedArtifactDefaultOmissionTest.kt`, `PM/commonTest/.../PythonxDefaultsTest.kt`. + A declaration with more than 6 defaulted parameters (`lightColorScheme`, `darkColorScheme`, + `ColorScheme.copy`, 48 each) binds with every parameter required, because the omission plan is + `2^n` (`ArtifactScanner.MAX_OMITTABLE_PARAMETERS`). A colour scheme is built from Python with the + `ColorScheme` constructor, all 48 arguments (`ComposableRenderTest.aColorSchemeBuiltByItsConstructorPaintsThePrimaryItWasGiven`, + `colorSchemeCopyIsBoundWithEveryParameterRequired`); a keyword factory that fills the rest is + pythonx-compose's job (#309). - **B-4** Value classes round-trip; only allow-listed ones (e.g. `Dp`) may be written as their raw primitive. `Status: implemented`, `GP/artifact/ComposableValueClassSlotTest.kt`, `WalkedArtifactPythonImportTest.kt`. A value class is opened as its raw primitive only when both its diff --git a/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt index ae014ac90..f9d7ef76b 100644 --- a/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt +++ b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt @@ -797,6 +797,97 @@ class ComposableRenderTest { assertTrue(0xFFFF0000.toInt() !in green, "a green scheme painted red, so the argument was not read: $green") } + /** + * Issue #309: a colour scheme from Python without `lightColorScheme`'s 48 defaults. + * + * `lightColorScheme` / `darkColorScheme` / `ColorScheme.copy` each declare 48 defaulted + * parameters, past `ArtifactScanner.MAX_OMITTABLE_PARAMETERS` (6), so they bind with every + * parameter required (a keyword factory that fills the rest is pythonx-compose's job; defaults + * depend on each other, e.g. `surfaceTint` defaults to `primary`, so "call defaults then copy" + * was rejected). What Python can do today is call the `ColorScheme` constructor, the public + * 48-argument one (no defaults; the 36- and 29-argument ones are `@Deprecated(HIDDEN)` and + * declined), and pass the handle to `MaterialTheme(colorScheme=...)`. + * + * Rendered, not just constructed: every slot black except `primary`, so a shifted argument would + * paint a different slot's black. + */ + @Test + fun aColorSchemeBuiltByItsConstructorPaintsThePrimaryItWasGiven() { + Python3.exec( + """ + import python_multiplatform.binding as _pm_binding + _ctors = [d for d in _pm_binding._BY_PACKAGE['androidx.compose.material3']['ColorScheme'] + if d.declared_arity() == 48] + assert len(_ctors) == 1, repr(_ctors) + assert all(_ctors[0].param_has_default[i] is False for i in range(48)), 'ColorScheme(...) has no defaults' + """.trimIndent(), + ) + val red = distinctColorsOf(schemeButton(constructed(255, 0, 0))) + val green = distinctColorsOf(schemeButton(constructed(0, 255, 0))) + println("compose render: MaterialTheme(ColorScheme(48 args, primary=red)) -> ${red.size} colors, green -> ${green.size}") + assertTrue(0xFFFF0000.toInt() in red, "the constructor's primary never reached the Button's container: $red") + assertTrue(0xFF00FF00.toInt() in green, "the green primary never reached the Button's container: $green") + assertTrue(0xFFFF0000.toInt() !in green, "a green scheme painted red: $green") + } + + /** + * `ColorScheme.copy` is a member with 48 defaulted parameters: past the cap, so every parameter is + * required. Expected today: `copy(primary=...)` alone raises `TypeError: ... no value for ...`, + * and `copy` with all 48 works and paints the new primary. If either half flips (the cap grew, or + * `copy` is not bound at all and this raises `AttributeError`), this test says which. + */ + @Test + fun colorSchemeCopyIsBoundWithEveryParameterRequired() { + Python3.exec( + """ + from androidx.compose.ui.graphics import Color__Int_Int_Int_Int as _Color + ${schemePrelude()} + _base = ColorScheme(**_written(255, 0, 0)) + try: + _base.copy(primary=_Color(0, 0, 255)) + raise AssertionError('copy(primary=) alone must not be callable: 48 defaults are past the omission cap') + except TypeError as _e: + assert 'no value for' in str(_e), str(_e) + """.trimIndent(), + ) + val blue = distinctColorsOf( + schemeButton( + """ + _base = ColorScheme(**_written(255, 0, 0)) + _scheme = _base.copy(**_written(0, 0, 255)) + """.trimIndent(), + ), + ) + assertTrue(0xFF0000FF.toInt() in blue, "copy(all 48) did not paint the copied primary: $blue") + assertTrue(0xFFFF0000.toInt() !in blue, "copy(all 48) kept the original primary: $blue") + } + + private fun schemePrelude(): String = + """ + import python_multiplatform.binding as _pm_binding + from androidx.compose.ui.graphics import Color__Int_Int_Int_Int as _Color + from androidx.compose.material3 import ColorScheme, MaterialTheme, Button + _slots = list([d for d in _pm_binding._BY_PACKAGE['androidx.compose.material3']['ColorScheme'] + if d.declared_arity() == 48][0].param_names) + def _written(r, g, b): + _w = dict((_s, _Color(0, 0, 0)) for _s in _slots) + _w['primary'] = _Color(r, g, b) + return _w + """.trimIndent() + + private fun constructed(red: Int, green: Int, blue: Int): String = + "_scheme = ColorScheme(**_written($red, $green, $blue))" + + /** [schemeBuild] must leave the scheme in `_scheme`. */ + private fun schemeButton(schemeBuild: String): String = + schemePrelude() + "\n" + schemeBuild + """ + + MaterialTheme( + colorScheme=_scheme, + content=lambda: Button(onClick=lambda: None, content=lambda: None), + ) + """.trimIndent() + /** * **`Icon`, which the same commit pinned as unreachable -- two of its three overloads are not.** * From 24f6a1e836eb1b3405bed7a54920501da9fb9d00 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:43:38 +0900 Subject: [PATCH 2/2] Test: build a colour scheme with the ColorScheme constructor; copy() is bound all-required (#309) lightColorScheme / darkColorScheme / ColorScheme.copy have 48 defaulted parameters, past the omission cap, so they bind with every parameter required (a defaults-then-copy strategy was rejected: defaults depend on other parameters, surfaceTint = primary). From Python, ColorScheme(48 args) works and paints its primary through MaterialTheme; copy(primary=...) alone raises 'no value for', copy(all 48) works. SPEC B-3 records it; a keyword factory is pythonx-compose's. --- .../kotlin/fixture/compose/ComposableRenderTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt index f9d7ef76b..a6f879952 100644 --- a/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt +++ b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ComposableRenderTest.kt @@ -838,10 +838,10 @@ class ComposableRenderTest { */ @Test fun colorSchemeCopyIsBoundWithEveryParameterRequired() { + // The prelude is multi-line, so it is joined rather than interpolated into an indented block, + // where trimIndent would see its unindented lines and keep the rest indented. Python3.exec( - """ - from androidx.compose.ui.graphics import Color__Int_Int_Int_Int as _Color - ${schemePrelude()} + schemePrelude() + "\n" + """ _base = ColorScheme(**_written(255, 0, 0)) try: _base.copy(primary=_Color(0, 0, 255))