diff --git a/pyproject.toml b/pyproject.toml index f57e7a5..a5f86ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,15 +53,25 @@ docs = [ srdatalog = "srdatalog.cli:main" # --------------------------------------------------------------------------- -# Plugin entry points (srdatalog.plugins) +# Plugin entry points # -# Phase E validation: built-in dialects ship as discoverable plugins via -# the same `srdatalog.plugins` entry-point group used by external -# packages. `Compiler.with_default_plugins()` walks this group at -# bootstrap. Spec: docs/phase_e_plugin_extensibility.md §2, §5. +# PR-1a (docs/phase_decomposition_redesign.md §3.3.4) splits the legacy +# `srdatalog.plugins` group into two semantic groups: +# +# - `srdatalog.dialects` — data-dialect / pragma / IIR contributions +# (where every built-in lives from PR-1a onward). +# - `srdatalog.targets` — render-target contributions (CUDA / TBB / +# SYCL / ...). Empty in PR-1a; populated when the CUDA target +# ships as a plugin in a later PR. +# +# The legacy `srdatalog.plugins` group is kept for one release as a +# back-compat shim so external packages (e.g. the jaccard demo) that +# still declare entries under the old group continue to load — with a +# one-shot DeprecationWarning per process at +# `Compiler.with_default_plugins()` time. Built-ins no longer use it. # --------------------------------------------------------------------------- -[project.entry-points."srdatalog.plugins"] +[project.entry-points."srdatalog.dialects"] d2l = "srdatalog.ir.dialects.relation.d2l:register" iir_cf = "srdatalog.ir.dialects.iir.cf:register" sorted_array = "srdatalog.ir.dialects.relation.sorted_array:register" @@ -69,6 +79,10 @@ parallel_data = "srdatalog.ir.dialects.parallel.data:register" parallel_atomic_ws = "srdatalog.ir.dialects.parallel.atomic_ws:register" parallel_block_group = "srdatalog.ir.dialects.parallel.block_group:register" +[project.entry-points."srdatalog.targets"] +# (none yet — populated in a later PR when the CUDA target ships as a +# plugin per docs/phase_decomposition_redesign.md §3.3.4) + [project.urls] Homepage = "https://github.com/anthropics/srdatalog-python" Issues = "https://github.com/anthropics/srdatalog-python/issues" diff --git a/src/srdatalog/ir/codegen/cuda/complete_runner.py b/src/srdatalog/ir/codegen/cuda/complete_runner.py index 039ebf2..e3c2237 100644 --- a/src/srdatalog/ir/codegen/cuda/complete_runner.py +++ b/src/srdatalog/ir/codegen/cuda/complete_runner.py @@ -43,7 +43,6 @@ has_tiled_cartesian_eligible, ) from srdatalog.ir.codegen.cuda.plugin import plugin_gen_host_view_setup, plugin_view_count -from srdatalog.ir.mir.passes import ep_has_work_stealing # Pure-template phase emitters now live in the dialect's runner module. # Local aliases preserve the legacy call sites until the rest of this @@ -75,6 +74,7 @@ compute_total_view_count, source_spec_key, ) +from srdatalog.ir.mir.passes import ep_has_work_stealing # ----------------------------------------------------------------------------- # Source spec extraction helpers (mirror Nim's inline lambdas) diff --git a/src/srdatalog/ir/core/__init__.py b/src/srdatalog/ir/core/__init__.py index 83ae14b..c68dfa4 100644 --- a/src/srdatalog/ir/core/__init__.py +++ b/src/srdatalog/ir/core/__init__.py @@ -59,6 +59,9 @@ program_pass, ) from srdatalog.ir.core.plugin import ( + DIALECT_ENTRY_POINT_GROUP, + ENTRY_POINT_GROUP, + TARGET_ENTRY_POINT_GROUP, PluginConflictError, PluginCycleError, PluginInfo, @@ -109,6 +112,9 @@ def assert_never(value: object) -> NoReturn: __all__ = [ + 'DIALECT_ENTRY_POINT_GROUP', + 'ENTRY_POINT_GROUP', + 'TARGET_ENTRY_POINT_GROUP', 'AmbiguousLowering', 'Compiler', 'Dialect', diff --git a/src/srdatalog/ir/core/dialect.py b/src/srdatalog/ir/core/dialect.py index cb3e899..c7a62bf 100644 --- a/src/srdatalog/ir/core/dialect.py +++ b/src/srdatalog/ir/core/dialect.py @@ -13,9 +13,13 @@ - `Compiler()` is empty. No auto-discovery. - `Compiler.with_default_plugins()` walks the - `srdatalog.plugins` entry-point group, topo-sorts by each - plugin's provides/requires attributes, and registers each in - order. + `srdatalog.dialects` + `srdatalog.targets` entry-point groups + plus the legacy `srdatalog.plugins` group (one-shot + `DeprecationWarning` per process when the legacy group has + entries; see PR-1a, `docs/phase_decomposition_redesign.md` + §3.3.4). Topo-sorts by each plugin's provides/requires + attributes and registers each in order. De-dupes by plugin name + across groups. - `Compiler.register_plugin(plugin)` accepts a plugin name, a register callable, or an `EntryPoint`. Idempotent. - Conflict detection: a dialect of the same name registered by a @@ -27,12 +31,15 @@ from __future__ import annotations +import warnings from collections.abc import Callable from dataclasses import dataclass, field from typing import Any from srdatalog.ir.core.plugin import ( + DIALECT_ENTRY_POINT_GROUP, ENTRY_POINT_GROUP, + TARGET_ENTRY_POINT_GROUP, PluginConflictError, PluginInfo, PluginLoadError, @@ -41,6 +48,14 @@ _topo_sort_plugins, ) +# One-shot DeprecationWarning emission marker. PR-1a's spec +# (`docs/phase_decomposition_redesign.md` §3.3.4) requires the legacy +# `srdatalog.plugins` group to keep working for one release while +# emitting `DeprecationWarning` exactly once per process when it +# contains entries (so a noisy install with two legacy plugins still +# only warns once, not twice). +_LEGACY_GROUP_WARNED = False + @dataclass class Dialect: @@ -252,11 +267,20 @@ def _resolve_plugin(plugin: Any) -> tuple[str, Callable[[Any], None]]: `register.plugin_name` if set, else `register.__name__`. ''' if isinstance(plugin, str): - # Look up by entry-point name in the default group. - for ep in _discover_entry_points(): - if ep.name == plugin: - return ep.name, ep.load() - raise LookupError(f'no plugin named {plugin!r} in entry-point group {ENTRY_POINT_GROUP!r}') + # Look up by entry-point name. PR-1a (§3.3.4) splits the legacy + # `srdatalog.plugins` group into `srdatalog.dialects` + + # `srdatalog.targets`; consult both new groups first, then the + # legacy group, mirroring the search order of + # `with_default_plugins()`. + for g in (DIALECT_ENTRY_POINT_GROUP, TARGET_ENTRY_POINT_GROUP, ENTRY_POINT_GROUP): + for ep in _discover_entry_points(g): + if ep.name == plugin: + return ep.name, ep.load() + raise LookupError( + f'no plugin named {plugin!r} in entry-point groups ' + f'{DIALECT_ENTRY_POINT_GROUP!r}, {TARGET_ENTRY_POINT_GROUP!r}, ' + f'or {ENTRY_POINT_GROUP!r}' + ) if hasattr(plugin, 'name') and hasattr(plugin, 'load'): # EntryPoint-shaped (importlib.metadata.EntryPoint or a test fake). @@ -276,30 +300,70 @@ def _resolve_plugin(plugin: Any) -> tuple[str, Callable[[Any], None]]: # -- discovery factory ----------------------------------------------------- @classmethod - def with_default_plugins(cls, *, group: str = ENTRY_POINT_GROUP) -> Compiler: + def with_default_plugins(cls, *, group: str | None = None) -> Compiler: '''Construct a `Compiler` and auto-discover all entry-point plugins. - Walks `importlib.metadata.entry_points(group=group)`, - topo-sorts by each plugin's `provides` / `requires` attributes - on the `register` callable (defaulting to `()` when absent), - then calls `register_plugin` on each in order. - - The `group` keyword exists for test isolation — production code - uses the default. See - `docs/phase_e_plugin_extensibility.md` §2. + Production form (`group=None`): walks all three entry-point + groups in order — `srdatalog.dialects` (primary), + `srdatalog.targets` (currently empty; populated when a target + plugin ships), then the legacy `srdatalog.plugins` group. If the + legacy group contains entries, a one-shot `DeprecationWarning` + fires per process (see PR-1a, `docs/phase_decomposition_redesign.md` + §3.3.4). De-dupes by plugin name across groups: a plugin that + appears in both the legacy group and one of the new groups is + loaded once, from the first new group it appears in. + + Test-isolation form (`group=`): walks only the named group. + Used by `tests/test_core_plugin.py` to install synthetic plugins + under a sandbox group. ''' compiler = cls() - # Discover, then resolve each EP to (name, register_fn) once so - # we can both topo-sort and register without double-loading. - eps = _discover_entry_points(group) + groups: list[str] + if group is None: + groups = [ + DIALECT_ENTRY_POINT_GROUP, + TARGET_ENTRY_POINT_GROUP, + ENTRY_POINT_GROUP, + ] + else: + groups = [group] + + # Discover across all groups, de-duping by plugin name. The first + # group that yields a given name wins — so a plugin redundantly + # listed in both the legacy group and a new group is loaded once. resolved: dict[str, tuple[Any, Callable[[Any], None]]] = {} infos: list[PluginInfo] = [] - for ep in eps: - register_fn = ep.load() - info = _info_for(ep.name, register_fn) - resolved[ep.name] = (ep, register_fn) - infos.append(info) + legacy_eps: list[Any] = [] + for g in groups: + eps = _discover_entry_points(g) + if g == ENTRY_POINT_GROUP and group is None: + legacy_eps = list(eps) + for ep in eps: + if ep.name in resolved: + # Already discovered under an earlier (new) group; skip. + continue + register_fn = ep.load() + info = _info_for(ep.name, register_fn) + resolved[ep.name] = (ep, register_fn) + infos.append(info) + + # Emit the legacy-group DeprecationWarning at most once per + # process when the legacy group has entries. Suppressed under + # the test-isolation form — those tests install fake plugins + # under custom groups and shouldn't trigger the legacy warning. + if group is None and legacy_eps: + global _LEGACY_GROUP_WARNED + if not _LEGACY_GROUP_WARNED: + _LEGACY_GROUP_WARNED = True + warnings.warn( + f'entry-point group {ENTRY_POINT_GROUP!r} is deprecated; ' + f'move plugin registrations to {DIALECT_ENTRY_POINT_GROUP!r} ' + f'(data dialects) or {TARGET_ENTRY_POINT_GROUP!r} (render ' + f'targets). See docs/phase_decomposition_redesign.md §3.3.4.', + DeprecationWarning, + stacklevel=2, + ) order = _topo_sort_plugins(infos) diff --git a/src/srdatalog/ir/core/plugin.py b/src/srdatalog/ir/core/plugin.py index 30dacc7..de0ba80 100644 --- a/src/srdatalog/ir/core/plugin.py +++ b/src/srdatalog/ir/core/plugin.py @@ -6,7 +6,12 @@ Plugins extend the compiler with new dialects, pragmas, targets, and the like. Each plugin is a callable `register(compiler)` that mutates the compiler's registries. Plugins are discovered via Python entry -points (group `srdatalog.plugins`) OR registered explicitly. +points OR registered explicitly. + +Entry-point groups (PR-1a, `docs/phase_decomposition_redesign.md` +§3.3.4): `srdatalog.dialects` for data-dialect / pragma / IIR +contributions, `srdatalog.targets` for render targets, and the legacy +`srdatalog.plugins` kept for one release as a back-compat shim. Per the locked design (see `docs/phase_zero_prerequisites.md` §3.5): @@ -49,9 +54,25 @@ # module (one-way dependency: dialect.py -> plugin.py). -# Public entry-point group name. Listed in plugin packages' -# `pyproject.toml` under `[project.entry-points."srdatalog.plugins"]`. -ENTRY_POINT_GROUP = 'srdatalog.plugins' +# Public entry-point group names. +# +# PR-1a (`docs/phase_decomposition_redesign.md` §3.3.4) splits the +# single legacy `srdatalog.plugins` group into two semantic groups: +# +# - `srdatalog.dialects` — data-dialect / pragma / IIR contributions +# (every built-in lives here from PR-1a onward). +# - `srdatalog.targets` — render-target contributions (CUDA / TBB / +# SYCL / ...). Empty in PR-1a; populated in a later PR when the +# CUDA target ships as a plugin. +# +# The legacy `srdatalog.plugins` group is kept for one release as a +# back-compat shim so external packages (e.g. the jaccard demo) that +# still declare entries under the old group continue to load — with a +# one-shot `DeprecationWarning` per process at +# `Compiler.with_default_plugins()` time. +DIALECT_ENTRY_POINT_GROUP = 'srdatalog.dialects' +TARGET_ENTRY_POINT_GROUP = 'srdatalog.targets' +ENTRY_POINT_GROUP = 'srdatalog.plugins' # LEGACY — kept for back-compat # ----------------------------------------------------------------------------- @@ -218,9 +239,11 @@ def _topo_sort_plugins(infos: Iterable[PluginInfo]) -> list[str]: __all__ = [ + 'DIALECT_ENTRY_POINT_GROUP', 'ENTRY_POINT_GROUP', 'PluginConflictError', 'PluginCycleError', 'PluginInfo', 'PluginLoadError', + 'TARGET_ENTRY_POINT_GROUP', ] diff --git a/tests/test_core_plugin.py b/tests/test_core_plugin.py index 7c9dfdc..f8e7ccd 100644 --- a/tests/test_core_plugin.py +++ b/tests/test_core_plugin.py @@ -389,26 +389,45 @@ class _Boom(RuntimeError): # ----------------------------------------------------------------------------- -# 10. Default group is `srdatalog.plugins`; override accepted +# 10. Group constants + default-walk includes the new + legacy groups # ----------------------------------------------------------------------------- -def test_default_group_is_srdatalog_plugins() -> None: - '''The constant the loader uses is `srdatalog.plugins`, matching - the spec in `docs/phase_e_plugin_extensibility.md` §2.''' +def test_legacy_group_constant_is_srdatalog_plugins() -> None: + '''The legacy back-compat constant remains `srdatalog.plugins`. PR-1a + (`docs/phase_decomposition_redesign.md` §3.3.4) keeps this group as + a one-release back-compat shim alongside the new + `srdatalog.dialects` + `srdatalog.targets` groups.''' assert plugin_mod.ENTRY_POINT_GROUP == 'srdatalog.plugins' -def test_with_default_plugins_default_group_uses_srdatalog_plugins( +def test_new_group_constants_are_dialects_and_targets() -> None: + '''PR-1a introduces `srdatalog.dialects` (primary, where every + built-in lives from PR-1a onward) and `srdatalog.targets` (empty in + PR-1a; populated by a later PR when the CUDA target ships as a + plugin).''' + assert plugin_mod.DIALECT_ENTRY_POINT_GROUP == 'srdatalog.dialects' + assert plugin_mod.TARGET_ENTRY_POINT_GROUP == 'srdatalog.targets' + + +def test_with_default_plugins_default_walk_includes_legacy_group( monkeypatch: pytest.MonkeyPatch, ) -> None: - '''Calling `with_default_plugins()` with no `group=` argument - walks the `srdatalog.plugins` group.''' + '''Calling `with_default_plugins()` with no `group=` argument walks + the new `srdatalog.dialects` + `srdatalog.targets` groups AND the + legacy `srdatalog.plugins` group. A plugin declared in the legacy + group is still loaded — with a one-shot DeprecationWarning the + dedicated test in `test_plugin_group_split.py` covers separately + (suppressed here via `simplefilter`).''' + import warnings as _warnings + reg = _make_register(dialects=('default_group_dialect',)) ep = FakeEntryPoint('default_plug', reg) _install_entry_points(monkeypatch, {plugin_mod.ENTRY_POINT_GROUP: [ep]}) - c = Compiler.with_default_plugins() + with _warnings.catch_warnings(): + _warnings.simplefilter('ignore', DeprecationWarning) + c = Compiler.with_default_plugins() assert c.get_dialect('default_group_dialect').name == 'default_group_dialect' diff --git a/tests/test_d2l_as_plugin.py b/tests/test_d2l_as_plugin.py index f1f75ee..abd229f 100644 --- a/tests/test_d2l_as_plugin.py +++ b/tests/test_d2l_as_plugin.py @@ -14,7 +14,7 @@ Three things asserted: 1. `Compiler.with_default_plugins()` discovers `d2l` through the - `srdatalog.plugins` entry-point group and registers its dialect + `srdatalog.dialects` entry-point group and registers its dialect on the resulting Compiler. 2. Calling `compiler.register_plugin(register)` twice is a no-op the second time (F4 idempotency, exercised against the real register @@ -36,7 +36,7 @@ from srdatalog.dsl import Program, Relation, Var from srdatalog.ir.codegen.cuda.api import compile_kernel_body from srdatalog.ir.core import Compiler -from srdatalog.ir.core.plugin import ENTRY_POINT_GROUP +from srdatalog.ir.core.plugin import DIALECT_ENTRY_POINT_GROUP from srdatalog.ir.default_pipelines import ( DEFAULT_PROGRAM_PIPELINE, InitialProg, @@ -109,7 +109,7 @@ def _build_first_ep_via(compiler: Compiler) -> mir.ExecutePipeline: def test_d2l_is_discovered_by_with_default_plugins() -> None: - '''`Compiler.with_default_plugins()` walks the `srdatalog.plugins` + '''`Compiler.with_default_plugins()` walks the `srdatalog.dialects` entry-point group; the `d2l` entry point shipped in this repo's `pyproject.toml` is loaded and registers the `relation.d2l` dialect. @@ -121,7 +121,7 @@ def test_d2l_is_discovered_by_with_default_plugins() -> None: compiler = Compiler.with_default_plugins() # The plugin name comes from the entry-point name in pyproject.toml - # (`[project.entry-points."srdatalog.plugins"] d2l = "..."`) + # (`[project.entry-points."srdatalog.dialects"] d2l = "..."`) # — F4's `_resolve_plugin` records that name in `_plugins_loaded`. assert 'd2l' in compiler._plugins_loaded, ( f'expected d2l in loaded plugins; got {list(compiler._plugins_loaded)!r}. ' @@ -141,11 +141,13 @@ def test_d2l_is_discovered_by_with_default_plugins() -> None: def test_entry_point_group_name_matches_f4() -> None: - '''The entry-point group name in `pyproject.toml` is exactly the - one F4's plugin discovery walks. Locked at `srdatalog.plugins` per - the spec (`docs/phase_e_plugin_extensibility.md` §2). + '''The entry-point group name for built-in data dialects is exactly + the one F4's plugin discovery walks. Locked at `srdatalog.dialects` + per PR-1a (`docs/phase_decomposition_redesign.md` §3.3.4); the + legacy `srdatalog.plugins` group remains as a back-compat shim and + is covered separately by `tests/test_plugin_group_split.py`. ''' - assert ENTRY_POINT_GROUP == 'srdatalog.plugins' + assert DIALECT_ENTRY_POINT_GROUP == 'srdatalog.dialects' # ----------------------------------------------------------------------------- diff --git a/tests/test_iir_cf_as_plugin.py b/tests/test_iir_cf_as_plugin.py index 9a1d093..93bb3b2 100644 --- a/tests/test_iir_cf_as_plugin.py +++ b/tests/test_iir_cf_as_plugin.py @@ -14,7 +14,7 @@ Three things asserted: 1. `Compiler.with_default_plugins()` discovers `iir.cf` through the - `srdatalog.plugins` entry-point group and registers its dialect + `srdatalog.dialects` entry-point group and registers its dialect on the resulting Compiler. 2. Calling `compiler.register_plugin(register)` twice is a no-op the second time (F4 idempotency, exercised against the real register @@ -36,7 +36,7 @@ from srdatalog.dsl import Program, Relation, Var from srdatalog.ir.codegen.cuda.api import compile_kernel_body from srdatalog.ir.core import Compiler -from srdatalog.ir.core.plugin import ENTRY_POINT_GROUP +from srdatalog.ir.core.plugin import DIALECT_ENTRY_POINT_GROUP from srdatalog.ir.default_pipelines import ( DEFAULT_PROGRAM_PIPELINE, InitialProg, @@ -110,7 +110,7 @@ def _build_first_ep_via(compiler: Compiler) -> mir.ExecutePipeline: def test_iir_cf_is_discovered_by_with_default_plugins() -> None: - '''`Compiler.with_default_plugins()` walks the `srdatalog.plugins` + '''`Compiler.with_default_plugins()` walks the `srdatalog.dialects` entry-point group; the `iir_cf` entry point shipped in this repo's `pyproject.toml` is loaded and registers the `iir.cf` dialect. @@ -122,7 +122,7 @@ def test_iir_cf_is_discovered_by_with_default_plugins() -> None: compiler = Compiler.with_default_plugins() # The plugin name comes from the entry-point name in pyproject.toml - # (`[project.entry-points."srdatalog.plugins"] iir_cf = "..."`) + # (`[project.entry-points."srdatalog.dialects"] iir_cf = "..."`) # — F4's `_resolve_plugin` records that name in `_plugins_loaded`. assert 'iir_cf' in compiler._plugins_loaded, ( f'expected iir_cf in loaded plugins; got {list(compiler._plugins_loaded)!r}. ' @@ -142,11 +142,13 @@ def test_iir_cf_is_discovered_by_with_default_plugins() -> None: def test_entry_point_group_name_matches_f4() -> None: - '''The entry-point group name in `pyproject.toml` is exactly the - one F4's plugin discovery walks. Locked at `srdatalog.plugins` per - the spec (`docs/phase_e_plugin_extensibility.md` §2). + '''The entry-point group name for built-in data dialects is exactly + the one F4's plugin discovery walks. Locked at `srdatalog.dialects` + per PR-1a (`docs/phase_decomposition_redesign.md` §3.3.4); the + legacy `srdatalog.plugins` group remains as a back-compat shim and + is covered separately by `tests/test_plugin_group_split.py`. ''' - assert ENTRY_POINT_GROUP == 'srdatalog.plugins' + assert DIALECT_ENTRY_POINT_GROUP == 'srdatalog.dialects' # ----------------------------------------------------------------------------- diff --git a/tests/test_parallel_atomic_ws_as_plugin.py b/tests/test_parallel_atomic_ws_as_plugin.py index daae26e..78ea2b0 100644 --- a/tests/test_parallel_atomic_ws_as_plugin.py +++ b/tests/test_parallel_atomic_ws_as_plugin.py @@ -16,7 +16,7 @@ Four things asserted: 1. `Compiler.with_default_plugins()` discovers `parallel.atomic_ws` - through the `srdatalog.plugins` entry-point group and registers + through the `srdatalog.dialects` entry-point group and registers its dialect on the resulting Compiler. 2. Calling `compiler.register_plugin(register)` twice is a no-op the second time (F4 idempotency, exercised against the real register @@ -46,7 +46,7 @@ from srdatalog.dsl import Program, Relation, Var from srdatalog.ir.codegen.cuda.api import compile_kernel_body from srdatalog.ir.core import Compiler -from srdatalog.ir.core.plugin import ENTRY_POINT_GROUP +from srdatalog.ir.core.plugin import DIALECT_ENTRY_POINT_GROUP from srdatalog.ir.default_pipelines import ( DEFAULT_PROGRAM_PIPELINE, InitialProg, @@ -160,7 +160,7 @@ def _build_ws_ep_via(compiler: Compiler) -> mir.ExecutePipeline: def test_parallel_atomic_ws_is_discovered_by_with_default_plugins() -> None: - '''`Compiler.with_default_plugins()` walks the `srdatalog.plugins` + '''`Compiler.with_default_plugins()` walks the `srdatalog.dialects` entry-point group; the `parallel_atomic_ws` entry point shipped in this repo's `pyproject.toml` is loaded and registers the `parallel.atomic_ws` dialect. @@ -173,7 +173,7 @@ def test_parallel_atomic_ws_is_discovered_by_with_default_plugins() -> None: compiler = Compiler.with_default_plugins() # The plugin name comes from the entry-point name in pyproject.toml - # (`[project.entry-points."srdatalog.plugins"] parallel_atomic_ws = "..."`) + # (`[project.entry-points."srdatalog.dialects"] parallel_atomic_ws = "..."`) # — F4's `_resolve_plugin` records that name in `_plugins_loaded`. assert 'parallel_atomic_ws' in compiler._plugins_loaded, ( f'expected parallel_atomic_ws in loaded plugins; got ' @@ -193,11 +193,13 @@ def test_parallel_atomic_ws_is_discovered_by_with_default_plugins() -> None: def test_entry_point_group_name_matches_f4() -> None: - '''The entry-point group name in `pyproject.toml` is exactly the - one F4's plugin discovery walks. Locked at `srdatalog.plugins` per - the spec (`docs/phase_e_plugin_extensibility.md` §2). + '''The entry-point group name for built-in data dialects is exactly + the one F4's plugin discovery walks. Locked at `srdatalog.dialects` + per PR-1a (`docs/phase_decomposition_redesign.md` §3.3.4); the + legacy `srdatalog.plugins` group remains as a back-compat shim and + is covered separately by `tests/test_plugin_group_split.py`. ''' - assert ENTRY_POINT_GROUP == 'srdatalog.plugins' + assert DIALECT_ENTRY_POINT_GROUP == 'srdatalog.dialects' # ----------------------------------------------------------------------------- diff --git a/tests/test_parallel_block_group_as_plugin.py b/tests/test_parallel_block_group_as_plugin.py index e2f80f9..5e68518 100644 --- a/tests/test_parallel_block_group_as_plugin.py +++ b/tests/test_parallel_block_group_as_plugin.py @@ -16,7 +16,7 @@ Three things asserted: 1. `Compiler.with_default_plugins()` discovers `parallel_block_group` - through the `srdatalog.plugins` entry-point group and registers + through the `srdatalog.dialects` entry-point group and registers its dialect on the resulting Compiler. 2. Calling `compiler.register_plugin(register)` twice is a no-op the second time (F4 idempotency, exercised against the real register @@ -42,7 +42,7 @@ from srdatalog.dsl import Program, Relation, Var from srdatalog.ir.codegen.cuda.api import compile_kernel_body from srdatalog.ir.core import Compiler -from srdatalog.ir.core.plugin import ENTRY_POINT_GROUP +from srdatalog.ir.core.plugin import DIALECT_ENTRY_POINT_GROUP from srdatalog.ir.default_pipelines import ( DEFAULT_PROGRAM_PIPELINE, InitialProg, @@ -180,7 +180,7 @@ def _build_first_bg_ep_via(compiler: Compiler) -> mir.ExecutePipeline: def test_parallel_block_group_is_discovered_by_with_default_plugins() -> None: - '''`Compiler.with_default_plugins()` walks the `srdatalog.plugins` + '''`Compiler.with_default_plugins()` walks the `srdatalog.dialects` entry-point group; the `parallel_block_group` entry point shipped in this repo's `pyproject.toml` is loaded and registers the `parallel.block_group` dialect. @@ -193,7 +193,7 @@ def test_parallel_block_group_is_discovered_by_with_default_plugins() -> None: compiler = Compiler.with_default_plugins() # The plugin name comes from the entry-point name in pyproject.toml - # (`[project.entry-points."srdatalog.plugins"] parallel_block_group = "..."`) + # (`[project.entry-points."srdatalog.dialects"] parallel_block_group = "..."`) # — F4's `_resolve_plugin` records that name in `_plugins_loaded`. assert 'parallel_block_group' in compiler._plugins_loaded, ( f'expected parallel_block_group in loaded plugins; got ' @@ -214,11 +214,13 @@ def test_parallel_block_group_is_discovered_by_with_default_plugins() -> None: def test_entry_point_group_name_matches_f4() -> None: - '''The entry-point group name in `pyproject.toml` is exactly the - one F4's plugin discovery walks. Locked at `srdatalog.plugins` per - the spec (`docs/phase_e_plugin_extensibility.md` §2). + '''The entry-point group name for built-in data dialects is exactly + the one F4's plugin discovery walks. Locked at `srdatalog.dialects` + per PR-1a (`docs/phase_decomposition_redesign.md` §3.3.4); the + legacy `srdatalog.plugins` group remains as a back-compat shim and + is covered separately by `tests/test_plugin_group_split.py`. ''' - assert ENTRY_POINT_GROUP == 'srdatalog.plugins' + assert DIALECT_ENTRY_POINT_GROUP == 'srdatalog.dialects' # ----------------------------------------------------------------------------- diff --git a/tests/test_parallel_data_as_plugin.py b/tests/test_parallel_data_as_plugin.py index 23d72af..ea1b7e4 100644 --- a/tests/test_parallel_data_as_plugin.py +++ b/tests/test_parallel_data_as_plugin.py @@ -14,7 +14,7 @@ Three things asserted: 1. `Compiler.with_default_plugins()` discovers `parallel.data` - through the `srdatalog.plugins` entry-point group and registers + through the `srdatalog.dialects` entry-point group and registers its dialect on the resulting Compiler. 2. Calling `compiler.register_plugin(register)` twice is a no-op the second time (F4 idempotency, exercised against the real register @@ -36,7 +36,7 @@ from srdatalog.dsl import Program, Relation, Var from srdatalog.ir.codegen.cuda.api import compile_kernel_body from srdatalog.ir.core import Compiler -from srdatalog.ir.core.plugin import ENTRY_POINT_GROUP +from srdatalog.ir.core.plugin import DIALECT_ENTRY_POINT_GROUP from srdatalog.ir.default_pipelines import ( DEFAULT_PROGRAM_PIPELINE, InitialProg, @@ -111,7 +111,7 @@ def _build_first_ep_via(compiler: Compiler) -> mir.ExecutePipeline: def test_parallel_data_is_discovered_by_with_default_plugins() -> None: - '''`Compiler.with_default_plugins()` walks the `srdatalog.plugins` + '''`Compiler.with_default_plugins()` walks the `srdatalog.dialects` entry-point group; the `parallel_data` entry point shipped in this repo's `pyproject.toml` is loaded and registers the `parallel.data` dialect. @@ -124,7 +124,7 @@ def test_parallel_data_is_discovered_by_with_default_plugins() -> None: compiler = Compiler.with_default_plugins() # The plugin name comes from the entry-point name in pyproject.toml - # (`[project.entry-points."srdatalog.plugins"] parallel_data = "..."`) + # (`[project.entry-points."srdatalog.dialects"] parallel_data = "..."`) # — F4's `_resolve_plugin` records that name in `_plugins_loaded`. assert 'parallel_data' in compiler._plugins_loaded, ( f'expected parallel_data in loaded plugins; got {list(compiler._plugins_loaded)!r}. ' @@ -144,11 +144,13 @@ def test_parallel_data_is_discovered_by_with_default_plugins() -> None: def test_entry_point_group_name_matches_f4() -> None: - '''The entry-point group name in `pyproject.toml` is exactly the - one F4's plugin discovery walks. Locked at `srdatalog.plugins` per - the spec (`docs/phase_e_plugin_extensibility.md` §2). + '''The entry-point group name for built-in data dialects is exactly + the one F4's plugin discovery walks. Locked at `srdatalog.dialects` + per PR-1a (`docs/phase_decomposition_redesign.md` §3.3.4); the + legacy `srdatalog.plugins` group remains as a back-compat shim and + is covered separately by `tests/test_plugin_group_split.py`. ''' - assert ENTRY_POINT_GROUP == 'srdatalog.plugins' + assert DIALECT_ENTRY_POINT_GROUP == 'srdatalog.dialects' # ----------------------------------------------------------------------------- diff --git a/tests/test_plugin_group_split.py b/tests/test_plugin_group_split.py new file mode 100644 index 0000000..b0fd368 --- /dev/null +++ b/tests/test_plugin_group_split.py @@ -0,0 +1,355 @@ +'''PR-1a tests — plugin entry-point group split: +`srdatalog.plugins` -> `srdatalog.dialects` + `srdatalog.targets`. + +Spec: `docs/phase_decomposition_redesign.md` §3.3.4. + +The four assertions in this file: + + 1. `_discover_entry_points(DIALECT_ENTRY_POINT_GROUP)` returns the + 6 built-in dialect plugins shipped under + `[project.entry-points."srdatalog.dialects"]` in `pyproject.toml`. + 2. `_discover_entry_points(TARGET_ENTRY_POINT_GROUP)` returns the + empty list — PR-1a does not ship any built-in target plugin; that + lands in a later PR when the CUDA target plugin ships. + 3. `_discover_entry_points(ENTRY_POINT_GROUP)` returns whatever + external packages have declared under the legacy group. With + no jaccard-style external plugin installed in the test env, the + list is empty; we don't pin a length here, just that the call + succeeds and the legacy constant is still wired. + 4. `Compiler.with_default_plugins()` (no `group=`) walks ALL THREE + groups — verified by patching the entry-point lookup to install + fake plugins under each group and asserting all three load. The + same test asserts that the one-shot DeprecationWarning fires + exactly once per process when the legacy group has entries. + +These tests use the same `FakeEntryPoint` + monkeypatch pattern as +`tests/test_core_plugin.py` for full control over what each group +returns — without depending on what's installed in the host env. +''' + +from __future__ import annotations + +import importlib.metadata +import warnings +from collections.abc import Callable +from typing import Any + +import pytest + +from srdatalog.ir.core import Compiler, Dialect +from srdatalog.ir.core import ( + plugin as plugin_mod, +) +from srdatalog.ir.core.plugin import ( + DIALECT_ENTRY_POINT_GROUP, + ENTRY_POINT_GROUP, + TARGET_ENTRY_POINT_GROUP, + _discover_entry_points, +) + +# ----------------------------------------------------------------------------- +# Built-in dialect plugin names from pyproject.toml. +# ----------------------------------------------------------------------------- + +BUILTIN_DIALECT_PLUGIN_NAMES: frozenset[str] = frozenset( + { + 'sorted_array', + 'iir_cf', + 'd2l', + 'parallel_data', + 'parallel_atomic_ws', + 'parallel_block_group', + } +) + + +# ----------------------------------------------------------------------------- +# Fake entry-point harness (same shape as tests/test_core_plugin.py). +# ----------------------------------------------------------------------------- + + +class FakeEntryPoint: + '''Minimal stand-in for `importlib.metadata.EntryPoint` — only `.name` + and `.load()` are exercised by the plugin loader. Mirrors the fake + used by `tests/test_core_plugin.py` to avoid coupling to the live + importlib.metadata API. + ''' + + def __init__(self, name: str, register_fn: Callable[[Compiler], None]) -> None: + self.name = name + self._register_fn = register_fn + + def load(self) -> Callable[[Compiler], None]: + return self._register_fn + + +def _install_entry_points( + monkeypatch: pytest.MonkeyPatch, + group_to_eps: dict[str, list[FakeEntryPoint]], +) -> None: + '''Patch `importlib.metadata.entry_points` so the plugin loader sees + only the fakes registered here, for each group it asks about.''' + + def _fake_entry_points(*, group: str) -> list[FakeEntryPoint]: + return list(group_to_eps.get(group, [])) + + monkeypatch.setattr(plugin_mod.importlib.metadata, 'entry_points', _fake_entry_points) + + +def _make_register(dialect_name: str) -> Callable[[Compiler], None]: + '''Build a synthetic `register(compiler)` that contributes one + Dialect named `dialect_name`. Carries no provides/requires/replaces + metadata; topo-sort orders it lex by plugin name.''' + + def register(compiler: Compiler) -> None: + compiler.register_dialect(Dialect(name=dialect_name)) + + return register + + +@pytest.fixture(autouse=True) +def _reset_legacy_warned_marker() -> Any: + '''Reset the one-shot DeprecationWarning marker between tests so each + test sees a fresh "haven't warned yet" state. Without this, the + first test that triggers the warning would silence every subsequent + test's warning assertion.''' + from srdatalog.ir.core import dialect as dialect_mod + + prev = dialect_mod._LEGACY_GROUP_WARNED + dialect_mod._LEGACY_GROUP_WARNED = False + yield + dialect_mod._LEGACY_GROUP_WARNED = prev + + +# ----------------------------------------------------------------------------- +# 1. The 6 built-ins are declared under `srdatalog.dialects` +# ----------------------------------------------------------------------------- + + +def test_dialect_group_lists_the_six_builtin_plugins() -> None: + '''The live (un-monkeypatched) `srdatalog.dialects` group contains + the 6 built-in dialect plugins shipped in this repo's pyproject.toml. + + If this test fails with "no plugins loaded" or a subset of the + expected names, the most likely cause is that the editable install's + `.egg-info` is stale — run + `pip install -e . --no-deps --force-reinstall` to refresh the + entry-point metadata. This is the same install-state warning every + per-dialect `_as_plugin` test carries. + ''' + eps = _discover_entry_points(DIALECT_ENTRY_POINT_GROUP) + names = {ep.name for ep in eps} + missing = BUILTIN_DIALECT_PLUGIN_NAMES - names + assert not missing, ( + f'missing expected built-in dialect plugins in ' + f'{DIALECT_ENTRY_POINT_GROUP!r}: {sorted(missing)!r}. ' + f'Got: {sorted(names)!r}. If empty, refresh the editable install: ' + f'`pip install -e . --no-deps --force-reinstall`.' + ) + + +# ----------------------------------------------------------------------------- +# 2. The targets group is empty in PR-1a +# ----------------------------------------------------------------------------- + + +def test_target_group_is_empty_in_pr_1a() -> None: + '''PR-1a creates the `srdatalog.targets` group but ships no + built-in target plugin under it — the CUDA target lands as a plugin + in a later PR. External plugin packages MAY declare entries here. + + We assert the BUILT-IN target plugin set is empty by filtering out + any entry-point whose value path starts with `srdatalog.` (our own + package) — leaving room for an externally-installed target plugin + (cpu_tbb / sycl / ...) without flaking this test. + ''' + eps = _discover_entry_points(TARGET_ENTRY_POINT_GROUP) + builtin_target_eps = [ep for ep in eps if getattr(ep, 'value', '').startswith('srdatalog.')] + assert builtin_target_eps == [], ( + f'PR-1a should ship NO built-in target plugins; got {builtin_target_eps!r}' + ) + + +# ----------------------------------------------------------------------------- +# 3. Legacy group is still walkable; no built-ins remain there +# ----------------------------------------------------------------------------- + + +def test_legacy_group_contains_no_builtins() -> None: + '''Post-PR-1a, NONE of the 6 built-in dialect plugins should remain + in the legacy `srdatalog.plugins` group — they all moved to + `srdatalog.dialects`. An external plugin (e.g. the jaccard demo) + may still appear here; we explicitly assert only the BUILT-IN + names are absent. + + This is the regression guard for the migration: if a future + refactor accidentally re-adds (or fails to delete) a built-in + entry in the legacy group, the entry would also remain reachable + via the new group and double-register at runtime (no-op due to + idempotency, but still meaningful as a contract violation). + ''' + eps = _discover_entry_points(ENTRY_POINT_GROUP) + legacy_builtin_names = {ep.name for ep in eps} & BUILTIN_DIALECT_PLUGIN_NAMES + assert legacy_builtin_names == set(), ( + f'built-in plugins still declared under legacy group ' + f'{ENTRY_POINT_GROUP!r}: {sorted(legacy_builtin_names)!r}. ' + f'They must move to {DIALECT_ENTRY_POINT_GROUP!r}.' + ) + + +def test_discover_entry_points_legacy_group_call_succeeds() -> None: + '''The legacy group is still a valid query target for + `_discover_entry_points`. The return list may be empty (no + external legacy-group plugin installed) or non-empty (e.g. the + jaccard demo is `pip install -e`d); we don't pin length. + ''' + eps = _discover_entry_points(ENTRY_POINT_GROUP) + assert isinstance(eps, list) + + +# ----------------------------------------------------------------------------- +# 4. with_default_plugins walks all three groups + de-dupes by name +# ----------------------------------------------------------------------------- + + +def test_with_default_plugins_walks_all_three_groups( + monkeypatch: pytest.MonkeyPatch, +) -> None: + '''`Compiler.with_default_plugins()` with no `group=` argument walks + `srdatalog.dialects` AND `srdatalog.targets` AND the legacy + `srdatalog.plugins` — verified by installing one fake plugin per + group and asserting all three show up in `_plugins_loaded`. + ''' + dialect_reg = _make_register('fake_dialect_thing') + target_reg = _make_register('fake_target_thing') + legacy_reg = _make_register('fake_legacy_thing') + + _install_entry_points( + monkeypatch, + { + DIALECT_ENTRY_POINT_GROUP: [FakeEntryPoint('plug_dialect', dialect_reg)], + TARGET_ENTRY_POINT_GROUP: [FakeEntryPoint('plug_target', target_reg)], + ENTRY_POINT_GROUP: [FakeEntryPoint('plug_legacy', legacy_reg)], + }, + ) + + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + c = Compiler.with_default_plugins() + + assert set(c._plugins_loaded) == {'plug_dialect', 'plug_target', 'plug_legacy'} + assert {d.name for d in c.dialects} == { + 'fake_dialect_thing', + 'fake_target_thing', + 'fake_legacy_thing', + } + + +def test_with_default_plugins_dedupes_by_plugin_name_across_groups( + monkeypatch: pytest.MonkeyPatch, +) -> None: + '''If the same plugin name appears under BOTH the new + `srdatalog.dialects` group and the legacy `srdatalog.plugins` + group (e.g. a package that updated for PR-1a but kept the legacy + entry around during the transition), it loads exactly once — from + the new group. De-duping prevents `PluginConflictError` on the + dialect re-registration that the second copy would trigger. + ''' + # Both groups carry a plugin named `dup_plug`, but with DIFFERENT + # register callables (contributing dialects of different names so we + # can tell which one ran). The new-group copy must win. + new_group_reg = _make_register('from_new_group') + legacy_reg = _make_register('from_legacy_group') + + _install_entry_points( + monkeypatch, + { + DIALECT_ENTRY_POINT_GROUP: [FakeEntryPoint('dup_plug', new_group_reg)], + ENTRY_POINT_GROUP: [FakeEntryPoint('dup_plug', legacy_reg)], + }, + ) + + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + c = Compiler.with_default_plugins() + + assert set(c._plugins_loaded) == {'dup_plug'} + assert {d.name for d in c.dialects} == {'from_new_group'} + + +# ----------------------------------------------------------------------------- +# 5. DeprecationWarning fires exactly once when legacy group has entries +# ----------------------------------------------------------------------------- + + +def test_legacy_group_emits_deprecation_warning_when_nonempty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + '''When the legacy `srdatalog.plugins` group contains entries, + `Compiler.with_default_plugins()` emits exactly one + `DeprecationWarning` per process. PR-1a (§3.3.4) requires both + the warning text and the once-per-process emission policy so a + noisy environment with 5 legacy plugins still produces 1 warning, + not 5. + ''' + legacy_reg = _make_register('legacy_dialect_thing') + _install_entry_points( + monkeypatch, + {ENTRY_POINT_GROUP: [FakeEntryPoint('legacy_plug', legacy_reg)]}, + ) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + Compiler.with_default_plugins() + Compiler.with_default_plugins() # second call must NOT re-warn + + dep = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert len(dep) == 1, f'expected exactly one DeprecationWarning, got {len(dep)}' + msg = str(dep[0].message) + assert ENTRY_POINT_GROUP in msg + assert DIALECT_ENTRY_POINT_GROUP in msg + assert TARGET_ENTRY_POINT_GROUP in msg + assert 'deprecated' in msg.lower() + + +def test_legacy_group_emits_no_warning_when_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + '''When the legacy group has no entries, no `DeprecationWarning` + fires — the warning is gated on the presence of legacy-group + entries so a clean install stays quiet. + ''' + # New group only; legacy group is implicitly empty. + _install_entry_points( + monkeypatch, + {DIALECT_ENTRY_POINT_GROUP: [FakeEntryPoint('only_new', _make_register('only_new_d'))]}, + ) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + Compiler.with_default_plugins() + + dep = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert dep == [], f'unexpected DeprecationWarning(s) when legacy group is empty: {dep!r}' + + +# ----------------------------------------------------------------------------- +# 6. importlib.metadata.entry_points still works on the live env +# ----------------------------------------------------------------------------- + + +def test_importlib_metadata_returns_the_dialect_group_entries() -> None: + '''Sanity: bypass our wrapper and confirm `importlib.metadata` itself + lists the built-in dialects under `srdatalog.dialects`. This pins + the contract that the entry-point declaration in `pyproject.toml` + actually reaches the installed metadata after + `pip install -e .` — independent of our discovery helper. + ''' + eps = importlib.metadata.entry_points(group=DIALECT_ENTRY_POINT_GROUP) + names = {ep.name for ep in eps} + missing = BUILTIN_DIALECT_PLUGIN_NAMES - names + assert not missing, ( + f'missing entries in importlib.metadata view of {DIALECT_ENTRY_POINT_GROUP!r}: ' + f'{sorted(missing)!r}. Refresh with ' + f'`pip install -e . --no-deps --force-reinstall`.' + ) diff --git a/tests/test_sorted_array_as_plugin.py b/tests/test_sorted_array_as_plugin.py index 264046e..bce40a6 100644 --- a/tests/test_sorted_array_as_plugin.py +++ b/tests/test_sorted_array_as_plugin.py @@ -11,7 +11,7 @@ Three things asserted: 1. `Compiler.with_default_plugins()` discovers `sorted_array` - through the `srdatalog.plugins` entry-point group and registers + through the `srdatalog.dialects` entry-point group and registers its dialect on the resulting Compiler. 2. Calling `compiler.register_plugin(register)` twice is a no-op the second time (F4 idempotency, exercised against the real @@ -33,7 +33,7 @@ from srdatalog.dsl import Program, Relation, Var from srdatalog.ir.codegen.cuda.api import compile_kernel_body from srdatalog.ir.core import Compiler -from srdatalog.ir.core.plugin import ENTRY_POINT_GROUP +from srdatalog.ir.core.plugin import DIALECT_ENTRY_POINT_GROUP from srdatalog.ir.default_pipelines import ( DEFAULT_PROGRAM_PIPELINE, InitialProg, @@ -108,7 +108,7 @@ def _build_first_ep_via(compiler: Compiler) -> mir.ExecutePipeline: def test_sorted_array_is_discovered_by_with_default_plugins() -> None: '''`Compiler.with_default_plugins()` walks the - `srdatalog.plugins` entry-point group; the `sorted_array` entry + `srdatalog.dialects` entry-point group; the `sorted_array` entry point shipped in this repo's `pyproject.toml` is loaded and registers the `relation.sorted_array` dialect. @@ -120,7 +120,7 @@ def test_sorted_array_is_discovered_by_with_default_plugins() -> None: compiler = Compiler.with_default_plugins() # The plugin name comes from the entry-point name in pyproject.toml - # (`[project.entry-points."srdatalog.plugins"] sorted_array = "..."`) + # (`[project.entry-points."srdatalog.dialects"] sorted_array = "..."`) # — F4's `_resolve_plugin` records that name in `_plugins_loaded`. assert 'sorted_array' in compiler._plugins_loaded, ( f'expected sorted_array in loaded plugins; got {list(compiler._plugins_loaded)!r}. ' @@ -140,11 +140,13 @@ def test_sorted_array_is_discovered_by_with_default_plugins() -> None: def test_entry_point_group_name_matches_f4() -> None: - '''The entry-point group name in `pyproject.toml` is exactly the - one F4's plugin discovery walks. Locked at `srdatalog.plugins` per - the spec (`docs/phase_e_plugin_extensibility.md` §2). + '''The entry-point group name for built-in data dialects is exactly + the one F4's plugin discovery walks. Locked at `srdatalog.dialects` + per PR-1a (`docs/phase_decomposition_redesign.md` §3.3.4); the + legacy `srdatalog.plugins` group remains as a back-compat shim and + is covered separately by `tests/test_plugin_group_split.py`. ''' - assert ENTRY_POINT_GROUP == 'srdatalog.plugins' + assert DIALECT_ENTRY_POINT_GROUP == 'srdatalog.dialects' # -----------------------------------------------------------------------------