mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
fix(plugins): prevent module collision between plugins with shared module names (#265)
When plugins share identically-named local modules (scroll_display.py, game_renderer.py, sports.py), the first plugin to load would populate sys.modules with its version, and subsequent plugins would reuse it instead of loading their own. This caused hockey-scoreboard to use soccer-scoreboard's ScrollDisplay class, which passes unsupported kwargs to ScrollHelper.__init__(), breaking Vegas scroll mode entirely. Fix: evict stale bare-name module entries from sys.modules before each plugin's exec_module, and delete bare entries after namespace isolation so they can't leak to the next plugin. Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -228,6 +228,43 @@ class PluginLoader:
|
|||||||
continue
|
continue
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _evict_stale_bare_modules(self, plugin_dir: Path) -> dict:
|
||||||
|
"""Temporarily remove bare-name sys.modules entries from other plugins.
|
||||||
|
|
||||||
|
Before exec_module, scan the current plugin directory for .py files.
|
||||||
|
For each, if sys.modules has a bare-name entry whose ``__file__`` lives
|
||||||
|
in a *different* directory, remove it so Python's import system will
|
||||||
|
load the current plugin's version instead of reusing the stale cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping evicted module names to their module objects
|
||||||
|
(for restoration on error).
|
||||||
|
"""
|
||||||
|
resolved_dir = plugin_dir.resolve()
|
||||||
|
evicted: dict = {}
|
||||||
|
|
||||||
|
for py_file in plugin_dir.glob("*.py"):
|
||||||
|
mod_name = py_file.stem
|
||||||
|
if mod_name.startswith("_"):
|
||||||
|
continue
|
||||||
|
existing = sys.modules.get(mod_name)
|
||||||
|
if existing is None:
|
||||||
|
continue
|
||||||
|
existing_file = getattr(existing, "__file__", None)
|
||||||
|
if not existing_file:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if not Path(existing_file).resolve().is_relative_to(resolved_dir):
|
||||||
|
evicted[mod_name] = sys.modules.pop(mod_name)
|
||||||
|
self.logger.debug(
|
||||||
|
"Evicted stale module '%s' (from %s) before loading plugin in %s",
|
||||||
|
mod_name, existing_file, plugin_dir,
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return evicted
|
||||||
|
|
||||||
def _namespace_plugin_modules(
|
def _namespace_plugin_modules(
|
||||||
self, plugin_id: str, plugin_dir: Path, before_keys: set
|
self, plugin_id: str, plugin_dir: Path, before_keys: set
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -254,12 +291,13 @@ class PluginLoader:
|
|||||||
for mod_name, mod in self._iter_plugin_bare_modules(plugin_dir, before_keys):
|
for mod_name, mod in self._iter_plugin_bare_modules(plugin_dir, before_keys):
|
||||||
namespaced = f"_plg_{safe_id}_{mod_name}"
|
namespaced = f"_plg_{safe_id}_{mod_name}"
|
||||||
sys.modules[namespaced] = mod
|
sys.modules[namespaced] = mod
|
||||||
# Keep sys.modules[mod_name] as an alias to the same object.
|
# Remove the bare sys.modules entry. The module object stays
|
||||||
# Removing it would cause lazy intra-plugin imports (e.g. a
|
# alive via the namespaced key and all existing Python-level
|
||||||
# deferred ``import scroll_display`` inside a method) to
|
# bindings (``from scroll_display import X`` already bound X
|
||||||
# re-import from disk and create a second, inconsistent copy
|
# to the class object). Leaving bare entries would cause the
|
||||||
# of the module. The next plugin's exec_module will naturally
|
# NEXT plugin's exec_module to find the cached entry and reuse
|
||||||
# overwrite the bare entry with its own version.
|
# it instead of loading its own version.
|
||||||
|
sys.modules.pop(mod_name, None)
|
||||||
namespaced_names.add(namespaced)
|
namespaced_names.add(namespaced)
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
"Namespace-isolated module '%s' -> '%s' for plugin %s",
|
"Namespace-isolated module '%s' -> '%s' for plugin %s",
|
||||||
@@ -345,6 +383,11 @@ class PluginLoader:
|
|||||||
# _namespace_plugin_modules and error cleanup only target
|
# _namespace_plugin_modules and error cleanup only target
|
||||||
# sub-modules, not the main module entry itself.
|
# sub-modules, not the main module entry itself.
|
||||||
before_keys = set(sys.modules.keys())
|
before_keys = set(sys.modules.keys())
|
||||||
|
|
||||||
|
# Evict stale bare-name modules from other plugin directories
|
||||||
|
# so Python's import system loads fresh copies from this plugin.
|
||||||
|
evicted = self._evict_stale_bare_modules(plugin_dir)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
@@ -352,6 +395,10 @@ class PluginLoader:
|
|||||||
# cannot collide with identically-named modules from other plugins
|
# cannot collide with identically-named modules from other plugins
|
||||||
self._namespace_plugin_modules(plugin_id, plugin_dir, before_keys)
|
self._namespace_plugin_modules(plugin_id, plugin_dir, before_keys)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# Restore evicted modules so other plugins are unaffected
|
||||||
|
for evicted_name, evicted_mod in evicted.items():
|
||||||
|
if evicted_name not in sys.modules:
|
||||||
|
sys.modules[evicted_name] = evicted_mod
|
||||||
# Clean up the partially-initialized main module and any
|
# Clean up the partially-initialized main module and any
|
||||||
# bare-name sub-modules that were added during exec_module
|
# bare-name sub-modules that were added during exec_module
|
||||||
# so they don't leak into subsequent plugin loads.
|
# so they don't leak into subsequent plugin loads.
|
||||||
|
|||||||
Reference in New Issue
Block a user