diff --git a/CHANGELOG.md b/CHANGELOG.md index ff4fe77..04ece51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Plugins - Two plugins may declare the same symbol: an identical model is registered once and sinks merge their kinds and positions; a real conflict names both plugins instead of failing the run on the first duplicate (#92). +- 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) diff --git a/docs/plugins.md b/docs/plugins.md index 02f2488..02537ce 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) 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