diff --git a/CHANGELOG.md b/CHANGELOG.md index 49d96a9..90635af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Plugins + +- Every plugin module is registered in `sys.modules` before it runs, so a single-file plugin may define a dataclass under `from __future__ import annotations` (#105). + ## 0.3.0 (2026-09-22) ### Plugins diff --git a/src/coretrace_python/plugins/loader.py b/src/coretrace_python/plugins/loader.py index a0b703e..17d2411 100644 --- a/src/coretrace_python/plugins/loader.py +++ b/src/coretrace_python/plugins/loader.py @@ -67,8 +67,9 @@ def _import_entrypoint(directory: Path, manifest: PluginManifest) -> type[Plugin """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.""" + ``models``. The module is registered in ``sys.modules`` under that name before it + runs: a package's relative imports resolve through it, and so does ``dataclasses`` + when it evaluates postponed annotations.""" package_path = directory / manifest.entrypoint.module / "__init__.py" is_package = package_path.is_file() @@ -87,8 +88,7 @@ def _import_entrypoint(directory: Path, manifest: PluginManifest) -> type[Plugin ) 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 + 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 d9704e6..289fb7e 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -378,6 +378,29 @@ def write_package(directory: Path, label: str) -> None: ) +def test_a_single_file_plugin_may_define_a_dataclass_with_postponed_annotations(tmp_path: Path) -> None: + write_manifest(tmp_path) + (tmp_path / "sample.py").write_text( + "from __future__ import annotations\n\n" + "from dataclasses import dataclass\n" + "from typing import ClassVar\n\n" + "from coretrace_python.plugins import Plugin, PluginContext\n\n\n" + "@dataclass(frozen=True)\n" + "class Rule:\n" + " prefix: str\n\n\n" + "class SamplePlugin(Plugin):\n" + ' name: ClassVar[str] = "sample"\n' + " rule = Rule('app')\n\n" + " def analyze(self, ctx: PluginContext) -> list:\n" + " return []\n", + encoding="utf-8", + ) + + loaded = load_plugin(tmp_path, manager_for("")) + + assert loaded.plugin.rule.prefix == "app" + + def test_loads_a_plugin_package_with_relative_imports(tmp_path: Path) -> None: write_manifest(tmp_path) write_package(tmp_path, "task")