From 5784f52cb9ef498a5caa2b28e36b4cf1b61d0e68 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:31:31 +0900 Subject: [PATCH] Fix: An int argument picks the overload its Kotlin literal would (#146) An integral slot takes an int only inside its Kotlin type's range, and a tie between overloads goes to the one whose integral slots match each int's literal type (Int if it fits in 32 bits, else Long), as kotlinc resolves Color(0xFFFFFFFF) to Color(Long). Red before the change: anIntArgumentPicksTheOverloadItsKotlinLiteralWould failed (18 run, 1 failed). --- docs/SPEC.md | 5 ++ .../ffi/pythonx/PythonxAdapter.kt | 53 +++++++++++++++++++ .../ffi/pythonx/ComposeShapedFragment.kt | 27 ++++++++++ .../ffi/pythonx/PythonxAdapterTest.kt | 39 ++++++++++++++ 4 files changed, 124 insertions(+) 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.