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
2 changes: 1 addition & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
`Owner.Companion` (`Owner.<CompanionName>` for a named companion), read as an attribute of the owner's
proxy class or of its module. When the companion lists the owner as a direct supertype in its Kotlin
metadata (`Modifier.Companion : Modifier`) the value is an `Owner` proxy instance (`isinstance` holds),
otherwise a proxy of the companion's own type. Each read returns a new handle to the same Kotlin object. Two proxies are `==` when they hold the same
otherwise a proxy of the companion's own type. An explicit `import pkg.Owner` of the same-named module keeps the class on the parent package (the module stays in `sys.modules`), so `import pkg.Owner as M` binds the class (#323). Each read returns a new handle to the same Kotlin object. Two proxies are `==` when they hold the same
Kotlin object (reference identity) and hash by its identity hash; `is` is not guaranteed; Kotlin
`equals` and `hashCode` are not used (value equality is not provided). On wasmJs, which has no
identity hash, the hash is the object's `hashCode()`, so there a mutable object that overrides it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,62 @@ class WalkedArtifactCompanionHandleTest {
""".trimIndent(),
)
}

// Issue #323: an explicit `import androidx.compose.ui.Modifier` makes the import system bind the
// module onto its parent after the loader returns; the class must stay. Each test first drops the
// module from sys.modules so the adapter's finder path (the one that sets the parent attribute) runs.
private val dropModule = """
import sys
import androidx.compose.ui as _ui
sys.modules.pop('androidx.compose.ui.Modifier', None)
""".trimIndent()

@Test
fun explicitImportOfTheSameNamedModuleKeepsTheClass() {
Python3.exec(
dropModule + "\n" + """
import androidx.compose.ui.Modifier
from androidx.compose.ui import Modifier
assert isinstance(Modifier, type), type(Modifier)
assert isinstance(Modifier.Companion, Modifier), type(Modifier.Companion)
""".trimIndent(),
)
}

@Test
fun classFirstThenExplicitImportKeepsTheClass() {
Python3.exec(
dropModule + "\n" + """
from androidx.compose.ui import Modifier
import androidx.compose.ui.Modifier
import androidx.compose.ui as _ui2
assert _ui2.Modifier is Modifier, type(_ui2.Modifier)
assert isinstance(Modifier.Companion, Modifier)
""".trimIndent(),
)
}

@Test
fun alreadyImportedModuleKeepsTheClass() {
Python3.exec(
"""
import androidx.compose.ui.Modifier
import androidx.compose.ui.Modifier
from androidx.compose.ui import Modifier
assert isinstance(Modifier, type), type(Modifier)
""".trimIndent(),
)
}

@Test
fun importAsBindsTheClass() {
Python3.exec(
dropModule + "\n" + """
import androidx.compose.ui.Modifier as M
from androidx.compose.ui import Modifier
assert M is Modifier, M
assert isinstance(M.Companion, M)
""".trimIndent(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2325,6 +2325,7 @@ object PythonxAdapter {
# Names adapted by a previous install are stale with it; the fresh `__getattr__` below
# re-resolves each on first use. Only this layer's own -- see `_drop_adapted`.
_drop_adapted(module)
_guard_parent(module)
if kotlin_package is None:
# A step on the way to a package that has bindings, carrying none of its own.
return module
Expand All @@ -2335,6 +2336,32 @@ object PythonxAdapter {
return module


def guarded_setattr(self, name, value):
'''`__setattr__` of a package module (issue #323). The import system binds a freshly
imported submodule onto its parent *after* the loader returns
(`importlib._bootstrap._find_and_load_unlocked`), so `import pkg.Owner` would replace the
proxy class `pkg.Owner` with the module of statics. When `class_wins_over_module` holds
for that name, the class is bound instead; the module stays in `sys.modules`. Only
writes pay for this; reads never reach it.'''
if isinstance(value, _types.ModuleType):
qualified = self.__name__ + '.' + name
if value.__name__ == qualified and class_wins_over_module(qualified):
value = _proxy_type(qualified)
_types.ModuleType.__setattr__(self, name, value)


def _guard_parent(module):
kind = type(module)
if not getattr(kind, '_pm_owned', False):
kind = type(
'_PmModule_' + module.__name__.replace('.', '_'),
(kind,),
{'_pm_owned': True},
)
module.__class__ = kind
kind.__setattr__ = guarded_setattr


def adapt_existing_modules():
'''Adapt every already-imported module a bound package names.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,11 @@ object PythonProxySource {
(_pm_types.ModuleType,),
{'_pm_owned': True},
)
# Issue #323: the binding layer's write guard, so `import pkg.Owner` cannot replace the
# proxy class `pkg.Owner` on this module. Writes only; reads are unchanged.
_guard = getattr(_pm_sys.modules.get('python_multiplatform.binding'), 'guarded_setattr', None)
if _guard is not None:
_t.__setattr__ = _guard
_mod.__class__ = _t
return _t

Expand Down