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.


Back to top

CRAne. The database is one SQLite file and it is yours.

This site uses Just the Docs, a documentation theme for Jekyll.