From 09c5444ce280b137303b6cf1840e94aa4cea5a72 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 22 Sep 2026 00:38:06 +0200 Subject: [PATCH 1/2] test: define plugin packages with relative imports --- tests/test_plugins.py | 45 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 1bd2422..d9704e6 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -357,6 +357,51 @@ def test_discovers_plugins_recursively_in_sorted_order(tmp_path: Path) -> None: assert [plugin.manifest.name for plugin in loaded] == ["first", "second"] +def write_package(directory: Path, label: str) -> None: + package = directory / "sample" + (package / "tables").mkdir(parents=True) + (package / "__init__.py").write_text( + "from typing import ClassVar\n\n" + "from coretrace_python.plugins import ModelPlugin\n\n" + "from .tables.celery import MODELS\n\n\n" + "class SamplePlugin(ModelPlugin):\n" + ' name: ClassVar[str] = "sample"\n' + " models = MODELS\n", + encoding="utf-8", + ) + (package / "tables" / "__init__.py").write_text("", encoding="utf-8") + (package / "tables" / "celery.py").write_text( + "from coretrace_python.semantic.symbols import SymbolId\n" + "from coretrace_python.taint import EntryPoint\n\n" + f'MODELS = (EntryPoint(SymbolId("python.celery.{label}"), "{label}"),)\n', + encoding="utf-8", + ) + + +def test_loads_a_plugin_package_with_relative_imports(tmp_path: Path) -> None: + write_manifest(tmp_path) + write_package(tmp_path, "task") + + loaded = load_plugin(tmp_path, manager_for("")) + + assert [m.symbol for m in loaded.plugin.models] == [SymbolId("python.celery.task")] + + +def test_plugin_packages_with_the_same_module_name_do_not_collide(tmp_path: Path) -> None: + for name in ("a", "b"): + directory = tmp_path / name + directory.mkdir() + write_manifest(directory, name=name) + write_package(directory, name) + + loaded = discover_plugins(tmp_path, manager_for("")) + + assert [m.symbol for p in loaded for m in p.plugin.models] == [ + SymbolId("python.celery.a"), + SymbolId("python.celery.b"), + ] + + # --------------------------------------------------------------------------- registry From 2430bfbfd63108bf65fddc4a27dc17feff7fb07f Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 22 Sep 2026 00:38:06 +0200 Subject: [PATCH 2/2] feat: load a plugin entrypoint as a package The loader accepted a single .py next to the manifest and never registered it in sys.modules, so a plugin could not split its tables across files. A /__init__.py is now loaded as a package with its directory as search location and registered under its path-derived name before it runs, which is what its relative imports resolve through. Closes #93 --- CHANGELOG.md | 6 ++++++ docs/plugins.md | 7 ++++++- src/coretrace_python/plugins/loader.py | 20 ++++++++++++++++++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 312ca68..77fa14f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Plugins + +- A plugin's entrypoint may be a package, `/__init__.py`, so a plugin can split its tables across files and import them relatively (#93). + ## 0.2.0 (2026-09-06) ### Adoption in an existing repository diff --git a/docs/plugins.md b/docs/plugins.md index 2013e3b..17cfe30 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -38,7 +38,12 @@ class = "SensitiveLoggingPlugin" | `plugin_api` | The range of plugin API versions the plugin supports. The current API is version 1. | | `requires` | Capabilities the plugin needs; a mismatch with the class's `requires` is a manifest error. | | `provides` | Capabilities the plugin offers, for documentation and conflict detection. | -| `entrypoint` | The module file (without `.py`, next to the manifest) and the class in it. | +| `entrypoint` | The module (without `.py`, next to the manifest) and the class in it. | + +The entrypoint module may be a package, `/__init__.py`, when a plugin grows past +one file: its submodules are reached through relative imports (`from .tables import +celery`). Two plugins may use the same module name; each is imported under a name derived +from its path. ## Symbols diff --git a/src/coretrace_python/plugins/loader.py b/src/coretrace_python/plugins/loader.py index cfba3c1..a0b703e 100644 --- a/src/coretrace_python/plugins/loader.py +++ b/src/coretrace_python/plugins/loader.py @@ -4,6 +4,7 @@ import hashlib import importlib.util +import sys from dataclasses import dataclass from pathlib import Path @@ -63,16 +64,31 @@ def discover_plugins(root: Path, manager: AnalysisManager) -> tuple[LoadedPlugin def _import_entrypoint(directory: Path, manifest: PluginManifest) -> type[Plugin]: - module_path = directory / f"{manifest.entrypoint.module}.py" + """Import the entrypoint, a module ``.py`` or a package ``/__init__.py``. + + The module name derives from the path, so two plugins may both call their module + ``models``; a package is registered in ``sys.modules`` under that name before it + runs, which is what its relative imports resolve through.""" + + package_path = directory / manifest.entrypoint.module / "__init__.py" + is_package = package_path.is_file() + module_path = package_path if is_package else directory / f"{manifest.entrypoint.module}.py" if not module_path.is_file(): raise ManifestError( f"{directory / MANIFEST_FILENAME}: entrypoint module" f" {manifest.entrypoint.module!r} not found" ) unique = hashlib.sha1(str(module_path.resolve()).encode()).hexdigest()[:12] - spec = importlib.util.spec_from_file_location(f"coretrace_plugin_{unique}", module_path) + name = f"coretrace_plugin_{unique}" + spec = importlib.util.spec_from_file_location( + name, + module_path, + submodule_search_locations=[str(module_path.parent)] if is_package else None, + ) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) + if is_package: + sys.modules[name] = module spec.loader.exec_module(module) candidate = getattr(module, manifest.entrypoint.class_name, None)