Status: current. The Dhall under src/ and the live PostgreSQL harness under
tests/ are the source of truth. This document describes the verified
greenfield client, not an intermediate migration design.
The generator consumes pgn's analyzed project and emits one typed Python
package. Async is always the root API. emitSync: true adds a sync facade and
sync I/O functions without duplicating SQL or model definitions.
pgn receives a List { path, content } from the generator and writes it below
the artifact root. packageName is converted to an import name by replacing
- with _.
For a project with custom types and emitSync: true, generated ownership is:
src/<pkg>/__init__.py
src/<pkg>/sync/__init__.py
src/<pkg>/_generated/__init__.py
src/<pkg>/_generated/_core.py
src/<pkg>/_generated/_runtime.py
src/<pkg>/_generated/_register.py
src/<pkg>/_generated/sync/_runtime.py
src/<pkg>/_generated/statements/__init__.py
src/<pkg>/_generated/statements/<query>.py
src/<pkg>/_generated/types/__init__.py
src/<pkg>/_generated/types/<custom_type>.py
The sync facade and runtime are omitted unless emitSync is true. Type and
registration files are omitted when the project has no custom types. A
consumer-owned shell, such as pyproject.toml and py.typed, sits around this
output and is not generated.
There is one package, one generated type namespace, and one canonical statement module per query. Internal generated paths are greenfield implementation detail; the project makes no compatibility promise for them.
Interpreters/Project.dhall prepends one header to every Python file. Its exact
first line is:
# @generated by python.gen (pGenie); regeneration overwrites manual changes.
The two SPDX lines immediately following it grant MIT-0 for emitted code. The marker is intentionally operational: manual edits are safe only after all regeneration paths have been disabled and the marker has been replaced.
Interpreters/Query.dhall compiles a query into a single statement module. The
module contains, in order:
- imports needed by parameters, results, and selected I/O surfaces;
- one
SQLstring; - one
@dataclass(frozen=True, slots=True)Row when the query returns rows; - the async function;
- an adjacent function with the
_syncsuffix whenemitSyncis true.
The public facades alias those canonical objects. The root facade exports the
async function. <pkg>.sync exports the adjacent sync function under the same
public name. Both facades export the exact same Row classes, custom types,
JsonValue, and NoRowError identities.
For a row-returning query the statement passes args_row(Row) to the runtime.
psycopg's BaseRowFactory constructs the dataclass positionally in result-column
order. There is no mapping row, generated query decoder, model codec,
typing.cast, or second Row definition. This makes the statement's Row
the canonical runtime result type for both surfaces.
Imports for custom annotations use the generated types namespace. The
ImportSet project index is both a deduplication key and stable sort key, so a
statement or composite imports each referenced type once in project order.
Python identifiers are sanitized only where Python syntax requires it.
Parameters, result fields, composite fields, and query function names use the
safe identifier. SQL placeholders and PostgreSQL names retain their original
spelling. For example, a PostgreSQL column named class becomes the Python
field class_, while its positional value still occupies the analyzed column
position.
Generated models are pure declarations:
- enums are
StrEnumsubclasses containing the exact PostgreSQL labels; - composites are frozen, slotted dataclasses containing their typed fields;
- statement rows are frozen, slotted dataclasses.
They contain no database conversion methods. psycopg owns loading and dumping.
Templates/RegisterModule.dhall emits class-aware registration into one shared
module:
EnumInfo.fetchandregister_enumreceive the generated enum class and an explicit member-to-label mapping;CompositeInfo.fetchandregister_compositereceive the generated dataclass plus validated object and sequence callbacks.
The callback factory compares sanitized PostgreSQL field names with dataclass field names before constructing or flattening an object. This catches schema and model drift at the adapter boundary.
register_types is async. When sync output is enabled, the same module also
contains register_types_sync, which the sync facade exports as
register_types. Registration is a per-new-connection precondition and must run
after the database migrations and custom types exist. Missing catalog entries
raise LookupError rather than falling back to text.
Registration order is dependency-first. Enums are ready immediately; composites are emitted only when all referenced custom types are already ready. A bounded pass either resolves the full order or reports unresolved or cyclic dependencies. This ordering lets psycopg recursively adapt nested scalar composites and arrays of the supported ranks.
Verified custom shapes are scalar enums, enum arrays through rank 2, scalar composite load and dump, rank-1 composite arrays, nested scalar composites, nullable fields, and sanitized fields. Async and sync return the same exact model identities.
Structures/Surface.dhall is a small render-token record for adjacent functions:
{ defKeyword : Text
, connType : Text
, awaitKw : Text
, functionSuffix : Text
, runtimePrefix : Text
, helperSuffix : Text
, corePrefix : Text
}The async value renders async def, AsyncConnection, and await. The sync
value renders def, Connection, and _sync implementation names. It does not
select between mutually exclusive package shapes.
_core.py is emitted once and owns JsonValue and NoRowError. The async
runtime is always emitted; the sync runtime is additive. Each runtime contains
the same five cardinality helpers:
| helper | result |
|---|---|
fetch_optional |
`Row |
fetch_single |
Row, or NoRowError |
fetch_many |
list[Row] |
execute_rows_affected |
int |
execute_void |
None |
Row helpers accept BaseRowFactory[T] and return psycopg's constructed object
directly. Statement SQL is passed as LiteralString. No runtime accepts encoded
SQL bytes. The generated package has no separately installed support library;
its only non-stdlib consumer dependency is psycopg>=3.3.4,<4.
The public Dhall config is:
{ packageName : Optional Text
, emitSync : Optional Bool
, onUnsupported : Optional < Fail | Skip >
}src/package.dhall passes that type and an all-None default to
Sdk.Sigs.generator. Project.run resolves each field independently:
packageName: project name in kebab case;emitSync:False;onUnsupported:Fail.
An omitted config block, an omitted field, and a null field therefore use the
same fallback. Async output is present for every value of emitSync; only true
adds sync output.
Source-derived identifiers receive lexical and reserved-name escaping only
(Structures/PyIdent.dhall); there is no rename-mapping config and no
generation-time namespace-collision detection. pgn's embedded Dhall evaluator
dropped Text/equal (pgn 0.11.0), which removed the only mechanism that could
compare two runtime Text values to decide a collision or resolve a mapping's
source against a query/type name; that decision is not reconstructible from
Text/replace alone (see section 10). Instead, the generated package is held
to basedpyright --strict returning zero errors and zero warnings. A duplicate
dataclass field name or a name that shadows a needed type surfaces there
(reportRedeclaration/reportInvalidTypeForm) rather than at pgn generate
time, and less precisely attributed (generated Python, not the originating
SQL/schema). A module-path collision can overwrite an earlier generated file
before the type checker runs, so input schemas must still guarantee unique
generated Python names.
pgenie-io/pgenie#75 asks pgn
to guarantee unique custom-type identities at the source.
Interpreters/Primitive.dhall maps pgn union constructors, not signature-file
strings. Supported scalars are Boolean, integer and OID, floating point,
numeric, textual, UUID, date/time/timestamp/interval, bytea, and JSON. Arrays
wrap the scalar type once per analyzed dimension, preserving element and outer
nullability independently. JSON parameters use psycopg's Json or Jsonb
wrapper only for a scalar value.
CustomKind.Lookup classifies each custom reference as enum, composite, or
absent. For enum and composite references it returns only the generated Python
class and module identity plus stable project order; composite fields stay with
the original model. That classification drives type imports, supported array
ranks, nested dependencies, and adapter registration. The generator reports
instead of guessing when a primitive or custom type cannot be mapped.
The explicit custom limits are:
- enum dimensions above 2 are unsupported;
- composite dimensions above 1 are unsupported;
- a custom-array field inside a composite is unsupported;
- an absent or unsupported custom type is unsupported.
Domains are unsupported. JSON array parameters are also rejected because a
single JSON wrapper cannot adapt elements faithfully. Every failure carries the
query or type path and member name through Compiled.nest.
Interpreters/Result.dhall preserves pgn's cardinality verbatim:
Rows Optionalmaps toRow | Noneandfetch_optional;Rows Singlemaps toRowandfetch_single;Rows Multiplemaps tolist[Row]andfetch_many;RowsAffectedmaps tointandexecute_rows_affected;Voidmaps toNoneandexecute_void.
The generator does not infer a narrower return from SQL predicates. If pgn
classifies a returning statement as multiple, its API remains list[Row].
Interpreters/QueryFragments.dhall converts pgn fragments into one Python
triple-quoted SQL string in each statement module:
- a variable becomes
%(<raw parameter name>)s; - raw SQL remains verbatim after Python-literal escaping;
- literal percent signs are doubled for psycopg named style;
- the variable branch is not percent-doubled.
The literal begins with a backslash so the first SQL line is flush and retains
a trailing newline. Parameter dictionaries use raw PostgreSQL names as keys and
sanitized Python expressions as values. Source SQL owns explicit database casts;
the generator does not append adaptation casts. The same SQL object is passed
to async and sync helpers as a string whose static type satisfies
LiteralString. Output is the raw Dhall render; no Python formatter or SQL
postprocessor rewrites generated files.
Interpreters/Project.dhall is the project-level coordinator:
optional config
-> resolved config
-> index-aligned kind classification (kindOf) of every custom type
-> one-pass survivor cascade (Sdk.CustomTypes.supportedCustomTypesReasoned)
masking unsupported indices to Absent in Skip mode
-> compile custom types and query checks against the masked lookup
-> dependency-first registration order
-> render facades, core, runtimes, types, registration, and statements
-> prepend the generated header
onUnsupported: Fail traverses the original project and propagates the first
compiled failure, so generation aborts without a partial client. In this mode
nothing is masked: the lookup is the full, unmasked kind classification.
onUnsupported: Skip computes survivorship in a single left-fold via
Sdk.CustomTypes.supportedCustomTypesReasoned. Because Project.customTypes
is topologically sorted (every referenced index precedes the referencing type),
one pass suffices: the fold marks a type unsupported if its own definition
cannot compile (ownDefinitionSupported) or if any custom type it references
was already marked unsupported. Unsupported indices are then masked to Absent
in the kind lookup, so a reference to a removed type reads exactly like a
missing type. Queries compile against that same masked lookup, so a removed type
also removes its dependent composites and statements. Facade exports, type
initializers, registration entries, statement files, and Row exports are
assembled only from survivors, and reports for dropped units are preserved as
warnings.
Query compilation is deliberately consolidated in QueryCheck: keep/drop state
and warning data come from one literal QueryGen.run call site before the final
render call. Dhall normalization substitutes call sites, and multiplying those
sites caused a measured minutes-scale regression. Custom-type checks use their
separately measured fast shape.
The source follows the gen-sdk interpreter/template split:
src/
package.dhall
Deps/ # sha256-pinned remote packages
Structures/
CustomKind.dhall
ImportSet.dhall
OnUnsupported.dhall
PyIdent.dhall
Surface.dhall
Interpreters/
Primitive.dhall
Scalar.dhall
Value.dhall
Member.dhall
ParamsMember.dhall
CustomType.dhall
QueryFragments.dhall
ResultColumns.dhall
Result.dhall
Query.dhall
Project.dhall
Templates/
CoreModule.dhall
RuntimeModule.dhall
StatementModule.dhall
RegisterModule.dhall
FacadeModule.dhall
EnumModule.dhall
CompositeModule.dhall
TypesInit.dhall
InitModule.dhall
Scalar identifies primitive versus custom values. Value adds array rank and
element nullability. Member and ParamsMember apply member nullability,
identifier safety, lookup classification, imports, and parameter wrapping.
ResultColumns builds Row field declarations in analyzed order. Query
combines SQL, Row, parameters, and surfaces. Project owns lookup scope,
single-pass survivor filtering, registration order, file selection, and
facades.
pgn 0.11.0 removed Text/equal (along with Text/length and Bool/equal)
from its embedded Dhall evaluator, to stay in line with the official Dhall
spec. There is no way to reconstruct a Bool or a differently-typed decision
from comparing two arbitrary runtime Text values using only Text/replace;
Text/replace-based tricks (this repository's own former keyword-marker
trick, and gen-sdk's Lude.Text.replaceIfEqual/replaceIfOneOf) only ever
transform text, they cannot branch into a different type. Every piece of this
generator that made a decision this way is gone: the queryNameMappings/
customTypeNameMappings rename-mapping config
(Structures/PythonNameMapping.dhall, matched a mapping's source against a
runtime name), validateCustomTypeIdentities (rejected two custom types
collapsing to the same unqualified contract Name), and
Structures/PythonNamespace.dhall's validate (4 call sites in
Interpreters/Project.dhall: per-query and per-custom-type local audits, the
project-wide facade/module audit, and per-type module-internal bindings).
pgenie-io/pgenie#75 asks pgn
to guarantee unique custom-type identities at the source instead. The
generated package's basedpyright --strict gate (see section 12) catches
collisions that remain visible in the output tree. It cannot detect an earlier
file that was overwritten at the same generated module path.
buildLookup, the last Text/equal user, compared a reference's snake-case
name against each project custom type's name and has been removed.
gen-contract v5's CustomTypeRef carries an index into Project.customTypes,
and gen-sdk v3's CustomTypes module folds over that topologically-sorted list
to compute survivorship without any text comparison. References now resolve by
index (Structures/CustomKind.dhall's at), so a repo-wide grep -rn "Text/equal" src/ finds only these explanatory comments. No live use remains.
The generator no longer needs fork-only text equality anywhere. It does still
rely on gen-contract v5's contract guarantees: every CustomTypeRef.index must
address the intended customTypes entry, and customTypes must be
topologically sorted (every referenced index precedes the referencing type),
which is what makes the single-pass survivor cascade sound. pgn remains the
supported evaluator and generation driver; upholding those index/ordering
invariants is the producer's responsibility.
PyIdent.dhall uses Lude.Text.replaceIfOneOf's bounded Text/replace
construction for keyword membership. ImportSet.dhall uses natural project
indexes for equality, deduplication, and ordering. Neither substitutes for the
project lookup.
The generated marker means regeneration is authoritative. A consumer may eject only as a coordinated transition:
- generate and commit the intended complete client;
- disable every local and CI regeneration path before edits;
- replace the marker with a project ownership header;
- maintain the result as ordinary Python;
- keep the package structure unless intentionally refactoring all imports;
- gate the result with basedpyright strict, Ruff, and database tests.
Once regeneration is disabled, there is no generator behavior involved. Before that point, manual edits are expected to be overwritten.
The final fixture was regenerated as raw output and verified against live PostgreSQL with these verdicts:
- H1 CONFIRM: psycopg 3.3.4; consumer
psycopg>=3.3.4,<4; class-awareEnumInfo/register_enumandCompositeInfo/register_composite; dependency-first registration; pureStrEnumand frozen slotted dataclasses;args_rowconstructs canonical Row; no query decoders, model codecs, casts, or bytes SQL. - H2 CONFIRM: 25 Python files / 1467 lines / 11 statement files / 801 statement lines / 11 SQL.
- H3 CONFIRM: Ruff 0/0, authored long0, SQL long0, raw output/no postformat.
- Tests: 50 passed, 0 skipped; pgn v0.12.0; strict basedpyright 0/0.
The H1 round trip covers both connection surfaces and exact cross-facade identities. It covers scalar enum, enum arrays including rank 2, scalar composite load and dump, rank-1 composite arrays, nested scalar composites, nullable fields, and sanitized names. Negative contract tests cover unsupported ranks, custom-array fields inside composites, and missing or unsupported custom types.
Use mise for every repository command. mise run test drives real pgn
subprocesses against a live PostgreSQL server; PGN_TEST_DATABASE_URL selects a
non-default server. The harness only creates and drops its own scratch database.
Golden regeneration is intentionally serial and memory-heavy; use only
mise run golden and review its diff.
src/Deps/*.dhall pins remote imports by sha256 and should be changed one at a
time. src/package.dhall is the working-tree generator entry point.
fixtures/Exhaustive.dhall is the contract fixture. The release workflow
resolves the entry point into resolved.dhall, publishes that release asset,
then byte-checks and bundles it into the wheel. Repository source is MIT,
generated Python is MIT-0, and the combined resolved artifact and wheel are
GPL-3.0-or-later because they inline gen-sdk.