From 18df81717f45cf9f167b7f36c6c9b640d699b5c4 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:57:59 +0900 Subject: [PATCH] Fix: A value class whose underlying property is not public crosses as a boxed object (#168) TextUnitType has a public constructor but an internal property, so Kotlin can only hand it to Python boxed; the walker read its parameter as the raw Long. The raw-primitive rule for a parameter now also needs a public underlying property, so such a parameter takes the object-handle path and kotlinc unboxes. Red before: WalkedArtifactValueClassBoxTest.aBoxedValueClassFillsItsOwnParameter (expected a number for TextUnitType). --- docs/SPEC.md | 6 +- ksp-fixtures/artifact/build.gradle.kts | 4 ++ .../kotlin/fixture/artifact/ComposeSeed.kt | 7 ++ .../WalkedArtifactValueClassBoxTest.kt | 71 +++++++++++++++++++ .../gradle/artifact/KotlinMetadata.kt | 7 +- 5 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactValueClassBoxTest.kt diff --git a/docs/SPEC.md b/docs/SPEC.md index 09f217f32..051b2ee83 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -377,7 +377,11 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. `ksp-fixtures/artifact/.../WalkedArtifactDefaultOmissionTest.kt`, `PM/commonTest/.../PythonxDefaultsTest.kt`. - **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`. + `WalkedArtifactPythonImportTest.kt`. A value class is opened as its raw primitive only when both its + constructor and its underlying property are public; otherwise (`TextUnitType`) it crosses as a boxed + handle, so a parameter of it takes the boxed value of its own class + (`TextUnit(30, TextUnitType.Sp)`) and a raw number is refused (#168, + `ksp-fixtures/artifact/.../WalkedArtifactValueClassBoxTest.kt`). - **B-5** Python callables can fill Kotlin function-typed parameters (arity and callability checked). `Status: partial`, `ksp-fixtures/artifact/.../WalkedArtifactCallbackTest.kt`; a value-returning slot (`() -> Float`) does not yet accept a Python callable (`M3ProofRenderTest.kt` diff --git a/ksp-fixtures/artifact/build.gradle.kts b/ksp-fixtures/artifact/build.gradle.kts index 0b37dedd8..c8857893c 100644 --- a/ksp-fixtures/artifact/build.gradle.kts +++ b/ksp-fixtures/artifact/build.gradle.kts @@ -127,6 +127,10 @@ pythonBindings { "androidx.compose.foundation.layout", "androidx.compose.foundation.ClickableKt", "kotlin.system.TimingKt", + // `TextUnit(30, TextUnitType.Sp)`: a value class over `Long` whose property is internal, + // so it can only ever be held in Python as a boxed handle (issue #168). + "androidx.compose.ui.unit.TextUnitKt", + "androidx.compose.ui.unit.TextUnitType", ), ) } diff --git a/ksp-fixtures/artifact/src/desktopMain/kotlin/fixture/artifact/ComposeSeed.kt b/ksp-fixtures/artifact/src/desktopMain/kotlin/fixture/artifact/ComposeSeed.kt index ef0651b0c..25a8f5873 100644 --- a/ksp-fixtures/artifact/src/desktopMain/kotlin/fixture/artifact/ComposeSeed.kt +++ b/ksp-fixtures/artifact/src/desktopMain/kotlin/fixture/artifact/ComposeSeed.kt @@ -6,7 +6,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp /** * The declarations `WalkedArtifactComposeModifierTest` needs that the *walker* cannot supply, @@ -84,3 +86,8 @@ fun equalsVerticalPadding(modifier: Modifier, vertical: Double): Boolean = /** The negative control for [equalsVerticalPadding]: the same number in the *horizontal* slot. */ fun equalsHorizontalPadding(modifier: Modifier, horizontal: Double): Boolean = modifier == Modifier.padding(horizontal = horizontal.dp) + +/** Structural equality of a boxed `TextUnit` against `size.sp`; the box is what a handle resolves to. */ +fun equalsSp(value: TextUnit, size: Double): Boolean = value == size.sp + +fun describeValue(value: TextUnit): String = value.toString() diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactValueClassBoxTest.kt b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactValueClassBoxTest.kt new file mode 100644 index 000000000..f2c9ae87e --- /dev/null +++ b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactValueClassBoxTest.kt @@ -0,0 +1,71 @@ +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 python.multiplatform.ffi.pythonx.PythonxAdapter +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +/** + * Issue #168: a value class parameter takes the boxed value of its own class, which is what Kotlin + * itself would pass, and still refuses a raw number unless the type is on the raw-primitive allowlist. + * + * `TextUnitType` is a value class over `Long` with an internal property, so `TextUnitType.Sp` reaches + * Python only as a boxed handle. `TextUnit(30, TextUnitType.Sp)` used to fail with + * `expected a number for TextUnitType`. + */ +class WalkedArtifactValueClassBoxTest { + + @BeforeTest + fun install() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(FunctionTable.fragments + ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonProxySource.install() + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + @Test + fun aBoxedValueClassFillsItsOwnParameter() { + Python3.exec( + """ + from androidx.compose.ui.unit import TextUnit, TextUnitType + from fixture.artifact import equalsSp, describeValue + + _sp = TextUnit(30, TextUnitType.Sp) + assert equalsSp(_sp._pm_handle, 30.0), 'TextUnit(30, TextUnitType.Sp) is not 30.sp: ' + describeValue(_sp._pm_handle) + ' / ' + describeValue(TextUnitType.Sp._pm_handle) + assert not equalsSp(_sp._pm_handle, 31.0), '30.sp matched 31.sp' + """.trimIndent(), + ) + } + + @Test + fun aRawNumberStillDoesNotFillAValueClassOffTheAllowlist() { + Python3.exec( + """ + from androidx.compose.ui.unit import TextUnit + + # The slot is an object slot now, so a bare number is read as a handle and Kotlin refuses it + # (no live handle, or a handle that is not a TextUnitType): it is never reinterpreted as + # the packed Long, which would be a TextUnitType of the wrong unit and no error. + try: + _result = TextUnit(30, 4294967296) + except Exception: + pass + else: + raise AssertionError('a raw number was accepted for TextUnitType: ' + repr(_result)) + """.trimIndent(), + ) + } +} diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt index 961fcf371..ee559b060 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt @@ -273,7 +273,12 @@ internal fun resolveKotlinBoundary( if (info != null) { val underlying = resolveKotlinBoundary(info.underlyingType, classpath, direction) val usable = when (direction) { - BoundaryDirection.PARAMETER -> info.constructorIsPublic + // Issue #168: opened as a raw primitive only when it can be opened **both ways**. A value + // class whose property is not public (`TextUnitType`) reaches Python as a boxed handle + // when Kotlin returns one (`TextUnitType.Sp`), so a parameter of it must take that handle. + // Marshalled as the raw primitive it would read a number and refuse the very value Kotlin + // handed out; as an object it is cast (`args[i] as TextUnitType`) and kotlinc unboxes. + BoundaryDirection.PARAMETER -> info.constructorIsPublic && info.propertyIsPublic && info.propertyName != null BoundaryDirection.RETURN -> info.propertyIsPublic && info.propertyName != null } // Falls through to the object handle below when the wrapper cannot be opened from outside