From 258ce58b44c25e34d79c1d34d951b40afc1b3767 Mon Sep 17 00:00:00 2001 From: Vignesh Narayanaswamy Date: Fri, 3 Jul 2026 08:53:21 -0700 Subject: [PATCH 1/4] refactor!: remove the scanner module, deprecated since v0.4.0 Scanner, InventoryScanner, ModelCandidate, ScanReport and the ScannerRegistry were deprecated in v0.4.0 with removal promised for v0.5.0; they survived to v0.7.7. Their replacement is the SourceConnector protocol with DataNode + Ledger.add()/connect(). Removes the module, its 35 tests, and the two top-level re-exports (ModelCandidate, Scanner). No other code imported it. Co-Authored-By: Claude Fable 5 Signed-off-by: Vignesh Narayanaswamy --- src/model_ledger/__init__.py | 3 - src/model_ledger/scanner/__init__.py | 23 -- src/model_ledger/scanner/connection.py | 20 - src/model_ledger/scanner/orchestrator.py | 163 -------- src/model_ledger/scanner/protocol.py | 39 -- src/model_ledger/scanner/registry.py | 59 --- src/model_ledger/scanner/report.py | 26 -- tests/test_scanner/__init__.py | 0 tests/test_scanner/test_connection.py | 33 -- tests/test_scanner/test_orchestrator.py | 482 ----------------------- tests/test_scanner/test_protocol.py | 157 -------- tests/test_scanner/test_registry.py | 67 ---- 12 files changed, 1072 deletions(-) delete mode 100644 src/model_ledger/scanner/__init__.py delete mode 100644 src/model_ledger/scanner/connection.py delete mode 100644 src/model_ledger/scanner/orchestrator.py delete mode 100644 src/model_ledger/scanner/protocol.py delete mode 100644 src/model_ledger/scanner/registry.py delete mode 100644 src/model_ledger/scanner/report.py delete mode 100644 tests/test_scanner/__init__.py delete mode 100644 tests/test_scanner/test_connection.py delete mode 100644 tests/test_scanner/test_orchestrator.py delete mode 100644 tests/test_scanner/test_protocol.py delete mode 100644 tests/test_scanner/test_registry.py diff --git a/src/model_ledger/__init__.py b/src/model_ledger/__init__.py index 0b9d2d3..a874def 100644 --- a/src/model_ledger/__init__.py +++ b/src/model_ledger/__init__.py @@ -30,7 +30,6 @@ from model_ledger.core.models import ComponentNode, Model, ModelVersion from model_ledger.graph.models import DataNode, DataPort from model_ledger.graph.protocol import SourceConnector -from model_ledger.scanner.protocol import ModelCandidate, Scanner from model_ledger.sdk.inventory import Inventory from model_ledger.sdk.ledger import Ledger @@ -69,8 +68,6 @@ "ModelRef", "Snapshot", "Tag", - "ModelCandidate", - "Scanner", # v0.4.0 — graph "DataNode", "DataPort", diff --git a/src/model_ledger/scanner/__init__.py b/src/model_ledger/scanner/__init__.py deleted file mode 100644 index 4dd6de9..0000000 --- a/src/model_ledger/scanner/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Scanner protocol — DEPRECATED in v0.4.0. - -Use SourceConnector + DataNode + Ledger.add()/connect() instead. -Scanner and InventoryScanner still work but will be removed in v0.5.0. -""" - -from model_ledger.scanner.connection import DBConnection -from model_ledger.scanner.orchestrator import InventoryScanner -from model_ledger.scanner.protocol import ( - EnrichableScanner, - ModelCandidate, - Scanner, -) -from model_ledger.scanner.report import ScanReport - -__all__ = [ - "DBConnection", - "EnrichableScanner", - "InventoryScanner", - "ModelCandidate", - "ScanReport", - "Scanner", -] diff --git a/src/model_ledger/scanner/connection.py b/src/model_ledger/scanner/connection.py deleted file mode 100644 index 0373357..0000000 --- a/src/model_ledger/scanner/connection.py +++ /dev/null @@ -1,20 +0,0 @@ -"""DBConnection protocol — thin database abstraction for scanners.""" - -from __future__ import annotations - -from typing import Any, Protocol, runtime_checkable - - -@runtime_checkable -class DBConnection(Protocol): - """Minimal database connection interface. - - Any database client that can execute SQL and return rows as dicts - satisfies this protocol: Postgres, MySQL, BigQuery, Snowflake, SQLite. - """ - - def execute( - self, - query: str, - params: dict[str, Any] | None = None, - ) -> list[dict[str, Any]]: ... diff --git a/src/model_ledger/scanner/orchestrator.py b/src/model_ledger/scanner/orchestrator.py deleted file mode 100644 index 5f1277c..0000000 --- a/src/model_ledger/scanner/orchestrator.py +++ /dev/null @@ -1,163 +0,0 @@ -"""InventoryScanner — orchestrates multiple scanners, deduplicates, registers.""" - -from __future__ import annotations - -from collections.abc import Callable -from datetime import datetime, timezone - -from model_ledger.core.ledger_models import ModelRef -from model_ledger.scanner.protocol import EnrichableScanner, ModelCandidate, Scanner -from model_ledger.scanner.report import ScanReport -from model_ledger.sdk.ledger import Ledger, ModelNotFoundError - - -def _now() -> datetime: - return datetime.now(timezone.utc) - - -class InventoryScanner: - def __init__( - self, - ledger: Ledger, - scanners: list[Scanner], - filter_fn: Callable[[ModelCandidate], bool] | None = None, - ) -> None: - self._ledger = ledger - self._scanners = {s.name: s for s in scanners} - self._filter_fn = filter_fn - - def discover_all(self) -> list[ScanReport]: - reports = [] - for scanner in self._scanners.values(): - report = self._run_scanner(scanner) - reports.append(report) - return reports - - def scan_platform(self, platform: str) -> ScanReport: - scanner = self._scanners.get(platform) - if scanner is None: - raise ValueError(f"Unknown platform: {platform}") - return self._run_scanner(scanner) - - def _get_last_scan_time(self, scanner_name: str) -> datetime | None: - for model in self._ledger.list(): - snaps: list = self._ledger.history(model) # type: ignore[assignment] - for s in snaps: - if s.source == scanner_name: - ts: datetime = s.timestamp - return ts - return None - - def _run_scanner(self, scanner: Scanner) -> ScanReport: - # Check has_changed before running full scan - last_scan = self._get_last_scan_time(scanner.name) - if last_scan is not None and not scanner.has_changed(last_scan): - return ScanReport( - platform=scanner.name, - scan_run_id=f"{scanner.name}:{_now().isoformat()}", - total_found=0, - new_models=0, - updated_models=0, - not_found_models=0, - candidates=[], - ) - - candidates = scanner.scan() - if self._filter_fn: - candidates = [c for c in candidates if self._filter_fn(c)] - - scan_run_id = f"{scanner.name}:{_now().isoformat()}" - new_count = 0 - updated_count = 0 - - found_names = {c.name for c in candidates} - - for candidate in candidates: - result = self._register_candidate(candidate, scan_run_id) - if result == "new": - new_count += 1 - elif result == "updated": - updated_count += 1 - - # Enrich if scanner supports it - if isinstance(scanner, EnrichableScanner): - enrichment = scanner.enrich(candidate) - if enrichment: - self._ledger.record( - candidate.name, - event="enriched", - source=scanner.name, - payload={**enrichment, "scan_run_id": scan_run_id}, - actor=f"scanner:{scanner.name}", - ) - - # Record not_found for models previously discovered on this platform - not_found_count = 0 - for model in self._ledger.list(): - if model.name in found_names: - continue - if self._was_discovered_by(model, scanner.name): - self._ledger.record( - model, - event="not_found", - source=scanner.name, - payload={"scan_run_id": scan_run_id}, - actor=f"scanner:{scanner.name}", - ) - not_found_count += 1 - - return ScanReport( - platform=scanner.name, - scan_run_id=scan_run_id, - total_found=len(candidates), - new_models=new_count, - updated_models=updated_count, - not_found_models=not_found_count, - candidates=candidates, - ) - - def _was_discovered_by(self, model: ModelRef, scanner_name: str) -> bool: - snaps = self._ledger.history(model) or [] - return any( - s.source == scanner_name and s.event_type in ("discovered", "scan_confirmed") - for s in snaps - ) - - def _register_candidate(self, candidate: ModelCandidate, scan_run_id: str) -> str: - try: - existing = self._ledger.get(candidate.name) - except ModelNotFoundError: - existing = None - - payload = { - "platform_id": candidate.platform_id, - "scan_run_id": scan_run_id, - **candidate.metadata, - } - - if existing is None: - self._ledger.register( - name=candidate.name, - owner=candidate.owner or "unknown", - model_type=candidate.model_type, - tier="unclassified", - purpose=f"Discovered on {candidate.platform}", - actor=f"scanner:{candidate.platform}", - ) - self._ledger.record( - candidate.name, - event="discovered", - source=candidate.platform, - payload=payload, - actor=f"scanner:{candidate.platform}", - ) - return "new" - else: - self._ledger.record( - existing, - event="scan_confirmed", - source=candidate.platform, - payload=payload, - actor=f"scanner:{candidate.platform}", - ) - return "updated" diff --git a/src/model_ledger/scanner/protocol.py b/src/model_ledger/scanner/protocol.py deleted file mode 100644 index e1bbf6f..0000000 --- a/src/model_ledger/scanner/protocol.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Scanner protocol — the interface all platform scanners implement.""" - -from __future__ import annotations - -from datetime import datetime -from typing import Any, Protocol, runtime_checkable - -from pydantic import BaseModel, Field - - -class ModelCandidate(BaseModel): - """Raw discovery result from a scanner — not yet a ModelRef.""" - - name: str - owner: str | None = None - model_type: str - platform: str - platform_id: str | None = None - parent_name: str | None = None - external_ids: dict[str, str] = Field(default_factory=dict) - metadata: dict[str, Any] = Field(default_factory=dict) - - -@runtime_checkable -class Scanner(Protocol): - """Discovers models/rules on a deployment platform (Phase A).""" - - name: str - platform_type: str - - def scan(self) -> list[ModelCandidate]: ... - def has_changed(self, last_scan: datetime) -> bool: ... - - -@runtime_checkable -class EnrichableScanner(Scanner, Protocol): - """Scanner that can also fetch richer metadata (Phase B).""" - - def enrich(self, candidate: ModelCandidate) -> dict: ... diff --git a/src/model_ledger/scanner/registry.py b/src/model_ledger/scanner/registry.py deleted file mode 100644 index 0b90544..0000000 --- a/src/model_ledger/scanner/registry.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Scanner registry with lazy entry point discovery.""" - -from __future__ import annotations - -from model_ledger.scanner.protocol import Scanner - -ENTRY_POINT_GROUP = "model_ledger.scanners" - -_registry: ScannerRegistry | None = None - - -class ScannerRegistry: - def __init__(self) -> None: - self._scanners: dict[str, Scanner] = {} - self._discovered = False - - def register(self, scanner: Scanner) -> None: - self._scanners[scanner.name] = scanner - - def get(self, name: str) -> Scanner: - self._ensure_discovered() - if name not in self._scanners: - raise KeyError(f"No scanner registered with name: {name}") - return self._scanners[name] - - def list_scanners(self) -> list[Scanner]: - self._ensure_discovered() - return list(self._scanners.values()) - - def _ensure_discovered(self) -> None: - if self._discovered: - return - self._discovered = True - try: - from importlib.metadata import entry_points - - eps = entry_points(group=ENTRY_POINT_GROUP) - for ep in sorted(eps, key=lambda e: e.name): - try: - cls = ep.load() - instance = cls() - if instance.name not in self._scanners: - self._scanners[instance.name] = instance - except Exception: - pass - except Exception: - pass - - -def get_registry() -> ScannerRegistry: - global _registry - if _registry is None: - _registry = ScannerRegistry() - return _registry - - -def reset_registry() -> None: - global _registry - _registry = None diff --git a/src/model_ledger/scanner/report.py b/src/model_ledger/scanner/report.py deleted file mode 100644 index bf5fb68..0000000 --- a/src/model_ledger/scanner/report.py +++ /dev/null @@ -1,26 +0,0 @@ -"""ScanReport — summary of a scanner run.""" - -from __future__ import annotations - -from datetime import datetime, timezone - -from pydantic import BaseModel, Field - -from model_ledger.scanner.protocol import ModelCandidate - - -def _now() -> datetime: - return datetime.now(timezone.utc) - - -class ScanReport(BaseModel): - """Summary of what a scanner found.""" - - platform: str - scan_run_id: str = "" - timestamp: datetime = Field(default_factory=_now) - total_found: int - new_models: int - updated_models: int - not_found_models: int - candidates: list[ModelCandidate] diff --git a/tests/test_scanner/__init__.py b/tests/test_scanner/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_scanner/test_connection.py b/tests/test_scanner/test_connection.py deleted file mode 100644 index 68b26cb..0000000 --- a/tests/test_scanner/test_connection.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Tests for DBConnection protocol.""" - -from model_ledger.scanner.connection import DBConnection - - -class FakeDBConnection: - def __init__(self, results: list[dict]): - self._results = results - self.last_query: str | None = None - self.last_params: dict | None = None - - def execute(self, query: str, params: dict | None = None) -> list[dict]: - self.last_query = query - self.last_params = params - return self._results - - -class TestDBConnection: - def test_implements_protocol(self): - conn = FakeDBConnection([]) - assert isinstance(conn, DBConnection) - - def test_execute_returns_rows(self): - rows = [{"id": 1, "name": "model-a"}, {"id": 2, "name": "model-b"}] - conn = FakeDBConnection(rows) - result = conn.execute("SELECT * FROM models") - assert len(result) == 2 - assert result[0]["name"] == "model-a" - - def test_execute_with_params(self): - conn = FakeDBConnection([{"id": 1}]) - conn.execute("SELECT * FROM models WHERE id = :id", {"id": 1}) - assert conn.last_params == {"id": 1} diff --git a/tests/test_scanner/test_orchestrator.py b/tests/test_scanner/test_orchestrator.py deleted file mode 100644 index b63b593..0000000 --- a/tests/test_scanner/test_orchestrator.py +++ /dev/null @@ -1,482 +0,0 @@ -"""Tests for InventoryScanner — orchestrates scanners, deduplicates, registers.""" - -from datetime import datetime - -import pytest - -from model_ledger.backends.ledger_memory import InMemoryLedgerBackend -from model_ledger.scanner.orchestrator import InventoryScanner -from model_ledger.scanner.protocol import ModelCandidate -from model_ledger.sdk.ledger import Ledger - - -class FakeScanner: - def __init__(self, name: str, candidates: list[ModelCandidate]): - self.name = name - self.platform_type = "test" - self._candidates = candidates - - def scan(self) -> list[ModelCandidate]: - return self._candidates - - def has_changed(self, last_scan: datetime) -> bool: - return True - - -class FakeChangelessScanner: - def __init__(self, name: str, candidates: list[ModelCandidate]): - self.name = name - self.platform_type = "test" - self._candidates = candidates - - def scan(self) -> list[ModelCandidate]: - return self._candidates - - def has_changed(self, last_scan: datetime) -> bool: - return False - - -class FakeEnrichableScanner: - def __init__(self, name: str, candidates: list[ModelCandidate]): - self.name = name - self.platform_type = "test" - self._candidates = candidates - - def scan(self) -> list[ModelCandidate]: - return self._candidates - - def has_changed(self, last_scan: datetime) -> bool: - return True - - def enrich(self, candidate: ModelCandidate) -> dict: - return {"enriched": True, "features": ["f1", "f2"]} - - -@pytest.fixture -def ledger(): - return Ledger(backend=InMemoryLedgerBackend()) - - -class TestDiscoverAll: - def test_discovers_and_registers(self, ledger): - scanner = FakeScanner( - "platform-a", - [ - ModelCandidate( - name="model-1", - owner="team-a", - model_type="ml_model", - platform="platform-a", - platform_id="id-1", - metadata={"algo": "xgb"}, - ), - ModelCandidate( - name="model-2", - owner="team-b", - model_type="heuristic", - platform="platform-a", - platform_id="id-2", - metadata={}, - ), - ], - ) - inv = InventoryScanner(ledger, [scanner]) - reports = inv.discover_all() - assert len(reports) == 1 - assert reports[0].total_found == 2 - assert reports[0].new_models == 2 - assert len(ledger.list()) == 2 - - def test_idempotent_scan(self, ledger): - candidates = [ - ModelCandidate( - name="model-1", - owner="team-a", - model_type="ml_model", - platform="p", - metadata={}, - ), - ] - scanner = FakeScanner("p", candidates) - inv = InventoryScanner(ledger, [scanner]) - inv.discover_all() - reports = inv.discover_all() - assert reports[0].new_models == 0 - assert len(ledger.list()) == 1 - - def test_multiple_scanners(self, ledger): - s1 = FakeScanner( - "ml_platform", - [ - ModelCandidate( - name="ml-1", - owner="t", - model_type="ml_model", - platform="ml_platform", - metadata={}, - ), - ], - ) - s2 = FakeScanner( - "etl_engine", - [ - ModelCandidate( - name="rule-1", - owner="t", - model_type="heuristic", - platform="etl_engine", - metadata={}, - ), - ], - ) - inv = InventoryScanner(ledger, [s1, s2]) - reports = inv.discover_all() - assert len(reports) == 2 - assert len(ledger.list()) == 2 - - def test_dedup_same_name_same_owner(self, ledger): - s1 = FakeScanner( - "ml_platform", - [ - ModelCandidate( - name="shared-model", - owner="team-a", - model_type="ml_model", - platform="ml_platform", - metadata={"source": "ml_platform"}, - ), - ], - ) - s2 = FakeScanner( - "etl_engine", - [ - ModelCandidate( - name="shared-model", - owner="team-a", - model_type="ml_model", - platform="etl_engine", - metadata={"source": "etl_engine"}, - ), - ], - ) - inv = InventoryScanner(ledger, [s1, s2]) - inv.discover_all() - assert len(ledger.list()) == 1 - snaps = ledger.history("shared-model") - # registered + discovered from ml_platform + scan_confirmed from etl_engine - assert len(snaps) >= 2 - - -class TestScanPlatform: - def test_scan_single_platform(self, ledger): - s1 = FakeScanner( - "ml_platform", - [ - ModelCandidate( - name="ml-1", - owner="t", - model_type="ml", - platform="ml_platform", - metadata={}, - ), - ], - ) - s2 = FakeScanner( - "etl_engine", - [ - ModelCandidate( - name="rule-1", - owner="t", - model_type="heuristic", - platform="etl_engine", - metadata={}, - ), - ], - ) - inv = InventoryScanner(ledger, [s1, s2]) - report = inv.scan_platform("ml_platform") - assert report.total_found == 1 - assert len(ledger.list()) == 1 - - def test_scan_unknown_platform_raises(self, ledger): - inv = InventoryScanner(ledger, []) - with pytest.raises(ValueError, match="Unknown platform"): - inv.scan_platform("nonexistent") - - -class TestFilterFn: - def test_filter_excludes_candidates(self, ledger): - scanner = FakeScanner( - "etl_engine", - [ - ModelCandidate( - name="risk-job", - owner="t", - model_type="heuristic", - platform="etl_engine", - metadata={"subject_area": "Risk"}, - ), - ModelCandidate( - name="hr-job", - owner="t", - model_type="heuristic", - platform="etl_engine", - metadata={"subject_area": "HR"}, - ), - ], - ) - - def risk_only(c: ModelCandidate) -> bool: - return c.metadata.get("subject_area") == "Risk" - - inv = InventoryScanner(ledger, [scanner], filter_fn=risk_only) - reports = inv.discover_all() - assert reports[0].total_found == 1 - assert len(ledger.list()) == 1 - assert ledger.get("risk-job").name == "risk-job" - - def test_no_filter_registers_all(self, ledger): - scanner = FakeScanner( - "p", - [ - ModelCandidate( - name="a", - owner="t", - model_type="ml", - platform="p", - metadata={}, - ), - ModelCandidate( - name="b", - owner="t", - model_type="ml", - platform="p", - metadata={}, - ), - ], - ) - inv = InventoryScanner(ledger, [scanner]) - inv.discover_all() - assert len(ledger.list()) == 2 - - -class TestScanRunId: - def test_scan_run_id_in_report(self, ledger): - scanner = FakeScanner( - "ml_platform", - [ - ModelCandidate( - name="m1", - owner="t", - model_type="ml", - platform="ml_platform", - metadata={}, - ), - ], - ) - inv = InventoryScanner(ledger, [scanner]) - reports = inv.discover_all() - assert reports[0].scan_run_id is not None - assert reports[0].scan_run_id.startswith("ml_platform:") - - def test_scan_run_id_in_snapshot_payloads(self, ledger): - scanner = FakeScanner( - "ml_platform", - [ - ModelCandidate( - name="m1", - owner="t", - model_type="ml", - platform="ml_platform", - metadata={}, - ), - ], - ) - inv = InventoryScanner(ledger, [scanner]) - reports = inv.discover_all() - scan_run_id = reports[0].scan_run_id - - snaps = ledger.history("m1") - discovered = [s for s in snaps if s.event_type == "discovered"] - assert discovered[0].payload["scan_run_id"] == scan_run_id - - -class TestNotFoundTracking: - def test_not_found_recorded_for_missing_model(self, ledger): - scanner = FakeScanner( - "ml_platform", - [ - ModelCandidate( - name="model-a", - owner="t", - model_type="ml", - platform="ml_platform", - metadata={}, - ), - ], - ) - inv = InventoryScanner(ledger, [scanner]) - inv.discover_all() - - # Second scan: model-a is gone - scanner._candidates = [] - reports = inv.discover_all() - assert reports[0].not_found_models == 1 - - snaps = ledger.history("model-a") - not_found = [s for s in snaps if s.event_type == "not_found"] - assert len(not_found) == 1 - - def test_not_found_only_for_same_platform(self, ledger): - s1 = FakeScanner( - "ml_platform", - [ - ModelCandidate( - name="model-a", - owner="t", - model_type="ml", - platform="ml_platform", - metadata={}, - ), - ], - ) - s2 = FakeScanner( - "etl_engine", - [ - ModelCandidate( - name="rule-b", - owner="t", - model_type="heuristic", - platform="etl_engine", - metadata={}, - ), - ], - ) - inv = InventoryScanner(ledger, [s1, s2]) - inv.discover_all() - - # model-a disappears from ml_platform — rule-b should NOT get not_found - s1._candidates = [] - reports = inv.discover_all() - platform_report = [r for r in reports if r.platform == "ml_platform"][0] - assert platform_report.not_found_models == 1 - - snaps = ledger.history("rule-b") - not_found = [s for s in snaps if s.event_type == "not_found"] - assert len(not_found) == 0 - - def test_rediscovered_model_gets_confirmed(self, ledger): - scanner = FakeScanner( - "ml_platform", - [ - ModelCandidate( - name="model-a", - owner="t", - model_type="ml", - platform="ml_platform", - metadata={}, - ), - ], - ) - inv = InventoryScanner(ledger, [scanner]) - inv.discover_all() - - # Disappear - scanner._candidates = [] - inv.discover_all() - - # Reappear - scanner._candidates = [ - ModelCandidate( - name="model-a", - owner="t", - model_type="ml", - platform="ml_platform", - metadata={}, - ), - ] - inv.discover_all() - - snaps = ledger.history("model-a") - events = [s.event_type for s in snaps] - assert "not_found" in events - assert "scan_confirmed" in events - - -class TestHasChanged: - def test_skips_scanner_when_not_changed(self, ledger): - # First scan to establish last_scan time - first_scanner = FakeScanner( - "ml_platform", - [ - ModelCandidate( - name="existing", - owner="t", - model_type="ml", - platform="ml_platform", - metadata={}, - ), - ], - ) - inv1 = InventoryScanner(ledger, [first_scanner]) - inv1.discover_all() - assert len(ledger.list()) == 1 - - # Second scan with changeless scanner — should be skipped - changeless = FakeChangelessScanner( - "ml_platform", - [ - ModelCandidate( - name="new-model", - owner="t", - model_type="ml", - platform="ml_platform", - metadata={}, - ), - ], - ) - inv2 = InventoryScanner(ledger, [changeless]) - reports = inv2.discover_all() - assert reports[0].total_found == 0 - # new-model should NOT have been registered - assert len(ledger.list()) == 1 - - def test_runs_scanner_when_changed(self, ledger): - scanner = FakeScanner( - "ml_platform", - [ - ModelCandidate( - name="m1", - owner="t", - model_type="ml", - platform="ml_platform", - metadata={}, - ), - ], - ) - inv = InventoryScanner(ledger, [scanner]) - reports = inv.discover_all() - assert reports[0].total_found == 1 - - -class TestEnrichment: - def test_enrichable_scanner_creates_enriched_snapshot(self, ledger): - scanner = FakeEnrichableScanner( - "ml_platform", - [ - ModelCandidate( - name="m1", - owner="t", - model_type="ml", - platform="ml_platform", - metadata={}, - ), - ], - ) - inv = InventoryScanner(ledger, [scanner]) - inv.discover_all() - - snaps = ledger.history("m1") - enriched = [s for s in snaps if s.event_type == "enriched"] - assert len(enriched) == 1 - assert enriched[0].payload["features"] == ["f1", "f2"] diff --git a/tests/test_scanner/test_protocol.py b/tests/test_scanner/test_protocol.py deleted file mode 100644 index a00c48c..0000000 --- a/tests/test_scanner/test_protocol.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Tests for Scanner protocol and ModelCandidate.""" - -from datetime import datetime - -from model_ledger.scanner.protocol import ( - EnrichableScanner, - ModelCandidate, - Scanner, -) -from model_ledger.scanner.report import ScanReport - - -class TestModelCandidate: - def test_create(self): - c = ModelCandidate( - name="fraud-rule-1", - owner="risk-team", - model_type="heuristic", - platform="etl_engine", - platform_id="job-123", - metadata={"schedule": "daily"}, - ) - assert c.name == "fraud-rule-1" - assert c.platform == "etl_engine" - - def test_owner_optional(self): - c = ModelCandidate( - name="unknown-rule", - model_type="heuristic", - platform="etl_engine", - metadata={}, - ) - assert c.owner is None - - def test_parent_name(self): - c = ModelCandidate( - name="ruleset-1", - owner="risk-team", - model_type="ruleset", - platform="rules-engine", - parent_name="TD-85", - ) - assert c.parent_name == "TD-85" - - def test_parent_name_defaults_none(self): - c = ModelCandidate( - name="model-1", - model_type="ml", - platform="ml_platform", - metadata={}, - ) - assert c.parent_name is None - - def test_external_ids(self): - c = ModelCandidate( - name="arr-v3", - model_type="ml_model", - platform="ml_platform", - external_ids={"ml_platform": "arr_v3", "rules_engine": "TD-85"}, - metadata={}, - ) - assert c.external_ids["ml_platform"] == "arr_v3" - - def test_external_ids_defaults_empty(self): - c = ModelCandidate( - name="model-1", - model_type="ml", - platform="ml_platform", - metadata={}, - ) - assert c.external_ids == {} - - -class FakeScanner: - name = "fake" - platform_type = "test" - - def __init__(self, candidates: list[ModelCandidate]): - self._candidates = candidates - self._changed = True - - def scan(self) -> list[ModelCandidate]: - return self._candidates - - def has_changed(self, last_scan: datetime) -> bool: - return self._changed - - -class TestScannerProtocol: - def test_implements_protocol(self): - scanner = FakeScanner([]) - assert isinstance(scanner, Scanner) - - def test_scan_returns_candidates(self): - candidates = [ - ModelCandidate( - name="m1", - model_type="ml", - platform="fake", - metadata={}, - ), - ModelCandidate( - name="m2", - model_type="ml", - platform="fake", - metadata={}, - ), - ] - scanner = FakeScanner(candidates) - result = scanner.scan() - assert len(result) == 2 - - -class FakeEnrichableScanner: - name = "enrichable" - platform_type = "test" - - def scan(self) -> list[ModelCandidate]: - return [] - - def has_changed(self, last_scan: datetime) -> bool: - return True - - def enrich(self, candidate: ModelCandidate) -> dict: - return {"enriched": True, "name": candidate.name} - - -class TestEnrichableScanner: - def test_implements_protocol(self): - scanner = FakeEnrichableScanner() - assert isinstance(scanner, EnrichableScanner) - - def test_enrich_returns_dict(self): - scanner = FakeEnrichableScanner() - c = ModelCandidate( - name="test", - model_type="ml", - platform="x", - metadata={}, - ) - result = scanner.enrich(c) - assert result["enriched"] is True - - -class TestScanReport: - def test_create(self): - report = ScanReport( - platform="ml_platform", - total_found=10, - new_models=3, - updated_models=1, - not_found_models=0, - candidates=[], - ) - assert report.total_found == 10 - assert report.new_models == 3 - assert report.timestamp is not None diff --git a/tests/test_scanner/test_registry.py b/tests/test_scanner/test_registry.py deleted file mode 100644 index f7d3ca1..0000000 --- a/tests/test_scanner/test_registry.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Tests for ScannerRegistry — scanner discovery via entry_points.""" - -from datetime import datetime - -import pytest - -from model_ledger.scanner.protocol import ModelCandidate, Scanner -from model_ledger.scanner.registry import ScannerRegistry, get_registry, reset_registry - - -class FakeScanner: - def __init__(self, name: str): - self.name = name - self.platform_type = "test" - - def scan(self) -> list[ModelCandidate]: - return [] - - def has_changed(self, last_scan: datetime) -> bool: - return True - - -class TestScannerRegistry: - def test_register_and_get(self): - reg = ScannerRegistry() - scanner = FakeScanner("ml_platform") - reg.register(scanner) - assert reg.get("ml_platform").name == "ml_platform" - - def test_get_unknown_raises(self): - reg = ScannerRegistry() - with pytest.raises(KeyError): - reg.get("nonexistent") - - def test_list_scanners(self): - reg = ScannerRegistry() - reg.register(FakeScanner("a")) - reg.register(FakeScanner("b")) - names = [s.name for s in reg.list_scanners()] - assert "a" in names - assert "b" in names - - def test_register_deduplicates_by_name(self): - reg = ScannerRegistry() - reg.register(FakeScanner("ml_platform")) - reg.register(FakeScanner("ml_platform")) - assert len(reg.list_scanners()) == 1 - - def test_implements_scanner_protocol(self): - scanner = FakeScanner("test") - assert isinstance(scanner, Scanner) - - -class TestGlobalRegistry: - def setup_method(self): - reset_registry() - - def test_get_registry_returns_same_instance(self): - r1 = get_registry() - r2 = get_registry() - assert r1 is r2 - - def test_reset_registry(self): - r1 = get_registry() - reset_registry() - r2 = get_registry() - assert r1 is not r2 From fbbe74e1c3220f14def67da08f545edd4e6f74da Mon Sep 17 00:00:00 2001 From: Vignesh Narayanaswamy Date: Fri, 3 Jul 2026 08:53:41 -0700 Subject: [PATCH 2/4] docs: credibility and accuracy pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: CI and downloads badges, production-scale benchmark callout (28.8k models / 212k events, sub-second — sourced to CHANGELOG v0.7.4), architecture diagram, security policy link, maintainer credit. The 'For organizations' section now states that the SR 11-7/SR 26-2, EU AI Act Annex IV, and NIST AI RMF validation profiles ship in the OSS core (model_ledger.validate) — it previously implied they lived in a downstream internal package. Docs: all Ledger examples persist to ./ledger.db instead of ./inventory.db, which collided with the CLI's legacy Inventory-format default (runtime guard lands in the next commit). Quickstart history() example now shows newest-first output and CI-asserts the ordering. Landing page notes the project was born at Block and runs in production there. Stale-reference sweep: MCP tool count 6 -> 8 in the server docstring and CLAUDE.md, v0.3.0 markers out of SDK docstrings, 'Task 11' comments out of rest/app.py. CHANGELOG: fold two stale Unreleased blocks into the releases that shipped them (v0.7.3 per PRs #5-#9, v0.4.8 per its own bump commit) and open a fresh Unreleased section. Add SECURITY.md (GitHub private vulnerability reporting) and ignore the .rp1/ workspace directory. Co-Authored-By: Claude Fable 5 Signed-off-by: Vignesh Narayanaswamy --- .gitignore | 3 ++ CHANGELOG.md | 22 +++++++++------ CLAUDE.md | 6 ++-- README.md | 51 ++++++++++++++++++++++++++++++---- SECURITY.md | 29 +++++++++++++++++++ docs/concepts/composite.md | 2 +- docs/concepts/snapshot.md | 2 +- docs/governance.md | 2 +- docs/guides/agents.md | 2 +- docs/guides/backends.md | 4 +-- docs/guides/connectors.md | 2 +- docs/index.md | 11 +++++--- docs/quickstart.md | 11 +++++--- docs/recipes/discover-sql.md | 2 +- docs/recipes/point-in-time.md | 2 +- src/model_ledger/mcp/server.py | 2 +- src/model_ledger/rest/app.py | 4 +-- src/model_ledger/sdk/ledger.py | 4 +-- 18 files changed, 123 insertions(+), 38 deletions(-) create mode 100644 SECURITY.md diff --git a/.gitignore b/.gitignore index 576a92e..7312913 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ docs/superpowers/ .rp1/context/meta.json .rp1/settings.toml # rp1:end + +# rp1 workspace artifacts — never commit +.rp1/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f4c0d..198682d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +- docs: README credibility pass — CI/downloads badges, production-scale benchmark callout, architecture diagram, maintainer credit; "For organizations" now states that the SR 11-7/SR 26-2, EU AI Act Annex IV, and NIST AI RMF validation profiles ship in the OSS core +- docs: quickstart and all Ledger examples persist to `./ledger.db` (the previous `./inventory.db` name collided with the CLI's legacy Inventory-format default and crashed `model-ledger list`); quickstart `history()` example now shows (and CI-asserts) newest-first ordering +- fix(cli): bare installs get a one-line install hint from `model-ledger --help` instead of a `ModuleNotFoundError` traceback (typer/rich live in the `[cli]` extra) +- fix(cli): inventory commands detect a Ledger event-log database and exit with guidance instead of an sqlite traceback +- fix(cli): `model-ledger validate` with an unknown profile exits with the available profile names instead of a `ValueError` traceback +- refactor!: remove the `scanner` module (`Scanner`, `InventoryScanner`, `ModelCandidate`, `ScanReport`), deprecated since v0.4.0 — use `SourceConnector` + `DataNode` + `Ledger.add()/connect()` +- chore: PyPI metadata — Development Status classifier to Beta; add `mcp`, `model-context-protocol`, `ai-governance`, `eu-ai-act`, `nist-ai-rmf`, `sr-26-2` keywords +- docs: add SECURITY.md (private vulnerability reporting) +- chore: stale-reference sweep — MCP tool count 6→8 in docstrings and CLAUDE.md, `v0.3.0` markers out of SDK docstrings, "Task 11" comments out of rest/app.py; fold two stale Unreleased changelog blocks into the releases that shipped them (v0.7.3, v0.4.8) + ## v0.7.7 - feat: `SnowflakeLedgerBackend` accepts a `connection_factory` and self-heals on auth-token expiry (Snowflake errno 390114) — on a detected expiry it obtains a fresh connection, swaps it in, and retries the same statement exactly once. Composes with `client_session_keep_alive` heartbeats as the backstop for residual expiries. Backward compatible: with only `connection`, behavior is unchanged. (#24) @@ -19,9 +31,6 @@ - Add `metadata: dict` field to `ModelRef`. Thread through `register()` and `register_group()`. Replaces the unintended per-link broadcast of `register_group(metadata=...)` to member links; metadata now lives on the composite ModelRef itself. Backward compatible: existing data loads with `metadata={}`. - Add optional `model_types` parameter to `composite_summary()` so callers can include custom composite-shaped types (e.g., `ml_model`, `heuristic`) beyond the default `"composite"`. Backward compatible. - -## Unreleased - - feat: `RecordOutput.model_hash` — the `/record` response now carries the server's canonical model hash so HTTP clients can reconcile their local `ModelRef` with authoritative server state - fix: `HttpLedgerBackend.save_model` adopts the server's canonical hash (reassigns `model.model_hash` on the incoming `ModelRef` and caches only the server hash) so follow-up hash-based flows like `Ledger.tag()` round-trip correctly even from a fresh backend instance - feat: `POST /tag` and `GET /tags/{model_name}` REST endpoints — create, move, and list tags over HTTP @@ -46,14 +55,11 @@ - feat: `github_connector()` — discover models from config files in GitHub repos - feat: Connector factories return `SourceConnector` instances for composability -## Unreleased - -- fix: deduplicate `ModelNotFoundError` — use canonical class from `core.exceptions` -- test: add coverage for `'value' AS model_name` extraction pattern - ## v0.4.8 - fix: exclude volatile timestamps from content hash dedup +- fix: deduplicate `ModelNotFoundError` — use canonical class from `core.exceptions` +- test: add coverage for `'value' AS model_name` extraction pattern ## v0.4.7 diff --git a/CLAUDE.md b/CLAUDE.md index 14003d7..e0bb286 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,8 +24,8 @@ Open-source model inventory and governance framework. Apache-2.0. - `src/model_ledger/backends/http.py` — HttpLedgerBackend (MCP → REST API pass-through) - `src/model_ledger/backends/json_files.py` — JsonFileLedgerBackend (git-friendly) - `src/model_ledger/sdk/ledger.py` — Ledger SDK with last_seen, change_detected/change_occurred -- `src/model_ledger/tools/` — 6 agent tool functions (record, query, investigate, trace, changelog, discover) -- `src/model_ledger/mcp/server.py` — MCP server (FastMCP, 6 tools + 3 resources) +- `src/model_ledger/tools/` — 8 agent tool functions (record, query, investigate, trace, changelog, discover, tag, list_tags) +- `src/model_ledger/mcp/server.py` — MCP server (FastMCP, 8 tools + 3 resources) - `src/model_ledger/rest/app.py` — REST API (FastAPI, create_app() factory) ### v0.4.x (DataNode graph) @@ -40,7 +40,7 @@ Open-source model inventory and governance framework. Apache-2.0. - `src/model_ledger/adapters/cron.py` — Cron expression translation ### v0.3.0 (event-log paradigm) -- `src/model_ledger/scanner/` — Scanner protocol, ModelCandidate, InventoryScanner, ScannerRegistry +- Scanner protocol removed (deprecated v0.4.0, removed post-v0.7.7) — use SourceConnector + DataNode + Ledger.add()/connect() ### v0.2.0 (legacy — retained for reference) - `src/model_ledger/core/models.py` — Model, ModelVersion, ComponentNode diff --git a/README.md b/README.md index d8bbac3..0a029e4 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,11 @@ **git for models** — know what models you have deployed, where they run, what they depend on, and what changed. +[![CI](https://github.com/block/model-ledger/actions/workflows/ci.yml/badge.svg)](https://github.com/block/model-ledger/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![Python](https://img.shields.io/badge/python-3.10+-blue.svg)](https://python.org) [![PyPI](https://img.shields.io/pypi/v/model-ledger)](https://pypi.org/project/model-ledger/) +[![Downloads](https://img.shields.io/pypi/dm/model-ledger)](https://pypistats.org/packages/model-ledger) [![Docs](https://img.shields.io/badge/docs-block.github.io/model--ledger-7a1a1a.svg)](https://block.github.io/model-ledger/) 📖 **[Documentation](https://block.github.io/model-ledger/)** · @@ -21,6 +23,9 @@ Unlike registries tied to a single platform (MLflow, SageMaker, W&B), it spans a them — as one connected graph — and it's built to be driven by AI agents through a native MCP server. +Benchmarked at production scale: full inventory reconstruction over a ledger of 28.8k +models and 212k events runs in under a second ([CHANGELOG, v0.7.4](CHANGELOG.md)). + ## Install ```bash @@ -75,21 +80,57 @@ runs in CI: - **[Agents (MCP)](https://block.github.io/model-ledger/guides/agents/)** — the eight-tool agent surface, with a worked transcript - **[Connectors](https://block.github.io/model-ledger/guides/connectors/)** — discover from SQL, REST, GitHub, or your own platform - **[Backends](https://block.github.io/model-ledger/guides/backends/)** — in-memory, SQLite, JSON, Snowflake, or remote HTTP -- **[Governance](https://block.github.io/model-ledger/governance/)** — how the primitives map to SR 26‑2, the EU AI Act, and NIST +- **[Governance](https://block.github.io/model-ledger/governance/)** — how the primitives map to SR 11‑7/SR 26‑2, the EU AI Act, and NIST AI RMF - **[API reference](https://block.github.io/model-ledger/reference/)** — generated from the source +## Architecture + +```mermaid +flowchart LR + subgraph Sources + C1[SQL / REST / GitHub / Prefect
connectors] + end + subgraph Core + L[Ledger
append-only event log,
point-in-time reconstruction] + G[Dependency graph] + V[Compliance profiles
SR 11-7/SR 26-2 · EU AI Act · NIST AI RMF] + end + subgraph Surfaces + S1[Python SDK] + S2[CLI] + S3[REST API] + S4[MCP server · 8 tools] + end + B1[(in-memory · SQLite · JSON ·
Snowflake · remote HTTP)] + C1 --> L + L --> G + L --> V + L --- B1 + S1 --> L + S2 --> L + S3 --> L + S4 --> L +``` + ## For organizations -The OSS core handles discovery, graph building, change tracking, storage, and the agent -protocol. Your internal package provides the thin layer on top — connector configs, -custom connectors for internal platforms, authentication, and compliance profiles. Thin -config and credentials, not reimplemented logic. +The OSS core handles discovery, graph building, change tracking, storage, the agent +protocol, and compliance validation — the SR 11‑7/SR 26‑2, EU AI Act Annex IV, and +NIST AI RMF profiles ship in `model_ledger.validate`. Your internal package provides +only the thin layer on top: connector configs, custom connectors for internal +platforms, and credentials. Thin config, not reimplemented logic. ## Contributing See [CONTRIBUTING.md](https://github.com/block/model-ledger/blob/main/CONTRIBUTING.md). All commits require DCO sign-off. +## Security + +See [SECURITY.md](SECURITY.md) for how to report vulnerabilities privately. + ## License Apache-2.0. See [LICENSE](https://github.com/block/model-ledger/blob/main/LICENSE). + +Created and maintained by [Vignesh Narayanaswamy](https://github.com/vigneshnarayanaswamy) at Block. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f787be7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security Policy + +## Reporting a vulnerability + +Please do not report security vulnerabilities through public GitHub issues. + +Instead, report them privately via the repository's **Security tab → Report a +vulnerability** ([GitHub's private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability)). + +For assistance or escalation, contact the +[Block Open Source Governance Committee](mailto:open-source-governance@block.xyz). + +## Scope notes + +model-ledger stores model inventory metadata. A few boundaries worth knowing when +assessing impact: + +- The ledger trusts its callers: `record()` accepts arbitrary payloads, and the REST + API does not ship authentication — deployments are expected to run it behind their + own auth layer (see the [backends guide](https://block.github.io/model-ledger/guides/backends/)). +- Snapshot hashes provide content addressing and tamper evidence for snapshot + payloads, not a cryptographic chain over the full event history — see + [guarantees](https://block.github.io/model-ledger/concepts/guarantees/) for the + precise integrity model. + +## Supported versions + +Security fixes land on `main` and ship in the next release; older releases are not +patched retroactively. diff --git a/docs/concepts/composite.md b/docs/concepts/composite.md index 05b2af8..cdc1639 100644 --- a/docs/concepts/composite.md +++ b/docs/concepts/composite.md @@ -20,7 +20,7 @@ validation. Composites are the layer no plain registry or catalog models. ```python from model_ledger import Ledger -ledger = Ledger.from_sqlite("./inventory.db") +ledger = Ledger.from_sqlite("./ledger.db") group = ledger.register_group( name="Credit Scorecard", diff --git a/docs/concepts/snapshot.md b/docs/concepts/snapshot.md index c52e79e..237c3e3 100644 --- a/docs/concepts/snapshot.md +++ b/docs/concepts/snapshot.md @@ -20,7 +20,7 @@ A model splits into two things: ```python from model_ledger import Ledger -ledger = Ledger.from_sqlite("./inventory.db") +ledger = Ledger.from_sqlite("./ledger.db") ref = ledger.register( name="fraud_scoring", owner="risk-team", diff --git a/docs/governance.md b/docs/governance.md index eb29577..4494a8a 100644 --- a/docs/governance.md +++ b/docs/governance.md @@ -29,7 +29,7 @@ true then?" is always reconstructable.** ```python from model_ledger import Ledger -ledger = Ledger.from_sqlite("./inventory.db") +ledger = Ledger.from_sqlite("./ledger.db") # Identity + risk tier — the minimum a regulator needs ledger.register( diff --git a/docs/guides/agents.md b/docs/guides/agents.md index d20ba5f..20f0444 100644 --- a/docs/guides/agents.md +++ b/docs/guides/agents.md @@ -17,7 +17,7 @@ pip install "model-ledger[mcp]" # Claude Code (one time). Drop --demo to start empty; add a backend to persist. claude mcp add model-ledger -- model-ledger mcp --demo -claude mcp add model-ledger -- model-ledger mcp --backend sqlite --path ./inventory.db +claude mcp add model-ledger -- model-ledger mcp --backend sqlite --path ./ledger.db ``` The server speaks stdio and works with any MCP client (Claude Desktop, Goose, Cursor). diff --git a/docs/guides/backends.md b/docs/guides/backends.md index 6e57586..387606b 100644 --- a/docs/guides/backends.md +++ b/docs/guides/backends.md @@ -22,7 +22,7 @@ from model_ledger.backends.json_files import JsonFileLedgerBackend from model_ledger.backends.http import HttpLedgerBackend Ledger() # in-memory -Ledger.from_sqlite("./inventory.db") # SQLite +Ledger.from_sqlite("./ledger.db") # SQLite Ledger(JsonFileLedgerBackend("./inventory")) # JSON files Ledger.from_snowflake(conn, schema="DB.MODEL_LEDGER") # Snowflake Ledger(HttpLedgerBackend("https://model-ledger:8000")) # remote REST @@ -50,7 +50,7 @@ inventory/ The CLI launches either agent or HTTP surfaces over any backend: ```bash -model-ledger serve --backend sqlite --path ./inventory.db --port 8000 +model-ledger serve --backend sqlite --path ./ledger.db --port 8000 model-ledger mcp --backend snowflake --schema DB.MODEL_LEDGER ``` diff --git a/docs/guides/connectors.md b/docs/guides/connectors.md index 352f170..cc3cb40 100644 --- a/docs/guides/connectors.md +++ b/docs/guides/connectors.md @@ -14,7 +14,7 @@ factory connectors ship in core; anything else is a small protocol implementatio ```python from model_ledger import Ledger, sql_connector -ledger = Ledger.from_sqlite("./inventory.db") +ledger = Ledger.from_sqlite("./ledger.db") # Simple: read a registry table models = sql_connector( diff --git a/docs/index.md b/docs/index.md index 756895b..0f3ef20 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,6 +35,9 @@ dependency graph** automatically, and **records every change as an immutable event**. Unlike registries tied to one platform (MLflow, SageMaker, W&B), it spans all of them — and it's built to be driven by AI agents through a native MCP server. +Born at Block, where it runs in production inventorying ML models, heuristic rules, +and pipelines across multiple platforms for a second-line model risk program. + [Get started in 60 seconds :octicons-arrow-right-24:](quickstart.md){ .md-button .md-button--primary } [Why a ledger, not a registry? :octicons-arrow-right-24:](#why-a-ledger-not-a-registry){ .md-button } @@ -104,7 +107,7 @@ dependency edge — no hand-wiring. ```python from model_ledger import Ledger, DataNode -ledger = Ledger.from_sqlite("./inventory.db") +ledger = Ledger.from_sqlite("./ledger.db") ledger.add([ DataNode("segmentation", platform="etl", outputs=["customer_segments"]), @@ -129,15 +132,15 @@ graph LR ## One operation, every surface -The SDK, the REST API, and the MCP tools are the **same six verbs** — `discover`, -`record`, `investigate`, `query`, `trace`, `changelog` (plus `tag`/`list_tags`). +The SDK, the REST API, and the MCP tools are the **same eight verbs** — `discover`, +`record`, `investigate`, `query`, `trace`, `changelog`, `tag`, `list_tags`. Registering a model looks like this everywhere: === "Python" ```python from model_ledger import Ledger - ledger = Ledger.from_sqlite("./inventory.db") + ledger = Ledger.from_sqlite("./ledger.db") ledger.register( name="fraud_scoring", owner="risk-team", diff --git a/docs/quickstart.md b/docs/quickstart.md index 6368909..c887381 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -82,7 +82,7 @@ A `DataNode` gives you the graph. [`register()`](reference/index.md) gives a mod ```python from model_ledger import Ledger -ledger = Ledger.from_sqlite("./inventory.db") +ledger = Ledger.from_sqlite("./ledger.db") ledger.register( name="fraud_scoring", @@ -96,10 +96,13 @@ ledger.register( ledger.record("fraud_scoring", event="retrained", actor="ml-pipeline", payload={"accuracy": 0.94, "features_added": ["velocity_24h"]}) -for snap in ledger.history("fraud_scoring"): +history = ledger.history("fraud_scoring") # newest first +for snap in history: print(snap.timestamp, snap.event_type) -# ... registered # ... retrained +# ... registered + +assert [s.event_type for s in history] == ["retrained", "registered"] ``` Every call appends an immutable [Snapshot](concepts/snapshot.md). Nothing is @@ -114,7 +117,7 @@ from model_ledger import Ledger from model_ledger.backends.json_files import JsonFileLedgerBackend Ledger() # in-memory — tests & demos -Ledger.from_sqlite("./inventory.db") # zero-infra, single file +Ledger.from_sqlite("./ledger.db") # zero-infra, single file Ledger(JsonFileLedgerBackend("./inventory")) # git-friendly JSON files Ledger.from_snowflake(conn, schema="DB.MODEL_LEDGER") # production ``` diff --git a/docs/recipes/discover-sql.md b/docs/recipes/discover-sql.md index 1b8c7af..c4bb23c 100644 --- a/docs/recipes/discover-sql.md +++ b/docs/recipes/discover-sql.md @@ -17,7 +17,7 @@ so re-running on a schedule only records genuine changes. import sqlite3 from model_ledger import Ledger, sql_connector -ledger = Ledger.from_sqlite("./inventory.db") +ledger = Ledger.from_sqlite("./ledger.db") source = sqlite3.connect("./ml_platform.db") models = sql_connector( diff --git a/docs/recipes/point-in-time.md b/docs/recipes/point-in-time.md index da87743..558ef6f 100644 --- a/docs/recipes/point-in-time.md +++ b/docs/recipes/point-in-time.md @@ -16,7 +16,7 @@ the inventory at any date is just a replay of the log up to that moment. from datetime import datetime, timezone, timedelta from model_ledger import Ledger -ledger = Ledger.from_sqlite("./inventory.db") +ledger = Ledger.from_sqlite("./ledger.db") ledger.register(name="fraud_scoring", owner="risk-team", model_type="ml_model", tier="high", diff --git a/src/model_ledger/mcp/server.py b/src/model_ledger/mcp/server.py index d6d92c3..beb557a 100644 --- a/src/model_ledger/mcp/server.py +++ b/src/model_ledger/mcp/server.py @@ -1,4 +1,4 @@ -"""FastMCP server wrapping model-ledger's 6 tools and 3 resources. +"""FastMCP server wrapping model-ledger's 8 tools and 3 resources. Usage: >>> from model_ledger.mcp.server import create_server diff --git a/src/model_ledger/rest/app.py b/src/model_ledger/rest/app.py index 58d5779..a301e1f 100644 --- a/src/model_ledger/rest/app.py +++ b/src/model_ledger/rest/app.py @@ -62,7 +62,7 @@ def create_app( Args: backend: Optional ledger backend. Defaults to in-memory. - demo: If True, pre-loads demo inventory data (requires Task 11). + demo: If True, pre-loads demo inventory data. Returns: A configured FastAPI application. @@ -75,7 +75,7 @@ def create_app( load_demo_inventory(ledger) except ImportError: - pass # demo dataset not yet available (Task 11) + pass # demo dataset module not installed app = FastAPI( title="Model Ledger API", diff --git a/src/model_ledger/sdk/ledger.py b/src/model_ledger/sdk/ledger.py index a5f8f91..aab4b14 100644 --- a/src/model_ledger/sdk/ledger.py +++ b/src/model_ledger/sdk/ledger.py @@ -1,4 +1,4 @@ -"""Ledger SDK — tool-shaped API for v0.3.0 event-log paradigm.""" +"""Ledger SDK — tool-shaped API for the event-log paradigm.""" from __future__ import annotations @@ -65,7 +65,7 @@ def _normalize_status(raw: object) -> str | None: class Ledger: - """The main entry point for model-ledger v0.3.0. + """The main entry point for model-ledger. Every public method is designed to work as an agent tool call: clear inputs, JSON-serializable outputs, no side effects beyond the ledger. From a942701b81c737c170d86efe97d55307c54f2256 Mon Sep 17 00:00:00 2001 From: Vignesh Narayanaswamy Date: Fri, 3 Jul 2026 08:53:42 -0700 Subject: [PATCH 3/4] fix(cli): friendly errors on the three first-touch failure paths - Bare install (no [cli] extra): 'model-ledger --help' printed a ModuleNotFoundError traceback for typer. The cli package now falls back to a stub entry point that prints the one-line install hint and exits 1. - Inventory commands pointed at a Ledger event-log database (e.g. the quickstart's ledger.db) raised a raw sqlite 'no such table' error. _get_inventory now sniffs sqlite_master and exits with guidance toward Ledger.from_sqlite() or 'model-ledger mcp'. - 'model-ledger validate --profile ' raised a ValueError traceback; it now exits 1 listing the available profiles. Tested end to end in a fresh venv: bare-install --help and the quickstart-then-list collision both exit cleanly with guidance. Co-Authored-By: Claude Fable 5 Signed-off-by: Vignesh Narayanaswamy --- src/model_ledger/cli/__init__.py | 17 +++++++++++++- src/model_ledger/cli/app.py | 38 +++++++++++++++++++++++++++++++- tests/test_cli/test_app.py | 31 ++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/model_ledger/cli/__init__.py b/src/model_ledger/cli/__init__.py index a5b1a12..824decb 100644 --- a/src/model_ledger/cli/__init__.py +++ b/src/model_ledger/cli/__init__.py @@ -1,3 +1,18 @@ -from model_ledger.cli.app import app +try: + from model_ledger.cli.app import app +except ModuleNotFoundError as _exc: # pragma: no cover — exercised only on bare installs + if _exc.name not in {"typer", "rich"}: + raise + + import sys + + def app() -> None: # type: ignore[misc] + """Fallback entry point when the CLI extra is not installed.""" + sys.stderr.write( + "The model-ledger CLI requires the [cli] extra:\n\n" + ' pip install "model-ledger[cli]"\n' + ) + sys.exit(1) + __all__ = ["app"] diff --git a/src/model_ledger/cli/app.py b/src/model_ledger/cli/app.py index 24befee..5e67480 100644 --- a/src/model_ledger/cli/app.py +++ b/src/model_ledger/cli/app.py @@ -111,7 +111,39 @@ def _default_db() -> str: return os.environ.get("MODEL_LEDGER_DB", "inventory.db") +def _guard_ledger_db(db: str) -> None: + """Exit with guidance when `db` is a Ledger event-log database. + + The inventory commands read the legacy Inventory format (models/versions + tables). Pointing them at a Ledger database (models/snapshots tables) — + e.g. the `ledger.db` file from the quickstart — used to surface as a raw + sqlite "no such table" traceback. + """ + import sqlite3 + from pathlib import Path + + if not Path(db).is_file(): + return + try: + with sqlite3.connect(db) as conn: + names = { + row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + } + except sqlite3.Error: + return + if "snapshots" in names and "versions" not in names: + console.print( + f"[red]Error:[/red] '{db}' is a model-ledger event-log (Ledger) database, " + "but this command reads the legacy Inventory format.\n" + "Work with a Ledger database via the Python SDK " + "([cyan]Ledger.from_sqlite(...)[/cyan]) or serve it to agents with " + f"[cyan]model-ledger mcp --backend sqlite --path {db}[/cyan]." + ) + raise typer.Exit(code=1) + + def _get_inventory(db: str) -> Inventory: + _guard_ledger_db(db) return Inventory(db_path=db) @@ -235,7 +267,11 @@ def validate_cmd( from model_ledger.validate.engine import validate - result = validate(model, ver, profile=profile) + try: + result = validate(model, ver, profile=profile) + except ValueError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(code=1) from None if format == "json": data = { diff --git a/tests/test_cli/test_app.py b/tests/test_cli/test_app.py index 126b7aa..7ad5454 100644 --- a/tests/test_cli/test_app.py +++ b/tests/test_cli/test_app.py @@ -82,3 +82,34 @@ def test_list_json_format(tmp_path): data = json.loads(result.output) assert len(data) == 1 assert data[0]["name"] == "test-model" + + +def test_list_on_ledger_db_exits_with_guidance(tmp_path): + """A Ledger event-log database gets a guidance message, not an sqlite traceback.""" + from model_ledger import Ledger + + db = str(tmp_path / "ledger.db") + ledger = Ledger.from_sqlite(db) + ledger.register( + name="fraud_scoring", + owner="risk-team", + model_type="ml_model", + tier="high", + purpose="testing", + ) + result = runner.invoke(app, ["list", "--db", db]) + assert result.exit_code == 1 + assert "event-log" in result.output + assert "model-ledger mcp" in result.output + + +def test_validate_unknown_profile_exits_cleanly(tmp_path): + """An unknown profile name exits with the available profiles, not a ValueError.""" + db = str(tmp_path / "test.db") + inv = Inventory(db_path=db) + inv.register_model(name="test-model", owner="tester", tier="low", intended_purpose="testing") + with inv.new_version("test-model"): + pass + result = runner.invoke(app, ["validate", "test-model", "--db", db, "--profile", "nope"]) + assert result.exit_code == 1 + assert "Unknown profile" in result.output From 48cd36ac6e171cf84177790e22754fc8fd46d025 Mon Sep 17 00:00:00 2001 From: Vignesh Narayanaswamy Date: Fri, 3 Jul 2026 08:53:44 -0700 Subject: [PATCH 4/4] chore: PyPI metadata for discoverability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Development Status classifier moves from Alpha to Beta — the package has had 10 releases, a four-Python CI matrix, and a production-scale deployment; Alpha undersold it. Keywords gain the terms people actually search: mcp, model-context-protocol, ai-governance, eu-ai-act, nist-ai-rmf, sr-26-2. Co-Authored-By: Claude Fable 5 Signed-off-by: Vignesh Narayanaswamy --- pyproject.toml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8b395ad..77bad9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,9 +9,22 @@ authors = [ { name = "Vignesh Narayanaswamy", email = "vigneshn@block.xyz" }, { name = "Block", email = "opensource@block.xyz" }, ] -keywords = ["mlops", "model-governance", "model-inventory", "model-risk", "sr-11-7", "compliance"] +keywords = [ + "mlops", + "model-governance", + "model-inventory", + "model-risk", + "sr-11-7", + "sr-26-2", + "compliance", + "mcp", + "model-context-protocol", + "ai-governance", + "eu-ai-act", + "nist-ai-rmf", +] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: Financial and Insurance Industry", "License :: OSI Approved :: Apache Software License",