Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<module>/__init__.py`, so a plugin can split its tables across files and import them relatively (#93).

## 0.2.0 (2026-09-06)

Expand Down
7 changes: 6 additions & 1 deletion docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<module>/__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

Expand Down
20 changes: 18 additions & 2 deletions src/coretrace_python/plugins/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import hashlib
import importlib.util
import sys
from dataclasses import dataclass
from pathlib import Path

Expand Down Expand Up @@ -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 ``<name>.py`` or a package ``<name>/__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)
Expand Down
45 changes: 45 additions & 0 deletions tests/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading