diff --git a/docs/SPEC.md b/docs/SPEC.md index f356d244..ca725728 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -256,7 +256,7 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. `Owner.Companion` (`Owner.` 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 diff --git a/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactCompanionHandleTest.kt b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactCompanionHandleTest.kt index e2ed568a..a53e3889 100644 --- a/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactCompanionHandleTest.kt +++ b/python-multiplatform-gradle-plugin/fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactCompanionHandleTest.kt @@ -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(), + ) + } } 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 eef800ae..25aa786c 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 @@ -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 @@ -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. diff --git a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PythonProxySource.kt b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PythonProxySource.kt index 0e8d0dd9..c815d598 100644 --- a/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PythonProxySource.kt +++ b/python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/upcall/PythonProxySource.kt @@ -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