Skip to content

Commit be679c9

Browse files
fix: stabilize the package — version, CI, connector discovery (#13)
* fix: single-source __version__ from package metadata The package reported three different versions: __init__ said 0.6.0, the REST app said 0.5.0, while the installed package was 0.7.3. A library that lies about its own version to users and agents is a credibility hit. Read it once, from importlib.metadata.version("model-ledger"), and drive the REST app's version off the same value. Falls back to "0.0.0+unknown" only when running from a source tree with no install. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: lint + types + tests on src across 3.10–3.13; honest coverage gate The repo had no CI that ran the test suite, ruff, or mypy — a contributor could merge red, and the configured 90% coverage gate never executed (nothing ran --cov). The 3.10–3.13 support claim in the classifiers was untested. - .github/workflows/ci.yml: * quality job (3.12): ruff check, ruff format --check, mypy src/, and pytest with the coverage gate. * tests job: full suite on 3.10/3.11/3.13 (3.12 covered by quality). * minimal permissions (contents: read), concurrency-cancel. - pyproject: lower the never-enforced fail_under from 90 to an honest 70 (real coverage ~74%); ratchet upward as tests are added. Verified locally: ruff/format/mypy clean, 714 passed / 11 skipped, 74.35%. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: run config-drivable connectors from the discover tool The creed is "the MCP server is the product," yet discover(source_type= "connector") raised NotImplementedError — an agent told to "scan my platforms" hit a hard wall. Now: - `rest` and `prefect` connectors are pure-config, so they run directly from connector_config through the discover tool. - `sql` and `github` need a live connection / parser callable that can't be expressed as JSON; discover returns an actionable message in DiscoverOutput.errors pointing to the SDK instead of raising. - Any connector failure (bad config, unknown name, missing connector_name) is returned in `errors` — the agent always gets a usable response, never a crash. Refactors the add/connect/summarize flow into a shared `_ingest` helper used by the inline, file, and connector paths. Updates the agent guide to match. Tests: 6 new connector tests (graceful errors + a config-driven ingest path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Vignesh Narayanaswamy <Vigneshn@squareup.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0023fd8 commit be679c9

7 files changed

Lines changed: 240 additions & 56 deletions

File tree

.github/workflows/ci.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: ci
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
concurrency:
9+
group: ci-${{ github.ref }}
10+
cancel-in-progress: true
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
quality:
17+
name: lint · types · coverage (3.12)
18+
runs-on: ubuntu-latest
19+
steps:
20+
# NOTE: tags are pinned to digests by Renovate (see renovate.json).
21+
- uses: actions/checkout@v4
22+
- uses: actions/setup-python@v5
23+
with:
24+
python-version: "3.12"
25+
- name: Install
26+
run: pip install -e ".[dev,cli,mcp,rest-api]"
27+
- name: Ruff (lint)
28+
run: ruff check .
29+
- name: Ruff (format)
30+
run: ruff format --check .
31+
- name: Mypy
32+
run: mypy src/
33+
- name: Pytest + coverage gate
34+
run: pytest --cov --cov-report=term-missing -q
35+
36+
tests:
37+
name: tests (${{ matrix.python }})
38+
runs-on: ubuntu-latest
39+
strategy:
40+
fail-fast: false
41+
matrix:
42+
python: ["3.10", "3.11", "3.13"] # 3.12 is covered by the quality job
43+
steps:
44+
- uses: actions/checkout@v4
45+
- uses: actions/setup-python@v5
46+
with:
47+
python-version: ${{ matrix.python }}
48+
- name: Install
49+
run: pip install -e ".[dev,cli,mcp,rest-api]"
50+
- name: Pytest
51+
run: pytest -q

docs/guides/agents.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,19 +95,24 @@ over the event log, not a static table.
9595

9696
## Discovering at scale
9797

98-
The `discover` tool imports inline model lists or a JSON file:
98+
The `discover` tool imports inline model lists, a JSON file, or a config-drivable connector:
9999

100100
```json
101101
// discover(source_type="inline", models=[{"name": "...", "platform": "..."}])
102102
{ "added": 12, "skipped": 0, "links_created": 8 }
103+
104+
// discover(source_type="connector", connector_name="rest",
105+
// connector_config={"name": "mlflow", "url": "...", "items_path": "...", "name_field": "..."})
106+
{ "models_added": 40, "links_created": 12, "errors": [] }
103107
```
104108

105-
!!! info "Connector-based discovery runs from the SDK"
106-
Pulling models *live* from SQL registries, REST APIs, or GitHub repos is done
107-
through the SDK connectors (see [Connectors & discovery](connectors.md)) and then
108-
persisted to a shared backend the agent reads. Wiring connector execution directly
109-
into the `discover` tool is on the roadmap — until then, agents read the inventory
110-
that scheduled connector runs populate.
109+
!!! info "Which connectors an agent can run"
110+
`rest` and `prefect` are pure-config connectors, so an agent can run them directly
111+
through `discover`. `sql` and `github` need a live database connection or a parser
112+
callable that can't be expressed as JSON — for those, `discover` returns a message in
113+
the result's `errors` field pointing you to the SDK (see
114+
[Connectors & discovery](connectors.md)). Connector problems come back as `errors`
115+
rather than raising, so the agent always gets a usable response.
111116

112117
## Your docs are an agent surface, too
113118

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,9 @@ source = ["src/model_ledger"]
157157
branch = true
158158

159159
[tool.coverage.report]
160-
fail_under = 90
160+
# Honest floor: real coverage is ~74% (2026-06). The previous 90 was never
161+
# enforced (no CI ran --cov). Ratchet this upward as tests are added.
162+
fail_under = 70
161163
exclude_lines = [
162164
"pragma: no cover",
163165
"if TYPE_CHECKING:",

src/model_ledger/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from __future__ import annotations
1313

14+
from importlib.metadata import PackageNotFoundError
15+
from importlib.metadata import version as _pkg_version
1416
from typing import TYPE_CHECKING, Any
1517

1618
from model_ledger.connectors import github_connector, rest_connector, sql_connector
@@ -120,7 +122,10 @@
120122
"TraceOutput",
121123
]
122124

123-
__version__ = "0.6.0"
125+
try:
126+
__version__ = _pkg_version("model-ledger")
127+
except PackageNotFoundError: # running from a source checkout without an install
128+
__version__ = "0.0.0+unknown"
124129

125130

126131
def introspect(obj: Any, *, introspector: str | None = None) -> IntrospectionResult:

src/model_ledger/rest/app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
from fastapi import FastAPI, HTTPException
2525

26+
from model_ledger import __version__
2627
from model_ledger.backends import batch_fallbacks
2728
from model_ledger.backends.ledger_protocol import LedgerBackend
2829
from model_ledger.core.exceptions import ModelNotFoundError
@@ -79,7 +80,7 @@ def create_app(
7980
app = FastAPI(
8081
title="Model Ledger API",
8182
description="REST API for model inventory and governance",
82-
version="0.5.0",
83+
version=__version__,
8384
)
8485

8586
@app.post("/record", response_model=RecordOutput)

src/model_ledger/tools/discover.py

Lines changed: 95 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,29 @@
33
from __future__ import annotations
44

55
import json
6+
from collections.abc import Callable
67
from typing import Any
78

9+
from model_ledger.connectors import prefect_connector, rest_connector
810
from model_ledger.graph.models import DataNode
11+
from model_ledger.graph.protocol import SourceConnector
912
from model_ledger.sdk.ledger import Ledger
1013
from model_ledger.tools.schemas import DiscoverInput, DiscoverOutput, ModelSummary
1114

15+
# Connectors whose entire configuration is plain data (no live connection
16+
# object, no callable), so an agent can drive them purely from JSON.
17+
_CONFIG_CONNECTORS: dict[str, Callable[[dict[str, Any]], SourceConnector]] = {
18+
"rest": lambda config: rest_connector(**config),
19+
"prefect": lambda config: prefect_connector(**config),
20+
}
21+
22+
# Connectors that require a non-serializable argument an agent can't pass as
23+
# JSON — point the caller to the SDK instead of failing opaquely.
24+
_SDK_ONLY_CONNECTORS: dict[str, str] = {
25+
"sql": "needs a live database connection",
26+
"github": "needs a parser callable",
27+
}
28+
1229

1330
def _dict_to_datanode(d: dict[str, Any]) -> DataNode:
1431
"""Convert a raw dict to a DataNode."""
@@ -21,67 +38,103 @@ def _dict_to_datanode(d: dict[str, Any]) -> DataNode:
2138
)
2239

2340

41+
def _error(message: str) -> DiscoverOutput:
42+
return DiscoverOutput(models_added=0, models_skipped=0, links_created=0, errors=[message])
43+
44+
45+
def _ingest(nodes: list[DataNode], ledger: Ledger, auto_connect: bool) -> DiscoverOutput:
46+
"""Add nodes to the ledger, optionally connect, and summarize."""
47+
add_result = ledger.add(nodes)
48+
added = add_result["added"]
49+
skipped = add_result["skipped"]
50+
51+
links_created = 0
52+
if auto_connect and added > 0:
53+
links_created = ledger.connect()["links_created"]
54+
55+
summaries: list[ModelSummary] = []
56+
for node in nodes:
57+
try:
58+
ref = ledger.get(node.name)
59+
except Exception:
60+
continue
61+
summaries.append(
62+
ModelSummary(
63+
name=ref.name,
64+
owner=ref.owner,
65+
model_type=ref.model_type,
66+
platform=node.platform or None,
67+
status=ref.status,
68+
)
69+
)
70+
71+
return DiscoverOutput(
72+
models_added=added,
73+
models_skipped=skipped,
74+
links_created=links_created,
75+
models=summaries,
76+
)
77+
78+
79+
def _discover_via_connector(input: DiscoverInput, ledger: Ledger) -> DiscoverOutput:
80+
"""Build a config-driven connector, run it, and ingest the result.
81+
82+
Returns errors in ``DiscoverOutput.errors`` (never raises) so an agent gets
83+
an actionable response instead of a crash.
84+
"""
85+
name = input.connector_name
86+
if not name:
87+
return _error("connector_name is required when source_type is 'connector'")
88+
89+
if name in _SDK_ONLY_CONNECTORS:
90+
reason = _SDK_ONLY_CONNECTORS[name]
91+
return _error(
92+
f"The '{name}' connector {reason}, which can't be supplied as JSON. "
93+
f"Run it from the Python SDK: ledger.add({name}_connector(...).discover())"
94+
)
95+
96+
factory = _CONFIG_CONNECTORS.get(name)
97+
if factory is None:
98+
return _error(
99+
f"Unknown connector '{name}'. Config-drivable connectors: "
100+
f"{sorted(_CONFIG_CONNECTORS)}. SDK-only connectors: {sorted(_SDK_ONLY_CONNECTORS)}."
101+
)
102+
103+
try:
104+
connector = factory(input.connector_config or {})
105+
nodes = list(connector.discover())
106+
except Exception as exc:
107+
return _error(f"connector '{name}' failed: {exc}")
108+
109+
return _ingest(nodes, ledger, input.auto_connect)
110+
111+
24112
def discover(input: DiscoverInput, ledger: Ledger) -> DiscoverOutput:
25113
"""Import models from external sources into the ledger.
26114
27115
Supports three source types:
28116
29117
- **inline**: models passed directly as a list of dicts.
30118
- **file**: models loaded from a JSON file on disk.
31-
- **connector**: not yet supported — raises ``NotImplementedError``.
119+
- **connector**: run a config-drivable connector (``rest``, ``prefect``) from
120+
``connector_config``. Connectors needing a live connection or a callable
121+
(``sql``, ``github``) return a message in ``errors`` directing to the SDK.
32122
33-
When ``auto_connect`` is True and models were added, runs
34-
``ledger.connect()`` to auto-link dependencies based on matching
35-
input/output ports.
123+
When ``auto_connect`` is True and models were added, runs ``ledger.connect()``
124+
to auto-link dependencies based on matching input/output ports.
36125
"""
37126
if input.source_type == "connector":
38-
raise NotImplementedError(
39-
"Connector execution via tool not yet supported. Use the Python SDK directly."
40-
)
127+
return _discover_via_connector(input, ledger)
41128

42129
if input.source_type == "file":
43130
if input.file_path is None:
44131
raise ValueError("file_path is required when source_type is 'file'")
45132
with open(input.file_path) as f:
46133
raw_models = json.load(f)
47134
nodes = [_dict_to_datanode(d) for d in raw_models]
48-
49135
else: # inline
50136
if input.models is None:
51137
raise ValueError("models is required when source_type is 'inline'")
52138
nodes = [_dict_to_datanode(d) for d in input.models]
53139

54-
# Add nodes to ledger (content-hash dedup)
55-
add_result = ledger.add(nodes)
56-
added = add_result["added"]
57-
skipped = add_result["skipped"]
58-
59-
# Auto-connect dependencies if requested and models were added
60-
links_created = 0
61-
if input.auto_connect and added > 0:
62-
connect_result = ledger.connect()
63-
links_created = connect_result["links_created"]
64-
65-
# Build summaries for added models
66-
summaries: list[ModelSummary] = []
67-
for node in nodes:
68-
try:
69-
ref = ledger.get(node.name)
70-
summaries.append(
71-
ModelSummary(
72-
name=ref.name,
73-
owner=ref.owner,
74-
model_type=ref.model_type,
75-
platform=node.platform or None,
76-
status=ref.status,
77-
)
78-
)
79-
except Exception:
80-
pass
81-
82-
return DiscoverOutput(
83-
models_added=added,
84-
models_skipped=skipped,
85-
links_created=links_created,
86-
models=summaries,
87-
)
140+
return _ingest(nodes, ledger, input.auto_connect)

tests/test_tools/test_discover.py

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,13 +158,80 @@ def test_file_none_path_raises(self, ledger):
158158

159159

160160
class TestDiscoverConnector:
161-
"""Connector source_type — should raise NotImplementedError."""
161+
"""Connector source_type — config-drivable connectors run; others return
162+
a graceful error in DiscoverOutput.errors rather than raising."""
162163

163-
def test_connector_raises(self, ledger):
164+
def test_unknown_connector_returns_error(self, ledger):
164165
inp = DiscoverInput(
165166
source_type="connector",
166167
connector_name="databricks",
167168
connector_config={"workspace": "test"},
168169
)
169-
with pytest.raises(NotImplementedError, match="not yet supported"):
170-
discover(inp, ledger)
170+
result = discover(inp, ledger)
171+
assert result.models_added == 0
172+
assert result.errors and "databricks" in result.errors[0]
173+
174+
def test_missing_connector_name_returns_error(self, ledger):
175+
inp = DiscoverInput(source_type="connector", connector_name=None)
176+
result = discover(inp, ledger)
177+
assert result.models_added == 0
178+
assert result.errors and "connector_name" in result.errors[0]
179+
180+
def test_sql_connector_directs_to_sdk(self, ledger):
181+
"""sql needs a live connection — can't come from JSON; point to the SDK."""
182+
inp = DiscoverInput(
183+
source_type="connector",
184+
connector_name="sql",
185+
connector_config={"query": "SELECT 1"},
186+
)
187+
result = discover(inp, ledger)
188+
assert result.models_added == 0
189+
assert result.errors
190+
msg = result.errors[0].lower()
191+
assert "sdk" in msg or "connection" in msg
192+
193+
def test_github_connector_directs_to_sdk(self, ledger):
194+
inp = DiscoverInput(source_type="connector", connector_name="github")
195+
result = discover(inp, ledger)
196+
assert result.models_added == 0
197+
assert result.errors
198+
199+
def test_rest_bad_config_returns_error(self, ledger):
200+
"""Missing required rest config is caught, not raised."""
201+
inp = DiscoverInput(source_type="connector", connector_name="rest", connector_config={})
202+
result = discover(inp, ledger)
203+
assert result.models_added == 0
204+
assert result.errors and "rest" in result.errors[0]
205+
206+
def test_config_connector_runs_and_ingests(self, ledger, monkeypatch):
207+
"""A config-drivable connector is built from config, run, and ingested."""
208+
from importlib import import_module
209+
210+
from model_ledger.graph.models import DataNode
211+
212+
# tools/__init__ rebinds `discover` to the function, so fetch the module
213+
# object itself to monkeypatch its connector registry.
214+
discover_mod = import_module("model_ledger.tools.discover")
215+
216+
class _StubConnector:
217+
name = "rest"
218+
219+
def discover(self):
220+
return [
221+
DataNode("feature_pipeline", platform="rest", outputs=["feature_table"]),
222+
DataNode("scoring_model", platform="rest", inputs=["feature_table"]),
223+
]
224+
225+
monkeypatch.setitem(
226+
discover_mod._CONFIG_CONNECTORS, "rest", lambda config: _StubConnector()
227+
)
228+
inp = DiscoverInput(
229+
source_type="connector",
230+
connector_name="rest",
231+
connector_config={"url": "https://x", "items_path": "i", "name_field": "n"},
232+
auto_connect=True,
233+
)
234+
result = discover(inp, ledger)
235+
assert result.models_added == 2
236+
assert result.links_created >= 1
237+
assert result.errors == []

0 commit comments

Comments
 (0)