Writing an adapter
You already have an inventory somewhere — a spreadsheet, a build tracker, a CMDB, a home-grown SQLite file — and no reason to retype it. An adapter reads that system and writes what it says into CRAne’s model.
crane.adapters.example is a complete worked one, in the repository, exercised by the
suite. Read it first: it is small enough to hold in your head, and everything below is
visible in it.
crane import adapter # lists what is installed
crane import adapter example <path> # runs the example against its invented source
crane import adapter example <path> --dry-run --json
Where an adapter lives
crane.adapters.<name> is what crane import adapter <name> imports. Discovery is by
module name and nothing else, and nothing in the core imports an adapter — so a deployment
that carries none works normally, and crane import adapter with no name says what is not
installed and exits 2.
Your adapter is not committed to this repository, and that is deliberate. An adapter
reads somebody else’s system, so it necessarily exposes that system’s table names and by
implication its data model. That is tenant tooling, not part of the product: it stays out of
the repository and out of every published image. .gitignore keeps src/crane/adapters/*.py
out, with example.py as the single exception — it reads an invented source that describes
nobody’s real system, which is what lets the contract be exercised in CI.
Keep your adapter in version control somewhere of your own. Nothing here backs it up.
The contract
Two functions. The CLI calls nothing else:
def run_import(conn, path, *, options=None, dry_run=False) -> Report: ...
def render_report(report) -> str: ...
run_import returns anything with as_dict() and unresolved. render_report returns the
reconciliation report as text. cmd_import_adapter checks for both by name and exits 2
without them, so an adapter that is half-written is refused rather than half-run.
Five rules
1. Open the source read only.
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
Not a convention — a URI the driver enforces. An adapter reads somebody’s system of record, and a bug in one must not be able to write there.
2. Account for every row. A row that imports is counted; a row that cannot be placed
becomes an unresolved entry naming the table, the row and the reason. Nothing is dropped
silently, because a migration that loses one row in a thousand is worse than one that fails
outright — nobody finds out about the first.
"2 rows unresolved" is not something anybody can act on. The table, the row and the reason
are.
3. Register vocabulary, do not translate it.
self.w.register_vocabulary(NAMESPACE, VOCABULARY, owning_adapter=NAMESPACE)
Your source says things CRAne has no core term for. Declaring them at run time, in your own namespace, means installing an adapter is never a core schema change — and a value keeps the meaning it had at the source instead of being flattened into the nearest core term and losing what it said.
4. Discovered is not decided. A row carrying an approver becomes an approved decision with that person named as the authority. Everything else lands as an unreviewed observation. Inventing an approval produces an audit trail nobody can distinguish from a real one, which is the one thing this tool exists to prevent.
5. Open a transaction explicitly.
conn.execute("BEGIN")
try:
...
conn.rollback() if self.dry_run else conn.commit()
except BaseException:
conn.rollback()
raise
db.connect opens SQLite with isolation_level=None, so a connection is in autocommit and
every statement is already its own transaction. Without the BEGIN, rollback() has nothing
to undo: --dry-run writes the entire import to disk while reporting that it did not, and a
failure part-way leaves half a portfolio behind.
Deterministic ids
Every target id is derived from a stable description of the source row, so a second run updates in place rather than duplicating.
ids.legacy_id(LEGACY_DB, "products", row["id"]) # a UUID for one source row
ids.dedupe_key(LEGACY_DB, "products", row["id"], "tier") # a readable idempotency key
Re-running an import is the ordinary way to pick up changes at the source, and it has to converge. An adapter whose second run doubles the portfolio is worse than one that refuses to run twice.
The invented source the example reads
One SQLite file, three tables. Small on purpose, and shaped to exercise the parts of the model an adapter usually has to reach.
| Table | Columns | What it exercises |
|---|---|---|
teams |
division, organization, team |
the three-level accountability hierarchy |
products |
name, lifecycle, owning_team, tier, in_scope, scope_approved_by |
ownership, registered vocabulary, approved versus observed scope |
components |
product_id, name, purl, source_repo, repo_public, build_location, open_source |
composition, distribution in both directions, and keeping where the build ran separate from where the code lives |
The fixture in tests/fixtures/example_source.py fills it with rows that are each a
different case rather than three of the same thing — including two that cannot be placed, so
the unresolved path is exercised rather than described.
Testing yours
tests/test_the_adapter_contract.py tests the contract rather than the example’s behaviour:
the two functions exist, unknown options are refused by name, the source is genuinely
read only, every row is imported or named, vocabulary is namespaced, approvals are never
invented, a dry run writes nothing and reports what a real run would do, and a second import
changes nothing.
Point it at your adapter and the same assertions apply. The adapter-agnostic tests in
test_core_independence.py, test_deployment.py and test_organization_and_distribution.py
name no adapter at all — they read ADAPTER_MODULE and build_all() from the fixture
module, so swapping which adapter is exercised is one import away.