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.
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 --fixjust 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 reasonjust dupes # refresh .jscpd-baseline.json, print every clone, list accepted ones lacking a reason; # write each reason under its fingerprint and commit both files togetherjust 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 diffjust 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 capjust test-cov # pytest with coverage (term-missing); what `check` runsjust 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 schemasjust example # regenerate the reference example against a throwaway Postgres; golden-testedjust example-vocabulary # regenerate the vocabulary example the same wayjust demo # regenerate the landing page's terminal recording; golden-testedjust site # build the documentation site: npm ci, astro build, then a dead-link checkjust preview # serve docs/ with live reloadjust 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.
ruffandtytrack their latest release deliberately —ruff.tomlenablesdefault + 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 attest.checkrunstest-cov, which enforcesfail_under = 80.just testdeliberately 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-covonce 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 autois sized by memory, not by cores.tests/conftest.pydivides the smallest of a 32 GiB ceiling, the cgroup limit andMemAvailableby a per-worker allowance;DBPRINT_TEST_MEMORY_MBoverrides 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 loadgroupspreads their tests over a few xdist groups, one instance each (_GROUPED_SUBSTRATESintests/conftest.py). - A test needing a server says so at collection. The collection hook marks an item
live_serverfrom its fixtures: a substrate fixture taken as an argument, or a vendor parameter resolved throughadapter_factory/sql_adapter_factory. A fixture fetched inside the body withrequest.getfixturevalueand no vendor parameter is invisible to it, sotest-fastwould 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(theservecommand: the MCP SDK plus the anyio, Starlette and uvicorn it imports directly),docs(thedbprint docssite: Flask, Markdown, inflect, and the Jinja2 and MarkupSafe it imports directly),dbt(sqlglot, fordbt_manifestlineage).deptryfailslinton a package imported but not declared, or declared but imported by nothing; a connector imported throughimportliband the test toolchain are named in[tool.deptry]instead.devpulls the test + lint toolchain, pluschdbfor the ClickHouse contract suite — the one substrate here that is a library, not a server — andpyspark/delta-sparkfor 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 theinformation_schema-absent fallback path only, never real Unity Catalog. BigQuery's contract suite runs against a realgoccy/bigquery-emulatorcontainer instead, reached through a REST-based test cursor rather than the realgoogle-cloud-bigqueryclient (measured to hang against this emulator) — a genuine query engine, but severalINFORMATION_SCHEMAviews are absent from it regardless. Vendor connectors are imported lazily so a base install never pays for them (see §1, Imports). - Virtualenv: the
justfileauto-routesuvto a container-local venv under/tmpwhen it detects a container; otherwise it uses.venv/in the working directory. Do not runuv syncdirectly inside a container — go throughjust.
1. Python conventions
These apply to every package. They are uniform across the codebase; deviations are bugs.
Module layout and docstrings
- Every
.pyfile opens with a module docstring, then a blank line, thenfrom __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
Raisesclause — 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; atest_*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
Raisescontract 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, aProtocolnamed in a signature below it, a helper feeding a module-level constant or a registration call), andspec/predicate.py, whoseparse/_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 annotationsin every module that contains code.ruff.tomlsetsrequired-imports, sojust checkfails 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, ...]. NeverOptional,Union,typing.List. - Return type on every function, including
-> None. Protocolfor structural contracts (cursor seams, callback shapes) — method bodies are.... Use aCallable[...]type alias for factory hooks.cast(...)is the sanctioned escape hatch for narrowing catalog strings into aLiteralor bridging a known signature mismatch. Use it with a short comment, not as a habit.
Imports
from __future__ import annotations
import jsonfrom pathlib import Path
import yaml
from dbprint.config import StatisticsConfigfrom .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.tomlsetsno-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_modulebecauseintrospectis 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, catchImportError, 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.F401is ignored in__init__.pyfor exactly that reason and enforced everywhere else, so an import left behind by a deletion failsjust checkrather 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, ...]overlist[str]for things that should not change after construction. Literalfor closed string sets — neverenum.Enum. Declare the alias at module top inPascalCase(Classification,Severity,RenderMode). Vocabulary membership sets arefrozenset.
Naming
- Private helpers, attributes, and constants take a leading
_. - Module-level constants are
UPPER_SNAKE; private ones_UPPER_SNAKE. Config defaults take a_DEFAULTsuffix; compiled regexes take an_REsuffix. - Type aliases are
PascalCase; everything else issnake_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 everyreturn/raisethat 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/finallychains. - Use
if/elif/elsefor mutually-exclusive return dispatch — not a sequence of bareif cond: returnfollowed by a fallthroughreturn. - 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.
COM812and the formatter's magic trailing comma enforce this together, andjust fixiterates 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, anOutcome(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-pasteableexclude:snippet, the command to run). raise NewError(...) from excwhenever 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 Exceptionis 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'sBLE001/S110flag 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, forBLE001/S110or 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_queryre-raises a driver's converted Ctrl-C asKeyboardInterrupt, 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_dumpcall — 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 viaadapters/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
Consolefor 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
AdapterABC (ARCHITECTURE.md §2); return typed intermediate records (TableMeta,ColumnStats, ...), never artifact-shaped dicts. - ✅ Honor the full abstract signature even when ignoring arguments (
delthe unused params with a comment rather than dropping them). - ✅ Parameterize values, using the placeholder style the driver's configured
paramstyleexpects (%sfor psycopg and mysql-connector;?for the Snowflake connector, which is opened withparamstyle="qmark"). Fold and quote identifiers only throughadapters/identifiers.py— the carrierlist_tablescaptures quotes every name a statement addresses;tests/adapters/test_identifier_guard.pyfails a hand-rolled copy. Build and split an FQN only throughspec/fqn.py, anywhere in the package;tests/test_fqn_guard.pyfails 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.filteris 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 selectsFROM. 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.filterbinds 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 throughstatements.seed_order. Catalog statements binding names stay parameterized.tests/adapters/test_dialect_guard.pyfails 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 theirexec_querydoubles every%first. Both test cursors do the drivers' rewrite. - ✅ Gate vendor connectors behind a lazy import + extras message; expose a
cursor_factoryseam 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.pysweeps 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 inadapters/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, neverINNER JOIN;FULL JOIN, neverFULL OUTER JOIN; a single short condition stays on theJOIN ... ONline, and several end that line withONand follow two spaces deeper,ANDleading. Every table and derived table carries an alias written withoutAS— a statistics source is alwayssrc(identifiers.SOURCE_ALIAS) — and every column reference is qualified with it, single-table statements included.COUNT(1)counts rows,COUNT(col)counts non-nulls, neverSUM(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 ascope.filterwrapper 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,DESCRIBEand DDL (a view's body included) follow the casing rule alone. A table function takes an alias like any table, soGENERATE_SERIES(1, n) gen (i)is read asgen.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 DISTINCTandLIMIT nkeep their word on the keyword's line); a select list puts one item per line, trailing commas; joins sit under the first source; aCASEwith more than oneWHENputs eachWHEN,ELSEandENDon its own line; a multi-line fragment interpolated into it goes throughindented(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, selectstbl.*or onetbl.column(anASalias on that column allowed), reads one table with no sample clause and no join, and has at most oneWHEREcondition with noANDorOR— an interpolatedscope.filtercounts as one, and thescope.filterwrapper keeps its unaliasedSELECT * FROM table WHERE ...form. A fragment that fits one source line is written on one line.tests/adapters/test_dialect_guard.py'sTestSqlStylechecks every swept statement against the casing, alias and qualification rules, andtests/adapters/test_sql_style.pychecks every.sqlfile against those and the layout above, with no line over 120 characters; an adapter page'ssqlfence is held to the casing rules alone. Review holds what neither can judge: the clause body's two-space indent, oneWHEREcondition per line, blank lines grouping a longSELECT, and whether aCASEhandles 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.yamlwith the classification it must publish; the adapter keeps no numeric/temporal/boolean/json tuples of its own (the shared predicates inspec/classification.pydecide the family), adds a vendor type its engine cannot compare or enumerate to_UNSUPPORTED_TYPES, and a vendor type it profiles astextto_TEXT_TYPES. - ✅ A statement near-identical across engines is written once, in
adapters/statements.py. What an engine spells differently becomes aDialectfield 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_aitsdeclinespredicate, and its Phase B goes throughrun_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.pyand nowhere else. A temporal type tuple or a_render*function in any other adapter module failstests/adapters/test_secondary_type_sweep.py, and a new abstractAdaptermethod joins that sweep's statement table or its allowlist. - ✅ A cell value reaches a
ValueCountonly throughspec.rounding.measured_value. No adapter converts a numeric cell itself;tests/adapters/test_exact_numerics.pyfails on afloat(...)or aValueCountthat bypasses it. - 🚫 Do not stamp
classificationonto stats — the engine applies the spec priority. Do not log anything beyond the one statement traceexec_query/pg_dumpalready 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 undercommands/, each exporting a<verb>_commandcallback wired into the root group inmain.pyviaadd_command. - ✅ A command callback's docstring is its
--helptext, and it carries every section: a one-line summary, then purpose + side effects, then**Arguments:**documenting positionals (Click renders nohelp=on arguments), then**Exit codes:**and**Examples:**. Every one of these is a Markdown heading inside the docstring itself:main.pysetsrich_click.TEXT_MARKUP = "markdown", so the docstring is the entire help text and Markdown renders as written. Make every optionhelp=concrete, with an inline example where the value is non-obvious. The reference atdocs/CLI.mdis generated from--help(just docs) and golden-tested, so help is the single source of truth — never hand-editdocs/CLI.md. - ✅ Exit via
ctx.exit(worst_exit(*codes))— engine exit codes are data on the result objects; the command combines them byEXIT_PRECEDENCE, never withmax. Neversys.exit. - ✅ Detect TTY in exactly one place (
rendering.resolve_render_mode, the onlyisatty()caller). The TTY/piped branch belongs only at renderer selection. Machine formats and--outputwrites 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;tuplefields;default_factoryfor nested config. - ✅ Validate
.dbprint.yamlwhole against the packagedconfig/project.schema.json— the schema editors use — plus the few cross-field checks a schema cannot state, and raise oneConfigErrorlisting 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()returnslist[Issue], ordered (it relies onIssuebeingfrozen=True, order=Truewith field order == sort order). It never raises for a format-level problem — malformed YAML becomes aschema.invalid-yamlIssue. - ✅ 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 incoverage_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_exhaustivereads it. - ✅ Issue codes are dotted namespaces (
schema.*,stats.*,version.*);spec_refcites the section (§2.2.3). Severity is exactlyerrororwarning— there is noinfo.
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_VERSIONlives inspec/v1/__init__.pyas the single source. JSON Schemas load once viaimportlib.resources(shipped in the wheel). - ✅ Artifact YAML is parsed only through
spec/artifact_yaml.py, neveryaml.safe_loador a PyYAML loader class: its loader runs on libyaml's parser, and a custom loader subclassesArtifactLoader.config/is exempt — it reads user-authored files, not artifacts.tests/test_artifact_yaml_guard.pyfails any other module naming a PyYAML load function or loader.
engine — orchestrate
- ✅
generate()andcompute_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,Literalstatus 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.mdorstatistics.annotations.yaml. - ✅ Every number a renderer shows goes through
spell_numberorspell_percent(spec/value_text.py): no format spec,round(),%-formatting or Jinjaround/formatfilter in a renderer outside layout geometry;tests/engine/test_number_spelling_sweep.pyfails one. - ✅ Artifact YAML targets YAML v1.2's core schema, not PyYAML's own (1.1-based) default resolver:
yaml_dumper.py'sstrrepresenter 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_textin the adapter'srendering.pyfor a string-like column,spec.rounding.measured_valuefor every other published cell — never by a new dumper representer. The dumper refusesset,bytesand any unrecognised type withUnrepresentableValuerather than writing a!!set/!!binarytag. - 🚫 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(inparser.pyonly). Bad predicates and failed checks return (MalformedPredicate,Outcome(malformed=True), anIssue) — the evaluators never raise for assertion failure. - ✅ The predicate AST is a closed set of frozen dataclasses unified by a
Predicateunion 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 itsIssuelist before returning — determinism is part of the contract. Issue codes come from the codes module, never hardcoded strings.
mcp — serve prints
- ✅
resources.pyandtools.pyare pure:(state, *args) -> dict | dataclass, no SDK import, noasync.server.pyis the only file that importsmcpand wires the pure handlers to the SDK (ARCHITECTURE.md §11). - ✅ The
[mcp]extra is gated by the CLI caller, not re-checked inserver.py. HTTP transport binds to loopback only (enforced upstream), andserver.pyanswers only a loopbackHostandOrigin(MCP.md 6.2). Every parse goes through the served state'sParseCache(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 subclassesException); codes are restricted to theJsonRpcCodeLiteral. Both entry points surface the typed error to the client:read_resourcetranslates it into the SDK's own error object (mcp.shared.exceptions.MCPError(code, message)), andcall_toolcatches it explicitly and returnsCallToolResult(..., 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_factoryexecution, enum/Literalmembership,isinstanceon 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_loadof 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.
<= CAPover 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_printis 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.pyis 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-levelCOVERSfrozenset and adds itself to_SURFACESintests/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@giventest 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@examplebeside its fix.just checkruns the derandomizedcheckprofile;DBPRINT_HYPOTHESIS_PROFILE=localsearches 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
conformanceis the format gate. Tests validate generated prints throughvalidate_print()and assert zeroerror-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 — passresponses={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/_*.pyhelper, and a production rule is imported, never respelled._printsbuilds mock tables and a connection at its defaults,_engine_runreads back what a run wrote,_cliwrites project files and exports credentials throughenv_var_name, and_scriptsloadsscripts/by path and ownsREPO_ROOT; Postgres connections and per-test databases come fromtests/conftest.py'spg_connectandfresh_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_printover 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 coverscurator,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_targetandfixture.type_probe.arboretumis 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).loanis 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_roundandsampleare further structural table names, carrying no fixed shape of their own.orchard,nursery,seed_lot,tally,gauge,bedandplotare 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_mvwhen 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 istests/engine/test_inference.py'spublic.curators, where the foreign-key stem rule under test is what strips thes— 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 isrank; a nullable integer isseed_count; a nullable timestamp iswithdrawn_at; a non-nullable future-dated timestamp ismatures_at; a row's last-update stamp, the one column a page may read as the data's currency, isupdated_at; a self-referential FK ismentor_id; an id column carrying no constraint isrecorded_by; a binary blob isfield_photo; a free-text column isfield_notes; a numeric measure isviability_pct;herbarium's own climate categorical isbiome; a digit-shaped numeric identifier carrying no sensitivity token of its own isaccession_number; a column existing only as an annotation, with no statistics of its own, isshelf_location; a free-typed column with no role beyond filling out a shape (a composite-key member, a column outside a declared key) iscondition,cohortorplot; a personal-data column is one ofseedbank.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 invocabulary_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_configurere-execs underbwrapwith 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 missingbwrapoutside 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.yamland credentials key, with defaults. - MCP.md — MCP server specification.
- ASSERTIONS.md — assertion DSL specification.