Skip to content

dbprint Guidelines

Audience: engineers working on this codebase. Not end users — nothing here describes how to use dbprint, and nothing here is part of its public contract.

Purpose: Coding conventions, style rules, and development commands. For how the pieces fit together at runtime, see ARCHITECTURE.md. For the on-disk format contract, see format/v1/SPEC.md (normative).

Conventions here reflect patterns actually in the codebase. Focus is on what is easy to get wrong — not on what a competent Python engineer already does by reflex.

Mandatory: run just check before every commit and fix every issue. A diff touching only comments and docstrings runs just lint instead; its tests are skipped.

Comments describe current state, not change history. No "was", "previously", "renamed from", "now does X instead of Y". Git tracks history; a comment explains what exists now and why it is non-obvious.

No ephemeral coordination identifiers in source. Issue numbers, dates, and tracker tags do not belong in code, comments, docstrings, or test names — a reader of this repository cannot resolve them. Code describes what is true now.

Python prose is ASCII; string literals are not policed. Docstrings and comments stay inside \x00-\x7F — just check fails otherwise, naming the file, line and code point (tests/test_charset.py). Spec citations are written SPEC 2.2.3 / MCP.md 3.1, never with a section sign. Prose dashes are the ASCII hyphen.

String literals are exempt as a category, not per line, and the boundary is drawn from the AST rather than from noqa markers: a string that is the first statement of a module, class or function is a docstring and is policed; every other string constant is data and is not. The exemption is deliberate — spec_ref values are written to the terminal by check and the format specification illustrates that field with a section sign, so rewriting them would both change what users see and diverge from the normative document. Error-message literals reach stderr for the same reason. This file and the rest of docs/ are Markdown and out of scope.

Ruff's RUF001-RUF003 are enabled but are not this gate: they flag only characters confusable with ASCII, so they miss the em dash, the section sign and the box-drawing dash entirely. They stay on as defence in depth against homoglyphs.

Markdown prose is one line per paragraph, with em dashes. A paragraph, list item or quote is never hard-wrapped, and a dash is —, never a spaced hyphen; the generators under scripts/ emit the same shape. Test substrates are named here and in ARCHITECTURE.md only — a user page states what the adapter does, not what the suite runs it on. tests/test_doc_shape.py enforces all three.


0. Development commands

All commands run from the repository root via just. just (no args) lists them.

Terminal window
just check # full pre-commit gate: lint + coverage-gated test. Run before every commit.
just fix # auto-fix: ruff check --fix + ruff format + ty check --fix
just lint # ruff check + ruff format --check + ty check + deptry + lint-imports + jscpd
# jscpd fails on a clone missing from .jscpd-baseline.json, naming both sides,
# and on an accepted clone with no reason in .jscpd-reasons.json, or a stale reason
just dupes # refresh .jscpd-baseline.json, print every clone, list accepted ones lacking a reason;
# write each reason under its fingerprint and commit both files together
just mutate PKG # mutation-test spec, assertions or conformance against its own tests, in a /tmp copy
# by hand, never in check; prints each surviving mutant as a diff
just test [ARGS] # pytest, no coverage; ARGS narrows (just test tests/engine -k diff)
# prints one SKIPPED line per distinct skip reason, with its count
# stops at a 30-minute wall clock or 80 GiB total resident memory, naming the cap
just test-cov # pytest with coverage (term-missing); what `check` runs
just test-fast # every test needing no database server, container or JVM; parallel, no coverage
# the inner loop while editing - never the commit gate, which is `check`
just docs # regenerate every generated document: CLI.md, MCP.md's tool schemas,
# reference/conformance.md, reference/statistics-matrix.md, the reading guide,
# the annotation schemas
just example # regenerate the reference example against a throwaway Postgres; golden-tested
just example-vocabulary # regenerate the vocabulary example the same way
just demo # regenerate the landing page's terminal recording; golden-tested
just site # build the documentation site: npm ci, astro build, then a dead-link check
just preview # serve docs/ with live reload
just install # uv sync --extra dev --extra mcp --extra docs
  • Python 3.13+. Linting and formatting: ruff (line length 100, isort enabled). Type checking: ty. Tests: pytest.
  • No upper bound on the lint/type-check toolchain. ruff and ty track their latest release deliberately — ruff.toml enables default + extend-select, never an enumerated rule list, so a version bump adopts whatever the new default is rather than freezing the set this project started with. A rule that turns out not to fit gets a stated per-site suppression (see §1 Error handling), never a config-level opt-out that silently re-narrows the set.
  • Coverage is gated at check, not at test. check runs test-cov, which enforces fail_under = 80. just test deliberately skips coverage so a scoped run (just test tests/cli) measures nothing and fails nothing — coverage of the whole package is only meaningful for the whole suite.
  • A per-file coverage percentage disagreeing between two runs of an unchanged tree is worth a second run before it is trusted as a regression. Re-run test-cov once more on the same tree; only a repeat disagreement is real. A single run's number is not self-certifying — coverage.py's own run-to-run stability on this suite has not been proven.
  • -n auto is sized by memory, not by cores. tests/conftest.py divides the smallest of a 32 GiB ceiling, the cgroup limit and MemAvailable by a per-worker allowance; DBPRINT_TEST_MEMORY_MB overrides the ceiling.
  • Postgres and MariaDB are one server per run, shared by every worker. The first worker to need one starts it; each test takes its own database, and the run's controller stops the server at session end. A Spark session and the BigQuery emulator cannot be shared that way, so --dist loadgroup spreads their tests over a few xdist groups, one instance each (_GROUPED_SUBSTRATES in tests/conftest.py).
  • A test needing a server says so at collection. The collection hook marks an item live_server from its fixtures: a substrate fixture taken as an argument, or a vendor parameter resolved through adapter_factory / sql_adapter_factory. A fixture fetched inside the body with request.getfixturevalue and no vendor parameter is invisible to it, so test-fast would start that server — take the fixture as an argument instead.
  • Optional extras: postgres (psycopg[binary]), mysql (mysql-connector-python), snowflake (snowflake-connector-python), duckdb (duckdb), clickhouse (clickhouse-connect), redshift (redshift-connector), databricks (databricks-sql-connector), bigquery (google-cloud-bigquery), mcp (the serve command: the MCP SDK plus the anyio, Starlette and uvicorn it imports directly), docs (the dbprint docs site: Flask, Markdown, inflect, and the Jinja2 and MarkupSafe it imports directly), dbt (sqlglot, for dbt_manifest lineage). deptry fails lint on a package imported but not declared, or declared but imported by nothing; a connector imported through importlib and the test toolchain are named in [tool.deptry] instead. dev pulls the test + lint toolchain, plus chdb for the ClickHouse contract suite — the one substrate here that is a library, not a server — and pyspark/delta-spark for the Databricks one. Redshift has no substrate of any kind, local or in-process; its contract suite runs against Postgres, wrapped in a shim that answers the Redshift-only catalog statements from Postgres's own catalog (tests/adapters/conftest.py). Databricks has none either: its production driver speaks Databricks' own wire protocol, which nothing local can stand in for, so its contract suite runs a real local PySpark + Delta session instead — a genuine engine, but the information_schema-absent fallback path only, never real Unity Catalog. BigQuery's contract suite runs against a real goccy/bigquery-emulator container instead, reached through a REST-based test cursor rather than the real google-cloud-bigquery client (measured to hang against this emulator) — a genuine query engine, but several INFORMATION_SCHEMA views are absent from it regardless. Vendor connectors are imported lazily so a base install never pays for them (see §1, Imports).
  • Virtualenv: the justfile auto-routes uv to a container-local venv under /tmp when it detects a container; otherwise it uses .venv/ in the working directory. Do not run uv sync directly inside a container — go through just.

1. Python conventions

These apply to every package. They are uniform across the codebase; deviations are bugs.

Module layout and docstrings

  • Every .py file opens with a module docstring, then a blank line, then from __future__ import annotations, then imports. The docstring's first line is a one-line summary; further lines are rare — see the terseness rule below.
  • Blank line after any docstring — module, class, function — before the body.
  • One line is the default for every docstring and comment, not the floor. Multi-line is earned by a caller's need — a non-obvious contract, an edge case, a Raises clause — never by a maintainer's argument for the code. Cite (SPEC N.N, ARCHITECTURE.md §N) instead of re-deriving what the citation already says; alternatives-considered reasoning and worked numeric examples belong in a test or nowhere, not in the docstring.
  • Class docstring wherever the name does not carry the role. Always on dataclasses and records, whose field semantics and sort/identity rules need stating. One line unless those rules need explaining. A Test* class carries one only when the grouping's rationale is not already in its name; a test_* function's name is its description.
  • Function docstring on public functions, sized to complexity: a one-liner when the name + signature already tell the story, multi-line only when behavior, an edge case, or a Raises contract cannot be read off the signature. Private helpers may omit the docstring when self-evident.
  • Do not restate the signature or type hints in prose. Document why and what is not obvious — in the fewest words that carry it.
  • Comment/docstring lines stay under ~100 chars (ruff's line-length). Compress wording first; if content genuinely doesn't fit, split into multiple tight lines (each a complete thought), never one crammed mega-line or word-wrapped prose.
  • Public definitions precede private ones at module level, so the surface reads before the machinery. Two shapes are exempt because moving them breaks or obscures resolution: a private consumed at import time (a decorator target such as cli/main.py's root group, a Protocol named in a signature below it, a helper feeding a module-level constant or a registration call), and spec/predicate.py, whose parse / _parse_* / evaluate / _eval_* grouping mirrors the two branches a new predicate form must add and is worth more than the ordering.

Type hints

  • from __future__ import annotations in every module that contains code. ruff.toml sets required-imports, so just check fails any module that omits it. Package markers holding nothing but a docstring are the one exception — there is nothing to annotate, and ruff does not flag them.
  • PEP 604 unions and built-in generics: str | None, list[str], dict[str, Any], tuple[str, ...]. Never Optional, Union, typing.List.
  • Return type on every function, including -> None.
  • Protocol for structural contracts (cursor seams, callback shapes) — method bodies are .... Use a Callable[...] type alias for factory hooks.
  • cast(...) is the sanctioned escape hatch for narrowing catalog strings into a Literal or bridging a known signature mismatch. Use it with a short comment, not as a habit.

Imports

from __future__ import annotations
import json
from pathlib import Path
import yaml
from dbprint.config import StatisticsConfig
from .base import Adapter
  • Four groups, blank-line separated: __future__ -> stdlib -> third-party -> first-party. Intra-package relative imports continue the first-party group with no blank line between them (ruff.toml sets no-lines-before = ["local-folder"], so isort enforces exactly this). Two blank lines before the first definition.
  • Cross-package imports are absolute (from dbprint.engine import ...); intra-package imports are relative (from .base import ...), including the parent package (from ..errors import ...).
  • Alias a sibling helper module only when its bare name would collide with a local binding: from . import introspect as introspect_module because introspect is also a variable there. Where no collision exists, import it bare (from . import errors).
  • Lazy / function-scoped imports for two reasons only: (1) optional vendor extras — import the connector inside the factory via importlib.import_module, catch ImportError, and re-raise an actionable "install dbprint[]" message; (2) breaking an import cycle or deferring a heavy import off the hot path.
  • Declare __all__ in packages that expose a curated public surface. F401 is ignored in __init__.py for exactly that reason and enforced everywhere else, so an import left behind by a deletion fails just check rather than lingering.

Data structures

  • @dataclass(frozen=True) is the default for records, value types, requests, parsed nodes, and config. Mutable dataclasses are reserved for accumulating result objects.
  • Mutable defaults via field(default_factory=...) — never a bare [] / {}. Copy a module-level default constant inside the factory so it cannot be mutated.
  • Prefer immutable fields: tuple[str, ...] over list[str] for things that should not change after construction.
  • Literal for closed string sets — never enum.Enum. Declare the alias at module top in PascalCase (Classification, Severity, RenderMode). Vocabulary membership sets are frozenset.

Naming

  • Private helpers, attributes, and constants take a leading _.
  • Module-level constants are UPPER_SNAKE; private ones _UPPER_SNAKE. Config defaults take a _DEFAULT suffix; compiled regexes take an _RE suffix.
  • Type aliases are PascalCase; everything else is snake_case.
  • Use keyword-only arguments (*) for helper options that would otherwise be positional noise.

Whitespace and layout

Separate logical phases with blank lines; keep cohesive runs tight. The codebase is uniformly spaced this way.

  • A blank line precedes every control block (if / for / while / with / try) and every return / raise that is not the first statement in its block.
  • A blank line follows a block before the next statement at the outer level.
  • Tight (no blank lines): dataclass field lists, dict / list literals, comprehensions, runs of related simple assignments, and elif / else / except / finally chains.
  • Use if / elif / else for mutually-exclusive return dispatch — not a sequence of bare if cond: return followed by a fallthrough return.
  • A construct split across lines puts one element per line. Lists, dicts, sets, tuples, call arguments and parameter lists are either on a single line or exploded one per line with a trailing comma — never split at the brackets with the contents sharing an indented line. COM812 and the formatter's magic trailing comma enforce this together, and just fix iterates because adding a comma re-wraps a construct and can expose a nested one. Comprehensions sit outside the rule: they carry no element commas, so the formatter splits them at the bracket and their shape is a review question.
def classify(name: str, cardinality: int | None) -> Classification:
"""Return the first matching classification per the priority order."""
sql_type = _base_type(name)
if sql_type in _UNSUPPORTED:
return "unsupported"
elif cardinality is not None and cardinality <= ENUMERATION_THRESHOLD:
return "categorical"
else:
return "text"

Error handling

  • Raise vs return is deliberate, and load-bearing. Raise for caller errors and structural problems the caller cannot recover from in line; return a typed outcome (an Issue, an Outcome(malformed=True), a sentinel node) when the failure is data the caller aggregates. When in doubt, match the package's existing split (see §2).
  • Custom exceptions subclass the most precise stdlib base (ConfigError(ValueError), IdentifierRejected(ValueError)). Define them next to the code that raises them. There is no single global exception base.
  • Messages are lowercase, actionable, and quote the offending value with !r. User-facing errors embed remediation (the install hint, a copy-pasteable exclude: snippet, the command to run).
  • raise NewError(...) from exc whenever wrapping a caught exception — preserve the cause.
  • Run-all-then-report: a failure in one unit (table, connection, check) never aborts its siblings; outcomes aggregate. See ARCHITECTURE.md §9 for the level matrix and the exit-code model.
  • A broad except Exception is common here — run-all-then-report, close-time cleanup, and a stdlib/protocol contract that forbids raising (logging.Handler.emit, an MCP tool call) all need one. Ruff's BLE001/S110 flag every occurrence; where the catch is deliberate, suppress it at the site rather than fixing what is not broken: except Exception: # noqa: BLE001 - why this site is intentional. The reason is mandatory and must be true of that site — identical wording across sites that genuinely share one idiom (six adapters degrading the same way on a failed temporal fetch) is fine; the same wording pasted onto a site it does not actually describe is not. No rule-level or file-level ignore, for BLE001/S110 or any other rule — see §0's "no ceiling" note; suppressing a rule everywhere is indistinguishable from never adopting it. An interrupt never reaches one: exec_query re-raises a driver's converted Ctrl-C as KeyboardInterrupt, so no seam needs its own check.

Logging

dbprint is a library; it stays quiet.

  • ✅ The engine uses stdlib logging for the rare operational warning: _LOG = logging.getLogger(__name__) at module level.
  • ✅ adapters may log one DEBUG statement trace per exec_query/pg_dump call — text, bound parameters (never merged into the text), elapsed time, row count where the driver reports one — tagged with the connection/table/operation context the engine sets via adapters/trace_context.py. This is the only logging this carve-out adds; it does not license further logging elsewhere in an adapter.
  • 🚫 config, conformance, spec, assertions, and mcp do not log at all; new adapter code adds no logging beyond that one statement trace. Every other adapter concern communicates through return values and raised exceptions.
  • ✅ User-facing output is the CLI's job: Rich Console for TTY rendering, click.echo(..., err=True) for messages and warnings to stderr.
  • 🚫 Never print() — except protocol I/O (the MCP stdio transport) and the CLI's piped-stream writes.

2. Package conventions

Each package's runtime role is documented in ARCHITECTURE.md; below are the style rules that ride on top. Dependencies flow downward only — see ARCHITECTURE.md §1 for the layering rule.

adapters — talk to one database

  • ✅ Implement the Adapter ABC (ARCHITECTURE.md §2); return typed intermediate records (TableMeta, ColumnStats, ...), never artifact-shaped dicts.
  • ✅ Honor the full abstract signature even when ignoring arguments (del the unused params with a comment rather than dropping them).
  • ✅ Parameterize values, using the placeholder style the driver's configured paramstyle expects (%s for psycopg and mysql-connector; ? for the Snowflake connector, which is opened with paramstyle="qmark"). Fold and quote identifiers only through adapters/identifiers.py — the carrier list_tables captures quotes every name a statement addresses; tests/adapters/test_identifier_guard.py fails a hand-rolled copy. Build and split an FQN only through spec/fqn.py, anywhere in the package; tests/test_fqn_guard.py fails a hand-rolled . split or join. Never interpolate raw values; row limits and sample sizes come from validated config integers, not from user input.
  • ✅ scope.filter is the one interpolated string, and the exception is deliberate. It cannot be bound: a placeholder carries a value, and this is a boolean expression — and SPEC 2.2.8 requires it recorded verbatim and forbids a producer parsing, rewriting or validating it, so there is nothing to hand a driver. It reaches SQL in exactly one place per adapter, the source expression every statistics query selects FROM. The trust boundary is .dbprint.yaml, which already names the connection whose credentials the run uses: anyone who can edit it can already reach the database. Nothing else in that block relaxes — the sample fraction is a float validated into (0, 1] at load, and a new scope key is parameterized or validated unless it is a predicate too.
  • ✅ A statement that carries a quoted column, a key literal or scope.filter binds no parameters. pyformat drivers parse % in the text on their own side once parameters are passed (psycopg, clickhouse-connect and BigQuery's DB-API any %, redshift_connector one outside quotes, mysql-connector %s), so the seed goes in as a literal through statements.seed_order. Catalog statements binding names stay parameterized. tests/adapters/test_dialect_guard.py fails a bound statement that carries a swept column or the filter. A key literal's escaping is correct under one string-literal grammar only, so an engine whose session can change that grammar pins it when the session opens.
  • ✅ An unbound statement reaches the engine as written. google-cloud-bigquery's DB-API and clickhouse-connect's DB-API cursor turn %% into % even with nothing bound, so their exec_query doubles every % first. Both test cursors do the drivers' rewrite.
  • ✅ Gate vendor connectors behind a lazy import + extras message; expose a cursor_factory seam for databases that cannot run in-process (see ARCHITECTURE.md §2).
  • ✅ An adapter's SQL is validated against its own engine's dialect, and that is not optional. Each adapter package declares a DIALECT (vendor + driver paramstyle); tests/adapters/test_dialect_guard.py sweeps every statement the adapter emits from a recording proxy on its live cursor and rejects syntax the declared vendor does not accept. A new adapter adds its declaration, and the sweep covers it. Two things the guard needs to keep working: the contract fixture must stay wide enough for the classification-specific statistics branches to run (they emit nothing on a three-row table, so their SQL would go unchecked), and a new vendor-exclusive construct belongs in adapters/dialect.py's support table.
  • ✅ Every hand-written SQL statement keeps one house style. Keywords, built-in function names and type names are uppercase; identifiers stay exactly as written. JOIN, never INNER JOIN; FULL JOIN, never FULL OUTER JOIN; a single short condition stays on the JOIN ... ON line, and several end that line with ON and follow two spaces deeper, AND leading. Every table and derived table carries an alias written without AS — a statistics source is always src (identifiers.SOURCE_ALIAS) — and every column reference is qualified with it, single-table statements included. COUNT(1) counts rows, COUNT(col) counts non-nulls, never SUM(CASE WHEN col IS NOT NULL ...). Percentages put the multiplier first (100 * x / y). Carve-outs: ClickHouse function and type names keep their own case, since ClickHouse resolves them case-sensitively; the table inside a scope.filter wrapper or a sample copy stays unaliased, so a filter written against the table's own name still resolves; output-column references (ORDER BY cnt, a CTE's own columns) stay bare; GRANT, SHOW, DESCRIBE and DDL (a view's body included) follow the casing rule alone. A table function takes an alias like any table, so GENERATE_SERIES(1, n) gen (i) is read as gen.i. Every alias is a three-letter mnemonic of its object (cls, att, nsp). Layout is judged on the Python source, not on the emitted text: a statement or fragment written across lines is one triple-quoted f-string, never pieces joined by \n. Inside it each clause keyword (SELECT, FROM, WHERE, GROUP BY, HAVING, QUALIFY, ORDER BY) stands alone and its body starts on the next line two spaces deeper (SELECT DISTINCT and LIMIT n keep their word on the keyword's line); a select list puts one item per line, trailing commas; joins sit under the first source; a CASE with more than one WHEN puts each WHEN, ELSE and END on its own line; a multi-line fragment interpolated into it goes through indented(x, N), N being the column it sits at in the source. A statement stays a one-line f-string only when all of these hold: it is under 100 characters, selects tbl.* or one tbl.column (an AS alias on that column allowed), reads one table with no sample clause and no join, and has at most one WHERE condition with no AND or OR — an interpolated scope.filter counts as one, and the scope.filter wrapper keeps its unaliased SELECT * FROM table WHERE ... form. A fragment that fits one source line is written on one line. tests/adapters/test_dialect_guard.py's TestSqlStyle checks every swept statement against the casing, alias and qualification rules, and tests/adapters/test_sql_style.py checks every .sql file against those and the layout above, with no line over 120 characters; an adapter page's sql fence is held to the casing rules alone. Review holds what neither can judge: the clause body's two-space indent, one WHERE condition per line, blank lines grouping a long SELECT, and whether a CASE handles NULL first where its input can be NULL.
  • ✅ Every type spelling an adapter's catalog can report is declared. It is listed in tests/adapters/fixtures/type_spellings.yaml with the classification it must publish; the adapter keeps no numeric/temporal/boolean/json tuples of its own (the shared predicates in spec/classification.py decide the family), adds a vendor type its engine cannot compare or enumerate to _UNSUPPORTED_TYPES, and a vendor type it profiles as text to _TEXT_TYPES.
  • ✅ A statement near-identical across engines is written once, in adapters/statements.py. What an engine spells differently becomes a Dialect field once a second engine needs the same seam; a quirk only one engine has stays in that adapter, passed in as an operand or a rendering.
  • ✅ A declined column is kept out of statements by the driver, never by a builder. An adapter hands run_phase_a its declines predicate, and its Phase B goes through run_phase_b, so one column's failed statement degrades that column, not its table.
  • ✅ A type with a lossless comparable form goes in the rendering module's operand map (render_operand), never into a statement: every comparing, grouping or aggregating statement reads the column through it.
  • ✅ A per-type rendering rule lives in the adapter's rendering.py and nowhere else. A temporal type tuple or a _render* function in any other adapter module fails tests/adapters/test_secondary_type_sweep.py, and a new abstract Adapter method joins that sweep's statement table or its allowlist.
  • ✅ A cell value reaches a ValueCount only through spec.rounding.measured_value. No adapter converts a numeric cell itself; tests/adapters/test_exact_numerics.py fails on a float(...) or a ValueCount that bypasses it.
  • 🚫 Do not stamp classification onto stats — the engine applies the spec priority. Do not log anything beyond the one statement trace exec_query/pg_dump already emit (see §0 Logging). Do not catch user-SQL execution errors (let them propagate so the assertion layer surfaces them).

What the dialect guard does not prove. It proves an adapter emits syntax its own engine accepts and that every branch producing SQL runs. It cannot prove the engine accepts the statement — only running it against that account does. The Snowflake adapter runs against an in-process duckdb substrate, so a green suite is evidence of dialect conformance, never of live validation.

cli — parse, dispatch, render

  • ✅ import rich_click as click. One module per subcommand under commands/, each exporting a <verb>_command callback wired into the root group in main.py via add_command.
  • ✅ A command callback's docstring is its --help text, and it carries every section: a one-line summary, then purpose + side effects, then **Arguments:** documenting positionals (Click renders no help= on arguments), then **Exit codes:** and **Examples:**. Every one of these is a Markdown heading inside the docstring itself: main.py sets rich_click.TEXT_MARKUP = "markdown", so the docstring is the entire help text and Markdown renders as written. Make every option help= concrete, with an inline example where the value is non-obvious. The reference at docs/CLI.md is generated from --help (just docs) and golden-tested, so help is the single source of truth — never hand-edit docs/CLI.md.
  • ✅ Exit via ctx.exit(worst_exit(*codes)) — engine exit codes are data on the result objects; the command combines them by EXIT_PRECEDENCE, never with max. Never sys.exit.
  • ✅ Detect TTY in exactly one place (rendering.resolve_render_mode, the only isatty() caller). The TTY/piped branch belongs only at renderer selection. Machine formats and --output writes always force piped.
  • ✅ Catch per-connection setup failures and turn them into a synthetic result with the connection exit code; never let one connection abort the run.
  • 🚫 No SQL, adapter, or engine logic in the CLI. It builds an adapter via the registry, constructs Engine, and renders the result.

config — load and validate

  • ✅ Pure parsing and validation of .dbprint.yaml, the connections file, and .env. Frozen dataclasses; tuple fields; default_factory for nested config.
  • ✅ Validate .dbprint.yaml whole against the packaged config/project.schema.json — the schema editors use — plus the few cross-field checks a schema cannot state, and raise one ConfigError listing every problem by its full path. The builders after it construct, never validate. Accumulate every unresolved credential key and raise once, listing them all.
  • 🚫 No database I/O. The only filesystem access is config discovery (walk up for .dbprint.yaml).

conformance — validate a print

  • ✅ validate_print() returns list[Issue], ordered (it relies on Issue being frozen=True, order=True with field order == sort order). It never raises for a format-level problem — malformed YAML becomes a schema.invalid-yaml Issue.
  • ✅ Raise only for filesystem/IO problems it genuinely cannot inspect.
  • ✅ A validator recomputes a published value through the same shared spec/ rule the producer wrote it with, clamp included (as in coverage_share) — a hand-rolled division instead rejects every clamp the producer legitimately applies. The clamp does hide the condition that caused it, so the operands it was applied to are checked separately, and that check is what reports the disagreement. Where the operands settle a flag the rule takes (exhaustive), derive it from them rather than reading it back, or the compare becomes a value against itself; where only the producer knows — a list may be short by its own truncation — the published field is the only source and _is_exhaustive reads it.
  • ✅ Issue codes are dotted namespaces (schema.*, stats.*, version.*); spec_ref cites the section (§2.2.3). Severity is exactly error or warning — there is no info.

spec — normative helpers

  • ✅ Pure, deterministic, no I/O, no state (classification.py, looks_like.py). The branch order is the spec priority — first match wins; do not reorder.
  • ✅ FORMAT_VERSION lives in spec/v1/__init__.py as the single source. JSON Schemas load once via importlib.resources (shipped in the wheel).
  • ✅ Artifact YAML is parsed only through spec/artifact_yaml.py, never yaml.safe_load or a PyYAML loader class: its loader runs on libyaml's parser, and a custom loader subclasses ArtifactLoader. config/ is exempt — it reads user-authored files, not artifacts. tests/test_artifact_yaml_guard.py fails any other module naming a PyYAML load function or loader.

engine — orchestrate

  • ✅ generate() and compute_diff() share the private _run_extraction() pipeline; the divergence is the write-or-not boundary (ARCHITECTURE.md §3). Frozen request dataclasses in, result dataclasses out, Literal status fields.
  • ✅ A run's writes go through its stage and commit together (ARCHITECTURE.md §4): the unit of all-or-nothing is the run's commit, and a caught table failure still isolates to that table. Never overwrite user-authored description.md or statistics.annotations.yaml.
  • ✅ Every number a renderer shows goes through spell_number or spell_percent (spec/value_text.py): no format spec, round(), %-formatting or Jinja round/format filter in a renderer outside layout geometry; tests/engine/test_number_spelling_sweep.py fails one.
  • ✅ Artifact YAML targets YAML v1.2's core schema, not PyYAML's own (1.1-based) default resolver: yaml_dumper.py's str representer quotes a scalar whenever a 1.2 parser would retype it (null/bool/int/float), covering the exponent-form float production 1.1 misses. A new driver type is handled where the value is read — render_text in the adapter's rendering.py for a string-like column, spec.rounding.measured_value for every other published cell — never by a new dumper representer. The dumper refuses set, bytes and any unrecognised type with UnrepresentableValue rather than writing a !!set/!!binary tag.
  • 🚫 The engine knows the on-disk layout but not Click — it returns typed results, it does not render.

assertions — the check DSL

  • ✅ Structural problems raise ParseError (in parser.py only). Bad predicates and failed checks return (MalformedPredicate, Outcome(malformed=True), an Issue) — the evaluators never raise for assertion failure.
  • ✅ The predicate AST is a closed set of frozen dataclasses unified by a Predicate union alias (not an enum, not a class hierarchy). Adding a form means: new dataclass, extend the union, add the _parse_* branch and the _eval_* branch.
  • ✅ Every evaluator's evaluate() sorts its Issue list before returning — determinism is part of the contract. Issue codes come from the codes module, never hardcoded strings.

mcp — serve prints

  • ✅ resources.py and tools.py are pure: (state, *args) -> dict | dataclass, no SDK import, no async. server.py is the only file that imports mcp and wires the pure handlers to the SDK (ARCHITECTURE.md §11).
  • ✅ The [mcp] extra is gated by the CLI caller, not re-checked in server.py. HTTP transport binds to loopback only (enforced upstream), and server.py answers only a loopback Host and Origin (MCP.md 6.2). Every parse goes through the served state's ParseCache (mcp/parse_cache.py), which re-checks each file's identity on disk per call (MCP.md 7.1); a cached document is shared, so no reader mutates what it loads.
  • ✅ Errors are factory functions returning a frozen McpError (which subclasses Exception); codes are restricted to the JsonRpcCode Literal. Both entry points surface the typed error to the client: read_resource translates it into the SDK's own error object (mcp.shared.exceptions.MCPError(code, message)), and call_tool catches it explicitly and returns CallToolResult(..., is_error=True) itself — the SDK does not wrap a raised exception into one.

3. Testing

The pytest tree under tests/ mirrors the source layout. just check enforces 80% coverage over the whole suite; just test runs without coverage so scoped runs stay usable.

Principles

  • Test behavior, not implementation. Call the public surface and assert observable outcomes. Use real dependencies where practical (the contract suite runs against real Postgres/MariaDB; Snowflake uses an in-process duckdb substrate — ARCHITECTURE.md §10).
  • ✅ Always assert an observable outcome — never "does not raise" without an explicit assertion. Parameterize tests that differ only in input values.
  • 🚫 Do not test language guarantees — dataclass defaults, default_factory execution, enum/Literal membership, isinstance on typed constructs. The declaration is the spec.
  • 🚫 Do not mock the only collaborator and assert its return reaches the response — that tests the framework, not the code.
  • 🚫 Do not re-implement production logic in the test. Call the real code path.
  • 🚫 Do not derive a test's expected value from the same artifact or object the assertion checks — parsing a config file and asserting a field read from that file, or reading a live object's own attribute and checking rendered output contains it. Use an independently authored literal instead.
  • 🚫 Do not take an expected value from a production constant, table or formula. A test reading CAP, a token table or the rounding rule it guards moves with the code and cannot fail. The expected value is a hand-written literal or a round trip (yaml.safe_load of what was printed).
  • 🚫 Do not pin private attributes, SQL template text or log message wording. Assert what a user sees — the rendered output, the published artifact — or, for a log, the record's level and the object it names.
  • 🚫 Do not test the stand-in database. A shim or fake that answers in place of a vendor is test infrastructure; a test whose outcome only the shim decides proves nothing about the adapter.
  • 🚫 Do not check a bound against a fixture that sits inside it. <= CAP over fewer rows than the cap passes with the cap removed; size the fixture past the bound.
  • Assert one behaviour once. Where a spec helper and the validator share a rule, the conformance test through validate_print is the one kept. The consumer suite is the exception: each surface asserts the whole claims register on its own, by design.
  • A consumer surface ships with register entries, not a byte-golden alone. tests/consumer/register.py is the shared claims register — one entry per consumer-visible state that has produced a wrong rendering, with the obligation every surface owes it. A new surface (dbprint context, an MCP channel, the docs site, the guide, or one added later) declares the register entries it asserts in a module-level COVERS frozenset and adds itself to _SURFACES in tests/consumer/test_register_coverage.py — that dict is hand-maintained, not a package scan, so a surface left out of it is never checked at all. Once listed, it fails short of the full register by name, and fails every surface a new register entry has not yet reached. A byte-golden still pins layout; the register is what pins truth.
  • A property test states the helper's contract, never its implementation. A pure helper whose docstring promises something for every input — a round trip, a spelling that never takes an exponent, a share that never reads as 0% — gets a Hypothesis @given test asserting that promise over generated inputs. The expected value comes from the contract or an independent parser (yaml.safe_load, float(), Decimal()), never from re-running the helper's own arithmetic, and a counterexample Hypothesis finds is pinned with @example beside its fix. just check runs the derandomized check profile; DBPRINT_HYPOTHESIS_PROFILE=local searches wider.

The acid test: if you replaced the implementation with a no-op or a wrong implementation, would the test fail? If not, it is not earning its keep. just mutate <package> runs it mechanically: each surviving mutant is a test gap, or an equivalent mutant recorded with its reason in [tool.mutmut]'s do_not_mutate_patterns.

Structure

  • conformance is the format gate. Tests validate generated prints through validate_print() and assert zero error-severity issues; the suite is also consumable by external CI (ARCHITECTURE.md §1).
  • MockAdapter (adapters/mock.py) drives engine tests without a database. Every adapter runs the shared contract battery (tests/adapters/test_base_contract.py) against its substrate. Its answers are the fixture's, so two reads of one table agree by construction — pass responses={method: [...]} where a test needs one read to disagree with the next, which is the only way a table taking writes between two statements is representable here.
  • Scaffolding a second module would copy lives in a tests/_*.py helper, and a production rule is imported, never respelled. _prints builds mock tables and a connection at its defaults, _engine_run reads back what a run wrote, _cli writes project files and exports credentials through env_var_name, and _scripts loads scripts/ by path and owns REPO_ROOT; Postgres connections and per-test databases come from tests/conftest.py's pg_connect and fresh_database. A test of a rule itself keeps literal values as its ground truth, since one derived from the rule cannot fail.
  • A producer test asserts against the validator, not against a remembered shape. A test that pins what the engine emits and never runs validate_print over its own output can bless an artifact the conformance suite rejects, and neither side will say so.
  • Fixture objects are named from the reference example's seed-bank domain. The canonical objects come from scripts/sql/schema.sql — seedbank.taxon, seedbank.collector, seedbank.vault, seedbank.accession, seedbank.germination_trial, seedbank.specimen_image, seedbank.storage_reading, fixture.shape_probe — and the same domain already covers curator, herbarium, herbarium_sheet, viability_check, botanist, curation_event, active_curators, garden, cultivar, fieldwork, sowing_trial, germination_reading, curator_profile, curator_note, batch, fixture.staging, fixture.contact_probe, fixture.credential_probe, fixture.probe_target and fixture.type_probe. arboretum is the vocabulary's word for the database-level segment of a three-part FQN, a tier none of the above names. Two things stay: the schema names a database itself supplies (public, information_schema, pg_catalog, mysql, sys), and structural placeholders carrying no domain at all (public.t, a/b/c, wide, narrow). loan is excluded, not sanctioned — a specimen loan is real vocabulary here, but the word reads as financial on sight; qualify it (specimen_loan) or reach for a different object. Extend the vocabulary here as a fixture needs it — add the word in the same commit that uses it. field_site, field_log, field_round and sample are further structural table names, carrying no fixed shape of their own. orchard, nursery, seed_lot, tally, gauge, bed and plot are further table names, and an inheritance or partition child suffixes its parent's name (orchard_row, nursery_bed, fieldwork_backlog, herbarium_eu, sowing_trial_early).
  • A view suffixes the object it reads: _v, or _mv when materialized. active_curators_v, specimen_loan_v, germination_by_taxon_mv. The stem stays a sanctioned name, so the suffix adds a shape rather than inventing vocabulary.
  • A pluralized object name is a divergence everywhere except where the plural is the subject. Fixtures are singular (seedbank.taxon, seedbank.botanist), because a plural reads as a different object from the one the vocabulary sanctions. The one exception is tests/engine/test_inference.py's public.curators, where the foreign-key stem rule under test is what strips the s — renaming it would delete the case.
  • A column is named by the role a fixture needs it to play, so a rename cannot silently move a test's subject: a foreign-key-shaped id is herbarium_id; a low-cardinality categorical is rank; a nullable integer is seed_count; a nullable timestamp is withdrawn_at; a non-nullable future-dated timestamp is matures_at; a row's last-update stamp, the one column a page may read as the data's currency, is updated_at; a self-referential FK is mentor_id; an id column carrying no constraint is recorded_by; a binary blob is field_photo; a free-text column is field_notes; a numeric measure is viability_pct; herbarium's own climate categorical is biome; a digit-shaped numeric identifier carrying no sensitivity token of its own is accession_number; a column existing only as an annotation, with no statistics of its own, is shelf_location; a free-typed column with no role beyond filling out a shape (a composite-key member, a column outside a declared key) is condition, cohort or plot; a personal-data column is one of seedbank.collector's own — full_name, email, phone, institution, street_address, postal_code, country_code. tests/spec/test_sensitivity.py's corpus is not fixture vocabulary: its names are the classifier's own input and stay real-world PII, finance, health, HR and telecom words; a shipped example needing a materialized sensitivity case already has one in vocabulary_schema.sql.
  • A fixture name implies a shape. A test may use a real seed-bank object's name only with that object's real columns; a fixture needing a different shape invents a different object rather than borrowing a name it does not honour.
  • Writes go to an ephemeral scratch tree (pytest tmp paths) — never the working tree. The live clusters build under the first system temp directory their own account can write to, and a session-scoped guard fails the run if anything the suite named appears outside it.
  • The suite sandboxes itself outside a container. pytest_configure re-execs under bwrap with the host bound read-only, only the scratch tree writable and a private PID space, so a fixture writing to an unexpected path fails instead of reaching the machine, and no server the run starts outlives it. Inside a container it is skipped deliberately — the blast radius is already the container, and namespace creation is usually unavailable there. A missing bwrap outside one raises rather than falling back: a sandbox that quietly does not run is worse than none, because it is trusted. CI gains nothing from this, since the workflow itself runs in a container.
  • A run reclaims what an interrupted run left behind, by owner, never by name. Every live substrate is recorded in dbprint--substrates/ under the scratch root before it starts; the next session's start stops each one whose recording process is provably dead and leaves a live or unjudgeable owner's alone, so two suites can share a machine.
  • A skip total means nothing without its reasons. Every skip is a declared capability or credential gate — no container runtime, no JVM, a live tier's credentials. A substrate that is present but cannot start fails its tests, naming what held the port or the memory, so a starved run cannot pass as a green one with tests silently unrun. Compare skips per reason line, never as a bare count.

Cross-references

  • ARCHITECTURE.md — module map, layering, adapter protocol, engine flow, write contract, error model, exit codes, test substrates.
  • format/v1/SPEC.md — normative on-disk format specification.
  • CONFIG.md — every .dbprint.yaml and credentials key, with defaults.
  • MCP.md — MCP server specification.
  • ASSERTIONS.md — assertion DSL specification.