From 7e5f52ef85b43623f3897177ab0d65439946d7f2 Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:52:23 +0900 Subject: [PATCH] Fix: an explicit import of a same-named module keeps the proxy class on its parent (#323) When the import system creates pkg.Owner through the finder it binds the module onto pkg after the loader returns, shadowing the proxy class pkg.Owner (a companion makes Owner both). The parent's module type now guards __setattr__ with class_wins_over_module, in the adapter and in PythonProxySource's per-module types, so the class stays and its __getattr__ serves the module's statics; 'import pkg.Owner as M' binds the class. Reads are unchanged. --- docs/SPEC.md | 2 +- .../WalkedArtifactCompanionHandleTest.kt | 58 +++++++++++++++++++ .../ffi/pythonx/PythonxAdapter.kt | 27 +++++++++ .../ffi/upcall/PythonProxySource.kt | 5 ++ 4 files changed, 91 insertions(+), 1 deletion(-) 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