From e01f35d73dca04595117f9bb771b06b1a3d3d395 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:44:16 +0900 Subject: [PATCH 1/3] Docs: A name that is both a function and a companion module is a callable module (SPEC U-8, B-7, #78) --- docs/SPEC.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index ae73cbc9f..18e6104c3 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -242,7 +242,13 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. one as an attribute imports it (#35) — Kotlin names only. No member or parameter is renamed; the binder creates no `pythonx` module and a real `pythonx` package on disk is what `import pythonx` loads. The answer is the same whichever installer - (`PythonProxySource`, `PythonxAdapter`) ran first for a table. `Status: implemented` on desktop — + (`PythonProxySource`, `PythonxAdapter`) ran first for a table. **A name that is both a function and a + module is callable** (#78): Kotlin has `TextRange(2)` (a top-level factory) and `TextRange.Zero` (a + companion constant), so the module `androidx.compose.ui.text.TextRange` is itself callable — calling it + runs the function or overload set of that name in its parent package — while its attributes stay the + companion's constants and functions. One rule with the class case (#73): a Kotlin name that is a + constructor or factory is what calling it does, whatever else lives under that name. `Status: + implemented` on desktop — `PM/desktopTest/.../pythonx/KotlinNamedSurfaceTest.kt`, `BinderNamespaceTest.kt`, `ksp-fixtures/compose/.../KotlinSignatureMetadataTest.kt`. - **U-9** A Pythonic package can serve extra member names on a Kotlin proxy through one hook, `python_multiplatform.binding.add_member_resolver(fn)`, `fn(kotlin_type_name, requested_name, @@ -341,6 +347,10 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. Pythonic stub product belongs to pythonx-compose. `Status: partial` — `GP/stubs/PyiRenderingTest.kt`, `GP/stubs/TypedStubTest.kt`, `GP/stubs/KotlinNamesOnlyStubTest.kt`, `ksp-fixtures/artifact/.../WalkedArtifactStubTest.kt`, `.../StubSignatureAgreesWithRuntimeTest.kt`, + `GP/stubs/CompanionFactoryStubTest.kt` (#78: a name that is both a function and a Kotlin-named + module is stubbed in its parent package as one attribute whose type has `__call__` (the function or + its `@overload`s) and the module's constants and functions as members — never as a bare `def`, which + would hide `TextRange.Zero` from a checker), `tools/stubs/check-stubs.sh` (mypy over the Compose stubs). A class whose name is also a function in its module is `Any` — except that a class's own bound constructor is that class's `__init__` (`@overload`ed for several), so the class keeps its stub, members and properties; a single one's From 19eb00c9eb2d9246af93264fe31b2aae9c86a9f5 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:44:16 +0900 Subject: [PATCH 2/3] Test: TextRange(2) and TextRange.Zero both work, in the binder and the stubs (#78) --- .../compose/TextFieldStateRenderTest.kt | 38 +++- .../gradle/stubs/CompanionFactoryStubTest.kt | 105 ++++++++++ .../ffi/pythonx/CompanionFactoryTest.kt | 182 ++++++++++++++++++ 3 files changed, 317 insertions(+), 8 deletions(-) create mode 100644 python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/CompanionFactoryStubTest.kt create mode 100644 python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/CompanionFactoryTest.kt diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt index b63587e49..2fe9a7c3c 100644 --- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt @@ -103,27 +103,49 @@ class TextFieldStateRenderTest { * crosses as a handle made by its walked factory `TextRange(index: Int)` -- separate from the test * above so a failure here says it is this slot. * - * Spelled by its table key, `TextRange__Int`. The bare name `TextRange` is not the factory's overload - * set in Python: `TextRange.Zero` (a companion constant) makes `androidx.compose.ui.text.TextRange` a - * module, and that module is what `from ... import TextRange` finds -- observed as - * "'_PmModule_androidx_compose_ui_text_TextRange' object is not callable". That collision predates - * issue #73 and is not this test's subject. + * Spelled `TextRange(...)`, as in Kotlin (issue #78): the module `...text.TextRange` holds the companion's + * `Zero` and is itself callable, dispatching to the factory's overload set. It used to have to be + * spelled by its table key, `TextRange__Int`, because the bare name was the module and not callable. */ @Test fun theInitialSelectionCrossesAsATextRangeHandle() { Python3.exec( """ from androidx.compose.foundation.text.input import TextFieldState - from androidx.compose.ui.text import TextRange__Int + from androidx.compose.ui.text import TextRange - _tfs_selected = TextFieldState('hello', TextRange__Int(2)) + _tfs_selected = TextFieldState('hello', TextRange(2)) assert _tfs_selected.text == 'hello', repr(_tfs_selected.text) - _tfs_kw = TextFieldState(initialText='kw', initialSelection=TextRange__Int(0)) + _tfs_kw = TextFieldState(initialText='kw', initialSelection=TextRange(0)) assert _tfs_kw.text == 'kw', repr(_tfs_kw.text) """.trimIndent(), ) } + /** + * Issue #78: Kotlin has `TextRange(2)` and `TextRange.Zero`; Python has both, from the one name. + * Also the explicit table-key spelling, which must keep working, and a two-argument overload. + */ + @Test + fun textRangeIsBothAFactoryAndTheHolderOfItsCompanionConstants() { + Python3.exec( + """ + from androidx.compose.ui.text import TextRange, TextRange__Int + from androidx.compose.foundation.text.input import TextFieldState + + assert callable(TextRange), type(TextRange) + _tr_zero = TextRange.Zero + _tr_two = TextRange(2) + _tr_pair = TextRange(1, 3) + _tr_key = TextRange__Int(2) + _tr_state = TextFieldState('hello', _tr_two) + assert _tr_state.text == 'hello', repr(_tr_state.text) + _tr_zero_state = TextFieldState('hello', TextRange.Zero) + assert _tr_zero_state.text == 'hello', repr(_tr_zero_state.text) + """.trimIndent(), + ) + } + /** * Made inside a composition by `rememberTextFieldState`, drawn by `TextField(state=...)` in the same * body, then written from Python **outside** the composition: the state is Compose snapshot state, diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/CompanionFactoryStubTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/CompanionFactoryStubTest.kt new file mode 100644 index 000000000..9d70c1518 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/stubs/CompanionFactoryStubTest.kt @@ -0,0 +1,105 @@ +package python.multiplatform.gradle.stubs + +import python.multiplatform.gradle.model.DeclarationModel +import python.multiplatform.gradle.model.DeclaredParameter +import python.multiplatform.gradle.model.KotlinTypeModel +import python.multiplatform.gradle.model.ValueClassModel +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Issue #78: `TextRange(2)` and `TextRange.Zero`. + * + * Kotlin has a top-level factory `TextRange(index: Int)` (bound as the overload set `TextRange__Int`, + * `TextRange__Int_Int`) and a companion constant `TextRange.Zero` (bound under the package + * `androidx.compose.ui.text.TextRange`). At run time the module `...text.TextRange` is callable. The + * stub of the parent package has to say both: one attribute `TextRange` whose type has `__call__` + * (the overload set) and `Zero` -- a bare `def TextRange` hides `TextRange.Zero` from a checker. + * + * Red before the fix: the parent stub has `@overload def TextRange(...)` and no `Zero` anywhere in it. + */ +class CompanionFactoryStubTest { + + private val pkg = "androidx.compose.ui.text" + private val range = KotlinTypeModel( + "$pkg.TextRange", + valueClass = ValueClassModel(KotlinTypeModel("kotlin.Long"), constructorIsPublic = false, propertyIsPublic = true), + ) + private val int = KotlinTypeModel("kotlin.Int") + + private fun factory(binding: String, vararg names: String) = DeclarationModel( + simpleName = "TextRange", + owner = pkg, + ownerIsClass = false, + receiver = null, + parameters = names.map { DeclaredParameter(it, int, false, "INT") }, + returnType = range, + returnBoundaryTag = "OBJECT", + bindingName = binding, + ) + + private val zero = DeclarationModel( + simpleName = "Zero", + owner = "$pkg.TextRange", + ownerIsClass = true, + receiver = null, + parameters = emptyList(), + returnType = range, + returnBoundaryTag = "OBJECT", + bindingName = "$pkg.TextRange.Zero", + kind = "STATIC_GETTER", + ) + + private val declarations = listOf( + factory("$pkg.TextRange__Int", "index"), + factory("$pkg.TextRange__Int_Int", "start", "end"), + zero, + ) + + private fun parentStub() = renderKotlinFqnStubs(declarations).getValue("androidx/compose/ui/text/__init__.pyi") + + @Test + fun theFactoryNameIsOneAttributeWhoseTypeIsCallable() { + val text = parentStub() + assertTrue(Regex("""^TextRange: _TextRange_callable_module$""", RegexOption.MULTILINE).containsMatchIn(text), text) + assertTrue("def __call__(self, index: int)" in text, text) + assertTrue("def __call__(self, start: int, end: int)" in text, text) + assertTrue("@_t.overload" in text, text) + } + + @Test + fun theCompanionConstantIsAMemberOfThatSameType() { + val text = parentStub() + val protocol = text.substringAfter("class _TextRange_callable_module").substringBefore("\nTextRange:") + assertTrue(Regex("""^ Zero: """, RegexOption.MULTILINE).containsMatchIn(protocol), text) + } + + @Test + fun noBareDefOfTheBaseNameRemains() { + val text = parentStub() + assertFalse(Regex("""^def TextRange\(""", RegexOption.MULTILINE).containsMatchIn(text), text) + assertFalse(Regex("""^def TextRange:""", RegexOption.MULTILINE).containsMatchIn(text), text) + } + + @Test + fun theExplicitTableKeySpellingsStayModuleFunctions() { + val text = parentStub() + assertTrue(Regex("""^def TextRange__Int\(index: int\)""", RegexOption.MULTILINE).containsMatchIn(text), text) + assertTrue(Regex("""^def TextRange__Int_Int\(""", RegexOption.MULTILINE).containsMatchIn(text), text) + } + + @Test + fun theModulesOwnStubStillExistsForAnExplicitImport() { + val files = renderKotlinFqnStubs(declarations) + assertTrue("androidx/compose/ui/text/TextRange/__init__.pyi" in files, files.keys.toString()) + } + + @Test + fun aFunctionWithNoModuleOfItsNameStaysAPlainOverloadSet() { + val plain = renderKotlinFqnStubs(declarations.filter { it.kind != "STATIC_GETTER" }) + .getValue("androidx/compose/ui/text/__init__.pyi") + assertTrue(Regex("""^@_t\.overload\ndef TextRange\(""", RegexOption.MULTILINE).containsMatchIn(plain), plain) + assertFalse("_callable_module" in plain, plain) + } +} diff --git a/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/CompanionFactoryTest.kt b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/CompanionFactoryTest.kt new file mode 100644 index 000000000..69852a78e --- /dev/null +++ b/python-multiplatform/src/desktopTest/kotlin/python/multiplatform/ffi/pythonx/CompanionFactoryTest.kt @@ -0,0 +1,182 @@ +package python.multiplatform.ffi.pythonx + +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.PythonTestFixture +import python.multiplatform.reflection.CallableKind +import python.multiplatform.reflection.ExposedCallable +import python.multiplatform.reflection.FunctionTableFragment +import python.multiplatform.reflection.HandleTable +import python.multiplatform.reflection.TypeTag +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Issue #78: a name that is both a function and a module. + * + * Kotlin has `TextRange(2)` (a top-level factory, bound as the overload set `TextRange__Int`, + * `TextRange__Int_Int`) **and** `TextRange.Zero` (a constant of the class's companion, bound under the + * package `...text.TextRange`). Python had only the second: the child module was bound onto the parent + * as the attribute `TextRange`, so `from ...text import TextRange` found the module and + * `TextRange(2)` failed with "'_PmModule_..._TextRange' object is not callable". SPEC U-8: the module is + * callable and dispatches to the function or overload set of that name, and its attributes stay the + * companion's. + * + * Red before the fix: every test here that calls the module raises TypeError (not callable). + */ +class CompanionFactoryTest { + + private object Fragment : FunctionTableFragment { + override val moduleName: String = "test_companion_factory" + val calls = mutableListOf() + + private const val PKG = "androidx.compose.ui.text" + private const val RANGE = "$PKG.TextRange" + + override fun entries(): List = listOf( + ExposedCallable( + name = "$RANGE.Zero", + arity = 0, + paramTypes = emptyList(), + returnType = TypeTag.OBJECT, + kind = CallableKind.STATIC_GETTER, + paramNames = emptyList(), + paramTypeNames = emptyList(), + returnTypeName = RANGE, + ) { calls += "Zero"; "range(0,0)" }, + ExposedCallable( + name = "${RANGE}__Int", + arity = 1, + paramTypes = listOf(TypeTag.INT), + returnType = TypeTag.OBJECT, + paramNames = listOf("index"), + paramTypeNames = listOf("kotlin.Int"), + returnTypeName = RANGE, + ) { args -> calls += "Int"; "range(${args[0]},${args[0]})" }, + ExposedCallable( + name = "${RANGE}__Int_Int", + arity = 2, + paramTypes = listOf(TypeTag.INT, TypeTag.INT), + returnType = TypeTag.OBJECT, + paramNames = listOf("start", "end"), + paramTypeNames = listOf("kotlin.Int", "kotlin.Int"), + returnTypeName = RANGE, + ) { args -> calls += "Int_Int"; "range(${args[0]},${args[1]})" }, + ExposedCallable( + name = "$PKG.describeRange", + arity = 1, + paramTypes = listOf(TypeTag.OBJECT), + returnType = TypeTag.STRING, + paramNames = listOf("range"), + paramTypeNames = listOf(RANGE), + returnTypeName = "kotlin.String", + ) { args -> args[0] as String }, + ) + } + + @BeforeTest + fun install() { + check(PythonTestFixture.available) { + "CPython could not be initialized (${PythonTestFixture.failureReason})" + } + UpcallTable.install(listOf(Fragment)) + Fragment.calls.clear() + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + HandleTable.releaseAll() + } + + private fun fresh(block: () -> Unit) = PythonTestFixture.withInterpreter { + Python3.exec( + """ + import sys as _cf_sys + for _cf_n in [_n for _n in _cf_sys.modules if _n == 'androidx' or _n.startswith('androidx.')]: + del _cf_sys.modules[_cf_n] + """.trimIndent(), + ) + PythonxAdapter.install() + block() + } + + @Test + fun theNameIsCallableAndItsAttributesAreTheCompanionsConstants() = fresh { + Python3.exec( + """ + from androidx.compose.ui.text import TextRange, describeRange + _cf = { + 'one': describeRange(TextRange(2)), + 'two': describeRange(TextRange(1, 3)), + 'kw': describeRange(TextRange(start=4, end=5)), + 'zero': describeRange(TextRange.Zero), + 'callable': callable(TextRange), + } + """.trimIndent(), + ) + assertEquals("range(2,2)", eval("_cf['one']")) + assertEquals("range(1,3)", eval("_cf['two']")) + assertEquals("range(4,5)", eval("_cf['kw']")) + assertEquals("range(0,0)", eval("_cf['zero']")) + assertEquals("True", eval("_cf['callable']")) + } + + @Test + fun theExplicitTableKeySpellingStillWorks() = fresh { + Python3.exec( + """ + from androidx.compose.ui.text import TextRange__Int, describeRange + _cf = describeRange(TextRange__Int(5)) + """.trimIndent(), + ) + assertEquals("range(5,5)", eval("_cf")) + } + + @Test + fun importingTheModuleByItsFullNameGivesTheSameCallableObject() = fresh { + Python3.exec( + """ + import importlib as _cf_il + from androidx.compose.ui.text import TextRange, describeRange + _cf_mod = _cf_il.import_module('androidx.compose.ui.text.TextRange') + _cf = {'same': _cf_mod is TextRange, 'call': describeRange(_cf_mod(9)), 'zero': describeRange(_cf_mod.Zero)} + """.trimIndent(), + ) + assertEquals("True", eval("_cf['same']")) + assertEquals("range(9,9)", eval("_cf['call']")) + assertEquals("range(0,0)", eval("_cf['zero']")) + } + + @Test + fun aModuleWithNoFunctionOfItsNameIsNotCallable() = fresh { + Python3.exec( + """ + import androidx.compose.ui as _cf_ui + _cf = callable(_cf_ui) + """.trimIndent(), + ) + assertEquals("False", eval("_cf"), "only a name that is also a function becomes callable") + } + + @Test + fun aNoMatchingOverloadIsATypeErrorNamingTheCandidates() = fresh { + Python3.exec( + """ + from androidx.compose.ui.text import TextRange + try: + TextRange('a', 'b', 'c') + _cf = 'no error' + except TypeError as _e: + _cf = str(_e) + """.trimIndent(), + ) + assertTrue("TextRange" in eval("_cf"), eval("_cf")) + assertTrue(eval("_cf") != "no error") + } + + private fun eval(expression: String): String = PythonTestFixture.eval(expression).toString() +} From d97bbf292c5d426ceb525461ff463157e14be467 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:44:16 +0900 Subject: [PATCH 3/3] Fix: Make a companion module callable when a factory shares its name (#78) --- .../gradle/stubs/PyiRendering.kt | 64 ++++++++++++++++++- .../ffi/pythonx/PythonxAdapter.kt | 55 ++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt index 15263b0ec..4cea7f641 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt @@ -418,6 +418,55 @@ private class StubRenderer(declarations: List) { } } + /** + * A name that is a function (or an overload set) in its package **and** a module of its own (issue + * #78): Kotlin's `TextRange(2)` and `TextRange.Zero`. At run time the module is callable, so the + * stub says one attribute of the parent package whose type has `__call__` -- the function, its + * `@overload`s -- and the module's constants and functions as members. A bare `def` would hide + * `TextRange.Zero` from a checker; a bare module would hide `TextRange(2)`. The module's own file + * still exists for `import pkg.TextRange`. Member order is table-key order, as everywhere. + */ + private fun callableModule( + name: String, + calls: List, + members: List, + ctx: ModuleOut, + ): String { + if (name in PYTHON_KEYWORDS) return "# '$name' is a Python keyword; reach it with getattr(..., '$name')" + val protocol = "_${name}_callable_module" + val sortedMembers = members.sortedBy { it.bindingName } + ctx.protocols += buildString { + appendLine("class $protocol($TYPING.Protocol):") + appendLine(" \"\"\"Kotlin: the function $name, and the members of the module $name\"\"\"") + calls.forEachIndexed { index, d -> + if (calls.size > 1) appendLine(" @$TYPING.overload") + appendLine(" def __call__(${parameters(d, ctx, method = true)}) -> ${result(d, ctx)}: ..." + shadowed(index)) + } + val attributes = sortedMapOf>() + sortedMembers.forEach { d -> + val leaf = leafOf(d) + attributes.getOrPut(leaf) { mutableListOf() } += d + if (d.kind != "STATIC_GETTER" && isSuffixed(leaf)) attributes.getOrPut(baseOf(leaf)) { mutableListOf() } += d + } + attributes.forEach { (attribute, group) -> + if (!isIdentifier(attribute) || attribute == "__call__") { + appendLine(" # '$attribute' cannot be written as an attribute here; reach it with getattr(..., '$attribute')") + return@forEach + } + val first = group.first() + if (first.kind == "STATIC_GETTER") { + appendLine(" $attribute: ${result(first, ctx)}") + } else { + group.forEachIndexed { index, d -> + if (group.size > 1) appendLine(" @$TYPING.overload") + appendLine(" def $attribute(${parameters(d, ctx, method = true)}) -> ${result(d, ctx)}: ..." + shadowed(index)) + } + } + } + }.trimEnd() + return "$name: $protocol" + } + private fun overloadDefs(name: String, group: List, ctx: ModuleOut): String = if (name in PYTHON_KEYWORDS) "# '$name' is a Python keyword; reach it with getattr(..., '$name')" else group.withIndex().joinToString("\n") { (index, d) -> @@ -447,7 +496,18 @@ private class StubRenderer(declarations: List) { // because the runtime serves it as one. A class with no stub keeps the old rendering. val (constructors, sorted) = entries.sortedBy { it.bindingName } .partition { isOwnConstructor(it) && classReference(it.returnType, ctx) != ANY } - sorted.forEach { ctx.defs += renderDef(it, ctx) } + // Issue #78: a name that is both a function here and a module of its own (`TextRange(2)` + // and `TextRange.Zero`) is one callable attribute, not a bare `def`; see [callableModule]. + val companions = sorted.filter { it.kind != "STATIC_GETTER" } + .map { baseOf(leafOf(it)) } + .filter { "$module.$it" in moduleNames } + .toSortedSet() + sorted.filter { it.kind == "STATIC_GETTER" || isSuffixed(leafOf(it)) || leafOf(it) !in companions } + .forEach { ctx.defs += renderDef(it, ctx) } + companions.forEach { base -> + val calls = sorted.filter { it.kind != "STATIC_GETTER" && baseOf(leafOf(it)) == base } + ctx.defs += callableModule(base, calls, byModule.getValue("$module.$base"), ctx) + } constructors.forEach { d -> if (isSuffixed(leafOf(d))) ctx.defs += renderDef(d, ctx) constructorsByClass.getOrPut(d.returnType.qualifiedName) { mutableListOf() } += d @@ -458,7 +518,7 @@ private class StubRenderer(declarations: List) { .groupBy { baseOf(leafOf(it)) } .toSortedMap() .forEach { (base, group) -> - if (base !in unsuffixedLeaves) ctx.defs += overloadDefs(base, group, ctx) + if (base !in unsuffixedLeaves && base !in companions) ctx.defs += overloadDefs(base, group, ctx) } } } 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 90d2690de..4fa114245 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 @@ -1939,6 +1939,51 @@ object PythonxAdapter { ) + def _is_function_set(decls): + '''True when every declaration is a callable (`FUNCTION`), none a constant.''' + return all(decl.kind == 'FUNCTION' for decl in decls) + + + def _module_call(self, *args, **kwargs): + '''Calls the function or overload set that shares this module's Kotlin name (issue #78). + + Resolved on every call, from the table, so a table that changed after the module was made + callable is followed and one that no longer holds the function is a TypeError. + ''' + parent, _, leaf = self._kotlin_package.rpartition('.') + decls = _BY_PACKAGE.get(parent, {}).get(leaf) + if not decls or not _is_function_set(decls): + raise TypeError("'" + self.__name__ + "' is a Kotlin object, not a function") + decl = decls[0] + target = _callable_named(decl.kotlin_name) if len(decls) == 1 else None + if target is None: + target = _callable_for(leaf, decls) + return target(*args, **kwargs) + + + def _make_callable(module, kotlin_package): + '''Makes [module] callable when its Kotlin name is also a function in its parent package. + + `__call__` is looked up on the type, so the module is reclassed onto a subclass of its own + type -- the same move the proxy layer's `_pm_module_type` makes, and marked `_pm_owned` + the same way so that layer reuses this type instead of replacing it. Per module, so the + hook exists on exactly the modules whose name is a function. + ''' + parent, _, leaf = kotlin_package.rpartition('.') + decls = _BY_PACKAGE.get(parent, {}).get(leaf) if parent else None + if not decls or not _is_function_set(decls): + return + kind = type(module) + if not getattr(kind, '_pm_owned', False): + kind = type( + '_PmModule_' + module.__name__.replace('.', '_'), + (kind,), + {'_pm_owned': True}, + ) + module.__class__ = kind + kind.__call__ = _module_call + + def _adapt(kotlin_package, name): '''The value or callable the Kotlin name [name] means in [kotlin_package], and whether `_module_getattr` may freeze it into the module dict. @@ -1951,6 +1996,15 @@ object PythonxAdapter { adapted name gets here is refused for this one kind, and every read re-enters Kotlin. ''' decls = _BY_PACKAGE.get(kotlin_package, {}).get(name) + if decls and _is_function_set(decls) and kotlin_package + '.' + name in _PACKAGES_SEEN: + # Issue #78: Kotlin has both `TextRange(2)` and `TextRange.Zero`. The name is the + # child module -- whose attributes are the companion's -- and that module is + # callable (`_make_callable`), dispatching to the function or overload set. One + # object answers both, whichever of the two layers or imports reached it first. + _child = _importlib.import_module(kotlin_package + '.' + name) + if getattr(_child, '_kotlin_package', None) != kotlin_package + '.' + name: + _adapt_module(_child, kotlin_package + '.' + name) + return _child, True if decls: if len(decls) == 1 and decls[0].kind == 'STATIC_GETTER': return _read_constant(decls[0]), False @@ -2036,6 +2090,7 @@ object PythonxAdapter { _MODULES.append(module) module.__getattr__ = _module_getattr(module, kotlin_package) module.__dir__ = _module_dir(kotlin_package) + _make_callable(module, kotlin_package) return module