Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 4 additions & 0 deletions ksp-fixtures/artifact/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Original file line number Diff line number Diff line change
@@ -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(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading