diff --git a/docs/SPEC.md b/docs/SPEC.md index df77b2529..91395f10d 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -240,6 +240,11 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. under the base name, and its signature as public metadata (`inspect.signature`, `python_multiplatform.describe`; contract in `KotlinSurface.kt`'s KDoc) - and, beside each Kotlin name, its Pythonic alias (U-12). + An `int` reaches an integral parameter only inside that Kotlin type's range, and when an overload + set still ties, the candidate whose integral parameters are each argument's Kotlin literal type + (`Int` if it fits in 32 bits, else `Long`) is called, as `kotlinc` resolves `Color(0xFFFFFFFF)` to + `Color(Long)` and `Color(0x11223344)` to `Color(Int)` (#146). `Status: implemented`, + `PM/commonTest/.../pythonx/PythonxAdapterTest.kt` `anIntArgumentPicksTheOverloadItsKotlinLiteralWould`. `describe(module, name)` describes any bound name, a named constant (`STATIC_GETTER`) included, without evaluating it (#36). A module's `dir()` lists its direct child packages/objects and reading one as an attribute imports it (#35) - Kotlin names only (the U-12 aliases are served, not listed). diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt index 8f11a333e..d8068c9a9 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapter.kt @@ -1469,6 +1469,43 @@ object PythonxAdapter { _LONG_MIN = -(1 << 63) _LONG_MAX = (1 << 63) - 1 + # Issue #146: the range of each Kotlin integral type an INT slot can declare. A slot whose + # declared type is not listed (a value class over `Int`, say) is not range-checked here. + _INTEGRAL_RANGES = { + 'kotlin.Byte': (-(1 << 7), (1 << 7) - 1), + 'kotlin.Short': (-(1 << 15), (1 << 15) - 1), + 'kotlin.Int': (_INT_MIN, _INT_MAX), + 'kotlin.Long': (_LONG_MIN, _LONG_MAX), + } + + + def _literal_misfits(decl, args, kwargs): + '''How many int arguments land in an integral slot of another width than their Kotlin literal. + + Kotlin types an integer literal as `Int` when it fits in 32 bits and as `Long` otherwise, + and resolves overloads on that type: `Color(0x11223344)` is `Color(Int)`, + `Color(0xFFFFFFFF)` is `Color(Long)`. `_Overloads` prefers the candidates with the fewest + misfits among those still tied, so a call picks the overload Kotlin would (#146). + ''' + if decl.composer_index >= 0 or not decl.param_type_names: + return 0 + pairs = list(enumerate(args)) + for key, value in kwargs.items(): + index = decl.keyword_slot(key) + if index >= 0: + pairs.append((index, value)) + misfits = 0 + for index, value in pairs: + if index >= len(decl.param_type_names) or isinstance(value, bool) or not isinstance(value, int): + continue + declared = decl.param_type_names[index] + if declared not in _INTEGRAL_RANGES: + continue + literal = 'kotlin.Int' if _INT_MIN <= value <= _INT_MAX else 'kotlin.Long' + if declared != literal: + misfits += 1 + return misfits + # What `_coerce` puts in a scalar's slot during a *trial* bind, for `_CALLABLE_PENDING`'s # reason: boxing crosses into Kotlin and roots the box, which a candidate that then loses # has no business doing. The winner is re-bound strictly before the call. @@ -1639,6 +1676,16 @@ object PythonxAdapter { ': it is a value class and not on the raw-primitive allowlist, so ' + repr(value) + ' would be reinterpreted rather than converted', ) + if tag == 'INT' and isinstance(value, int): + # Issue #146: an integral slot takes an int only inside its Kotlin type's range, + # as `kotlinc` would refuse the literal. Without this an `Int` slot also matched + # `0xFFFFFFFF`, and `Color(0xFFFFFFFF)` tied with `Color(Long)`. + bounds = _INTEGRAL_RANGES.get(type_name) + if bounds is not None and not (bounds[0] <= value <= bounds[1]): + return _refuse( + strict, + repr(value) + ' does not fit in ' + _simple_name(type_name), + ) return float(value) if tag == 'FLOAT' else int(value) if tag == 'BOOLEAN': if not isinstance(value, bool): @@ -1959,6 +2006,12 @@ object PythonxAdapter { if matched: fewest = min(entry[2] for entry in matched) matched = [entry for entry in matched if entry[2] == fewest] + if len(matched) > 1: + # Still tied: prefer the overload whose integral slots match each int's Kotlin + # literal type (#146). Candidates that agree on that stay tied and are refused. + scored = [(_literal_misfits(entry[0], args, kwargs), entry) for entry in matched] + least = min(score for score, _ in scored) + matched = [entry for score, entry in scored if score == least] if len(matched) == 1: decl = matched[0][0] # Re-bound strictly, and the trial binding above is discarded. A trial bind is a diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/ComposeShapedFragment.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/ComposeShapedFragment.kt index c6bc03926..e071d7148 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/ComposeShapedFragment.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/ComposeShapedFragment.kt @@ -38,6 +38,7 @@ import python.multiplatform.reflection.TypeTag * | `paddingFromBaseline__TextUnit` | the value-class reject list: a packed wrapper must refuse a raw number | * | `emptyModifier` | where a chain starts. Compose has no bound declaration for this; see [EMPTY_MODIFIER] | * | `Arrangement.Start` / `.End` | `kind = STATIC_GETTER`: a value behind a name, read as an attribute and not called | + * | `Color__Int` / `Color__Long` | one integral parameter at two widths: an int picks the overload its Kotlin literal would (#146) | */ object ComposeShapedFragment : FunctionTableFragment { @@ -94,6 +95,32 @@ object ComposeShapedFragment : FunctionTableFragment { paramTypeNames = listOf(MODIFIER), returnTypeName = "kotlin.String", ) { args -> (args[0] as StubModifier).describe() }, + // `Color(color: Int)` and `Color(color: Long)` (#146): the same TypeTag, two Kotlin widths. Kotlin + // types `0xFFFFFFFF` as a Long literal and `0x11223344` as an Int one, and picks accordingly. + ExposedCallable( + name = "androidx.compose.ui.graphics.Color__Int", + arity = 1, + paramTypes = listOf(TypeTag.INT), + returnType = TypeTag.STRING, + paramNames = listOf("color"), + paramTypeNames = listOf("kotlin.Int"), + returnTypeName = "kotlin.String", + ) { args -> + calls += "Color__Int" + "Int:${(args[0] as Number).toLong()}" + }, + ExposedCallable( + name = "androidx.compose.ui.graphics.Color__Long", + arity = 1, + paramTypes = listOf(TypeTag.INT), + returnType = TypeTag.STRING, + paramNames = listOf("color"), + paramTypeNames = listOf("kotlin.Long"), + returnTypeName = "kotlin.String", + ) { args -> + calls += "Color__Long" + "Long:${(args[0] as Number).toLong()}" + }, // padding, all four overloads, with the receiver in slot 0 exactly as the walker emits it. extension( name = "androidx.compose.foundation.layout.padding__Dp", diff --git a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapterTest.kt b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapterTest.kt index bb4f06098..f17a7b0f8 100644 --- a/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapterTest.kt +++ b/python-multiplatform/src/commonTest/kotlin/python/multiplatform/ffi/pythonx/PythonxAdapterTest.kt @@ -377,6 +377,45 @@ class PythonxAdapterTest { ) } + /** + * An int picks the overload its Kotlin literal would (#146, SPEC U-8). `Color(0xFFFFFFFF)` is the + * notebook's own spelling (pythonx-compose `UI.ipynb`); Kotlin types that literal as `Long`, since it + * does not fit in 32 bits, and `0x11223344` as `Int`. An `Int` slot never takes an int outside 32 + * bits, even when named explicitly, and an int outside 64 bits fits no overload at all. + */ + @Test + fun anIntArgumentPicksTheOverloadItsKotlinLiteralWould() = withAdapter { + Python3.exec( + """ + from androidx.compose.ui.graphics import Color, Color__Int, Color__Long + _px = { + 'long': Color(0xFFFFFFFF), + 'int': Color(0x11223344), + 'negative': Color(-1), + 'wide': Color(1 << 40), + 'explicitLong': Color__Long(5), + } + for _key, _call in (('tooWide', lambda: Color(1 << 70)), ('intOverflow', lambda: Color__Int(1 << 31))): + try: + _px[_key] = 'call succeeded: ' + _call() + except TypeError as e: + _px[_key] = 'TypeError' + """.trimIndent(), + ) + + assertEquals("Long:4294967295", eval("_px['long']")) + assertEquals("Int:287454020", eval("_px['int']")) + assertEquals("Int:-1", eval("_px['negative']")) + assertEquals("Long:1099511627776", eval("_px['wide']")) + assertEquals("Long:5", eval("_px['explicitLong']")) + assertEquals("TypeError", eval("_px['tooWide']"), "an int outside 64 bits reached an integral slot") + assertEquals("TypeError", eval("_px['intOverflow']"), "an Int slot took an int outside 32 bits") + assertEquals( + listOf("Color__Long", "Color__Int", "Color__Int", "Color__Long", "Color__Long"), + ComposeShapedFragment.calls, + ) + } + /** * What the dispatcher does when it cannot decide, and what it does when the caller has already * decided.