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
26 changes: 20 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,22 +53,36 @@ 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"
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"
Expand Down
2 changes: 1 addition & 1 deletion src/srdatalog/ir/codegen/cuda/complete_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions src/srdatalog/ir/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
114 changes: 89 additions & 25 deletions src/srdatalog/ir/core/dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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).
Expand All @@ -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=<str>`): 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)

Expand Down
31 changes: 27 additions & 4 deletions src/srdatalog/ir/core/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down Expand Up @@ -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


# -----------------------------------------------------------------------------
Expand Down Expand Up @@ -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',
]
35 changes: 27 additions & 8 deletions tests/test_core_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'


Expand Down
Loading
Loading