diff --git a/.github/scripts/build-contract-shell.sh b/.github/scripts/build-contract-shell.sh index 3845a1d..9fbc79c 100755 --- a/.github/scripts/build-contract-shell.sh +++ b/.github/scripts/build-contract-shell.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Wraps the Dhall-generated package (from tests/Exhaustive.dhall) in a minimal +# Wraps the Dhall-generated package (from demos/Exhaustive.dhall) in a minimal # consumer shell, mirroring the hand-written tests/golden/ shell (pyproject.toml # + py.typed) so basedpyright strict runs against the same layout a real # consumer would import, per the full_package pattern in tests/conftest.py. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7664a04..b5d526f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,7 +121,7 @@ jobs: with: ref: ${{ inputs.ref || github.sha }} - # The `as Source` import mode on gen/Deps only changes how pgn loads the + # The `as Source` import mode on src/Deps only changes how pgn loads the # remote packages (unnormalized, to save RAM), not what they evaluate to. # The pinned action below bundles a dhall fork that predates the mode, so # strip it here; the evaluation is semantically identical either way. The @@ -132,16 +132,16 @@ jobs: shell: bash run: | set -euo pipefail - sed -i -e 's/^[[:space:]]*as Source$//' -e 's/^[[:space:]]*sha256:[0-9a-f]\{64\}$//' gen/Deps/*.dhall + sed -i -e 's/^[[:space:]]*as Source$//' -e 's/^[[:space:]]*sha256:[0-9a-f]\{64\}$//' src/Deps/*.dhall - # A plain, standard-Dhall evaluator cannot run this: gen/Interpreters/Project.dhall's + # A plain, standard-Dhall evaluator cannot run this: src/Interpreters/Project.dhall's # buildLookup and gen-sdk's own Fixtures.Exhaustive both use Text/equal, a builtin # from pgn's forked Dhall, absent from the upstream dhall-lang Prelude. This action # bundles that same fork. - name: Generate output from Dhall uses: nikita-volkov/dhall-directory-tree.github-action@60a18dc647d6daea805263ea0fed7bb8011f3bcd # v2 with: - dhall_file: tests/Exhaustive.dhall + dhall_file: demos/Exhaustive.dhall output_dir: contract-output - name: Assert the compile did not fail diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25e92c7..91764bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,7 +86,7 @@ jobs: id: resolve uses: nikita-volkov/dhall-resolve.github-action@7caaf1fdb40ac864bc02e37575f577ee084713a8 # v3 with: - file: gen/Gen.dhall + file: src/package.dhall minify: true - name: Prepare changelog for release diff --git a/AGENTS.md b/AGENTS.md index f6d2533..7da3b7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,6 @@ already says. ## Dhall -`gen/` pins its remote imports by sha256 (`gen/Deps/*.dhall`). Bump those +`src/` pins its remote imports by sha256 (`src/Deps/*.dhall`). Bump those deliberately, one at a time, and re-run the harness before committing a pin change. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc7b2c..b55b2bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,57 @@ # Upcoming +- `buildLookup` (`Interpreters/Project.dhall`) and, with it, this generator's + last dependency on pgn's fork-only `Text/equal` builtin are removed from + `src/`: custom-type decode/encode now dispatches through named + `_decode`/`_encode` methods generated onto each custom type's own Python + class (`CompositeModule.dhall`/`EnumModule.dhall`), called by name from + every reference site, instead of resolving classification and fields via a + project-wide structural search (`grep -rn "Text/equal" src` now returns + only two explanatory comments, zero invocations). Array (dims > 0) + decode/encode is built at the call site (`Member.dhall`/ + `ParamsMember.dhall`) instead of a third per-type method, delegating only + the per-element transform to `_decode`/`_encode`: an earlier draft this + session added a per-type `_decode_array` to `EnumModule.dhall`, but it + could not express `elementIsNullable` (a per-column fact, not a per-type + one) and silently broke nullable-element enum-array decode and + enum-array param encode — both working, corpus-exercised paths — caught + by the final whole-branch review and fixed before merge. Behavior change: + because the call site is now kind-uniform, a 1-D composite-array column + or param is no longer rejected at Dhall-generation time the way it used + to be, and — unlike the `_decode_array` design it replaces — no longer + depends on `basedpyright strict` catching a missing method either, since + `_decode`/`_encode` genuinely exist on a composite class too. **This path + has not been exercised against real Postgres, and `tests/golden/` has NOT + been regenerated for this change this session** — the composite-array + fixture addition, its golden regeneration, and confirming actual Postgres + round-trip behavior are a known, deliberate gap in this commit, deferred + to a follow-up pass on a properly provisioned machine (see + `docs/plans/2026-07-11-reusable-custom-type-codecs.md`). + Separately, a composite field nesting another custom type is *also* no + longer rejected at generation time: the `nestedLookup = Absent` stub that + used to force it down the same loud-fail path is gone (it only existed + to satisfy `Member.run`'s old signature). This is not the same kind of + change as the composite-array case above, though — `CompositeModule.dhall`'s + `_decode`/`_encode` still do a blind flat `cast(tuple[...], src)`/splat, + unchanged by this refactor, and never recurse into the nested type's own + codec, so the field silently decodes/encodes wrong rather than being + caught by a type checker. Because the failure mode is `cast()`, which + suppresses type-checking on its argument by design, this is **not** + expected to be caught by `basedpyright strict`. It is a real, silent + architecture gap, flagged here as an open follow-up design question, not + a shipped or backstopped behavior change. +- Migrated the generator's internal dependencies to `gen-contract` v4.0.1 + and `gen-sdk` v2.0.0, adopting `Sdk.Sigs` in place of the local + `Algebras/` module, and restructured the repository layout to match the + pGenie generator architecture: implementation moved from `gen/` to + `src/`, the public entry point renamed from `gen/Gen.dhall` to + `src/package.dhall`, and the fixture driver moved from + `tests/Exhaustive.dhall` to `demos/Exhaustive.dhall`. No change to + generated output or the public Dhall interface (`artifacts..gen` + URLs pointing at a previously-released `resolved.dhall` are unaffected; + only the next release's URL path changes, from `.../gen/Gen.dhall` — the + unresolved source path some projects may reference directly instead of a + frozen release — to `.../src/package.dhall`). - The test harness now runs every pgn subprocess in its own process group under an RSS watchdog: a thread polls `ps -o rss=` every 2 s and, on breach of `PGN_MAX_RSS_GB` (default 40 GB), kills the whole group and fails the test with diff --git a/DESIGN.md b/DESIGN.md index bff5a2c..952e4cc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,7 +1,7 @@ # python.gen DESIGN Status: current. This describes the generator as shipped, not a plan. The code -is the source of truth: the Dhall generator under `gen/`, the fixture project +is the source of truth: the Dhall generator under `src/`, the fixture project and golden output under `tests/`. When this doc and the tree disagree, the tree wins. @@ -125,18 +125,28 @@ those the decode is a `cast(, row[""])` to satisfy strict - enum column: `Mood(cast(str, row["feeling"]))`; nullable guards `None`. - enum array column: element-wise rebuild, - `[Mood(v) for v in cast(list[str], row["..."])]`, with per-element and - outer `None` guards driven by `elementIsNullable` / column nullability. - Requires the enum's TypeInfo registered (section 6). + `[Mood._decode(v) for v in cast(list[str], row["..."])]`, with per-element + and outer `None` guards driven by `elementIsNullable` / column nullability, + built at the reference site in `Member.dhall` rather than a per-type array + method (see below). Requires the enum's TypeInfo registered (section 6). - composite column: psycopg returns a namedtuple once the composite is registered; decode is `TypeName(*cast(tuple[...], row["..."]))` with the exact field types. `Member.run` (`Interpreters/Member.dhall`) builds `decodeExpr` as a `Text -> Text` function next to the type info, so `_rows.py` and `types/` -compose the same logic. A composite array, or an enum array with -`dims > 1`, is unimplemented and fails loudly (`Compiled.report`) rather -than emitting wrong Python. +compose the same logic. Any custom-type array with `dims > 1` is +unimplemented and fails loudly (`Compiled.report`), regardless of kind. For +`dims == 1`, `Member.dhall` builds the list comprehension itself +(`[${typeName}._decode(v) for v in cast(...)]`, with per-element/outer +`None` guards driven by `value.elementIsNullable`/`isNullable`) instead of +calling a per-type array method: a per-type, zero-argument method has no way +to see `elementIsNullable`, a per-*column* fact, so it cannot express it. +This makes the branch kind-uniform: a 1-D composite-array column now +type-checks and attempts a real decode the same way an enum-array column +does, instead of being rejected at Dhall-generation time the way it used +to be. See section 12 for why that changed and for the currently-unverified +state of that path. --- @@ -226,10 +236,15 @@ no partial output for the affected query under `Fail`. Add a mapping to The param side (`Interpreters/ParamsMember.dhall`) carries the same loud-fail contract for bind shapes psycopg cannot adapt faithfully. It -rejects, rather than silently mis-binds: - -- a `json`/`jsonb` ARRAY param (`Jsonb` wraps a scalar, not element-wise). -- a composite ARRAY param (psycopg cannot adapt the dataclass array). +still rejects a `json`/`jsonb` ARRAY param (`Jsonb` wraps a scalar, not +element-wise). A composite ARRAY param is no longer rejected here the way +it used to be (see section 12): encode branches on `Natural/isZero +value.dims`, calling `._encode()` for a scalar custom-type param and +building `[x._encode() for x in ]` (with the same +`elementIsNullable`/outer-nullable guards as decode) for an array one, so a +composite-array param now type-checks and attempts a genuine per-element +encode rather than depending on `basedpyright strict` to catch a missing +method. ### Arrays and nullability @@ -276,12 +291,34 @@ composite (a single `value: str | None` field) exists specifically to cover this, exercised as both a param and a `RETURNING` result column in the same statement, with a round-trip test. -Composite fields cannot themselves reference another custom type. -`CustomType.dhall` hardcodes the nested-member lookup to `Absent` -(`nestedLookup`), so a composite nesting a composite (or an enum) fails the -same loud-fail path as any other unresolvable reference rather than -guessing a decode. Widening this is possible but has not been needed by any -project this generator has shipped against yet. +Composite fields nesting another custom type used to be explicitly +rejected: `CustomType.dhall` called `Member.run` with a `nestedLookup` stub +hardcoded to `Absent`, forcing the same loud-fail path as any other +unresolvable reference rather than guessing a decode. That stub is gone — +it existed only to satisfy `Member.run`'s old signature, and was deleted +along with `buildLookup` (section 12) — but its removal does not make this +case work; it only removed the one thing that used to reject it at +generation time. `Member.run` does compute a named-codec `decodeExpr` +(`${typeName}._decode(...)`) for a `Custom`-typed field, but +`CustomType.dhall`'s Composite branch never threads it anywhere: it maps +each member down to a flat `{fieldName, fieldType}` pair (the `Field` shape +`Templates/CompositeModule.dhall` takes) and discards `decodeExpr` +entirely. `CompositeModule.dhall`'s `_decode`/`_encode` — unchanged by this +refactor — render a single blind `${typeName}(*cast(tuple[...], src))` +splat and a flat `(self.field1, ...)` tuple; neither ever calls a nested +field's own `_decode`/`_encode`. So a composite field whose own type is +another custom type still does not decode/encode correctly at +runtime — it is just no longer *rejected* at generation time the way it +used to be. Unlike the composite-array case (section 12), this is **not** +expected to be caught by `basedpyright strict`: `cast()` exists +specifically to suppress type-checking on its argument, so the checker +sees exactly the annotated field type and raises nothing. This is a real, +silent architecture gap introduced by this refactor — flagged here as an +open follow-up design question (should `CustomType.dhall` thread a +member's own `decodeExpr` through to `CompositeModule.dhall`, or does +`_decode` need to become field-aware instead of a blind tuple cast?), not +something fixed in this commit and not on the same footing as the +composite-array case's deferred-but-backstopped behavior change. --- @@ -378,37 +415,44 @@ overwritten on every run. Do not hand-edit it. ## 10. Generator decomposition -`gen/Gen.dhall` is the entry point handed to gen-sdk: +`src/package.dhall` is the entry point handed to gen-sdk: ```dhall let Sdk = ./Deps/Sdk.dhall -in Sdk ./Config.dhall ./compile.dhall -``` +let OnUnsupported = ./Structures/OnUnsupported.dhall + +let ProjectInterpreter = ./Interpreters/Project.dhall -The Sdk `module` function has signature `\(Config : Type) -> \(compile) -> -{ contractVersion, Config, compile, compileToFileMap }`, and -`compile : Optional Config -> Project -> Lude.Compiled.Type Lude.Files.Type`, -where `Files.Type = List { path : Text, content : Text }`. `compile.dhall` -folds the optional user config into the internal interpreter config and -calls `Interpreters/Project.dhall`, which traverses queries and custom types -and assembles the file list. +let Config = { packageName : Optional Text, emitSync : Optional Bool, onUnsupported : Optional OnUnsupported.Mode } + +let Config/default = { packageName = None Text, emitSync = None Bool, onUnsupported = None OnUnsupported.Mode } + +in Sdk.Sigs.generator Config Config/default ProjectInterpreter.run +``` -`gen/` mirrors a typical pgn gen-sdk generator, Python-flavored. The -algebra/interpreter/template split keeps assembly separate from rendering. +`Sdk.Sigs.generator` has signature `\(Config : Type) -> \(defaultConfig : Config) -> +\(interpret : Config -> Contract.Project -> Contract.Output) -> ...`; it curries +`interpret` against `defaultConfig` whenever the user config is absent and hands +the result to gen-contract's `Contract.module`. `Config` is passed straight +through to `Interpreters/Project.dhall` as that interpreter's own `Config` -- +there is no separate config type or resolve step in between, matching every +other gen (java.gen, rust.gen, haskell.gen). `Project.run` folds the optional +user config into the fully-resolved internal config itself (see "Config flow" +below), traverses queries and custom types, and assembles the file list +(`Contract.Output`). + +`src/` mirrors a typical pgn gen-sdk generator, Python-flavored: `Interpreters/` +assembles data, `Templates/` renders it to Python text. The interpreter/template +algebra signatures themselves live in gen-sdk's `Sdk.Sigs` (`interpreter.dhall`/ +`template.dhall`), not a local `Algebras/` dir. ```text -gen/ - Gen.dhall # Sdk Config compile (entry handed to gen-sdk) - Config.dhall # user config TYPE: { packageName, emitSync, onUnsupported } - compile.dhall # derive interpreter Config from user Config, call Project.run - Deps/ # pinned remote imports (gen-sdk module + Project, lude, Prelude) - Algebras/ - Interpreter.dhall # Config + `module Input Output run` ; Run = Config -> Input -> Compiled Output - Template.dhall # `module Params run` ; Run = Params -> Text +src/ + package.dhall # Config, Config/default, Sdk.Sigs.generator Config Config/default ProjectInterpreter.run + Deps/ # pinned remote imports: gen-sdk, gen-contract, lude, dhall Prelude Structures/ Surface.dhall # async/sync token table (section 4) - CustomKind.dhall # Lookup : Name -> < Enum | Composite | Absent > + composite fields ImportSet.dhall # per-module import flags + combine PyIdent.dhall # sanitize names that become Python identifiers (keyword -> name_) OnUnsupported.dhall # < Fail | Skip > (section 11) @@ -426,6 +470,7 @@ gen/ Project.dhall # traverse queries+customTypes, assemble all files + facade + header, # apply the Skip filter (section 11) Templates/ + CoreModule.dhall # shared _core.py: JsonValue, NoRowError/DecodeError, require_array RuntimeModule.dhall # async + sync _runtime.py bodies RowsModule.dhall # shared _rows.py (Row dataclasses + decode fns) StatementModule.dhall # one per-surface statement wrapper @@ -434,29 +479,39 @@ gen/ EnumModule.dhall / CompositeModule.dhall / TypesInit.dhall / InitModule.dhall ``` -The cross-cutting dependency is the custom-type lookup: `Scalar`/`Value`/ -`Primitive` stop at "Custom + Name"; `Project.run` builds a -`CustomKind.Lookup : Name -> < Enum | Composite | Absent >` from the -(post-Skip-filter) custom types and threads it to `Query.run` -> -`Result`/`ResultColumns`/`ParamsMember`/`Member`. The lookup compares by -`name.inSnakeCase` (the column carries a distinct `Name` occurrence, so -record identity cannot be relied on) and carries the type's alphabetical -index so per-module custom-type import blocks stay sorted. Section 12 -covers why this one comparison still needs a Dhall fork builtin. +The cross-cutting dependency used to be a project-wide custom-type lookup: +`Project.run` built a `CustomKind.Lookup : Name -> < Enum | Composite | +Absent >` from the (post-Skip-filter) custom types and threaded it to +`Query.run` -> `Result`/`ResultColumns`/`ParamsMember`/`Member`. That +lookup, and `Structures/CustomKind.dhall` itself, are deleted. `Scalar`/ +`Value`/`Primitive` still stop at "Custom + Name", but `Member.dhall`/ +`ParamsMember.dhall` now resolve a `Custom` reference by calling the +generated class's `_decode`/`_encode` method directly, keyed off +`name.inPascalCase` — no project-wide search, no classification step +threaded through the query pipeline. Array (dims > 0) decode/encode stays +local to the call site rather than becoming a third per-type method, +because `elementIsNullable` is a per-column fact a per-type method cannot +see. Section 12 covers the removal and the behavior change it introduced. +The old lookup's alphabetical index, used to keep per-module custom-type +import blocks sorted and deduped, is also gone: `ImportSet.dhall` now +renders custom-type imports in encounter order, unsorted and undeduped +(see the comment at its top). ### Config flow -`Config.dhall` is the user-facing type `{ packageName : Optional Text, -emitSync : Optional Bool, onUnsupported : Optional OnUnsupported.Mode }`. -`compile.dhall` folds the optional user config, and each of its optional -fields, into the internal interpreter Config `{ packageName, importName, -emitAsync = True, emitSync, onUnsupported }`, with `packageName` falling -back to the project name in kebab case, `emitSync` to `False`, and -`onUnsupported` to `Fail`. `importName` = `packageName` with `-` -> `_`. -`emitAsync` is always `True`. A project's artifact config can therefore omit -`config:` entirely, supply `config: {}`, or set any subset of the three -keys; see the README's Config reference for the decode semantics pgn itself -applies before this fold ever runs. +`package.dhall`'s `Config` is the user-facing type `{ packageName : Optional +Text, emitSync : Optional Bool, onUnsupported : Optional OnUnsupported.Mode +}`, passed straight through to `Interpreters/Project.dhall` as its own +`Config` -- there is no separate config type or resolve step in between. +`Project.run` folds the optional config, and each of its optional fields, +into the fully-resolved `ResolvedConfig` it passes to every interpreter +below it: `{ packageName, importName, emitSync, onUnsupported }`, with +`packageName` falling back to the project name in kebab case, `emitSync` to +`False`, and `onUnsupported` to `Fail`. `importName` = `packageName` with +`-` -> `_`. A project's artifact config can therefore omit `config:` +entirely, supply `config: {}`, or set any subset of the three keys; see the +README's Config reference for the decode semantics pgn itself applies +before `Project.run` ever sees the value. --- @@ -470,13 +525,18 @@ aborts the whole `Project.run` non-zero via `Lude.Compiled.report`. drop the smallest self-consistent unit and keep the rest of the project generating. `Project.dhall`'s `run` computes, once, whether each custom type and each query would have compiled cleanly (`typeSucceeds` / the `keep` -field of a per-query `QueryCheck`), filters the failing ones out before -building the custom-type lookup and the final query list, and separately -collects a `Report` per dropped unit into `combined.warnings`. A query that -references a skipped custom type resolves that reference to `Absent` -through the (already-filtered) lookup and fails its own compile the same -way any other unsupported shape would, which is what makes the drop cascade -without any special-cased "type X depends on type Y" bookkeeping. +field of a per-query `QueryCheck`), filters the failing ones out to produce +the final surviving custom-type and query lists, and separately collects a +`Report` per dropped unit into `combined.warnings`. + +Whether a query referencing a *specific* skipped custom type still cascades +into its own compile failure is worth re-checking rather than assumed: the +project-wide custom-type lookup this cascade used to route through is gone +(section 12), and neither `Member.dhall` nor `ParamsMember.dhall` currently +take the surviving custom-type list as an input to cross-check a +`customRef` name against. This is unrelated to the `Text/equal` removal and +out of scope for this pass; flagged here only so the Skip-mode cascade +isn't assumed unchanged without someone verifying it. The precedent for this shape is java.gen, an earlier gen-sdk generator for a different target language: it skips unconditionally and silently (an @@ -492,34 +552,108 @@ generator is already ready for that; it isn't waiting on it. --- -## 12. Forked-Dhall (`Text/equal`) dependency risk - -ACCEPTED RISK, recorded so nobody is surprised. pgn is a prebuilt binary -that embeds a FORKED Dhall providing a `Text/equal` builtin, which is not -part of the upstream Dhall standard Prelude. This generator uses it in -exactly one place left: `Interpreters/Project.dhall`'s `buildLookup`, to -match a custom type by its snake-case name while building the -`CustomKind.Lookup`. Everywhere else that used to need name equality -(keyword sanitizing in `PyIdent.dhall`) has since been rewritten against a -`Text/replace`-based trick that needs no fork builtin at all; section 13 -covers how. - -`buildLookup`'s use is harder to remove the same way: it returns a -structural `TypeKind` value, not `Text`, so the replace-based marker trick -(built for sanitizing a `Text -> Text` name) doesn't carry over as-is. -gen-sdk's own `Fixtures` module relies on the same builtin, so a Dhall -evaluator without it can't even typecheck gen-sdk's full package entry -point, only the narrower `module.dhall`/`Project.dhall` imports this -generator pins directly. The clean fix is upstream: a `kind` tag or a -`Natural` index carried directly on `Scalar.Custom`, so the lookup becomes -an equality-free structural match. That is a planned ask against gen-sdk, -not something this generator can do unilaterally. - -Consequence: end-to-end regeneration requires the pgn binary (for its -embedded fork Dhall), a live Postgres, and currently-live remote Dhall -imports (`Deps/*` resolve gen-sdk, lude, and the Prelude over the network, -pinned by sha256). There is no pure-upstream-dhall path to reproduce the -output today. +## 12. Forked-Dhall (`Text/equal`) dependency: removed from this generator + +RESOLVED, not an accepted risk anymore. `Interpreters/Project.dhall`'s +`buildLookup` was this generator's last consumer of pgn's FORKED-Dhall-only +`Text/equal` builtin (not part of the upstream Dhall standard Prelude): it +matched a custom type by its snake-case name while building a +`CustomKind.Lookup : Name -> < Enum | Composite | Absent >`, threaded +through `Query.run` so `Result`/`ResultColumns`/`ParamsMember`/`Member` +could classify a `Custom` reference and pull its fields. `buildLookup` and +`CustomKind.Lookup` usage are removed in commit `ffdd9bd`; the now-empty +`Structures/CustomKind.dhall` file itself is deleted separately in commit +`ab8f4df`. + +In their place, `CompositeModule.dhall`/`EnumModule.dhall` now emit a +`_decode`/`_encode` method directly onto each generated custom type's +Python class, covering the scalar (`dims == 0`) case. `Member.dhall` and +`ParamsMember.dhall` call it by name off `name.inPascalCase` at the +reference site (e.g. `${typeName}._decode(src)`) instead of resolving +classification/fields via a project-wide name search. No name-equality +comparison is needed at all anymore, so there's nothing left for +`Text/equal` to do here. `grep -rn "Text/equal" src` confirms this: it +returns exactly two hits, both explanatory comments about why a mechanism +does *not* use the builtin (`Structures/ImportSet.dhall:7`, +`Structures/PyIdent.dhall:48`), zero actual invocations anywhere in `src/`. + +Array (dims > 0) decode/encode is deliberately NOT a third per-type method. +An earlier draft this session gave `EnumModule.dhall` a `_decode_array` +staticmethod, but the final whole-branch review caught that a per-type, +zero-argument array codec cannot express `elementIsNullable` — an +`Optional`-array-settings field that varies per *column*, not per type (a +`moods: list[Mood | None] | None` column needs a different per-element guard +than a `list[Mood]` column of the same enum). That method hardcoded the +non-nullable-element shape unconditionally, silently breaking +nullable-element enum-array decode (a runtime crash on any `NULL` array +element) and, symmetrically, `ParamsMember.dhall`'s unconditional +`${field}._encode()` broke enum-array param encode (calling `._encode()` on +a `list`). Both were working, corpus-exercised paths before this refactor. +The fix moves array handling back to the call site, exactly where it lived +before this refactor: `Member.dhall`'s dims==1 branch and +`ParamsMember.dhall`'s `Natural/isZero value.dims` branch build the list +comprehension locally, reading `value.elementIsNullable`/`value.dims` off +the column- or param-local `Value.Output`, and delegate only the +per-element transform to `${typeName}._decode(v)` / `x._encode()`. This +restores exact parity with the pre-refactor `enumArrayDecode`/array-param +behavior for enums (verified against +`tests/golden/src/specimen_client/_generated/_rows.py`'s `moods` column and +`statements/insert_specimen.py`'s `moods` param — same shape, just calling +`._decode`/`._encode` per element instead of the enum constructor/bare +pass-through). + +A behavior change worth flagging, now unavoidable rather than accidental: +because the call site's array branch is kind-uniform (the same +`${typeName}._decode(v)`/`x._encode()` call per element regardless of +whether `typeName` is an enum or a composite), a 1-D composite-array column +or param is no longer rejected at Dhall-generation time the way it used to +be (the old "Array of a composite type is not supported" reports are +gone), and — unlike the `_decode_array` design it replaces — no longer +depends on `basedpyright strict` catching a missing method either, since +`CompositeModule.dhall`'s `_decode`/`_encode` genuinely exist. A +composite-array column/param now type-checks and attempts a real +per-element decode/encode. **This path remains unverified against real +Postgres either way** — composite arrays were never tested before this +refactor (they were rejected outright at generation time) and aren't +tested now (same deferred gap, just no longer expected to fail statically). +Adding that fixture, regenerating golden, and confirming actual Postgres +round-trip behavior for a composite-array column/param are deferred to a +follow-up pass on a properly provisioned machine; see +`docs/plans/2026-07-11-reusable-custom-type-codecs.md` for the original +design decision (Option A, chosen deliberately) and the deferred task list. + +A second, related change here: `CustomType.dhall`'s Composite branch used +to call `Member.run` with a `nestedLookup` stub hardcoded to `Absent`, +which forced a composite field nesting another custom type down the same +loud-fail path as any unresolvable reference (see section 5). That stub is +gone — it existed only to satisfy `Member.run`'s old signature, and was +deleted along with `buildLookup` — but, unlike the composite-array case +just above, this is not "the same behavior change, just unexercised." +`CompositeModule.dhall`'s `_decode`/`_encode` do a blind flat +`cast(tuple[...], src)`/splat and never recurse into a nested field's own +codec, unchanged by this refactor; removing the stub only removed the +thing that used to reject a nested custom-type composite field at +generation time, it did not make that field decode/encode correctly. And +because the failure mode runs through `cast()` — which suppresses +type-checking on its argument by design — this is **not** expected to be +caught by `basedpyright strict` the way the composite-array case is. This +is a real, silent architecture gap, not a deferred-but-backstopped +behavior change; see section 5 for detail, and treat it as an open +follow-up design question rather than something covered by the +composite-array follow-up plan. + +This removal is scoped to this generator's own Dhall source. It does not +unblock end-to-end regeneration: `demos/Exhaustive.dhall`/`mise run golden` +still needs the pinned pgn binary (for its embedded fork Dhall) regardless, +because gen-sdk's own `Fixtures` module independently relies on the same +`Text/equal` builtin, and this change doesn't touch gen-sdk. A live +Postgres and currently-live remote Dhall imports (`Deps/*` resolve gen-sdk, +lude, and the Prelude over the network, pinned by sha256) are also still +required for that path. + +Section 13 covers a different, still-in-place mechanism (`PyIdent.dhall`'s +keyword sanitizing), which never depended on `buildLookup` and was already +rewritten off `Text/equal` before this change; it is unaffected either way. --- @@ -629,11 +763,11 @@ SQL rendering are largely driver-agnostic. CI runs two independent jobs (`.github/workflows/ci.yml`): `harness` (the pytest suite against a live Postgres) and `contract` (compiles gen-sdk's -`Fixtures.Exhaustive` via `tests/Exhaustive.dhall` and runs basedpyright +`Fixtures.Exhaustive` via `demos/Exhaustive.dhall` and runs basedpyright strict on the result). The `contract` job needs `nikita-volkov/dhall-directory-tree.github-action`, a Docker action bundling a forked Dhall evaluator; the local `dhall` CLI most people have installed is the standard dhall-lang build and does not -understand `Text/equal`, so it cannot run `tests/Exhaustive.dhall` directly. +understand `Text/equal`, so it cannot run `demos/Exhaustive.dhall` directly. Reproduce the `contract` job locally with [`act`](https://github.com/nektos/act) (not installed in this environment; `act -j contract` pulls the same pinned Docker action and runs the job as GitHub would). diff --git a/README.md b/README.md index db3226f..9e9a17f 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ name: ```yaml artifacts: python: - gen: https://raw.githubusercontent.com/slavashvets/python.gen/master/gen/Gen.dhall + gen: https://raw.githubusercontent.com/slavashvets/python.gen/master/src/package.dhall config: packageName: my-db-client emitSync: true @@ -61,10 +61,10 @@ database. | form | example | works? | | --------------- | --------------------------------------- | ------------------------------------------ | -| plain http(s) | `https://.../python.gen/gen/Gen.dhall` | yes; pgn fetches it and every relative import over HTTP | -| relative path | `../path/to/python.gen/gen/Gen.dhall` | yes, if you keep a local checkout next to your project | -| absolute path | `/abs/path/to/python.gen/gen/Gen.dhall` | yes, but the resulting freeze key is machine-specific | -| `file://` URL | `file:///abs/.../Gen.dhall` | rejected; pgn's project schema does not accept `file://` | +| plain http(s) | `https://.../python.gen/src/package.dhall` | yes; pgn fetches it and every relative import over HTTP | +| relative path | `../path/to/python.gen/src/package.dhall` | yes, if you keep a local checkout next to your project | +| absolute path | `/abs/path/to/python.gen/src/package.dhall` | yes, but the resulting freeze key is machine-specific | +| `file://` URL | `file:///abs/.../package.dhall` | rejected; pgn's project schema does not accept `file://` | Whichever form you use, the freeze file that caches the resolved generator (section "Freeze lifecycle" below) keys on the literal `gen:` value and diff --git a/bench/as-source.sh b/bench/as-source.sh index 203b973..c16945b 100755 --- a/bench/as-source.sh +++ b/bench/as-source.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Cold-cache benchmark of `pgn generate` on the fixture project, comparing the -# current gen (Deps imported `as Source`) against the same tree with the +# current src (Deps imported `as Source`) against the same tree with the # pre-as-Source Deps (plain pinned imports). Both variants run the same pgn # binary by default, so the measurement isolates the import mode itself. # @@ -24,7 +24,7 @@ trap 'rm -rf "$work"' EXIT prepare() { # $1 = variant root; mirrors the repo layout the fixture expects rm -rf "$1" && mkdir -p "$1/tests" - cp -R "$root/gen" "$1/gen" + cp -R "$root/src" "$1/src" cp -R "$root/tests/fixture-project" "$1/tests/fixture-project" rm -f "$1/tests/fixture-project/freeze1.pgn.yaml" rm -rf "$1/tests/fixture-project/artifacts" @@ -34,9 +34,23 @@ prepare() { # $1 = variant root; mirrors the repo layout the fixture expects # normalized-expression pins (an `as Source` pin hashes the import's source, # so the two modes need different sha256 values for the same version). strip_as_source() { # $1 = variant root - perl -i -ne 'print unless /^\s*as Source$/' "$1"/gen/Deps/*.dhall - perl -i -pe 's/8d43544ecb0e612406af3133bdbca51138c704a77a5a29ef62fe034d0e77a3a6/b9f7bb842345f3864c71e877fda4200306ba5c044a43e6f7713a23bc4769b91a/' "$1/gen/Deps/Sdk.dhall" - perl -i -pe 's/46b527b071eba96a17e76b4bc5774645714dd5b4355974d221e705aa7c126e77/14c43eec97972ae27afe3386ff937d04db66f84273d5551476361db12d2c4b50/' "$1/gen/Deps/Lude.dhall" + perl -i -ne 'print unless /^\s*as Source$/' "$1"/src/Deps/*.dhall + + # gen-sdk v2.0.0's src/package.dhall: `mise x -- dhall hash` against the + # live GitHub-hosted package (both `... as Source` and the plain import) + # returns the SAME sha256 (b9def6ab1179bc4aaae7fc6e91977f094f75934cd5755175c294a9e97ca71b15), + # matching the value already committed in src/Deps/Sdk.dhall -- so, unlike + # the old v0.11.0 pin this pair used to target (where the two genuinely + # differed: 8d43544e...->b9f7bb84...), no character swap is needed here + # after the strip above; the committed pin already equals the plain-import + # target. See docs/superpowers/plans/2026-07-11-gen-sdk-v2-migration.md + # Task 6 for how this was verified (dhall's local import cache made the + # lookup instant; a cold, uncached fetch of a *different* URL hung in this + # sandbox, so treat network reachability here as best-effort, not given). + + # lude v5.1.0 is unchanged by this migration (see src/Deps/Lude.dhall), so + # its existing as-Source -> plain-import hash swap below is still correct. + perl -i -pe 's/46b527b071eba96a17e76b4bc5774645714dd5b4355974d221e705aa7c126e77/14c43eec97972ae27afe3386ff937d04db66f84273d5551476361db12d2c4b50/' "$1/src/Deps/Lude.dhall" } measure() { # $1 = label, $2 = variant root, $3 = pgn binary diff --git a/bench/generate.sh b/bench/generate.sh index a2acd50..2769621 100755 --- a/bench/generate.sh +++ b/bench/generate.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Build the fixture client from the working-tree gen, in one of two variants: -# with - gen/Deps as committed (imports `as Source`) +# Build the fixture client from the working-tree src, in one of two variants: +# with - src/Deps as committed (imports `as Source`) # without - the pre-as-Source Deps (mode stripped, normalized-expression # pins restored; byte-identical to commit fb78869's Deps) # Output lands in ./demo-with-as-source or ./demo-without-as-source. @@ -15,15 +15,29 @@ url="${PGN_TEST_DATABASE_URL:-postgresql://postgres:postgres@localhost:5432/post out="$root/demo-$variant-as-source" rm -rf "$out" && mkdir -p "$out/tests" -cp -R "$root/gen" "$out/gen" +cp -R "$root/src" "$out/src" cp -R "$root/tests/fixture-project" "$out/tests/fixture-project" rm -f "$out/tests/fixture-project/freeze1.pgn.yaml" rm -rf "$out/tests/fixture-project/artifacts" if [ "$variant" = without ]; then - perl -i -ne 'print unless /^\s*as Source$/' "$out"/gen/Deps/*.dhall - perl -i -pe 's/8d43544ecb0e612406af3133bdbca51138c704a77a5a29ef62fe034d0e77a3a6/b9f7bb842345f3864c71e877fda4200306ba5c044a43e6f7713a23bc4769b91a/' "$out/gen/Deps/Sdk.dhall" - perl -i -pe 's/46b527b071eba96a17e76b4bc5774645714dd5b4355974d221e705aa7c126e77/14c43eec97972ae27afe3386ff937d04db66f84273d5551476361db12d2c4b50/' "$out/gen/Deps/Lude.dhall" + perl -i -ne 'print unless /^\s*as Source$/' "$out"/src/Deps/*.dhall + + # gen-sdk v2.0.0's src/package.dhall: `mise x -- dhall hash` against the + # live GitHub-hosted package (both `... as Source` and the plain import) + # returns the SAME sha256 (b9def6ab1179bc4aaae7fc6e91977f094f75934cd5755175c294a9e97ca71b15), + # matching the value already committed in src/Deps/Sdk.dhall -- so, unlike + # the old v0.11.0 pin this pair used to target (where the two genuinely + # differed: 8d43544e...->b9f7bb84...), no character swap is needed here + # after the strip above; the committed pin already equals the plain-import + # target. See docs/superpowers/plans/2026-07-11-gen-sdk-v2-migration.md + # Task 6 for how this was verified (dhall's local import cache made the + # lookup instant; a cold, uncached fetch of a *different* URL hung in this + # sandbox, so treat network reachability here as best-effort, not given). + + # lude v5.1.0 is unchanged by this migration (see src/Deps/Lude.dhall), so + # its existing as-Source -> plain-import hash swap below is still correct. + perl -i -pe 's/46b527b071eba96a17e76b4bc5774645714dd5b4355974d221e705aa7c126e77/14c43eec97972ae27afe3386ff937d04db66f84273d5551476361db12d2c4b50/' "$out/src/Deps/Lude.dhall" fi cd "$out/tests/fixture-project" diff --git a/tests/Exhaustive.dhall b/demos/Exhaustive.dhall similarity index 63% rename from tests/Exhaustive.dhall rename to demos/Exhaustive.dhall index bfe8b84..35e8f28 100644 --- a/tests/Exhaustive.dhall +++ b/demos/Exhaustive.dhall @@ -1,9 +1,9 @@ -- Applies this generator to gen-sdk's shared cross-backend fixture project --- (the same "music_catalogue" project java.gen's own tests/Exhaustive.dhall +-- (the same "music_catalogue" project java.gen's own demos/Exhaustive.dhall -- exercises), so a Python client compiles from it and passes basedpyright -- strict. Pinned directly at gen-sdk's package.dhall, separately from --- gen/Deps/Sdk.dhall: that file only imports gen-sdk's `module.dhall` (the --- generator-construction function), which has no `Fixtures` field. +-- src/Deps/Sdk.dhall: that file only imports gen-sdk's `package.dhall` `as +-- Source` for RAM, and this fixture load doesn't need that mode. -- -- The fixture project deliberately covers PG types this generator does not -- support (box, inet, money, ranges, ...), so onUnsupported is set to Skip: @@ -13,13 +13,13 @@ -- Intended to be executed with: -- -- ```bash --- dhall to-directory-tree --file tests/Exhaustive.dhall --output --allow-path-separators +-- dhall to-directory-tree --file demos/Exhaustive.dhall --output --allow-path-separators -- ``` -let Sdk = ../gen/Deps/Sdk.dhall +let Sdk = ../src/Deps/Sdk.dhall -let Gen = ../gen/Gen.dhall +let Gen = ../src/package.dhall -let OnUnsupported = ../gen/Structures/OnUnsupported.dhall +let OnUnsupported = ../src/Structures/OnUnsupported.dhall let project = Sdk.Fixtures.Exhaustive @@ -30,4 +30,4 @@ let config = , onUnsupported = Some OnUnsupported.Mode.Skip } -in Gen.compileToFileMap config project +in Sdk.Output.toFileMap (Gen.compile config project) diff --git a/docs/upstream-asks.md b/docs/upstream-asks.md index 44c3417..8d10b37 100644 --- a/docs/upstream-asks.md +++ b/docs/upstream-asks.md @@ -102,6 +102,18 @@ and the new stderr output only appears when warnings are non-empty. ## 3. gen-sdk: `kind` tag or `Natural` index on `Scalar.Custom` +**WITHDRAWN.** `buildLookup`'s only consumer of the fork-only `Text/equal` +builtin was resolved locally, with no gen-sdk contract change needed: +custom-type decode/encode now dispatches through named +`_decode`/`_decode_array`/`_encode` methods generated onto each custom +type's own Python class (`CompositeModule.dhall`/`EnumModule.dhall`), +called by name from every reference site, instead of resolving +classification/fields via a project-wide structural search. `buildLookup` +and `Structures/CustomKind.dhall` are deleted; see +`docs/plans/2026-07-11-reusable-custom-type-codecs.md` and DESIGN.md +section 12. This section is kept for the historical record of why the ask +existed, not as an open request. + ### Motivation This is the ask already planned in DESIGN.md, section 12. The generator's diff --git a/gen/Algebras/Interpreter.dhall b/gen/Algebras/Interpreter.dhall deleted file mode 100644 index 7532153..0000000 --- a/gen/Algebras/Interpreter.dhall +++ /dev/null @@ -1,21 +0,0 @@ -let Deps = ../Deps/package.dhall - -let OnUnsupported = ../Structures/OnUnsupported.dhall - -let Config = - { packageName : Text - , importName : Text - , emitSync : Bool - , onUnsupported : OnUnsupported.Mode - } - -let module = - \(Input : Type) -> - \(Output : Type) -> - let Result = Deps.Lude.Compiled.Type Output - - let Run = Config -> Input -> Result - - in \(run : Run) -> { Input, Output, Result, Run, run } - -in { Config, module } diff --git a/gen/Algebras/Template.dhall b/gen/Algebras/Template.dhall deleted file mode 100644 index 15f2624..0000000 --- a/gen/Algebras/Template.dhall +++ /dev/null @@ -1,5 +0,0 @@ -let module = - \(Params : Type) -> - let Run = Params -> Text in \(run : Run) -> { Params, Run, run } - -in { module } diff --git a/gen/Algebras/package.dhall b/gen/Algebras/package.dhall deleted file mode 100644 index f0f64bd..0000000 --- a/gen/Algebras/package.dhall +++ /dev/null @@ -1 +0,0 @@ -{ Interpreter = ./Interpreter.dhall, Template = ./Template.dhall } diff --git a/gen/Config.dhall b/gen/Config.dhall deleted file mode 100644 index 7ec1fe6..0000000 --- a/gen/Config.dhall +++ /dev/null @@ -1,15 +0,0 @@ --- User-facing config for this generator. `emitSync` adds a parallel sync surface --- (psycopg.Connection) alongside the default async one, so one project can serve --- both an async backend and a sync (Dagster) consumer from shared Row types. --- `onUnsupported` picks Fail (default, abort loudly) or Skip (drop the --- unsupported statement/type and its dependents, with a warning) when a query --- or custom type hits a PG shape the generator cannot render; see --- Structures/OnUnsupported.dhall. All fields are Optional so a project may omit --- the whole config block or any subset of its keys; compile.dhall supplies the --- defaults. -let OnUnsupported = ./Structures/OnUnsupported.dhall - -in { packageName : Optional Text - , emitSync : Optional Bool - , onUnsupported : Optional OnUnsupported.Mode - } : Type diff --git a/gen/Deps/Lude.dhall b/gen/Deps/Lude.dhall deleted file mode 100644 index 30f1f5a..0000000 --- a/gen/Deps/Lude.dhall +++ /dev/null @@ -1,3 +0,0 @@ -https://raw.githubusercontent.com/codemine-io/lude.dhall/v5.1.0/src/package.dhall - sha256:46b527b071eba96a17e76b4bc5774645714dd5b4355974d221e705aa7c126e77 - as Source diff --git a/gen/Deps/Sdk.dhall b/gen/Deps/Sdk.dhall deleted file mode 100644 index 6cf6014..0000000 --- a/gen/Deps/Sdk.dhall +++ /dev/null @@ -1,3 +0,0 @@ -https://raw.githubusercontent.com/pgenie-io/gen-sdk/v0.11.0/dhall/package.dhall - sha256:8d43544ecb0e612406af3133bdbca51138c704a77a5a29ef62fe034d0e77a3a6 - as Source diff --git a/gen/Deps/package.dhall b/gen/Deps/package.dhall deleted file mode 100644 index b395cd9..0000000 --- a/gen/Deps/package.dhall +++ /dev/null @@ -1,4 +0,0 @@ -{ Sdk = ./Sdk.dhall -, Lude = ./Lude.dhall -, Prelude = ./Prelude.dhall -} diff --git a/gen/Gen.dhall b/gen/Gen.dhall deleted file mode 100644 index 9cb87fb..0000000 --- a/gen/Gen.dhall +++ /dev/null @@ -1,3 +0,0 @@ -let Sdk = ./Deps/Sdk.dhall - -in Sdk.module ./Config.dhall ./compile.dhall diff --git a/gen/Interpreters/Member.dhall b/gen/Interpreters/Member.dhall deleted file mode 100644 index 672dd18..0000000 --- a/gen/Interpreters/Member.dhall +++ /dev/null @@ -1,240 +0,0 @@ -let Deps = ../Deps/package.dhall - -let ImportSet = ../Structures/ImportSet.dhall - -let CustomKind = ../Structures/CustomKind.dhall - -let PyIdent = ../Structures/PyIdent.dhall - -let Algebra = ../Algebras/Interpreter.dhall - -let Lude = Deps.Lude - -let Model = Deps.Sdk.Project - -let Value = ./Value.dhall - -let Input = Model.Member - --- decodeExpr is a Dhall function: given the source expression (e.g. row["x"] or --- a composite tuple slot), it returns the Python decode expression. ResultColumns --- and CustomType compose these so decode logic stays next to the type info. -let Output = - { fieldName : Text - , pgName : Text - , pyType : Text - , isNullable : Bool - , imports : ImportSet.Type - , decodeExpr : Text -> Text - } - -let run = - \(config : Algebra.Config) -> - \(lookup : CustomKind.Lookup) -> - \(input : Input) -> - -- Result-column / composite-field name becomes a dataclass field and decode - -- kwarg, so a keyword-named column must be sanitized; row["..."] keeps the - -- raw pgName. Only the keyword set is load-bearing here (a field named `row` - -- is valid), unlike params which also avoid the template locals. - let fieldName = PyIdent.pySafeName input.name.inSnakeCase - - let buildOutput = - \(value : Value.Output) -> - let nullableSuffix = if input.isNullable then " | None" else "" - - let pyType = value.pyType ++ nullableSuffix - - let castTarget = pyType - - let baseImports = value.imports - - let passthroughDecode = - \(src : Text) -> "cast(${castTarget}, ${src})" - - let enumDecode = - \(enumName : Text) -> - \(src : Text) -> - let call = "${enumName}(cast(str, ${src}))" - - in if input.isNullable - then "None if ${src} is None else ${call}" - else call - - -- psycopg returns an enum array as a list of text, so each element - -- is rebuilt into the StrEnum. The cast pins the iterable's element - -- type; the per-element None guard mirrors elementIsNullable and the - -- outer None guard mirrors a nullable column. - let enumArrayDecode = - \(enumName : Text) -> - \(src : Text) -> - let elemCast = - if value.elementIsNullable - then "list[str | None]" - else "list[str]" - - let elemDecode = - if value.elementIsNullable - then "None if v is None else ${enumName}(v)" - else "${enumName}(v)" - - -- require_array fails loudly if the array came back as - -- text (the connection did not register the enum type) - -- instead of iterating a string into bogus members. - let elements = - "[${elemDecode} for v in cast(${elemCast}, require_array(${src}))]" - - in if input.isNullable - then "None if ${src} is None else ${elements}" - else elements - - -- Composites are registered per connection (see register_types), - -- so psycopg returns a namedtuple. The fixed-length tuple cast - -- with each field's exact pyType lets the splat satisfy strict. - let compositeDecode = - \(typeName : Text) -> - \(fields : List CustomKind.CompositeField) -> - \(src : Text) -> - let fieldTypes = - Deps.Prelude.Text.concatMapSep - ", " - CustomKind.CompositeField - (\(f : CustomKind.CompositeField) -> f.pyType) - fields - - let call = - "${typeName}(*cast(tuple[${fieldTypes}], ${src}))" - - in if input.isNullable - then "None if ${src} is None else ${call}" - else call - - in merge - { Passthrough = - Lude.Compiled.ok - Output - { fieldName - , pgName = input.pgName - , pyType - , isNullable = input.isNullable - , imports = baseImports - , decodeExpr = passthroughDecode - } - , Custom = - Deps.Prelude.Optional.fold - Model.Name - value.scalar.customRef - (Lude.Compiled.Type Output) - ( \(name : Model.Name) -> - let typeName = name.inPascalCase - - let customImport = - \(order : Natural) -> - { className = typeName - , moduleName = name.inSnakeCase - , order - } - - let mkOutput = - \(customImports : ImportSet.Type) -> - \(decodeExpr : Text -> Text) -> - { fieldName - , pgName = input.pgName - , pyType - , isNullable = input.isNullable - , imports = - ImportSet.combine - baseImports - customImports - , decodeExpr - } - - -- A 1-D enum array decodes element-wise; a scalar - -- custom (dims == 0) keeps the single-value decode. - -- Composite arrays and dims > 1 are unimplemented, - -- so fail loudly rather than emit wrong Python. - let dimsIsOne = - Natural/isZero (Natural/subtract 1 value.dims) - - in merge - { Enum = - \(order : Natural) -> - let enumImport = - ImportSet.customEnum - (customImport order) - - in if Natural/isZero value.dims - then Lude.Compiled.ok - Output - ( mkOutput - enumImport - (enumDecode typeName) - ) - else if dimsIsOne - then Lude.Compiled.ok - Output - ( mkOutput - ( ImportSet.combine - enumImport - ImportSet.enumArray - ) - (enumArrayDecode typeName) - ) - else Lude.Compiled.report - Output - [ input.pgName - , name.inSnakeCase - ] - "Array of an enum with dimensionality > 1 is not supported" - , Composite = - \ ( composite - : { fields : - List CustomKind.CompositeField - , order : Natural - } - ) -> - if Natural/isZero value.dims - then Lude.Compiled.ok - Output - ( mkOutput - ( ImportSet.customComposite - (customImport composite.order) - ) - ( compositeDecode - typeName - composite.fields - ) - ) - else Lude.Compiled.report - Output - [ input.pgName - , name.inSnakeCase - ] - "Array of a composite type is not supported (element-wise decode is unimplemented)" - , Absent = - Lude.Compiled.report - Output - [ name.inSnakeCase ] - "Custom type not found in project customTypes" - } - (lookup name) - ) - ( Lude.Compiled.report - Output - [ input.pgName ] - "Custom scalar without a customRef name" - ) - } - value.scalar.decode - - let compiledValue - : Lude.Compiled.Type Value.Output - = Lude.Compiled.nest - Value.Output - input.pgName - (Value.run config input.value) - - in Lude.Compiled.flatMap Value.Output Output buildOutput compiledValue - -let Run = Algebra.Config -> CustomKind.Lookup -> Input -> Lude.Compiled.Type Output - -in { Input, Output, Run, run } diff --git a/gen/Structures/CustomKind.dhall b/gen/Structures/CustomKind.dhall deleted file mode 100644 index 22d3031..0000000 --- a/gen/Structures/CustomKind.dhall +++ /dev/null @@ -1,30 +0,0 @@ -let Deps = ../Deps/package.dhall - -let Model = Deps.Sdk.Project - --- A composite field as the decode/encode sites need it: the Python attribute --- name and the rendered Python type (already nullability-applied). Threaded so --- a column decode can build `Point2D(*cast(tuple[x, y], src))` and a param --- encode can build `(value.x, value.y)` without re-reading the customType. -let CompositeField = { fieldName : Text, pyType : Text } - --- Classification of a custom type referenced by a Scalar.Custom name. Composite --- carries its field list so callers render per-field decode/encode. Absent means --- the name did not resolve against project.customTypes, which is a model --- inconsistency the caller turns into a Compiled error. --- Enum and Composite both carry `order`, the type's alphabetical position in --- project.customTypes; the import renderer sorts on it so the per-module --- `from ..types.X import Y` block stays alphabetical regardless of column order. --- NOTE: union is named TypeKind because `Kind` is a reserved Dhall keyword. -let TypeKind = - < Enum : Natural - | Composite : { fields : List CompositeField, order : Natural } - | Absent - > - --- Resolves a custom-type Name to its TypeKind. Project.run builds this from --- project.customTypes and threads it into Member/ParamsMember (and onward to --- Result/ResultColumns) so those interpreters can pick the right decode/encode. -let Lookup = Model.Name -> TypeKind - -in { TypeKind, Lookup, CompositeField } diff --git a/gen/Structures/ImportSet.dhall b/gen/Structures/ImportSet.dhall deleted file mode 100644 index 04fc239..0000000 --- a/gen/Structures/ImportSet.dhall +++ /dev/null @@ -1,220 +0,0 @@ -let Prelude = ../Deps/Prelude.dhall - --- A custom-type import line: "from ..types. import ". --- dedupKey identifies the type for deduplication and orders the rendered import --- block. Dhall (upstream) has no Text comparison, so the caller assigns each --- distinct custom type its alphabetical position in project.customTypes as the --- key; dedup keys on it and the render step sorts ascending to stay alphabetical. -let CustomImport = { className : Text, moduleName : Text, dedupKey : Natural } - -let Self = - { uuid : Bool - , datetime : Bool - , date : Bool - , time : Bool - , timedelta : Bool - , decimal : Bool - , jsonb : Bool - , json : Bool - , jsonValue : Bool - , enumArray : Bool - , customTypes : List CustomImport - } - -let base = - { uuid = False - , datetime = False - , date = False - , time = False - , timedelta = False - , decimal = False - , jsonb = False - , json = False - , jsonValue = False - , enumArray = False - , customTypes = [] : List CustomImport - } - -let empty - : Self - = base - -let uuid - : Self - = base // { uuid = True } - -let datetime - : Self - = base // { datetime = True } - -let date - : Self - = base // { date = True } - -let time - : Self - = base // { time = True } - -let timedelta - : Self - = base // { timedelta = True } - -let decimal - : Self - = base // { decimal = True } - -let jsonb - : Self - = base // { jsonb = True } - -let json - : Self - = base // { json = True } - -let jsonValue - : Self - = base // { jsonValue = True } - -let enumArray - : Self - = base // { enumArray = True } - -let custom - : CustomImport -> Self - = \(c : CustomImport) -> base // { customTypes = [ c ] } - --- Named-type imports dedup and sort on `order`, the type's alphabetical index in --- project.customTypes. Two references to the same type carry the same order and --- collapse; distinct types keep distinct keys so the render step can sort them. -let customEnum - : { className : Text, moduleName : Text, order : Natural } -> Self - = \(c : { className : Text, moduleName : Text, order : Natural }) -> - custom - { className = c.className, moduleName = c.moduleName, dedupKey = c.order } - -let customComposite - : { className : Text, moduleName : Text, order : Natural } -> Self - = \(c : { className : Text, moduleName : Text, order : Natural }) -> - custom - { className = c.className, moduleName = c.moduleName, dedupKey = c.order } - -let eqNat = - \(a : Natural) -> - \(b : Natural) -> - Natural/isZero (Natural/subtract a b) - && Natural/isZero (Natural/subtract b a) - -let dedupCustoms - : List CustomImport -> List CustomImport - = \(items : List CustomImport) -> - let State = { seen : List Natural, acc : List CustomImport } - - let step = - \(item : CustomImport) -> - \(state : State) -> - let known = - Prelude.List.any - Natural - (\(k : Natural) -> eqNat k item.dedupKey) - state.seen - - in if known - then state - else { seen = state.seen # [ item.dedupKey ] - , acc = state.acc # [ item ] - } - - in ( List/fold - CustomImport - items - State - step - { seen = [] : List Natural, acc = [] : List CustomImport } - ).acc - -let leNat = \(a : Natural) -> \(b : Natural) -> Natural/isZero (Natural/subtract b a) - --- Insertion sort over custom imports by ascending dedupKey (the alphabetical --- order). The list is tiny (one or two types per module), so the cost is moot --- and the result is a stable alphabetical import block. -let sortCustoms - : List CustomImport -> List CustomImport - = \(items : List CustomImport) -> - let insert = - \(item : CustomImport) -> - \(acc : List CustomImport) -> - let State = { placed : Bool, out : List CustomImport } - - -- foldLeft so the scan runs left-to-right and `item` lands before - -- the FIRST element that is >= it (a right List/fold visits the - -- list back-to-front and would insert before the last such - -- element, mis-ordering blocks of 3+ types). - let step = - \(state : State) -> - \(cur : CustomImport) -> - if state.placed - then { placed = True, out = state.out # [ cur ] } - else if leNat item.dedupKey cur.dedupKey - then { placed = True - , out = state.out # [ item, cur ] - } - else { placed = False, out = state.out # [ cur ] } - - let folded = - Prelude.List.foldLeft - CustomImport - acc - State - step - { placed = False, out = [] : List CustomImport } - - in if folded.placed - then folded.out - else folded.out # [ item ] - - in List/fold CustomImport items (List CustomImport) insert ([] : List CustomImport) - -let combine = - \(left : Self) -> - \(right : Self) -> - { uuid = left.uuid || right.uuid - , datetime = left.datetime || right.datetime - , date = left.date || right.date - , time = left.time || right.time - , timedelta = left.timedelta || right.timedelta - , decimal = left.decimal || right.decimal - , jsonb = left.jsonb || right.jsonb - , json = left.json || right.json - , jsonValue = left.jsonValue || right.jsonValue - , enumArray = left.enumArray || right.enumArray - , customTypes = dedupCustoms (left.customTypes # right.customTypes) - } - -let combineAll - : List Self -> Self - = \(sets : List Self) -> List/fold Self sets Self combine empty - -let sortedCustoms - : Self -> List CustomImport - = \(self : Self) -> sortCustoms self.customTypes - -in { Type = Self - , CustomImport - , empty - , uuid - , datetime - , date - , time - , timedelta - , decimal - , jsonb - , json - , jsonValue - , enumArray - , custom - , customEnum - , customComposite - , combine - , combineAll - , sortedCustoms - } diff --git a/gen/Templates/CompositeModule.dhall b/gen/Templates/CompositeModule.dhall deleted file mode 100644 index 198c77d..0000000 --- a/gen/Templates/CompositeModule.dhall +++ /dev/null @@ -1,46 +0,0 @@ -let Deps = ../Deps/package.dhall - -let Algebra = ../Algebras/Template.dhall - -let Field = { fieldName : Text, fieldType : Text } - --- extraImports are the extra import lines a field type needs (e.g. --- "from uuid import UUID"). They sit between the dataclass import and the class, --- separated by one blank line; when empty only the dataclass import is emitted. -let Params = { typeName : Text, extraImports : List Text, fields : List Field } - -let run = - \(params : Params) -> - let fieldLines = - Deps.Prelude.Text.concatMapSep - "\n" - Field - ( \(field : Field) -> - " ${field.fieldName}: ${field.fieldType}" - ) - params.fields - - let imports = - if Deps.Prelude.List.null Text params.extraImports - then "from dataclasses import dataclass" - else '' - from dataclasses import dataclass - - ${Deps.Prelude.Text.concatSep "\n" params.extraImports}'' - - in '' - ${imports} - - - @dataclass(frozen=True, slots=True) - class ${params.typeName}: - """Decoding/encoding this composite requires register_types(conn) first. - - Without per-connection registration psycopg returns the value as a - raw string, which the generated _decode cannot splat into the dataclass. - """ - - ${fieldLines} - '' - -in Algebra.module Params run /\ { Field } diff --git a/gen/compile.dhall b/gen/compile.dhall deleted file mode 100644 index cd12642..0000000 --- a/gen/compile.dhall +++ /dev/null @@ -1,67 +0,0 @@ -let Deps = ./Deps/package.dhall - -let Model = Deps.Sdk.Project - -let Prelude = Deps.Prelude - -let Config = ./Config.dhall - -let OnUnsupported = ./Structures/OnUnsupported.dhall - -let ProjectInterpreter = ./Interpreters/Project.dhall - --- Entry point handed to gen-sdk's module. `config` and each of its fields are --- Optional, so a project may omit the whole config block or any subset of its --- keys; `defaults` collects every fallback in one place (packageName from the --- project name in kebab case, emitSync off, onUnsupported Fail), so a future --- knob's default is added here alongside the others. The async surface is --- always emitted; emitSync adds the sync mirror. -in \(config : Optional Config) -> - \(project : Model.Project) -> - let defaults = - { packageName = project.name.inKebabCase - , emitSync = False - , onUnsupported = OnUnsupported.Mode.Fail - } - - let packageName = - Prelude.Optional.fold - Config - config - Text - (\(c : Config) -> - Prelude.Optional.fold Text c.packageName Text (\(t : Text) -> t) defaults.packageName - ) - defaults.packageName - - let emitSync = - Prelude.Optional.fold - Config - config - Bool - (\(c : Config) -> - Prelude.Optional.fold Bool c.emitSync Bool (\(b : Bool) -> b) defaults.emitSync - ) - defaults.emitSync - - let onUnsupported = - Prelude.Optional.fold - Config - config - OnUnsupported.Mode - ( \(c : Config) -> - Prelude.Optional.fold - OnUnsupported.Mode - c.onUnsupported - OnUnsupported.Mode - (\(m : OnUnsupported.Mode) -> m) - defaults.onUnsupported - ) - defaults.onUnsupported - - let importName = Prelude.Text.replace "-" "_" packageName - - let interpreterConfig = - { packageName, importName, emitSync, onUnsupported } - - in ProjectInterpreter.run interpreterConfig project diff --git a/mise.toml b/mise.toml index 040920b..68903bf 100644 --- a/mise.toml +++ b/mise.toml @@ -35,10 +35,10 @@ run = "bench/generate.sh without" # Regenerates only the "python" artifact: a full 7-artifact generate peaks at # ~31 GB RSS (memory goes to normalizing the generator closure per artifact, # see ci.yml), a single-artifact one at ~10 GB. The temp copy guarantees a -# fresh resolve of the working-tree gen/ (no stale freeze) and keeps pgn's +# fresh resolve of the working-tree src/ (no stale freeze) and keeps pgn's # scratch files out of the repo. [tasks.golden] -description = "Refresh tests/golden from the working-tree gen/ (single-artifact run)" +description = "Refresh tests/golden from the working-tree src/ (single-artifact run)" run = ''' #!/usr/bin/env bash set -euo pipefail @@ -48,14 +48,14 @@ trap 'rm -rf "$tmp"' EXIT cp -R "$root/tests/fixture-project/." "$tmp/fixture" rm -f "$tmp/fixture/freeze1.pgn.yaml" rm -rf "$tmp/fixture/artifacts" -python3 - "$tmp/fixture/project1.pgn.yaml" "$root/gen/Gen.dhall" <<'EOF' +python3 - "$tmp/fixture/project1.pgn.yaml" "$root/src/package.dhall" <<'EOF' import sys from pathlib import Path p = Path(sys.argv[1]) head, sep, _ = p.read_text().partition(" # The variants below") assert sep, "variants marker not found in project1.pgn.yaml" -_ = p.write_text(head.rstrip().replace("../../gen/Gen.dhall", sys.argv[2]) + "\n") +_ = p.write_text(head.rstrip().replace("../../src/package.dhall", sys.argv[2]) + "\n") EOF cd "$tmp/fixture" pgn --database-url "${PGN_TEST_DATABASE_URL:-postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable}" generate diff --git a/src/Deps/Contract.dhall b/src/Deps/Contract.dhall new file mode 100644 index 0000000..ea9e805 --- /dev/null +++ b/src/Deps/Contract.dhall @@ -0,0 +1,2 @@ +https://raw.githubusercontent.com/pgenie-io/gen-contract/v4.0.1/src/package.dhall + sha256:4a130ba7fbaa152a776babbb1bf2994a4833931ca76bde9bf6930d354225651e diff --git a/src/Deps/Lude.dhall b/src/Deps/Lude.dhall new file mode 100644 index 0000000..a4b5116 --- /dev/null +++ b/src/Deps/Lude.dhall @@ -0,0 +1,2 @@ +https://raw.githubusercontent.com/codemine-io/lude.dhall/v5.2.0/src/package.dhall + sha256:b04ff9a38c8be087dfacfe76d057fc5272afec60cac28c9e6c1b78cade7ff6ef diff --git a/gen/Deps/Prelude.dhall b/src/Deps/Prelude.dhall similarity index 93% rename from gen/Deps/Prelude.dhall rename to src/Deps/Prelude.dhall index 54107fc..76e2a7a 100644 --- a/gen/Deps/Prelude.dhall +++ b/src/Deps/Prelude.dhall @@ -1,3 +1,2 @@ https://raw.githubusercontent.com/dhall-lang/dhall-lang/v23.1.0/Prelude/package.dhall sha256:931cbfae9d746c4611b07633ab1e547637ab4ba138b16bf65ef1b9ad66a60b7f - as Source diff --git a/src/Deps/Sdk.dhall b/src/Deps/Sdk.dhall new file mode 100644 index 0000000..18f9fce --- /dev/null +++ b/src/Deps/Sdk.dhall @@ -0,0 +1,2 @@ +https://raw.githubusercontent.com/pgenie-io/gen-sdk/v2.0.0/src/package.dhall + sha256:b9def6ab1179bc4aaae7fc6e91977f094f75934cd5755175c294a9e97ca71b15 diff --git a/gen/Interpreters/CustomType.dhall b/src/Interpreters/CustomType.dhall similarity index 88% rename from gen/Interpreters/CustomType.dhall rename to src/Interpreters/CustomType.dhall index 7927f0a..abdf94f 100644 --- a/gen/Interpreters/CustomType.dhall +++ b/src/Interpreters/CustomType.dhall @@ -1,16 +1,14 @@ -let Deps = ../Deps/package.dhall +let Lude = ../Deps/Lude.dhall -let ImportSet = ../Structures/ImportSet.dhall - -let CustomKind = ../Structures/CustomKind.dhall +let Prelude = ../Deps/Prelude.dhall -let Algebra = ../Algebras/Interpreter.dhall +let Model = ../Deps/Contract.dhall -let Lude = Deps.Lude +let Sdk = ../Deps/Sdk.dhall -let Prelude = Deps.Prelude +let ImportSet = ../Structures/ImportSet.dhall -let Model = Deps.Sdk.Project +let OnUnsupported = ../Structures/OnUnsupported.dhall let MemberGen = ./Member.dhall @@ -18,6 +16,13 @@ let EnumModule = ../Templates/EnumModule.dhall let CompositeModule = ../Templates/CompositeModule.dhall +let Config = + { packageName : Text + , importName : Text + , emitSync : Bool + , onUnsupported : OnUnsupported.Mode + } + let Input = Model.CustomType let TypeKind = < Enum | Composite > @@ -37,14 +42,6 @@ let Output = , kind : TypeKind } --- Composite fields could in principle reference other custom types, but pgn never --- nests customs in our corpus and CustomType.run does not receive the project --- lookup (Project.run threads it only to queries). Resolving any nested custom to --- Absent makes Member.run fail loudly instead of guessing a type. -let nestedLookup - : CustomKind.Lookup - = \(_ : Model.Name) -> CustomKind.TypeKind.Absent - -- Render the stdlib/runtime imports a composite field type needs, in a fixed -- order so output stays byte-stable. Reads only the standard flags; nested custom -- types are out of Wave 2 scope (no composite in the corpus references one). @@ -75,7 +72,7 @@ let renderExtraImports = ) let run = - \(config : Algebra.Config) -> + \(config : Config) -> \(input : Input) -> let typeName = input.name.inPascalCase @@ -116,9 +113,7 @@ let run = = Lude.Compiled.traverseList Model.Member MemberGen.Output - ( \(m : Model.Member) -> - MemberGen.run config nestedLookup m - ) + ( \(m : Model.Member) -> MemberGen.run config m ) members let assemble = @@ -174,4 +169,4 @@ let run = } input.definition -in { Input, Output, TypeKind, run } +in Sdk.Sigs.interpreter Config Input Output run diff --git a/src/Interpreters/Member.dhall b/src/Interpreters/Member.dhall new file mode 100644 index 0000000..c4e8273 --- /dev/null +++ b/src/Interpreters/Member.dhall @@ -0,0 +1,159 @@ +let Lude = ../Deps/Lude.dhall + +let Prelude = ../Deps/Prelude.dhall + +let Model = ../Deps/Contract.dhall + +let Sdk = ../Deps/Sdk.dhall + +let ImportSet = ../Structures/ImportSet.dhall + +let PyIdent = ../Structures/PyIdent.dhall + +let OnUnsupported = ../Structures/OnUnsupported.dhall + +let Value = ./Value.dhall + +let Config = + { packageName : Text + , importName : Text + , emitSync : Bool + , onUnsupported : OnUnsupported.Mode + } + +let Input = Model.Member + +-- decodeExpr is a Dhall function: given the source expression (e.g. row["x"] or +-- a composite tuple slot), it returns the Python decode expression. ResultColumns +-- and CustomType compose these so decode logic stays next to the type info. +let Output = + { fieldName : Text + , pgName : Text + , pyType : Text + , isNullable : Bool + , imports : ImportSet.Type + , decodeExpr : Text -> Text + } + +let run = + \(config : Config) -> + \(input : Input) -> + -- Result-column / composite-field name becomes a dataclass field and decode + -- kwarg, so a keyword-named column must be sanitized; row["..."] keeps the + -- raw pgName. Only the keyword set is load-bearing here (a field named `row` + -- is valid), unlike params which also avoid the template locals. + let fieldName = PyIdent.pySafeName input.name.inSnakeCase + + let buildOutput = + \(value : Value.Output) -> + let nullableSuffix = if input.isNullable then " | None" else "" + + let pyType = value.pyType ++ nullableSuffix + + let castTarget = pyType + + let baseImports = value.imports + + let passthroughDecode = + \(src : Text) -> "cast(${castTarget}, ${src})" + + in merge + { Passthrough = + Lude.Compiled.ok + Output + { fieldName + , pgName = input.pgName + , pyType + , isNullable = input.isNullable + , imports = baseImports + , decodeExpr = passthroughDecode + } + , Custom = + Prelude.Optional.fold + Model.Name + value.scalar.customRef + (Lude.Compiled.Type Output) + ( \(name : Model.Name) -> + let typeName = name.inPascalCase + + let customImport = + { className = typeName, moduleName = name.inSnakeCase } + + let mkOutput = + \(customImports : ImportSet.Type) -> + \(decodeExpr : Text -> Text) -> + { fieldName + , pgName = input.pgName + , pyType + , isNullable = input.isNullable + , imports = + ImportSet.combine baseImports customImports + , decodeExpr + } + + let wrapNullable = + \(call : Text -> Text) -> + \(src : Text) -> + if input.isNullable + then "None if ${src} is None else ${call src}" + else call src + + let dimsIsOne = + Natural/isZero (Natural/subtract 1 value.dims) + + in if Natural/isZero value.dims + then Lude.Compiled.ok + Output + ( mkOutput + (ImportSet.custom customImport) + ( wrapNullable + (\(src : Text) -> "${typeName}._decode(${src})") + ) + ) + else if dimsIsOne + then let elemCast = + if value.elementIsNullable + then "list[str | None]" + else "list[str]" + + let elemDecode = + if value.elementIsNullable + then "None if v is None else ${typeName}._decode(v)" + else "${typeName}._decode(v)" + + in Lude.Compiled.ok + Output + ( mkOutput + ( ImportSet.combine + (ImportSet.custom customImport) + ImportSet.enumArray + ) + ( wrapNullable + ( \(src : Text) -> + "[${elemDecode} for v in cast(${elemCast}, require_array(${src}))]" + ) + ) + ) + else Lude.Compiled.report + Output + [ input.pgName, name.inSnakeCase ] + "Array of dimensionality > 1 is not supported" + ) + ( Lude.Compiled.report + Output + [ input.pgName ] + "Custom scalar without a customRef name" + ) + } + value.scalar.decode + + let compiledValue + : Lude.Compiled.Type Value.Output + = Lude.Compiled.nest + Value.Output + input.pgName + (Value.run config input.value) + + in Lude.Compiled.flatMap Value.Output Output buildOutput compiledValue + +in Sdk.Sigs.interpreter Config Input Output run diff --git a/gen/Interpreters/ParamsMember.dhall b/src/Interpreters/ParamsMember.dhall similarity index 63% rename from gen/Interpreters/ParamsMember.dhall rename to src/Interpreters/ParamsMember.dhall index 47bef3f..457631d 100644 --- a/gen/Interpreters/ParamsMember.dhall +++ b/src/Interpreters/ParamsMember.dhall @@ -1,17 +1,24 @@ -let Deps = ../Deps/package.dhall +let Lude = ../Deps/Lude.dhall -let ImportSet = ../Structures/ImportSet.dhall +let Prelude = ../Deps/Prelude.dhall -let CustomKind = ../Structures/CustomKind.dhall +let Model = ../Deps/Contract.dhall -let Algebra = ../Algebras/Interpreter.dhall +let Sdk = ../Deps/Sdk.dhall -let Lude = Deps.Lude +let ImportSet = ../Structures/ImportSet.dhall -let Model = Deps.Sdk.Project +let OnUnsupported = ../Structures/OnUnsupported.dhall let Value = ./Value.dhall +let Config = + { packageName : Text + , importName : Text + , emitSync : Bool + , onUnsupported : OnUnsupported.Mode + } + let Input = Model.Member let PyIdent = ../Structures/PyIdent.dhall @@ -192,7 +199,7 @@ let scalarIsJsonb = let valueIsArray = \(value : Model.Value) -> - Deps.Prelude.Optional.fold + Prelude.Optional.fold Model.ArraySettings value.arraySettings Bool @@ -204,15 +211,15 @@ let valueIsArray = -- the two must not be collapsed. let isJsonbScalar = \(value : Model.Value) -> - Deps.Prelude.Bool.and - [ scalarIsJsonb value, Deps.Prelude.Bool.not (valueIsArray value) ] + Prelude.Bool.and + [ scalarIsJsonb value, Prelude.Bool.not (valueIsArray value) ] let isJsonScalar = \(value : Model.Value) -> - Deps.Prelude.Bool.and + Prelude.Bool.and [ scalarIsJson value - , Deps.Prelude.Bool.not (scalarIsJsonb value) - , Deps.Prelude.Bool.not (valueIsArray value) + , Prelude.Bool.not (scalarIsJsonb value) + , Prelude.Bool.not (valueIsArray value) ] -- A json/jsonb ARRAY param has no faithful psycopg bind (Jsonb wraps a scalar, @@ -221,11 +228,10 @@ let isJsonScalar = -- in SQL is the supported route (the param then types as text[], not json[]). let isJsonArray = \(value : Model.Value) -> - Deps.Prelude.Bool.and [ scalarIsJson value, valueIsArray value ] + Prelude.Bool.and [ scalarIsJson value, valueIsArray value ] let run = - \(config : Algebra.Config) -> - \(lookup : CustomKind.Lookup) -> + \(config : Config) -> \(input : Input) -> let fieldName = pySafeName input.name.inSnakeCase @@ -246,40 +252,6 @@ let run = then wrapJson "Jsonb" else if needsJsonImport then wrapJson "Json" else fieldName - -- psycopg binds a bare tuple to an anonymous composite, but cannot adapt - -- a dataclass, so a composite param is converted to its field tuple. - let compositeBind = - \(fields : List CustomKind.CompositeField) -> - let joinedFields = - Deps.Prelude.Text.concatMapSep - ", " - CustomKind.CompositeField - ( \(f : CustomKind.CompositeField) -> - "${fieldName}.${f.fieldName}" - ) - fields - - -- concatMapSep never emits a separator for a single-element list, so - -- a one-field composite would render "(x.f)": parens around a bare - -- expression, not a tuple. Python only treats trailing-comma parens - -- as a 1-tuple, so force it for exactly one field; concatMapSep - -- already inserts the internal comma for two or more. - let trailingComma = - if Deps.Prelude.Natural.equal - ( Deps.Prelude.List.length - CustomKind.CompositeField - fields - ) - 1 - then "," - else "" - - let tupleExpr = "(" ++ joinedFields ++ trailingComma ++ ")" - - in if input.isNullable - then "None if ${fieldName} is None else ${tupleExpr}" - else tupleExpr - let buildOutput = \(value : Value.Output) -> let pyType = @@ -301,62 +273,42 @@ let run = , needsJsonbImport } - in Deps.Prelude.Optional.fold + in Prelude.Optional.fold Model.Name value.scalar.customRef (Lude.Compiled.Type Output) ( \(name : Model.Name) -> let customImport = - \(order : Natural) -> - { className = name.inPascalCase - , moduleName = name.inSnakeCase - , order - } - - in merge - { Enum = - \(order : Natural) -> - Lude.Compiled.ok - Output - ( mkOutput - ( ImportSet.combine - value.imports - ( ImportSet.customEnum - (customImport order) - ) - ) - defaultBind - ) - , Composite = - \ ( composite - : { fields : - List CustomKind.CompositeField - , order : Natural - } - ) -> - if Natural/isZero value.dims - then Lude.Compiled.ok - Output - ( mkOutput - ( ImportSet.combine - value.imports - ( ImportSet.customComposite - (customImport composite.order) - ) - ) - (compositeBind composite.fields) - ) - else Lude.Compiled.report - Output - [ input.pgName, name.inSnakeCase ] - "Array of a composite type as a parameter is not supported" - , Absent = - Lude.Compiled.report - Output - [ name.inSnakeCase ] - "Custom type not found in project customTypes" + { className = name.inPascalCase + , moduleName = name.inSnakeCase } - (lookup name) + + let scalarEncode = + if input.isNullable + then "None if ${fieldName} is None else ${fieldName}._encode()" + else "${fieldName}._encode()" + + let arrayElemEncode = + if value.elementIsNullable + then "None if x is None else x._encode()" + else "x._encode()" + + let arrayEncode = + let base = "[${arrayElemEncode} for x in ${fieldName}]" + + in if input.isNullable + then "None if ${fieldName} is None else ${base}" + else base + + let encodeExpr = + if Natural/isZero value.dims then scalarEncode else arrayEncode + + in Lude.Compiled.ok + Output + ( mkOutput + (ImportSet.combine value.imports (ImportSet.custom customImport)) + encodeExpr + ) ) ( if isJsonArrayParam then Lude.Compiled.report @@ -377,6 +329,4 @@ let run = in Lude.Compiled.flatMap Value.Output Output buildOutput compiledValue -let Run = Algebra.Config -> CustomKind.Lookup -> Input -> Lude.Compiled.Type Output - -in { Input, Output, Run, run } +in Sdk.Sigs.interpreter Config Input Output run diff --git a/gen/Interpreters/Primitive.dhall b/src/Interpreters/Primitive.dhall similarity index 86% rename from gen/Interpreters/Primitive.dhall rename to src/Interpreters/Primitive.dhall index 0e1cb70..052ef1b 100644 --- a/gen/Interpreters/Primitive.dhall +++ b/src/Interpreters/Primitive.dhall @@ -1,10 +1,19 @@ -let Deps = ../Deps/package.dhall +let Lude = ../Deps/Lude.dhall + +let Model = ../Deps/Contract.dhall + +let Sdk = ../Deps/Sdk.dhall let ImportSet = ../Structures/ImportSet.dhall -let Algebra = ../Algebras/Interpreter.dhall +let OnUnsupported = ../Structures/OnUnsupported.dhall -let Model = Deps.Sdk.Project +let Config = + { packageName : Text + , importName : Text + , emitSync : Bool + , onUnsupported : OnUnsupported.Mode + } let Input = Model.Primitive @@ -13,16 +22,16 @@ let Output = { pyType : Text, imports : ImportSet.Type } let supported = \(pyType : Text) -> \(imports : ImportSet.Type) -> - Deps.Lude.Compiled.ok Output { pyType, imports } + Lude.Compiled.ok Output { pyType, imports } let unsupported = \(pgType : Text) -> - Deps.Lude.Compiled.report Output [ pgType ] "Unsupported type" + Lude.Compiled.report Output [ pgType ] "Unsupported type" let plain = \(pyType : Text) -> supported pyType ImportSet.empty let run = - \(config : Algebra.Config) -> + \(config : Config) -> \(input : Input) -> merge { Bit = unsupported "bit" @@ -89,4 +98,4 @@ let run = } input -in Algebra.module Input Output run +in Sdk.Sigs.interpreter Config Input Output run diff --git a/gen/Interpreters/Project.dhall b/src/Interpreters/Project.dhall similarity index 75% rename from gen/Interpreters/Project.dhall rename to src/Interpreters/Project.dhall index d51438f..f142a99 100644 --- a/gen/Interpreters/Project.dhall +++ b/src/Interpreters/Project.dhall @@ -1,18 +1,10 @@ -let Deps = ../Deps/package.dhall +let Lude = ../Deps/Lude.dhall -let Algebra = ../Algebras/Interpreter.dhall +let Prelude = ../Deps/Prelude.dhall -let Lude = Deps.Lude +let Model = ../Deps/Contract.dhall -let Prelude = Deps.Prelude - -let Model = Deps.Sdk.Project - -let CustomKind = ../Structures/CustomKind.dhall - -let PyIdent = ../Structures/PyIdent.dhall - -let Value = ./Value.dhall +let Sdk = ../Deps/Sdk.dhall let QueryGen = ./Query.dhall @@ -40,6 +32,26 @@ let OnUnsupported = ../Structures/OnUnsupported.dhall let Report = { path : List Text, message : Text } +-- The generator's public Config: every field is independently Optional, so a +-- project may omit the whole `config:` block or any subset of its keys. +-- `run` below resolves the fallbacks itself (packageName from the project +-- name, emitSync off, onUnsupported Fail); there is no separate config type +-- or resolve step between package.dhall and here. +let Config = + { packageName : Optional Text + , emitSync : Optional Bool + , onUnsupported : Optional OnUnsupported.Mode + } + +-- The fully-resolved shape every downstream interpreter (Query, CustomType, +-- and everything below them) actually declares as its own `Config`. +let ResolvedConfig = + { packageName : Text + , importName : Text + , emitSync : Bool + , onUnsupported : OnUnsupported.Mode + } + let Input = Model.Project let Output = Lude.Files.Type @@ -64,44 +76,6 @@ let withHeader = \(file : Lude.File.Type) -> { path = file.path, content = generatedHeader ++ file.content } --- Internal interpreter config (importName etc.) is only needed to satisfy --- Value.run's signature; rendering a composite field's pyType does not read it. -let lookupConfig - : Algebra.Config - = { packageName = "" - , importName = "" - , emitSync = False - , onUnsupported = OnUnsupported.Mode.Fail - } - --- Render a composite member's Python type purely. The Err branch is unreachable --- for a valid composite (CustomType.run fails the whole generation first on any --- unsupported field type), so the fallback string is dead. -let memberPyType = - \(member : Model.Member) -> - let valuePyType = - merge - { Ok = - \(wrapped : { value : Value.Output, warnings : List Report }) -> - wrapped.value.pyType - , Err = \(_ : Report) -> "object" - } - (Value.run lookupConfig member.value) - - in valuePyType ++ (if member.isNullable then " | None" else "") - -let compositeFields = - \(members : List Model.Member) -> - Prelude.List.map - Model.Member - CustomKind.CompositeField - ( \(member : Model.Member) -> - { fieldName = PyIdent.pySafeName member.name.inSnakeCase - , pyType = memberPyType member - } - ) - members - -- Schema-qualified type names for psycopg CompositeInfo.fetch (search-path -- safe). Reads CustomTypeGen.Output (already a combineOutputs parameter, and -- already the post-Skip-filter surviving set), not Model.CustomType, so @@ -131,58 +105,18 @@ let enumPgNames = customTypes ) --- A custom type referenced by a Scalar.Custom name resolves by matching its --- snake-case key. The query/column carries a distinct Name occurrence, so the --- lookup compares by inSnakeCase rather than relying on record identity. -let IndexedCustomType = { index : Natural, value : Model.CustomType } - --- pgn emits customTypes alphabetically by name, so the list index is the type's --- alphabetical order. The import renderer sorts on it to keep the per-module --- `from ..types.X import Y` block alphabetical independent of column order. -let buildLookup = - \(customTypes : List Model.CustomType) -> - Prelude.List.fold - IndexedCustomType - (Prelude.List.indexed Model.CustomType customTypes) - CustomKind.Lookup - ( \(entry : IndexedCustomType) -> - \(rest : CustomKind.Lookup) -> - \(name : Model.Name) -> - let ct = entry.value - - -- Text/equal is a pgn embedded-Dhall builtin, absent from the Dhall - -- standard 23.1 Prelude. PyIdent.dhall's replace-trick cannot stand in - -- here: this branch returns a structural TypeKind, not Text. gen-sdk's - -- Fixtures module relies on the same builtin; a kind tag or a Natural - -- index on Scalar.Custom is a planned upstream ask. - in if Text/equal name.inSnakeCase ct.name.inSnakeCase - then merge - { Composite = - \(members : List Model.Member) -> - CustomKind.TypeKind.Composite - { fields = compositeFields members - , order = entry.index - } - , Enum = - \(_ : List Model.EnumVariant) -> - CustomKind.TypeKind.Enum entry.index - , Domain = - \(_ : Model.Value) -> CustomKind.TypeKind.Absent - } - ct.definition - else rest name - ) - (\(_ : Model.Name) -> CustomKind.TypeKind.Absent) - let combineOutputs = - \(config : Algebra.Config) -> + \(config : ResolvedConfig) -> \(input : Input) -> - \(queries : List QueryGen.Output) -> -- Already the post-Skip-filter surviving set (see `run`); equal to - -- input.customTypes' compiled outputs verbatim when nothing was skipped + -- input.queries' compiled outputs verbatim when nothing was skipped -- (including every Fail-mode run, since Fail never drops anything). - -- Facade/typesInit/register entries are built from this, not - -- input.customTypes, so a skipped type leaves no dangling export. + -- Facade/statement/row entries are built from this, not input.queries, + -- so a skipped query leaves no dangling export. + \(queries : List QueryGen.Output) -> + -- Same as above, but for customTypes. Facade/typesInit/register + -- entries are built from this, not input.customTypes, so a skipped + -- type leaves no dangling export. \(customTypes : List CustomTypeGen.Output) -> -- The generator emits the _generated subtree plus the package-root -- __init__.py facade. The rest of the shell (pyproject.toml, py.typed) is @@ -418,21 +352,54 @@ let combineOutputs = -- warning list. Custom types use a pair of plain functions instead of an -- equivalent record (see `typeSucceeds`/`typeWarning` below) purely because -- that was the faster shape empirically for the type side, and the query --- side re-uses `queryChecks` because `lookup` (built from the surviving --- custom types) threads into every query's Member/ParamsMember resolution: --- calling QueryGen.run config lookup query from more than one place in this --- function (once to decide keep/drop, again to render, again for a --- warning -- each a fresh, separate call site in the source) measurably --- multiplies Dhall's normalization cost per extra call site, confirmed by --- bisection against `pgn generate` wall time (a few seconds regressed to --- minutes with three call sites; this file keeps it to two: one to build --- `queryChecks`, one for the final render). +-- side re-uses `queryChecks` because calling QueryGen.run config query from +-- more than one place in this function (once to decide keep/drop, again to +-- render, again for a warning -- each a fresh, separate call site in the +-- source) measurably multiplies Dhall's normalization cost per extra call +-- site, confirmed by bisection against `pgn generate` wall time (a few +-- seconds regressed to minutes with three call sites; this file keeps it to +-- two: one to build `queryChecks`, one for the final render). let QueryCheck = { query : Model.Query, keep : Bool, warning : Optional Report } let run = - \(config : Algebra.Config) -> + \(config : Config) -> \(input : Input) -> - let skip = merge { Fail = False, Skip = True } config.onUnsupported + -- config's fields are independently Optional (a project may omit the + -- whole config: block or any subset of its keys); the fallbacks are + -- resolved here rather than in a separate config type or resolve + -- step, since this is the root interpreter and Config above is + -- exactly the generator's public Config. + let packageName = + Prelude.Optional.fold + Text + config.packageName + Text + (\(t : Text) -> t) + input.name.inKebabCase + + let emitSync = + Prelude.Optional.fold + Bool + config.emitSync + Bool + (\(b : Bool) -> b) + False + + let onUnsupported = + Prelude.Optional.fold + OnUnsupported.Mode + config.onUnsupported + OnUnsupported.Mode + (\(m : OnUnsupported.Mode) -> m) + OnUnsupported.Mode.Fail + + let importName = Prelude.Text.replace "-" "_" packageName + + let resolvedConfig + : ResolvedConfig + = { packageName, importName, emitSync, onUnsupported } + + let skip = merge { Fail = False, Skip = True } resolvedConfig.onUnsupported let typeSucceeds : Model.CustomType -> Bool @@ -443,7 +410,7 @@ let run = True , Err = \(_ : Report) -> False } - (CustomTypeGen.run config ct) + (CustomTypeGen.run resolvedConfig ct) -- Nested under the type's own name so the warning names the type -- that failed, not just the inner member/column that triggered it @@ -458,7 +425,7 @@ let run = , Err = \(err : Report) -> Some { path = [ ct.name.inSnakeCase ] # err.path, message = err.message } } - (CustomTypeGen.run config ct) + (CustomTypeGen.run resolvedConfig ct) -- A skipped custom type resolves to Absent for any query that -- references it, and that query's own Member/ParamsMember @@ -471,8 +438,6 @@ let run = then Prelude.List.filter Model.CustomType typeSucceeds input.customTypes else input.customTypes - let lookup = buildLookup effectiveCustomTypes - -- Fail mode: identical to the pre-Skip code (traverseList straight -- over input.customTypes), so its error message/path is unchanged. let typesForCombine @@ -480,7 +445,7 @@ let run = = Lude.Compiled.traverseList Model.CustomType CustomTypeGen.Output - (\(ct : Model.CustomType) -> CustomTypeGen.run config ct) + (\(ct : Model.CustomType) -> CustomTypeGen.run resolvedConfig ct) effectiveCustomTypes let queryChecks @@ -495,7 +460,7 @@ let run = { query, keep = True, warning = None Report } , Err = \(err : Report) -> { query, keep = False, warning = Some err } } - (QueryGen.run config lookup query) + (QueryGen.run resolvedConfig query) ) input.queries @@ -516,7 +481,7 @@ let run = = Lude.Compiled.traverseList Model.Query QueryGen.Output - (\(query : Model.Query) -> QueryGen.run config lookup query) + (\(query : Model.Query) -> QueryGen.run resolvedConfig query) effectiveQueries let skipWarnings @@ -536,10 +501,10 @@ let run = (List QueryGen.Output) (List CustomTypeGen.Output) Output - (combineOutputs config input) + (combineOutputs resolvedConfig input) queriesForCombine typesForCombine in Lude.Compiled.appendWarnings Output skipWarnings combined -in Algebra.module Input Output run +in Sdk.Sigs.interpreter Config Input Output run diff --git a/gen/Interpreters/Query.dhall b/src/Interpreters/Query.dhall similarity index 90% rename from gen/Interpreters/Query.dhall rename to src/Interpreters/Query.dhall index b0e688d..b3e86cd 100644 --- a/gen/Interpreters/Query.dhall +++ b/src/Interpreters/Query.dhall @@ -1,15 +1,17 @@ -let Deps = ../Deps/package.dhall +let Prelude = ../Deps/Prelude.dhall -let Algebra = ../Algebras/Interpreter.dhall +let Lude = ../Deps/Lude.dhall -let ImportSet = ../Structures/ImportSet.dhall +let Sdk = ../Deps/Sdk.dhall -let CustomKind = ../Structures/CustomKind.dhall +let ImportSet = ../Structures/ImportSet.dhall let PyIdent = ../Structures/PyIdent.dhall let Surface = ../Structures/Surface.dhall +let OnUnsupported = ../Structures/OnUnsupported.dhall + let RowsModule = ../Templates/RowsModule.dhall let ResultModule = ./Result.dhall @@ -20,13 +22,16 @@ let ParamsMember = ./ParamsMember.dhall let StatementModule = ../Templates/StatementModule.dhall -let Prelude = Deps.Prelude - -let Lude = Deps.Lude +let Config = + { packageName : Text + , importName : Text + , emitSync : Bool + , onUnsupported : OnUnsupported.Mode + } let Compiled = Lude.Compiled -let Model = Deps.Sdk.Project +let Model = ../Deps/Contract.dhall let Input = Model.Query @@ -46,7 +51,7 @@ let Output = } let render = - \(config : Algebra.Config) -> + \(config : Config) -> \(input : Input) -> \(result : ResultModule.Output) -> \(fragments : QueryFragmentsModule.Output) -> @@ -131,8 +136,7 @@ let render = } let run = - \(config : Algebra.Config) -> - \(lookup : CustomKind.Lookup) -> + \(config : Config) -> \(input : Input) -> let rowClassName = input.name.inPascalCase ++ "Row" @@ -148,7 +152,7 @@ let run = ( Compiled.nest ResultModule.Output "result" - (ResultModule.run config lookup rowClassName input.result) + (ResultModule.run (config /\ { rowClassName }) input.result) ) ( Compiled.nest QueryFragmentsModule.Output @@ -165,11 +169,11 @@ let run = Compiled.nest ParamsMember.Output member.pgName - (ParamsMember.run config lookup member) + (ParamsMember.run config member) ) input.params ) ) ) -in { Input, Output, run } +in Sdk.Sigs.interpreter Config Input Output run diff --git a/gen/Interpreters/QueryFragments.dhall b/src/Interpreters/QueryFragments.dhall similarity index 75% rename from gen/Interpreters/QueryFragments.dhall rename to src/Interpreters/QueryFragments.dhall index 066bc75..7813900 100644 --- a/gen/Interpreters/QueryFragments.dhall +++ b/src/Interpreters/QueryFragments.dhall @@ -1,16 +1,21 @@ -let Deps = ../Deps/package.dhall +let Prelude = ../Deps/Prelude.dhall -let Algebra = ../Algebras/Interpreter.dhall +let Lude = ../Deps/Lude.dhall -let Prelude = Deps.Prelude +let Sdk = ../Deps/Sdk.dhall -let Sdk = Deps.Sdk +let Compiled = Lude.Compiled -let Lude = Deps.Lude +let Model = ../Deps/Contract.dhall -let Compiled = Lude.Compiled +let OnUnsupported = ../Structures/OnUnsupported.dhall -let Model = Deps.Sdk.Project +let Config = + { packageName : Text + , importName : Text + , emitSync : Bool + , onUnsupported : OnUnsupported.Mode + } let Input = Model.QueryFragments @@ -47,8 +52,8 @@ let renderSql Prelude.Text.concatMap Model.QueryFragment renderFragment fragments let run = - \(config : Algebra.Config) -> + \(config : Config) -> \(input : Input) -> Compiled.ok Output { sqlLiteral = renderSql input } -in Algebra.module Input Output run +in Sdk.Sigs.interpreter Config Input Output run diff --git a/gen/Interpreters/Result.dhall b/src/Interpreters/Result.dhall similarity index 65% rename from gen/Interpreters/Result.dhall rename to src/Interpreters/Result.dhall index a6638e3..b20190d 100644 --- a/gen/Interpreters/Result.dhall +++ b/src/Interpreters/Result.dhall @@ -1,20 +1,31 @@ -let Deps = ../Deps/package.dhall +let Prelude = ../Deps/Prelude.dhall -let Algebra = ../Algebras/Interpreter.dhall +let Lude = ../Deps/Lude.dhall -let ImportSet = ../Structures/ImportSet.dhall +let Model = ../Deps/Contract.dhall -let CustomKind = ../Structures/CustomKind.dhall +let Sdk = ../Deps/Sdk.dhall -let ResultColumns = ./ResultColumns.dhall +let ImportSet = ../Structures/ImportSet.dhall -let Prelude = Deps.Prelude +let OnUnsupported = ../Structures/OnUnsupported.dhall -let Lude = Deps.Lude +let ResultColumns = ./ResultColumns.dhall let Compiled = Lude.Compiled -let Model = Deps.Sdk.Project +-- rowClassName is supplied by the caller (Query.dhall derives it from the +-- query's own name) rather than living on Model.Result, so it rides on this +-- interpreter's own local Config instead of widening Input away from +-- Model.Result. ResultColumns below does not need it, so it is projected +-- back down to the narrower shared shape at that call site. +let Config = + { packageName : Text + , importName : Text + , emitSync : Bool + , onUnsupported : OnUnsupported.Mode + , rowClassName : Text + } let Input = Model.Result @@ -57,11 +68,9 @@ let cardinalityShape cardinality let rowsOutput = - \(config : Algebra.Config) -> - \(lookup : CustomKind.Lookup) -> - \(rowClassName : Text) -> + \(config : Config) -> \(rows : Model.ResultRows) -> - let shape = cardinalityShape rows.cardinality rowClassName + let shape = cardinalityShape rows.cardinality config.rowClassName let columns = Prelude.NonEmpty.toList Model.Member rows.columns @@ -73,7 +82,7 @@ let rowsOutput = { returnType = shape.returnType , helperName = shape.helperName , rowClass = Some - { name = rowClassName + { name = config.rowClassName , fieldsBlock = cols.fieldsBlock , decodeBlock = cols.decodeBlock } @@ -81,19 +90,20 @@ let rowsOutput = , callsDecode = True } ) - (ResultColumns.run config lookup rowClassName columns) + ( ResultColumns.run + config.{ packageName, importName, emitSync, onUnsupported } + columns + ) let run = - \(config : Algebra.Config) -> - \(lookup : CustomKind.Lookup) -> - \(rowClassName : Text) -> + \(config : Config) -> \(input : Input) -> merge { Void = Compiled.ok Output (noResult "None" "execute_void") , RowsAffected = Compiled.ok Output (noResult "int" "execute_rows_affected") - , Rows = rowsOutput config lookup rowClassName + , Rows = rowsOutput config } input -in { Input, Output, RowClass, run } +in Sdk.Sigs.interpreter Config Input Output run /\ { RowClass } diff --git a/gen/Interpreters/ResultColumns.dhall b/src/Interpreters/ResultColumns.dhall similarity index 77% rename from gen/Interpreters/ResultColumns.dhall rename to src/Interpreters/ResultColumns.dhall index ef550ec..6a589a2 100644 --- a/gen/Interpreters/ResultColumns.dhall +++ b/src/Interpreters/ResultColumns.dhall @@ -1,20 +1,25 @@ -let Deps = ../Deps/package.dhall +let Prelude = ../Deps/Prelude.dhall -let Algebra = ../Algebras/Interpreter.dhall +let Lude = ../Deps/Lude.dhall -let ImportSet = ../Structures/ImportSet.dhall +let Model = ../Deps/Contract.dhall -let CustomKind = ../Structures/CustomKind.dhall +let Sdk = ../Deps/Sdk.dhall -let Member = ./Member.dhall +let ImportSet = ../Structures/ImportSet.dhall -let Prelude = Deps.Prelude +let OnUnsupported = ../Structures/OnUnsupported.dhall -let Lude = Deps.Lude +let Member = ./Member.dhall let Compiled = Lude.Compiled -let Model = Deps.Sdk.Project +let Config = + { packageName : Text + , importName : Text + , emitSync : Bool + , onUnsupported : OnUnsupported.Mode + } let Input = List Model.Member @@ -53,9 +58,7 @@ let assemble } let run = - \(config : Algebra.Config) -> - \(lookup : CustomKind.Lookup) -> - \(rowClassName : Text) -> + \(config : Config) -> \(input : Input) -> Compiled.map (List Member.Output) @@ -68,9 +71,9 @@ let run = Compiled.nest Member.Output member.pgName - (Member.run config lookup member) + (Member.run config member) ) input ) -in { Input, Output, run } +in Sdk.Sigs.interpreter Config Input Output run diff --git a/gen/Interpreters/Scalar.dhall b/src/Interpreters/Scalar.dhall similarity index 77% rename from gen/Interpreters/Scalar.dhall rename to src/Interpreters/Scalar.dhall index b0eff70..63ec788 100644 --- a/gen/Interpreters/Scalar.dhall +++ b/src/Interpreters/Scalar.dhall @@ -1,15 +1,22 @@ -let Deps = ../Deps/package.dhall +let Lude = ../Deps/Lude.dhall -let ImportSet = ../Structures/ImportSet.dhall +let Model = ../Deps/Contract.dhall -let Algebra = ../Algebras/Interpreter.dhall +let Sdk = ../Deps/Sdk.dhall -let Lude = Deps.Lude +let ImportSet = ../Structures/ImportSet.dhall -let Model = Deps.Sdk.Project +let OnUnsupported = ../Structures/OnUnsupported.dhall let Primitive = ./Primitive.dhall +let Config = + { packageName : Text + , importName : Text + , emitSync : Bool + , onUnsupported : OnUnsupported.Mode + } + let Input = Model.Scalar -- Passthrough for primitives; Custom is opaque here. The enum-vs-composite @@ -24,7 +31,7 @@ let Output = } let run = - \(config : Algebra.Config) -> + \(config : Config) -> \(input : Input) -> merge { Primitive = @@ -52,4 +59,4 @@ let run = } input -in Algebra.module Input Output run /\ { ScalarDecode } +in Sdk.Sigs.interpreter Config Input Output run /\ { ScalarDecode } diff --git a/gen/Interpreters/Value.dhall b/src/Interpreters/Value.dhall similarity index 80% rename from gen/Interpreters/Value.dhall rename to src/Interpreters/Value.dhall index de69fd0..e1c8d6a 100644 --- a/gen/Interpreters/Value.dhall +++ b/src/Interpreters/Value.dhall @@ -1,17 +1,24 @@ -let Deps = ../Deps/package.dhall +let Lude = ../Deps/Lude.dhall -let ImportSet = ../Structures/ImportSet.dhall +let Prelude = ../Deps/Prelude.dhall -let Algebra = ../Algebras/Interpreter.dhall +let Model = ../Deps/Contract.dhall -let Lude = Deps.Lude +let Sdk = ../Deps/Sdk.dhall -let Prelude = Deps.Prelude +let ImportSet = ../Structures/ImportSet.dhall -let Model = Deps.Sdk.Project +let OnUnsupported = ../Structures/OnUnsupported.dhall let Scalar = ./Scalar.dhall +let Config = + { packageName : Text + , importName : Text + , emitSync : Bool + , onUnsupported : OnUnsupported.Mode + } + let Input = Model.Value let Output = @@ -23,7 +30,7 @@ let Output = } let run = - \(config : Algebra.Config) -> + \(config : Config) -> \(input : Input) -> Lude.Compiled.map Scalar.Output @@ -62,4 +69,4 @@ let run = ) (Scalar.run config input.scalar) -in Algebra.module Input Output run +in Sdk.Sigs.interpreter Config Input Output run diff --git a/src/Structures/ImportSet.dhall b/src/Structures/ImportSet.dhall new file mode 100644 index 0000000..b5c7b81 --- /dev/null +++ b/src/Structures/ImportSet.dhall @@ -0,0 +1,127 @@ +let Prelude = ../Deps/Prelude.dhall + +-- A custom-type import line: "from ..types. import ". +-- Emitted in encounter order (the order the referencing columns/params were +-- declared), not sorted. Dhall (upstream) has no Text comparison, so there is +-- no way to alphabetize or dedupe by moduleName/className without either the +-- pgn fork's Text/equal or a project-wide Natural id (previously `order`, +-- sourced from Project.dhall's buildLookup — see +-- docs/plans/2026-07-11-encounter-order-custom-imports.md for why that's +-- gone and why dedup isn't reintroduced some other way). Two references to +-- the same type currently produce two identical lines; harmless to Python +-- and to basedpyright, just not deduped. +let CustomImport = { className : Text, moduleName : Text } + +let Self = + { uuid : Bool + , datetime : Bool + , date : Bool + , time : Bool + , timedelta : Bool + , decimal : Bool + , jsonb : Bool + , json : Bool + , jsonValue : Bool + , enumArray : Bool + , customTypes : List CustomImport + } + +let base = + { uuid = False + , datetime = False + , date = False + , time = False + , timedelta = False + , decimal = False + , jsonb = False + , json = False + , jsonValue = False + , enumArray = False + , customTypes = [] : List CustomImport + } + +let empty + : Self + = base + +let uuid + : Self + = base // { uuid = True } + +let datetime + : Self + = base // { datetime = True } + +let date + : Self + = base // { date = True } + +let time + : Self + = base // { time = True } + +let timedelta + : Self + = base // { timedelta = True } + +let decimal + : Self + = base // { decimal = True } + +let jsonb + : Self + = base // { jsonb = True } + +let json + : Self + = base // { json = True } + +let jsonValue + : Self + = base // { jsonValue = True } + +let enumArray + : Self + = base // { enumArray = True } + +let custom + : CustomImport -> Self + = \(c : CustomImport) -> base // { customTypes = [ c ] } + +let combine = + \(left : Self) -> + \(right : Self) -> + { uuid = left.uuid || right.uuid + , datetime = left.datetime || right.datetime + , date = left.date || right.date + , time = left.time || right.time + , timedelta = left.timedelta || right.timedelta + , decimal = left.decimal || right.decimal + , jsonb = left.jsonb || right.jsonb + , json = left.json || right.json + , jsonValue = left.jsonValue || right.jsonValue + , enumArray = left.enumArray || right.enumArray + , customTypes = left.customTypes # right.customTypes + } + +let combineAll + : List Self -> Self + = \(sets : List Self) -> List/fold Self sets Self combine empty + +in { Type = Self + , CustomImport + , empty + , uuid + , datetime + , date + , time + , timedelta + , decimal + , jsonb + , json + , jsonValue + , enumArray + , custom + , combine + , combineAll + } diff --git a/gen/Structures/OnUnsupported.dhall b/src/Structures/OnUnsupported.dhall similarity index 100% rename from gen/Structures/OnUnsupported.dhall rename to src/Structures/OnUnsupported.dhall diff --git a/gen/Structures/PyIdent.dhall b/src/Structures/PyIdent.dhall similarity index 100% rename from gen/Structures/PyIdent.dhall rename to src/Structures/PyIdent.dhall diff --git a/gen/Structures/Surface.dhall b/src/Structures/Surface.dhall similarity index 100% rename from gen/Structures/Surface.dhall rename to src/Structures/Surface.dhall diff --git a/src/Templates/CompositeModule.dhall b/src/Templates/CompositeModule.dhall new file mode 100644 index 0000000..32c4ee5 --- /dev/null +++ b/src/Templates/CompositeModule.dhall @@ -0,0 +1,86 @@ +let Prelude = ../Deps/Prelude.dhall + +let Sdk = ../Deps/Sdk.dhall + +let Field = { fieldName : Text, fieldType : Text } + +-- extraImports are the extra import lines a field type needs (e.g. +-- "from uuid import UUID"). They sit between the dataclass import and the class, +-- separated by one blank line; when empty only the dataclass import is emitted. +let Params = { typeName : Text, extraImports : List Text, fields : List Field } + +let run = + \(params : Params) -> + let fieldLines = + Prelude.Text.concatMapSep + "\n" + Field + ( \(field : Field) -> + " ${field.fieldName}: ${field.fieldType}" + ) + params.fields + + let fieldCount = Prelude.List.length Field params.fields + + let fieldTypesJoined = + Prelude.Text.concatMapSep + ", " + Field + (\(field : Field) -> field.fieldType) + params.fields + + let selfFieldsJoined = + Prelude.Text.concatMapSep + ", " + Field + (\(field : Field) -> "self.${field.fieldName}") + params.fields + + -- Python only treats trailing-comma parens as a 1-tuple; concatMapSep + -- never emits an internal comma for a single-element list, so force one + -- here. Mirrors ParamsMember.dhall's existing compositeBind trick. + let encodeTupleExpr = + if Prelude.Natural.equal fieldCount 1 + then "(${selfFieldsJoined},)" + else "(${selfFieldsJoined})" + + -- _decode/_encode are emitted as literal lines (not a nested multi-line + -- ''...'' block) because Dhall dedents a multi-line literal against its + -- OWN source indentation before splicing it into the outer literal; a + -- nested block loses its intended 4/8-space class-body indentation. + -- Verified against `dhall text` during design. + let codecMethods = + "\n" + ++ " @staticmethod\n" + ++ " def _decode(src: object) -> \"${params.typeName}\":\n" + ++ " return ${params.typeName}(*cast(tuple[${fieldTypesJoined}], src))\n" + ++ "\n" + ++ " def _encode(self) -> tuple[${fieldTypesJoined}]:\n" + ++ " return ${encodeTupleExpr}" + + let imports = + if Prelude.List.null Text params.extraImports + then "from dataclasses import dataclass\nfrom typing import cast" + else '' + from dataclasses import dataclass + from typing import cast + + ${Prelude.Text.concatSep "\n" params.extraImports}'' + + in '' + ${imports} + + + @dataclass(frozen=True, slots=True) + class ${params.typeName}: + """Decoding/encoding this composite requires register_types(conn) first. + + Without per-connection registration psycopg returns the value as a + raw string, which the generated _decode cannot splat into the dataclass. + """ + + ${fieldLines} + ${codecMethods} + '' + +in Sdk.Sigs.template Params run /\ { Field } diff --git a/gen/Templates/CoreModule.dhall b/src/Templates/CoreModule.dhall similarity index 95% rename from gen/Templates/CoreModule.dhall rename to src/Templates/CoreModule.dhall index d3938c2..34a4943 100644 --- a/gen/Templates/CoreModule.dhall +++ b/src/Templates/CoreModule.dhall @@ -1,4 +1,4 @@ -let Algebra = ../Algebras/Template.dhall +let Sdk = ../Deps/Sdk.dhall -- The surface-agnostic core of a generated package, emitted once at -- _generated/_core.py. It owns the names shared by every module and by both the @@ -43,4 +43,4 @@ let content = ) '' -in Algebra.module {} (\(_ : {}) -> content) +in Sdk.Sigs.template {} (\(_ : {}) -> content) diff --git a/gen/Templates/EnumModule.dhall b/src/Templates/EnumModule.dhall similarity index 55% rename from gen/Templates/EnumModule.dhall rename to src/Templates/EnumModule.dhall index 7f82ebf..04c4bfe 100644 --- a/gen/Templates/EnumModule.dhall +++ b/src/Templates/EnumModule.dhall @@ -1,6 +1,6 @@ -let Deps = ../Deps/package.dhall +let Prelude = ../Deps/Prelude.dhall -let Algebra = ../Algebras/Template.dhall +let Sdk = ../Deps/Sdk.dhall let Variant = { memberName : Text, pgValue : Text } @@ -16,18 +16,18 @@ let run = let escapeLabel : Text -> Text = \(raw : Text) -> - Deps.Prelude.Function.composeList + Prelude.Function.composeList Text - [ Deps.Prelude.Text.replace "\\" "\\\\" - , Deps.Prelude.Text.replace "\r" "\\r" - , Deps.Prelude.Text.replace "\n" "\\n" - , Deps.Prelude.Text.replace "\t" "\\t" - , Deps.Prelude.Text.replace "\"" "\\\"" + [ Prelude.Text.replace "\\" "\\\\" + , Prelude.Text.replace "\r" "\\r" + , Prelude.Text.replace "\n" "\\n" + , Prelude.Text.replace "\t" "\\t" + , Prelude.Text.replace "\"" "\\\"" ] raw let memberLines = - Deps.Prelude.Text.concatMapSep + Prelude.Text.concatMapSep "\n" Variant ( \(variant : Variant) -> @@ -35,12 +35,23 @@ let run = ) params.variants + let codecMethods = + "\n" + ++ " @staticmethod\n" + ++ " def _decode(src: object) -> \"${params.typeName}\":\n" + ++ " return ${params.typeName}(cast(str, src))\n" + ++ "\n" + ++ " def _encode(self) -> \"${params.typeName}\":\n" + ++ " return self" + in '' from enum import StrEnum + from typing import cast class ${params.typeName}(StrEnum): ${memberLines} + ${codecMethods} '' -in Algebra.module Params run /\ { Variant } +in Sdk.Sigs.template Params run /\ { Variant } diff --git a/gen/Templates/FacadeModule.dhall b/src/Templates/FacadeModule.dhall similarity index 96% rename from gen/Templates/FacadeModule.dhall rename to src/Templates/FacadeModule.dhall index 2beb74c..18d6f2e 100644 --- a/gen/Templates/FacadeModule.dhall +++ b/src/Templates/FacadeModule.dhall @@ -1,8 +1,6 @@ -let Deps = ../Deps/package.dhall +let Prelude = ../Deps/Prelude.dhall -let Algebra = ../Algebras/Template.dhall - -let Prelude = Deps.Prelude +let Sdk = ../Deps/Sdk.dhall -- A statement's public surface: the function and, when the query returns rows, -- its frozen Row dataclass. functionName doubles as the leaf module name. @@ -126,4 +124,4 @@ let run = ${allEntries}] '' -in Algebra.module Params run /\ { StatementExport, TypeExport } +in Sdk.Sigs.template Params run /\ { StatementExport, TypeExport } diff --git a/gen/Templates/InitModule.dhall b/src/Templates/InitModule.dhall similarity index 87% rename from gen/Templates/InitModule.dhall rename to src/Templates/InitModule.dhall index f609ed6..cf5fc9f 100644 --- a/gen/Templates/InitModule.dhall +++ b/src/Templates/InitModule.dhall @@ -1,4 +1,4 @@ -let Algebra = ../Algebras/Template.dhall +let Sdk = ../Deps/Sdk.dhall -- A minimal package __init__.py: a module docstring plus an empty __all__. -- Used for the _generated subpackage root and the statements subpackage. The @@ -15,4 +15,4 @@ let render = __all__: list[str] = [] '' -in Algebra.module Params render +in Sdk.Sigs.template Params render diff --git a/gen/Templates/RegisterModule.dhall b/src/Templates/RegisterModule.dhall similarity index 96% rename from gen/Templates/RegisterModule.dhall rename to src/Templates/RegisterModule.dhall index ec6886b..cc94bfb 100644 --- a/gen/Templates/RegisterModule.dhall +++ b/src/Templates/RegisterModule.dhall @@ -1,11 +1,9 @@ -let Deps = ../Deps/package.dhall +let Prelude = ../Deps/Prelude.dhall -let Algebra = ../Algebras/Template.dhall +let Sdk = ../Deps/Sdk.dhall let Surface = ../Structures/Surface.dhall -let Prelude = Deps.Prelude - -- Per-connection type registration, emitted once per surface. psycopg decodes an -- unregistered composite as a text string; registering its CompositeInfo makes -- it decode to a namedtuple, which the generated decode then splats into the @@ -96,4 +94,4 @@ let run = in Prelude.Text.concatSep "\n" allLines ++ "\n" -in Algebra.module Params run +in Sdk.Sigs.template Params run diff --git a/gen/Templates/RowsModule.dhall b/src/Templates/RowsModule.dhall similarity index 93% rename from gen/Templates/RowsModule.dhall rename to src/Templates/RowsModule.dhall index 0107fa2..b1d5b13 100644 --- a/gen/Templates/RowsModule.dhall +++ b/src/Templates/RowsModule.dhall @@ -1,10 +1,10 @@ -let Deps = ../Deps/package.dhall +let Prelude = ../Deps/Prelude.dhall -let Algebra = ../Algebras/Template.dhall +let Lude = ../Deps/Lude.dhall -let ImportSet = ../Structures/ImportSet.dhall +let Sdk = ../Deps/Sdk.dhall -let Prelude = Deps.Prelude +let ImportSet = ../Structures/ImportSet.dhall -- The shared row module: every query's frozen Row dataclass and its decode -- function live here once, so the async and sync statement modules import the @@ -25,7 +25,7 @@ let indentAll \(text : Text) -> let pad = Prelude.Text.replicate n " " - in pad ++ Deps.Lude.Text.indentNonEmpty n text + in pad ++ Lude.Text.indentNonEmpty n text let importLineIf : Bool -> Text -> List Text @@ -55,7 +55,7 @@ let customImportLines ( \(c : ImportSet.CustomImport) -> "from .types." ++ c.moduleName ++ " import " ++ c.className ) - (ImportSet.sortedCustoms imports) + imports.customTypes let renderImports : ImportSet.Type -> Text @@ -118,4 +118,4 @@ let run = ++ Prelude.Text.concatMapSep "\n\n\n" RowDef renderRow params.rows ++ "\n" -in Algebra.module Params run /\ { RowDef } +in Sdk.Sigs.template Params run /\ { RowDef } diff --git a/gen/Templates/RuntimeModule.dhall b/src/Templates/RuntimeModule.dhall similarity index 98% rename from gen/Templates/RuntimeModule.dhall rename to src/Templates/RuntimeModule.dhall index ddaf53a..baa56bc 100644 --- a/gen/Templates/RuntimeModule.dhall +++ b/src/Templates/RuntimeModule.dhall @@ -1,4 +1,4 @@ -let Algebra = ../Algebras/Template.dhall +let Sdk = ../Deps/Sdk.dhall -- The fixed _runtime.py body, emitted once per generated package. No per-query -- customization. Mirrors DESIGN section 3 with two strict-clean adjustments the @@ -160,5 +160,5 @@ let syncContent = _ = cur.execute(sql, params) '' -in Algebra.module {} (\(_ : {}) -> content) +in Sdk.Sigs.template {} (\(_ : {}) -> content) /\ { runSync = \(_ : {}) -> syncContent } diff --git a/gen/Templates/StatementModule.dhall b/src/Templates/StatementModule.dhall similarity index 96% rename from gen/Templates/StatementModule.dhall rename to src/Templates/StatementModule.dhall index f717aa7..325e21f 100644 --- a/gen/Templates/StatementModule.dhall +++ b/src/Templates/StatementModule.dhall @@ -1,13 +1,13 @@ -let Algebra = ../Algebras/Template.dhall +let Prelude = ../Deps/Prelude.dhall -let Deps = ../Deps/package.dhall +let Lude = ../Deps/Lude.dhall + +let Sdk = ../Deps/Sdk.dhall let ImportSet = ../Structures/ImportSet.dhall let Surface = ../Structures/Surface.dhall -let Prelude = Deps.Prelude - -- Prefix every line (including the first) with `n` spaces, leaving blank lines -- untouched so trailing whitespace never appears. let indentAll @@ -16,7 +16,7 @@ let indentAll \(text : Text) -> let pad = Prelude.Text.replicate n " " - in pad ++ Deps.Lude.Text.indentNonEmpty n text + in pad ++ Lude.Text.indentNonEmpty n text -- A statement module is the thin per-surface I/O wrapper: it imports its Row -- dataclass and decode function from the shared `_rows` module and renders one @@ -79,7 +79,7 @@ let customImportLines ( \(c : ImportSet.CustomImport) -> "from ${typesPrefix}.${c.moduleName} import ${c.className}" ) - (ImportSet.sortedCustoms imports) + imports.customTypes let rowsImportLine : Params -> List Text @@ -187,7 +187,7 @@ let renderCall then "return ${await}${params.helperName}(conn, _SQL, params, ${params.decodeName})" else "return ${await}${params.helperName}(conn, _SQL, params)" -in Algebra.module +in Sdk.Sigs.template Params ( \(params : Params) -> renderImports params diff --git a/gen/Templates/TypesInit.dhall b/src/Templates/TypesInit.dhall similarity index 73% rename from gen/Templates/TypesInit.dhall rename to src/Templates/TypesInit.dhall index 7583526..3182085 100644 --- a/gen/Templates/TypesInit.dhall +++ b/src/Templates/TypesInit.dhall @@ -1,6 +1,6 @@ -let Deps = ../Deps/package.dhall +let Prelude = ../Deps/Prelude.dhall -let Algebra = ../Algebras/Template.dhall +let Sdk = ../Deps/Sdk.dhall let Export = { moduleName : Text, typeName : Text } @@ -9,7 +9,7 @@ let Params = { exports : List Export } let run = \(params : Params) -> let exportLines = - Deps.Prelude.Text.concatMapSep + Prelude.Text.concatMapSep "\n" Export ( \(export : Export) -> @@ -21,4 +21,4 @@ let run = ${exportLines} '' -in Algebra.module Params run /\ { Export } +in Sdk.Sigs.template Params run /\ { Export } diff --git a/src/package.dhall b/src/package.dhall new file mode 100644 index 0000000..5161367 --- /dev/null +++ b/src/package.dhall @@ -0,0 +1,29 @@ +let Sdk = ./Deps/Sdk.dhall + +let OnUnsupported = ./Structures/OnUnsupported.dhall + +let ProjectInterpreter = ./Interpreters/Project.dhall + +-- User-facing config for this generator. `emitSync` adds a parallel sync +-- surface (psycopg.Connection) alongside the default async one, so one +-- project can serve both an async backend and a sync (Dagster) consumer from +-- shared Row types. `onUnsupported` picks Fail (default, abort loudly) or +-- Skip (drop the unsupported statement/type and its dependents, with a +-- warning) when a query or custom type hits a PG shape the generator cannot +-- render; see Structures/OnUnsupported.dhall. All fields are Optional so a +-- project may omit the whole config block or any subset of its keys; +-- Interpreters/Project.dhall's `run` supplies the defaults. +let Config = + { packageName : Optional Text + , emitSync : Optional Bool + , onUnsupported : Optional OnUnsupported.Mode + } : Type + +let Config/default + : Config + = { packageName = None Text + , emitSync = None Bool + , onUnsupported = None OnUnsupported.Mode + } + +in Sdk.Sigs.generator Config Config/default ProjectInterpreter.run diff --git a/tests/_harness.py b/tests/_harness.py index 037ac41..9e5ffbd 100644 --- a/tests/_harness.py +++ b/tests/_harness.py @@ -23,7 +23,7 @@ DEFAULT_MAX_RSS_GB = 40.0 HERE = Path(__file__).resolve().parent -GEN_DIR = HERE.parent / "gen" +SRC_DIR = HERE.parent / "src" FIXTURE_PROJECT = HERE / "fixture-project" GOLDEN_DIR = HERE / "golden" diff --git a/tests/conftest.py b/tests/conftest.py index d0ccc23..5bac571 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,9 +15,9 @@ from tests._harness import ( FIXTURE_PROJECT, - GEN_DIR, GOLDEN_DIR, HERE, + SRC_DIR, admin_database_url, effective_database_name, run_pgn, @@ -67,15 +67,15 @@ def fixture_copy(tmp_path: Path) -> Path: def generated_tree(pgn_bin: str, pgn_admin_url: str, tmp_path_factory: pytest.TempPathFactory) -> Path: """Generate the fixture client once and return its artifacts/python dir. - The generator path in project1.pgn.yaml is `../../gen/Gen.dhall`, + The generator path in project1.pgn.yaml is `../../src/package.dhall`, relative to the fixture project. To keep it resolvable the copy mirrors the - real layout: `/gen` and `/tests/fixture-project`. The freeze + real layout: `/src` and `/tests/fixture-project`. The freeze file is dropped so pgn re-resolves the working-tree generator instead of a - cached hash (a stale freeze makes pgn ignore gen/ edits and silently + cached hash (a stale freeze makes pgn ignore src/ edits and silently emit the old output). """ root = tmp_path_factory.mktemp("pygen") - _ = shutil.copytree(GEN_DIR, root / "gen") + _ = shutil.copytree(SRC_DIR, root / "src") project = shutil.copytree(FIXTURE_PROJECT, root / "tests" / "fixture-project") (project / "freeze1.pgn.yaml").unlink(missing_ok=True) shutil.rmtree(project / "artifacts", ignore_errors=True) diff --git a/tests/fixture-project/project1.pgn.yaml b/tests/fixture-project/project1.pgn.yaml index f6bec82..ff75999 100644 --- a/tests/fixture-project/project1.pgn.yaml +++ b/tests/fixture-project/project1.pgn.yaml @@ -4,7 +4,7 @@ version: 0.0.0 postgres: 18 artifacts: python: - gen: ../../gen/Gen.dhall + gen: ../../src/package.dhall config: packageName: specimen-client emitSync: true @@ -12,25 +12,25 @@ artifacts: # Optional Config knobs (undocumented by pgn itself); see # tests/test_config_variants.py for the assertions. python-name-only: - gen: ../../gen/Gen.dhall + gen: ../../src/package.dhall config: packageName: name-only-client python-sync-only: - gen: ../../gen/Gen.dhall + gen: ../../src/package.dhall config: emitSync: true python-empty: - gen: ../../gen/Gen.dhall + gen: ../../src/package.dhall config: {} python-bare: - gen: ../../gen/Gen.dhall + gen: ../../src/package.dhall python-unknown-key: - gen: ../../gen/Gen.dhall + gen: ../../src/package.dhall config: packageName: unknown-key-client bogusField: 1 python-null: - gen: ../../gen/Gen.dhall + gen: ../../src/package.dhall config: packageName: null-client emitSync: null diff --git a/tests/test_config_variants.py b/tests/test_config_variants.py index 9b23d18..e0d3ecf 100644 --- a/tests/test_config_variants.py +++ b/tests/test_config_variants.py @@ -66,7 +66,7 @@ def test_absent_config_key_defaults_both_fields(generated_tree: Path) -> None: def test_unknown_config_key_is_ignored_not_rejected(generated_tree: Path) -> None: - """An extra key not in Config.dhall (bogusField) does not fail generation.""" + """An extra key not in the generator's Config type (bogusField) does not fail generation.""" package = _package_dir(generated_tree, "python-unknown-key") assert package.name == "unknown_key_client" assert not (package / "sync").exists() diff --git a/tests/test_unsupported_types.py b/tests/test_unsupported_types.py index 9d99eab..642db87 100644 --- a/tests/test_unsupported_types.py +++ b/tests/test_unsupported_types.py @@ -24,14 +24,14 @@ import pytest -from tests._harness import FIXTURE_PROJECT, GEN_DIR, HERE, run_pgn +from tests._harness import FIXTURE_PROJECT, HERE, SRC_DIR, run_pgn HARNESS_ROOT = HERE.parent def test_unsupported_pg_type_fails_loudly(pgn_bin: str, pgn_admin_url: str, tmp_path: Path) -> None: root = tmp_path / "pygen" - _ = shutil.copytree(GEN_DIR, root / "gen") + _ = shutil.copytree(SRC_DIR, root / "src") project = shutil.copytree(FIXTURE_PROJECT, root / "tests" / "fixture-project") (project / "freeze1.pgn.yaml").unlink(missing_ok=True) shutil.rmtree(project / "artifacts", ignore_errors=True) @@ -51,7 +51,7 @@ def test_unsupported_pg_type_fails_loudly(pgn_bin: str, pgn_admin_url: str, tmp_ def test_json_array_param_fails_loudly(pgn_bin: str, pgn_admin_url: str, tmp_path: Path) -> None: root = tmp_path / "pygen" - _ = shutil.copytree(GEN_DIR, root / "gen") + _ = shutil.copytree(SRC_DIR, root / "src") project = shutil.copytree(FIXTURE_PROJECT, root / "tests" / "fixture-project") (project / "freeze1.pgn.yaml").unlink(missing_ok=True) shutil.rmtree(project / "artifacts", ignore_errors=True) @@ -66,7 +66,7 @@ def test_json_array_param_fails_loudly(pgn_bin: str, pgn_admin_url: str, tmp_pat "postgres: 18\n" "artifacts:\n" " python:\n" - " gen: ../../gen/Gen.dhall\n" + " gen: ../../src/package.dhall\n" " config:\n" " onUnsupported: Fail\n" ) @@ -104,7 +104,7 @@ def test_skip_unsupported_drops_offending_units_and_cascades( golden package is held to. """ root = tmp_path / "pygen" - _ = shutil.copytree(GEN_DIR, root / "gen") + _ = shutil.copytree(SRC_DIR, root / "src") project = shutil.copytree(FIXTURE_PROJECT, root / "tests" / "fixture-project") (project / "freeze1.pgn.yaml").unlink(missing_ok=True) shutil.rmtree(project / "artifacts", ignore_errors=True) @@ -129,7 +129,7 @@ def test_skip_unsupported_drops_offending_units_and_cascades( "postgres: 18\n" "artifacts:\n" " python:\n" - " gen: ../../gen/Gen.dhall\n" + " gen: ../../src/package.dhall\n" " config:\n" " onUnsupported: Skip\n" )