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
18 changes: 18 additions & 0 deletions docs/guides/backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`) 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://..."
```
41 changes: 41 additions & 0 deletions src/model_ledger/backends/registry.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +37 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail instead of falling back when backend plugin won't load

When an installed third-party backend entry point matches the requested name but ep.load() raises (for example because the plugin has a missing dependency or an invalid target), this broad except returns None. In the inspected CLI path, both model-ledger mcp --backend <name> and model-ledger serve --backend <name> pass that None into create_server/create_app, which default to an in-memory backend, so a production service can silently start with transient empty storage instead of reporting the broken configured backend.

Useful? React with 👍 / 👎.

return None
7 changes: 7 additions & 0 deletions src/model_ledger/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
51 changes: 51 additions & 0 deletions tests/test_backends_entrypoints.py
Original file line number Diff line number Diff line change
@@ -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)
Loading