diff --git a/docs/guides/backends.md b/docs/guides/backends.md index a2ddfc0..6e57586 100644 --- a/docs/guides/backends.md +++ b/docs/guides/backends.md @@ -64,3 +64,21 @@ Snowflake reads credentials from the environment (`SNOWFLAKE_ACCOUNT`, Anything that satisfies the `LedgerBackend` protocol works — Postgres, DynamoDB, a graph DB. Implement the protocol methods and pass an instance to `Ledger(...)`. See the [API reference](../reference/index.md) for the protocol surface. + +To make your backend resolvable by name from the CLI (`--backend `) without any +change to the core, register it under the `model_ledger.backends` **entry point** in your +package — the storage-agnostic extension contract from +[ADR 0005](../adr/0005-storage-agnostic.md): + +```toml +# your package's pyproject.toml +[project.entry-points."model_ledger.backends"] +postgres = "my_package:PostgresBackend" +``` + +model-ledger discovers it and constructs it with the connection string if one is given +(`PostgresBackend(path)`), otherwise with no arguments: + +```bash +model-ledger serve --backend postgres --path "postgresql://..." +``` diff --git a/src/model_ledger/backends/registry.py b/src/model_ledger/backends/registry.py new file mode 100644 index 0000000..560459d --- /dev/null +++ b/src/model_ledger/backends/registry.py @@ -0,0 +1,41 @@ +"""Discover third-party LedgerBackend implementations via entry points. + +Built-in backends (in-memory, SQLite, JSON, Snowflake, HTTP) are resolved +directly. This module fulfills the storage-agnostic extension contract from +ADR 0005: a downstream package can register its own backend without any change +to the core, by declaring an entry point — + + # in the downstream package's pyproject.toml + [project.entry-points."model_ledger.backends"] + postgres = "my_package:PostgresBackend" + +The registered target is called with the connection string if one is given +(``Backend(path)``), otherwise with no arguments (``Backend()``). +""" + +from __future__ import annotations + +from typing import Any + +ENTRY_POINT_GROUP = "model_ledger.backends" + + +def load_backend_class(name: str) -> Any: + """Return the backend target registered under ``name``, or ``None`` if absent. + + Args: + name: The entry-point name to look up in the ``model_ledger.backends`` group. + + Returns: + The loaded class or factory, or ``None`` if no entry point matches (or the + metadata is unavailable). + """ + try: + from importlib.metadata import entry_points + + for ep in entry_points(group=ENTRY_POINT_GROUP): + if ep.name == name: + return ep.load() + except Exception: + return None + return None diff --git a/src/model_ledger/cli/app.py b/src/model_ledger/cli/app.py index 7041ea9..24befee 100644 --- a/src/model_ledger/cli/app.py +++ b/src/model_ledger/cli/app.py @@ -52,6 +52,13 @@ def _resolve_backend(backend: str, path: str | None, schema: str | None = None): from model_ledger.backends.ledger_memory import InMemoryLedgerBackend return InMemoryLedgerBackend() + + # Third-party backends registered via the model_ledger.backends entry-point group. + from model_ledger.backends.registry import load_backend_class + + backend_cls = load_backend_class(backend) + if backend_cls is not None: + return backend_cls(path) if path else backend_cls() return None diff --git a/tests/test_backends_entrypoints.py b/tests/test_backends_entrypoints.py new file mode 100644 index 0000000..2e7e3fa --- /dev/null +++ b/tests/test_backends_entrypoints.py @@ -0,0 +1,51 @@ +"""Third-party backends resolve via the model_ledger.backends entry-point group (ADR 0005).""" + +from __future__ import annotations + +import importlib.metadata + +from model_ledger.backends import registry +from model_ledger.backends.ledger_memory import InMemoryLedgerBackend +from model_ledger.backends.ledger_protocol import LedgerBackend + + +class _StubBackend: + """Stand-in for a downstream package's backend target.""" + + def __init__(self, path: str | None = None) -> None: + self.path = path + + +class _FakeEntryPoint: + name = "stub" + + def load(self): + return _StubBackend + + +def _fake_entry_points(*, group: str | None = None): + return [_FakeEntryPoint()] if group == registry.ENTRY_POINT_GROUP else [] + + +def test_load_backend_class_resolves_entry_point(monkeypatch): + monkeypatch.setattr(importlib.metadata, "entry_points", _fake_entry_points) + assert registry.load_backend_class("stub") is _StubBackend + + +def test_load_backend_class_unknown_returns_none(monkeypatch): + monkeypatch.setattr(importlib.metadata, "entry_points", _fake_entry_points) + assert registry.load_backend_class("nope") is None + + +def test_resolve_backend_instantiates_entry_point(monkeypatch): + monkeypatch.setattr(importlib.metadata, "entry_points", _fake_entry_points) + from model_ledger.cli.app import _resolve_backend + + backend = _resolve_backend("stub", "conn-str") + assert isinstance(backend, _StubBackend) + assert backend.path == "conn-str" + + +def test_inmemory_backend_satisfies_protocol(): + """Conformance: a real backend is recognized by the runtime-checkable protocol.""" + assert isinstance(InMemoryLedgerBackend(), LedgerBackend)