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
12 changes: 11 additions & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,55 @@ private class StubRenderer(declarations: List<DeclarationModel>) {
}
}

/**
* 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<DeclarationModel>,
members: List<DeclarationModel>,
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<String, MutableList<DeclarationModel>>()
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<DeclarationModel>, 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) ->
Expand Down Expand Up @@ -447,7 +496,18 @@ private class StubRenderer(declarations: List<DeclarationModel>) {
// 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
Expand All @@ -458,7 +518,7 @@ private class StubRenderer(declarations: List<DeclarationModel>) {
.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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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


Expand Down
Loading
Loading