From 7fee358cf98c80b3f82e32f7e88f6d64f701b24a Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sat, 11 Jul 2026 09:19:23 +0300 Subject: [PATCH 01/41] feat(gen): migrate to gen-contract v4.0.1 / gen-sdk v2.0.0, adopt Sdk.Sigs Pins gen/Deps/Contract.dhall (new) and bumps gen/Deps/Sdk.dhall to gen-sdk v2.0.0, removing the gen/Deps/package.dhall barrel. Rewires Structures/CustomKind.dhall and all 11 Interpreters/*.dhall plus all 10 Templates/*.dhall to import Lude/Prelude/Contract/Sdk directly instead of through the barrel, replacing the local gen/Algebras/{Interpreter,Template} sig-constructors with gen-sdk's Sdk.Sigs.interpreter / Sdk.Sigs.template (now deleted). Matches the shape java.gen's current release already uses. Query.dhall, Result.dhall, and ResultColumns.dhall keep their pre-existing bare-export shape (like Member.dhall/ParamsMember.dhall) instead of adopting Sdk.Sigs.interpreter: their `run` functions take 3/4 curried arguments (config, lookup, [rowClassName], input), which doesn't fit Sigs.interpreter's fixed 2-argument Config -> Input -> Result shape. This extends the Member/ParamsMember exception already called out in the migration plan to these three files, confirmed against their actual call sites in Project.dhall/Query.dhall. gen/compile.dhall and gen/Gen.dhall still reference the removed barrel and are intentionally left broken until the next task rewrites the root entry point (Config.dhall/compile.dhall -> Interpret.dhall, Gen.dhall -> package.dhall). --- gen/Algebras/Interpreter.dhall | 21 ------------- gen/Algebras/Template.dhall | 5 --- gen/Algebras/package.dhall | 1 - gen/Deps/Contract.dhall | 2 ++ gen/Deps/Sdk.dhall | 4 +-- gen/Deps/package.dhall | 4 --- gen/Interpreters/CustomType.dhall | 25 +++++++++------ gen/Interpreters/Member.dhall | 27 ++++++++++------ gen/Interpreters/ParamsMember.dhall | 45 ++++++++++++++++----------- gen/Interpreters/Primitive.dhall | 23 +++++++++----- gen/Interpreters/Project.dhall | 25 +++++++++------ gen/Interpreters/Query.dhall | 23 +++++++++----- gen/Interpreters/QueryFragments.dhall | 23 ++++++++------ gen/Interpreters/Result.dhall | 26 ++++++++++------ gen/Interpreters/ResultColumns.dhall | 24 +++++++++----- gen/Interpreters/Scalar.dhall | 21 ++++++++----- gen/Interpreters/Value.dhall | 23 +++++++++----- gen/Structures/CustomKind.dhall | 4 +-- gen/Templates/CompositeModule.dhall | 12 +++---- gen/Templates/CoreModule.dhall | 4 +-- gen/Templates/EnumModule.dhall | 20 ++++++------ gen/Templates/FacadeModule.dhall | 8 ++--- gen/Templates/InitModule.dhall | 4 +-- gen/Templates/RegisterModule.dhall | 8 ++--- gen/Templates/RowsModule.dhall | 12 +++---- gen/Templates/RuntimeModule.dhall | 4 +-- gen/Templates/StatementModule.dhall | 12 +++---- gen/Templates/TypesInit.dhall | 8 ++--- 28 files changed, 230 insertions(+), 188 deletions(-) delete mode 100644 gen/Algebras/Interpreter.dhall delete mode 100644 gen/Algebras/Template.dhall delete mode 100644 gen/Algebras/package.dhall create mode 100644 gen/Deps/Contract.dhall delete mode 100644 gen/Deps/package.dhall 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/Deps/Contract.dhall b/gen/Deps/Contract.dhall new file mode 100644 index 0000000..ea9e805 --- /dev/null +++ b/gen/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/gen/Deps/Sdk.dhall b/gen/Deps/Sdk.dhall index 6cf6014..5602c43 100644 --- a/gen/Deps/Sdk.dhall +++ b/gen/Deps/Sdk.dhall @@ -1,3 +1,3 @@ -https://raw.githubusercontent.com/pgenie-io/gen-sdk/v0.11.0/dhall/package.dhall - sha256:8d43544ecb0e612406af3133bdbca51138c704a77a5a29ef62fe034d0e77a3a6 +https://raw.githubusercontent.com/pgenie-io/gen-sdk/v2.0.0/src/package.dhall + sha256:b9def6ab1179bc4aaae7fc6e91977f094f75934cd5755175c294a9e97ca71b15 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/Interpreters/CustomType.dhall b/gen/Interpreters/CustomType.dhall index 7927f0a..676f9ab 100644 --- a/gen/Interpreters/CustomType.dhall +++ b/gen/Interpreters/CustomType.dhall @@ -1,16 +1,16 @@ -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 Prelude = Deps.Prelude +let CustomKind = ../Structures/CustomKind.dhall -let Model = Deps.Sdk.Project +let OnUnsupported = ../Structures/OnUnsupported.dhall let MemberGen = ./Member.dhall @@ -18,6 +18,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 > @@ -75,7 +82,7 @@ let renderExtraImports = ) let run = - \(config : Algebra.Config) -> + \(config : Config) -> \(input : Input) -> let typeName = input.name.inPascalCase @@ -174,4 +181,4 @@ let run = } input.definition -in { Input, Output, TypeKind, run } +in Sdk.Sigs.interpreter Config Input Output run diff --git a/gen/Interpreters/Member.dhall b/gen/Interpreters/Member.dhall index 672dd18..8d2d27c 100644 --- a/gen/Interpreters/Member.dhall +++ b/gen/Interpreters/Member.dhall @@ -1,4 +1,8 @@ -let Deps = ../Deps/package.dhall +let Lude = ../Deps/Lude.dhall + +let Prelude = ../Deps/Prelude.dhall + +let Model = ../Deps/Contract.dhall let ImportSet = ../Structures/ImportSet.dhall @@ -6,14 +10,17 @@ 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 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 @@ -29,7 +36,7 @@ let Output = } let run = - \(config : Algebra.Config) -> + \(config : Config) -> \(lookup : CustomKind.Lookup) -> \(input : Input) -> -- Result-column / composite-field name becomes a dataclass field and decode @@ -95,7 +102,7 @@ let run = \(fields : List CustomKind.CompositeField) -> \(src : Text) -> let fieldTypes = - Deps.Prelude.Text.concatMapSep + Prelude.Text.concatMapSep ", " CustomKind.CompositeField (\(f : CustomKind.CompositeField) -> f.pyType) @@ -120,7 +127,7 @@ let run = , decodeExpr = passthroughDecode } , Custom = - Deps.Prelude.Optional.fold + Prelude.Optional.fold Model.Name value.scalar.customRef (Lude.Compiled.Type Output) @@ -235,6 +242,6 @@ let run = in Lude.Compiled.flatMap Value.Output Output buildOutput compiledValue -let Run = Algebra.Config -> CustomKind.Lookup -> Input -> Lude.Compiled.Type Output +let Run = Config -> CustomKind.Lookup -> Input -> Lude.Compiled.Type Output in { Input, Output, Run, run } diff --git a/gen/Interpreters/ParamsMember.dhall b/gen/Interpreters/ParamsMember.dhall index 47bef3f..2a4b469 100644 --- a/gen/Interpreters/ParamsMember.dhall +++ b/gen/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 ImportSet = ../Structures/ImportSet.dhall -let Lude = Deps.Lude +let CustomKind = ../Structures/CustomKind.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,10 +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) -> + \(config : Config) -> \(lookup : CustomKind.Lookup) -> \(input : Input) -> let fieldName = pySafeName input.name.inSnakeCase @@ -251,7 +258,7 @@ let run = let compositeBind = \(fields : List CustomKind.CompositeField) -> let joinedFields = - Deps.Prelude.Text.concatMapSep + Prelude.Text.concatMapSep ", " CustomKind.CompositeField ( \(f : CustomKind.CompositeField) -> @@ -265,8 +272,8 @@ let run = -- 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 + if Prelude.Natural.equal + ( Prelude.List.length CustomKind.CompositeField fields ) @@ -301,7 +308,7 @@ let run = , needsJsonbImport } - in Deps.Prelude.Optional.fold + in Prelude.Optional.fold Model.Name value.scalar.customRef (Lude.Compiled.Type Output) @@ -377,6 +384,6 @@ let run = in Lude.Compiled.flatMap Value.Output Output buildOutput compiledValue -let Run = Algebra.Config -> CustomKind.Lookup -> Input -> Lude.Compiled.Type Output +let Run = Config -> CustomKind.Lookup -> Input -> Lude.Compiled.Type Output in { Input, Output, Run, run } diff --git a/gen/Interpreters/Primitive.dhall b/gen/Interpreters/Primitive.dhall index 0e1cb70..052ef1b 100644 --- a/gen/Interpreters/Primitive.dhall +++ b/gen/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/gen/Interpreters/Project.dhall index d51438f..d4fa6d6 100644 --- a/gen/Interpreters/Project.dhall +++ b/gen/Interpreters/Project.dhall @@ -1,12 +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 Sdk = ../Deps/Sdk.dhall let CustomKind = ../Structures/CustomKind.dhall @@ -40,6 +38,13 @@ let OnUnsupported = ../Structures/OnUnsupported.dhall let Report = { path : List Text, message : Text } +let Config = + { packageName : Text + , importName : Text + , emitSync : Bool + , onUnsupported : OnUnsupported.Mode + } + let Input = Model.Project let Output = Lude.Files.Type @@ -67,7 +72,7 @@ let withHeader = -- 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 + : Config = { packageName = "" , importName = "" , emitSync = False @@ -175,7 +180,7 @@ let buildLookup = (\(_ : Model.Name) -> CustomKind.TypeKind.Absent) let combineOutputs = - \(config : Algebra.Config) -> + \(config : Config) -> \(input : Input) -> \(queries : List QueryGen.Output) -> -- Already the post-Skip-filter surviving set (see `run`); equal to @@ -430,7 +435,7 @@ let combineOutputs = 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 @@ -542,4 +547,4 @@ let run = 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/gen/Interpreters/Query.dhall index b0e688d..daa1d47 100644 --- a/gen/Interpreters/Query.dhall +++ b/gen/Interpreters/Query.dhall @@ -1,6 +1,6 @@ -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 @@ -10,6 +10,8 @@ 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,7 +136,7 @@ let render = } let run = - \(config : Algebra.Config) -> + \(config : Config) -> \(lookup : CustomKind.Lookup) -> \(input : Input) -> let rowClassName = input.name.inPascalCase ++ "Row" @@ -172,4 +177,6 @@ let run = ) ) +let Run = Config -> CustomKind.Lookup -> Input -> Lude.Compiled.Type Output + in { Input, Output, run } diff --git a/gen/Interpreters/QueryFragments.dhall b/gen/Interpreters/QueryFragments.dhall index 066bc75..7813900 100644 --- a/gen/Interpreters/QueryFragments.dhall +++ b/gen/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/gen/Interpreters/Result.dhall index a6638e3..83f3ef1 100644 --- a/gen/Interpreters/Result.dhall +++ b/gen/Interpreters/Result.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 Model = ../Deps/Contract.dhall let ImportSet = ../Structures/ImportSet.dhall let CustomKind = ../Structures/CustomKind.dhall -let ResultColumns = ./ResultColumns.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 +let Config = + { packageName : Text + , importName : Text + , emitSync : Bool + , onUnsupported : OnUnsupported.Mode + } let Input = Model.Result @@ -57,7 +62,7 @@ let cardinalityShape cardinality let rowsOutput = - \(config : Algebra.Config) -> + \(config : Config) -> \(lookup : CustomKind.Lookup) -> \(rowClassName : Text) -> \(rows : Model.ResultRows) -> @@ -84,7 +89,7 @@ let rowsOutput = (ResultColumns.run config lookup rowClassName columns) let run = - \(config : Algebra.Config) -> + \(config : Config) -> \(lookup : CustomKind.Lookup) -> \(rowClassName : Text) -> \(input : Input) -> @@ -96,4 +101,7 @@ let run = } input +let Run = + Config -> CustomKind.Lookup -> Text -> Input -> Lude.Compiled.Type Output + in { Input, Output, RowClass, run } diff --git a/gen/Interpreters/ResultColumns.dhall b/gen/Interpreters/ResultColumns.dhall index ef550ec..b2fc737 100644 --- a/gen/Interpreters/ResultColumns.dhall +++ b/gen/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 Model = ../Deps/Contract.dhall let ImportSet = ../Structures/ImportSet.dhall let CustomKind = ../Structures/CustomKind.dhall -let Member = ./Member.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,7 +58,7 @@ let assemble } let run = - \(config : Algebra.Config) -> + \(config : Config) -> \(lookup : CustomKind.Lookup) -> \(rowClassName : Text) -> \(input : Input) -> @@ -73,4 +78,7 @@ let run = input ) +let Run = + Config -> CustomKind.Lookup -> Text -> Input -> Lude.Compiled.Type Output + in { Input, Output, run } diff --git a/gen/Interpreters/Scalar.dhall b/gen/Interpreters/Scalar.dhall index b0eff70..63ec788 100644 --- a/gen/Interpreters/Scalar.dhall +++ b/gen/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/gen/Interpreters/Value.dhall index de69fd0..e1c8d6a 100644 --- a/gen/Interpreters/Value.dhall +++ b/gen/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/gen/Structures/CustomKind.dhall b/gen/Structures/CustomKind.dhall index 22d3031..f2c4a1b 100644 --- a/gen/Structures/CustomKind.dhall +++ b/gen/Structures/CustomKind.dhall @@ -1,6 +1,4 @@ -let Deps = ../Deps/package.dhall - -let Model = Deps.Sdk.Project +let Model = ../Deps/Contract.dhall -- A composite field as the decode/encode sites need it: the Python attribute -- name and the rendered Python type (already nullability-applied). Threaded so diff --git a/gen/Templates/CompositeModule.dhall b/gen/Templates/CompositeModule.dhall index 198c77d..b1ab3e0 100644 --- a/gen/Templates/CompositeModule.dhall +++ b/gen/Templates/CompositeModule.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 Field = { fieldName : Text, fieldType : Text } @@ -12,7 +12,7 @@ let Params = { typeName : Text, extraImports : List Text, fields : List Field } let run = \(params : Params) -> let fieldLines = - Deps.Prelude.Text.concatMapSep + Prelude.Text.concatMapSep "\n" Field ( \(field : Field) -> @@ -21,12 +21,12 @@ let run = params.fields let imports = - if Deps.Prelude.List.null Text params.extraImports + if Prelude.List.null Text params.extraImports then "from dataclasses import dataclass" else '' from dataclasses import dataclass - ${Deps.Prelude.Text.concatSep "\n" params.extraImports}'' + ${Prelude.Text.concatSep "\n" params.extraImports}'' in '' ${imports} @@ -43,4 +43,4 @@ let run = ${fieldLines} '' -in Algebra.module Params run /\ { Field } +in Sdk.Sigs.template Params run /\ { Field } diff --git a/gen/Templates/CoreModule.dhall b/gen/Templates/CoreModule.dhall index d3938c2..34a4943 100644 --- a/gen/Templates/CoreModule.dhall +++ b/gen/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/gen/Templates/EnumModule.dhall index 7f82ebf..1ebf486 100644 --- a/gen/Templates/EnumModule.dhall +++ b/gen/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) -> @@ -43,4 +43,4 @@ let run = ${memberLines} '' -in Algebra.module Params run /\ { Variant } +in Sdk.Sigs.template Params run /\ { Variant } diff --git a/gen/Templates/FacadeModule.dhall b/gen/Templates/FacadeModule.dhall index 2beb74c..18d6f2e 100644 --- a/gen/Templates/FacadeModule.dhall +++ b/gen/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/gen/Templates/InitModule.dhall index f609ed6..cf5fc9f 100644 --- a/gen/Templates/InitModule.dhall +++ b/gen/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/gen/Templates/RegisterModule.dhall index ec6886b..cc94bfb 100644 --- a/gen/Templates/RegisterModule.dhall +++ b/gen/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/gen/Templates/RowsModule.dhall index 0107fa2..18da31b 100644 --- a/gen/Templates/RowsModule.dhall +++ b/gen/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 @@ -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/gen/Templates/RuntimeModule.dhall index ddaf53a..baa56bc 100644 --- a/gen/Templates/RuntimeModule.dhall +++ b/gen/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/gen/Templates/StatementModule.dhall index f717aa7..f2a6f61 100644 --- a/gen/Templates/StatementModule.dhall +++ b/gen/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 @@ -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/gen/Templates/TypesInit.dhall index 7583526..3182085 100644 --- a/gen/Templates/TypesInit.dhall +++ b/gen/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 } From a5b4530f477c839541374ac485fa7892e76f52ae Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sat, 11 Jul 2026 09:31:19 +0300 Subject: [PATCH 02/41] feat(gen): rewrite compile.dhall/Gen.dhall as Interpret.dhall/package.dhall Task 4 of the Dhall dependency/layout migration: consumes Interpreters/Project.dhall's Sdk.Sigs.interpreter-shaped run and produces the Sdk.Sigs.generator-built entry point, now that the Deps barrel is gone (Tasks 1-3). - gen/compile.dhall -> gen/Interpret.dhall: drops the outer Optional Config unwrap (Sdk.Sigs.generator substitutes defaultConfig for the omitted-block case), keeps the per-field Prelude.Optional.fold defaults and importName derivation; imports Contract/Prelude directly instead of through the deleted barrel. - gen/Gen.dhall -> gen/package.dhall: builds Sdk.Sigs.generator Config Config/default interpret, with Config/default supplying an all-None Config. --- gen/Gen.dhall | 3 -- gen/Interpret.dhall | 54 ++++++++++++++++++++++++++++++++++++ gen/compile.dhall | 67 --------------------------------------------- gen/package.dhall | 16 +++++++++++ 4 files changed, 70 insertions(+), 70 deletions(-) delete mode 100644 gen/Gen.dhall create mode 100644 gen/Interpret.dhall delete mode 100644 gen/compile.dhall create mode 100644 gen/package.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/Interpret.dhall b/gen/Interpret.dhall new file mode 100644 index 0000000..de4642f --- /dev/null +++ b/gen/Interpret.dhall @@ -0,0 +1,54 @@ +let Contract = ./Deps/Contract.dhall + +let Prelude = ./Deps/Prelude.dhall + +let Config = ./Config.dhall + +let OnUnsupported = ./Structures/OnUnsupported.dhall + +let ProjectInterpreter = ./Interpreters/Project.dhall + +-- Entry point handed to gen-sdk's Sdk.Sigs.generator as `interpret`. Each +-- field of Config is independently Optional, so a project may omit the +-- whole config block (Sdk.Sigs.generator substitutes an all-None +-- defaultConfig, see package.dhall) 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). The async surface is always +-- emitted; emitSync adds the sync mirror. +in \(config : Config) -> + \(project : Contract.Project) -> + let defaults = + { packageName = project.name.inKebabCase + , emitSync = False + , onUnsupported = OnUnsupported.Mode.Fail + } + + let packageName = + Prelude.Optional.fold + Text + config.packageName + Text + (\(t : Text) -> t) + defaults.packageName + + let emitSync = + Prelude.Optional.fold + Bool + config.emitSync + Bool + (\(b : Bool) -> b) + defaults.emitSync + + let onUnsupported = + Prelude.Optional.fold + OnUnsupported.Mode + config.onUnsupported + OnUnsupported.Mode + (\(m : OnUnsupported.Mode) -> m) + defaults.onUnsupported + + let importName = Prelude.Text.replace "-" "_" packageName + + let interpreterConfig = { packageName, importName, emitSync, onUnsupported } + + in ProjectInterpreter.run interpreterConfig project 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/gen/package.dhall b/gen/package.dhall new file mode 100644 index 0000000..3d07cf7 --- /dev/null +++ b/gen/package.dhall @@ -0,0 +1,16 @@ +let Sdk = ./Deps/Sdk.dhall + +let OnUnsupported = ./Structures/OnUnsupported.dhall + +let Config = ./Config.dhall + +let Config/default + : Config + = { packageName = None Text + , emitSync = None Bool + , onUnsupported = None OnUnsupported.Mode + } + +let interpret = ./Interpret.dhall + +in Sdk.Sigs.generator Config Config/default interpret From b13e42764765280e075d9390e9cea31dd5e3db8c Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sat, 11 Jul 2026 09:38:42 +0300 Subject: [PATCH 03/41] refactor(gen): rename gen/ to src/, move tests/Exhaustive.dhall to demos/ Pure directory move for gen/ -> src/ (relative imports inside are unaffected). tests/Exhaustive.dhall becomes demos/Exhaustive.dhall and is rewritten to use Sdk.Output.toFileMap, since Sdk.Sigs.generator-built modules no longer expose compileToFileMap. --- {tests => demos}/Exhaustive.dhall | 16 ++++++++-------- {gen => src}/Config.dhall | 0 {gen => src}/Deps/Contract.dhall | 0 {gen => src}/Deps/Lude.dhall | 0 {gen => src}/Deps/Prelude.dhall | 0 {gen => src}/Deps/Sdk.dhall | 0 {gen => src}/Interpret.dhall | 0 {gen => src}/Interpreters/CustomType.dhall | 0 {gen => src}/Interpreters/Member.dhall | 0 {gen => src}/Interpreters/ParamsMember.dhall | 0 {gen => src}/Interpreters/Primitive.dhall | 0 {gen => src}/Interpreters/Project.dhall | 0 {gen => src}/Interpreters/Query.dhall | 0 {gen => src}/Interpreters/QueryFragments.dhall | 0 {gen => src}/Interpreters/Result.dhall | 0 {gen => src}/Interpreters/ResultColumns.dhall | 0 {gen => src}/Interpreters/Scalar.dhall | 0 {gen => src}/Interpreters/Value.dhall | 0 {gen => src}/Structures/CustomKind.dhall | 0 {gen => src}/Structures/ImportSet.dhall | 0 {gen => src}/Structures/OnUnsupported.dhall | 0 {gen => src}/Structures/PyIdent.dhall | 0 {gen => src}/Structures/Surface.dhall | 0 {gen => src}/Templates/CompositeModule.dhall | 0 {gen => src}/Templates/CoreModule.dhall | 0 {gen => src}/Templates/EnumModule.dhall | 0 {gen => src}/Templates/FacadeModule.dhall | 0 {gen => src}/Templates/InitModule.dhall | 0 {gen => src}/Templates/RegisterModule.dhall | 0 {gen => src}/Templates/RowsModule.dhall | 0 {gen => src}/Templates/RuntimeModule.dhall | 0 {gen => src}/Templates/StatementModule.dhall | 0 {gen => src}/Templates/TypesInit.dhall | 0 {gen => src}/package.dhall | 0 34 files changed, 8 insertions(+), 8 deletions(-) rename {tests => demos}/Exhaustive.dhall (63%) rename {gen => src}/Config.dhall (100%) rename {gen => src}/Deps/Contract.dhall (100%) rename {gen => src}/Deps/Lude.dhall (100%) rename {gen => src}/Deps/Prelude.dhall (100%) rename {gen => src}/Deps/Sdk.dhall (100%) rename {gen => src}/Interpret.dhall (100%) rename {gen => src}/Interpreters/CustomType.dhall (100%) rename {gen => src}/Interpreters/Member.dhall (100%) rename {gen => src}/Interpreters/ParamsMember.dhall (100%) rename {gen => src}/Interpreters/Primitive.dhall (100%) rename {gen => src}/Interpreters/Project.dhall (100%) rename {gen => src}/Interpreters/Query.dhall (100%) rename {gen => src}/Interpreters/QueryFragments.dhall (100%) rename {gen => src}/Interpreters/Result.dhall (100%) rename {gen => src}/Interpreters/ResultColumns.dhall (100%) rename {gen => src}/Interpreters/Scalar.dhall (100%) rename {gen => src}/Interpreters/Value.dhall (100%) rename {gen => src}/Structures/CustomKind.dhall (100%) rename {gen => src}/Structures/ImportSet.dhall (100%) rename {gen => src}/Structures/OnUnsupported.dhall (100%) rename {gen => src}/Structures/PyIdent.dhall (100%) rename {gen => src}/Structures/Surface.dhall (100%) rename {gen => src}/Templates/CompositeModule.dhall (100%) rename {gen => src}/Templates/CoreModule.dhall (100%) rename {gen => src}/Templates/EnumModule.dhall (100%) rename {gen => src}/Templates/FacadeModule.dhall (100%) rename {gen => src}/Templates/InitModule.dhall (100%) rename {gen => src}/Templates/RegisterModule.dhall (100%) rename {gen => src}/Templates/RowsModule.dhall (100%) rename {gen => src}/Templates/RuntimeModule.dhall (100%) rename {gen => src}/Templates/StatementModule.dhall (100%) rename {gen => src}/Templates/TypesInit.dhall (100%) rename {gen => src}/package.dhall (100%) 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/gen/Config.dhall b/src/Config.dhall similarity index 100% rename from gen/Config.dhall rename to src/Config.dhall diff --git a/gen/Deps/Contract.dhall b/src/Deps/Contract.dhall similarity index 100% rename from gen/Deps/Contract.dhall rename to src/Deps/Contract.dhall diff --git a/gen/Deps/Lude.dhall b/src/Deps/Lude.dhall similarity index 100% rename from gen/Deps/Lude.dhall rename to src/Deps/Lude.dhall diff --git a/gen/Deps/Prelude.dhall b/src/Deps/Prelude.dhall similarity index 100% rename from gen/Deps/Prelude.dhall rename to src/Deps/Prelude.dhall diff --git a/gen/Deps/Sdk.dhall b/src/Deps/Sdk.dhall similarity index 100% rename from gen/Deps/Sdk.dhall rename to src/Deps/Sdk.dhall diff --git a/gen/Interpret.dhall b/src/Interpret.dhall similarity index 100% rename from gen/Interpret.dhall rename to src/Interpret.dhall diff --git a/gen/Interpreters/CustomType.dhall b/src/Interpreters/CustomType.dhall similarity index 100% rename from gen/Interpreters/CustomType.dhall rename to src/Interpreters/CustomType.dhall diff --git a/gen/Interpreters/Member.dhall b/src/Interpreters/Member.dhall similarity index 100% rename from gen/Interpreters/Member.dhall rename to src/Interpreters/Member.dhall diff --git a/gen/Interpreters/ParamsMember.dhall b/src/Interpreters/ParamsMember.dhall similarity index 100% rename from gen/Interpreters/ParamsMember.dhall rename to src/Interpreters/ParamsMember.dhall diff --git a/gen/Interpreters/Primitive.dhall b/src/Interpreters/Primitive.dhall similarity index 100% rename from gen/Interpreters/Primitive.dhall rename to src/Interpreters/Primitive.dhall diff --git a/gen/Interpreters/Project.dhall b/src/Interpreters/Project.dhall similarity index 100% rename from gen/Interpreters/Project.dhall rename to src/Interpreters/Project.dhall diff --git a/gen/Interpreters/Query.dhall b/src/Interpreters/Query.dhall similarity index 100% rename from gen/Interpreters/Query.dhall rename to src/Interpreters/Query.dhall diff --git a/gen/Interpreters/QueryFragments.dhall b/src/Interpreters/QueryFragments.dhall similarity index 100% rename from gen/Interpreters/QueryFragments.dhall rename to src/Interpreters/QueryFragments.dhall diff --git a/gen/Interpreters/Result.dhall b/src/Interpreters/Result.dhall similarity index 100% rename from gen/Interpreters/Result.dhall rename to src/Interpreters/Result.dhall diff --git a/gen/Interpreters/ResultColumns.dhall b/src/Interpreters/ResultColumns.dhall similarity index 100% rename from gen/Interpreters/ResultColumns.dhall rename to src/Interpreters/ResultColumns.dhall diff --git a/gen/Interpreters/Scalar.dhall b/src/Interpreters/Scalar.dhall similarity index 100% rename from gen/Interpreters/Scalar.dhall rename to src/Interpreters/Scalar.dhall diff --git a/gen/Interpreters/Value.dhall b/src/Interpreters/Value.dhall similarity index 100% rename from gen/Interpreters/Value.dhall rename to src/Interpreters/Value.dhall diff --git a/gen/Structures/CustomKind.dhall b/src/Structures/CustomKind.dhall similarity index 100% rename from gen/Structures/CustomKind.dhall rename to src/Structures/CustomKind.dhall diff --git a/gen/Structures/ImportSet.dhall b/src/Structures/ImportSet.dhall similarity index 100% rename from gen/Structures/ImportSet.dhall rename to src/Structures/ImportSet.dhall 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/gen/Templates/CompositeModule.dhall b/src/Templates/CompositeModule.dhall similarity index 100% rename from gen/Templates/CompositeModule.dhall rename to src/Templates/CompositeModule.dhall diff --git a/gen/Templates/CoreModule.dhall b/src/Templates/CoreModule.dhall similarity index 100% rename from gen/Templates/CoreModule.dhall rename to src/Templates/CoreModule.dhall diff --git a/gen/Templates/EnumModule.dhall b/src/Templates/EnumModule.dhall similarity index 100% rename from gen/Templates/EnumModule.dhall rename to src/Templates/EnumModule.dhall diff --git a/gen/Templates/FacadeModule.dhall b/src/Templates/FacadeModule.dhall similarity index 100% rename from gen/Templates/FacadeModule.dhall rename to src/Templates/FacadeModule.dhall diff --git a/gen/Templates/InitModule.dhall b/src/Templates/InitModule.dhall similarity index 100% rename from gen/Templates/InitModule.dhall rename to src/Templates/InitModule.dhall diff --git a/gen/Templates/RegisterModule.dhall b/src/Templates/RegisterModule.dhall similarity index 100% rename from gen/Templates/RegisterModule.dhall rename to src/Templates/RegisterModule.dhall diff --git a/gen/Templates/RowsModule.dhall b/src/Templates/RowsModule.dhall similarity index 100% rename from gen/Templates/RowsModule.dhall rename to src/Templates/RowsModule.dhall diff --git a/gen/Templates/RuntimeModule.dhall b/src/Templates/RuntimeModule.dhall similarity index 100% rename from gen/Templates/RuntimeModule.dhall rename to src/Templates/RuntimeModule.dhall diff --git a/gen/Templates/StatementModule.dhall b/src/Templates/StatementModule.dhall similarity index 100% rename from gen/Templates/StatementModule.dhall rename to src/Templates/StatementModule.dhall diff --git a/gen/Templates/TypesInit.dhall b/src/Templates/TypesInit.dhall similarity index 100% rename from gen/Templates/TypesInit.dhall rename to src/Templates/TypesInit.dhall diff --git a/gen/package.dhall b/src/package.dhall similarity index 100% rename from gen/package.dhall rename to src/package.dhall From 2c9073fc3716a54536e77f2cebfe2878c2b21d0a Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sat, 11 Jul 2026 10:01:44 +0300 Subject: [PATCH 04/41] refactor(gen): update path references from gen/ to src/, tests/Exhaustive.dhall to demos/ Tasks 1-5 renamed gen/ to src/, gen/Gen.dhall to src/package.dhall, and tests/Exhaustive.dhall to demos/Exhaustive.dhall. This updates every external reference to the old paths across CI workflows, dev scripts, docs, and the bench harness. - .github/workflows/ci.yml, release.yml, build-contract-shell.sh: path refs. - README.md, AGENTS.md, DESIGN.md: doc path refs; DESIGN.md's section 10 tree diagram and entry-point description rewritten to match the actual post-move src/ layout (Interpret.dhall/package.dhall, no more Algebras/, Sdk.Sigs.generator instead of Sdk.module). - build.bash, mise.toml: dev-script path refs. - bench/generate.sh, bench/as-source.sh: gen/ -> src/ copies; the as-Source/plain-import sha256 substitution pair for gen-sdk is verified via `dhall hash` to be a no-op for v2.0.0 (both modes hash to the same value, unlike the old v0.11.0 pair), so only a comment remains; the lude pair is untouched since lude's pin is unchanged by this migration. - tests/fixture-project/project1.pgn.yaml: the fixture project's own `gen:` key still pointed at the now-deleted gen/Gen.dhall, which would break the harness and the `golden` mise task; updated to src/package.dhall. --- .github/scripts/build-contract-shell.sh | 2 +- .github/workflows/ci.yml | 8 ++--- .github/workflows/release.yml | 2 +- AGENTS.md | 2 +- DESIGN.md | 46 ++++++++++++++----------- README.md | 10 +++--- bench/as-source.sh | 24 ++++++++++--- bench/generate.sh | 26 ++++++++++---- mise.toml | 8 ++--- tests/fixture-project/project1.pgn.yaml | 14 ++++---- 10 files changed, 87 insertions(+), 55 deletions(-) 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/DESIGN.md b/DESIGN.md index bff5a2c..79e8a7c 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. @@ -378,34 +378,37 @@ 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 Config = ./Config.dhall + +let interpret = ./Interpret.dhall + +in Sdk.Sigs.generator Config Config/default interpret ``` -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. +`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`. `Interpret.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 (`Contract.Output`). -`gen/` mirrors a typical pgn gen-sdk generator, Python-flavored. The -algebra/interpreter/template split keeps assembly separate from rendering. +`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) +src/ + package.dhall # Sdk.Sigs.generator Config Config/default interpret (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 + Interpret.dhall # derive interpreter Config from user Config, call Project.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 @@ -426,6 +429,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 @@ -629,11 +633,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/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/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 From a30ac3209dfc7556fe82bacb2b7ca7ac52d5f359 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sat, 11 Jul 2026 10:05:34 +0300 Subject: [PATCH 05/41] fix(gen): update gen/ path references in the pytest harness to src/ tests/_harness.py's GEN_DIR (and its two consumers) still pointed the harness at the repo-root gen/ directory, which Task 5 deleted when it renamed gen/ to src/. Every test using the session-scoped generated_tree fixture, or building its own copytree in test_unsupported_types.py, would have hit FileNotFoundError on the very first pgn run. Renamed the constant to SRC_DIR (was misleading otherwise), pointed it at src/, matched the copied-tree destination directory name to what project1.pgn.yaml's `gen: ../../src/package.dhall` (fixed in the prior commit) actually resolves against, and fixed the two hardcoded yaml literals in test_unsupported_types.py plus conftest.py's docstring. Confirmed via `python3 -m py_compile` and a repo-wide grep for any remaining gen/-as-directory or GEN_DIR references in .py files (none). --- tests/_harness.py | 2 +- tests/conftest.py | 10 +++++----- tests/test_unsupported_types.py | 12 ++++++------ 3 files changed, 12 insertions(+), 12 deletions(-) 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/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" ) From ffea69fe9ccccfafaef73b343fc9514cd6b21840 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sat, 11 Jul 2026 14:19:59 +0300 Subject: [PATCH 06/41] docs(gen): add Task 7 CHANGELOG entry, fix stale Config.dhall comment Documents the gen-contract v4.0.1 / gen-sdk v2.0.0 migration and gen/->src/ layout restructure under # Upcoming, and updates Config.dhall's doc comment to reference Interpret.dhall instead of the old compile.dhall name. --- CHANGELOG.md | 12 ++++++++++++ src/Config.dhall | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc7b2c..f98fe04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Upcoming +- 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/src/Config.dhall b/src/Config.dhall index 7ec1fe6..1377127 100644 --- a/src/Config.dhall +++ b/src/Config.dhall @@ -5,7 +5,7 @@ -- 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 +-- the whole config block or any subset of its keys; Interpret.dhall supplies the -- defaults. let OnUnsupported = ./Structures/OnUnsupported.dhall From 9c33a23dbbec070c530430900ea7f770054a4fcd Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sat, 11 Jul 2026 14:29:51 +0300 Subject: [PATCH 07/41] refactor(gen): drop dead Run type-alias bindings in Query/Result/ResultColumns These were declared but never exported (unlike Member.dhall/ParamsMember.dhall, which do export Run), left over from documenting each file's actual multi-arg call signature during the gen-sdk v2 migration. Flagged by final review as harmless but pointless; removing rather than exporting keeps these three files' tails exactly as they were pre-migration, matching the migration's "keep bare exports verbatim" instruction for this exception group. --- src/Interpreters/Query.dhall | 2 -- src/Interpreters/Result.dhall | 3 --- src/Interpreters/ResultColumns.dhall | 3 --- 3 files changed, 8 deletions(-) diff --git a/src/Interpreters/Query.dhall b/src/Interpreters/Query.dhall index daa1d47..cce3fcd 100644 --- a/src/Interpreters/Query.dhall +++ b/src/Interpreters/Query.dhall @@ -177,6 +177,4 @@ let run = ) ) -let Run = Config -> CustomKind.Lookup -> Input -> Lude.Compiled.Type Output - in { Input, Output, run } diff --git a/src/Interpreters/Result.dhall b/src/Interpreters/Result.dhall index 83f3ef1..5d652a0 100644 --- a/src/Interpreters/Result.dhall +++ b/src/Interpreters/Result.dhall @@ -101,7 +101,4 @@ let run = } input -let Run = - Config -> CustomKind.Lookup -> Text -> Input -> Lude.Compiled.Type Output - in { Input, Output, RowClass, run } diff --git a/src/Interpreters/ResultColumns.dhall b/src/Interpreters/ResultColumns.dhall index b2fc737..e0bbfaa 100644 --- a/src/Interpreters/ResultColumns.dhall +++ b/src/Interpreters/ResultColumns.dhall @@ -78,7 +78,4 @@ let run = input ) -let Run = - Config -> CustomKind.Lookup -> Text -> Input -> Lude.Compiled.Type Output - in { Input, Output, run } From 15c28361de3192aa2d0e896fa32076c5f841ece3 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sat, 11 Jul 2026 16:30:39 +0300 Subject: [PATCH 08/41] The plan --- .../plans/2026-07-11-gen-sdk-v2-migration.md | 666 ++++++++++++++++++ 1 file changed, 666 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-gen-sdk-v2-migration.md diff --git a/docs/superpowers/plans/2026-07-11-gen-sdk-v2-migration.md b/docs/superpowers/plans/2026-07-11-gen-sdk-v2-migration.md new file mode 100644 index 0000000..5e0499c --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-gen-sdk-v2-migration.md @@ -0,0 +1,666 @@ +# python.gen: migrate to gen-contract v4.0.1 / gen-sdk v2.0.0, adopt architecture layout + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring `python.gen` up to the same structural shape as `java.gen`'s +last release (`v1.1.0`): pin `gen-contract`/`gen-sdk` directly (no bundled +SDK), adopt `Sdk.Sigs` in place of the local `Algebras/` folder, and move to +the `src/`-rooted repo layout the normative architecture doc describes — with +**zero change to generated output**. + +**Architecture:** Reference is +`gen-sdk/docs/generator-architecture.md` (already read in full) and +`java.gen`'s current `master` (tag `v1.1.0`, commit `658508c`). `python.gen` +is currently on pre-split `gen-sdk v0.11.0` (no `Sigs`, bundles `Project` +itself) with a hand-rolled `gen/Algebras/{Interpreter,Template}.dhall`, a +`gen/Deps/package.dhall` barrel, and the old `gen/` + `tests/Exhaustive.dhall` +layout — i.e. it has never done *any* of the steps `java.gen` went through +(`gen-migration-plan.md` at the pgenie repo root covers `rust.gen`/`haskell.gen`, +which already had `Sigs`; it does not cover `python.gen` at all). This plan +folds all of `java.gen`'s historical steps into one destination state, since +there is no reason to recreate `java.gen`'s intermediate commits. + +**Tech Stack:** Dhall (fork `pgn`'s dhall, package name `dhll`, needed for the +`Text/equal` builtin and `as Source` import mode — see Global Constraints), +Python 3.12 / `uv` / `pytest` for the harness, `mise` for tool pinning. + +## Global Constraints + +- **No behavior change.** This is a dependency/layout migration, not a + feature change. If `demos/Exhaustive.dhall` (see Task 5) produces different + file paths or content than `tests/Exhaustive.dhall` did before the move, + that is a bug in the migration, not an expected diff. +- **Do not touch interpreter algorithms.** `Interpreters/Project.dhall`'s + `Skip`/`Fail` logic (`typeSucceeds`/`queryChecks`/`effectiveQueries`) is + deliberately hand-rolled instead of using `Typeclasses.Classes.Alternative` + the way `java.gen` does, per an explicit in-file comment: multiple + `QueryGen.run`/`CustomTypeGen.run` call sites for the same query measurably + multiplied Dhall normalization time (seconds → minutes), confirmed by wall-time + bisection. This is orthogonal to the Deps/Sigs migration — leave it as is. + Do **not** add `src/Deps/Typeclasses.dhall` since nothing will use it. +- **`Interpreters/Member.dhall` and `Interpreters/ParamsMember.dhall` keep + their 3-argument `Run` type** (`Config -> CustomKind.Lookup -> Input -> + Compiled Output`) instead of conforming to `Sdk.Sigs.interpreter`'s fixed + 2-argument shape (`Config -> Input -> Result`). `java.gen`'s own + `Member.dhall` doesn't need a lookup table; `python.gen`'s does (custom-type + name resolution — see the `buildLookup`/`IndexedCustomType` comments in + `Interpreters/Project.dhall`). Bundling `Lookup` into `Config` or `Input` + to force-fit the Sig is a bigger, separate refactor — out of scope here. + These two files get the Deps-import and `Algebra.Config` → local `Config` + changes (Task 2) but keep their existing bare `{ Input, Output, Run, run }` + export, not `Sdk.Sigs.interpreter Config Input Output run`. +- **Config narrowing is deferred.** The architecture doc's ideal is each + interpreter declaring only the `Config` fields it (and its children) needs. + `java.gen` does this trivially (`{ useOptional : Bool }` everywhere, since + that's its whole config). `python.gen`'s config has 4 fields + (`packageName`, `importName`, `emitSync`, `onUnsupported`); auditing exactly + which fields each of the 11 interpreters actually reads and narrowing each + is real, separate work with real risk of missing a field some deeply nested + path needs. This plan keeps the **same 4-field `Config` record, declared + locally and verbatim in each interpreter module** (no more shared + `Algebra.Config` alias) — this satisfies "`Config` is a parameter each + module declares itself," just not narrowed. Note it as a follow-up, don't + do it now. +- **Local verification gap.** This sandbox has a global `dhall`/`pgn` (cabal + build, `dhll-1.42.3`) that is **not** the `mise`-pinned `pgn v0.9.1` this + repo's CI/tests actually use — a plain `dhall type --file tests/Exhaustive.dhall` + here fails on the *pre-migration* tree already, with an `as Source` + hash-integrity mismatch on `gen/Deps/Sdk.dhall` (confirmed during planning: + expected `8d43544e...`, actual `573b4655...`). This is a toolchain mismatch, + not evidence of a real problem. **Whoever executes this plan must run + verification through `mise x -- dhall ...` / `mise x -- pgn ...` / `mise x + -- uv run pytest`**, matching `mise.toml`'s pin, not a bare global `dhall`. + If `mise` isn't available in the execution environment either, at minimum + run `dhall format --transitive` (syntax-only, tool-version-agnostic) and + flag that deeper verification (`dhall type`, fixture diff, pytest, + basedpyright) still needs to happen on a properly provisioned machine/CI + before this is considered done. + +--- + +## File Structure + +``` +src/ (was gen/) + package.dhall (was Gen.dhall — now built via Sdk.Sigs.generator) + Config.dhall (unchanged content, moved) + Interpret.dhall (was compile.dhall — Config no longer Optional at top) + Deps/ + Contract.dhall (NEW — gen-contract v4.0.1 pin) + Sdk.dhall (bumped gen-sdk v0.11.0 → v2.0.0) + Lude.dhall (unchanged content, moved) + Prelude.dhall (unchanged content, moved) + (package.dhall barrel REMOVED) + Interpreters/ (11 files: Deps.Sdk.Project → Deps.Contract, Algebra → Sdk.Sigs) + Templates/ (10 files: Algebra → Sdk.Sigs; 7 also de-barrel Deps) + Structures/ (CustomKind.dhall: Deps.Sdk.Project → Deps.Contract; others untouched) + (Algebras/ REMOVED) +demos/ + Exhaustive.dhall (was tests/Exhaustive.dhall — rewritten for Sdk.Output.toFileMap) +tests/ (Python pytest harness — unchanged except any gen/-path references) +.github/workflows/{ci,release}.yml, .github/scripts/build-contract-shell.sh, +README.md, AGENTS.md, DESIGN.md, build.bash, bench/*.sh, mise.toml + (path references updated: gen/ → src/, tests/Exhaustive.dhall → demos/Exhaustive.dhall) +``` + +--- + +### Task 1: Add the new Deps pins and remove the `Deps/package.dhall` barrel + +**Files:** +- Create: `gen/Deps/Contract.dhall` +- Modify: `gen/Deps/Sdk.dhall` +- Delete: `gen/Deps/package.dhall` +- Modify (de-barrel): every file that currently has `let Deps = ../Deps/package.dhall` (see the full list in Tasks 2–3 — do this as part of those tasks, not twice) + +This task only stages the new pins; Task 2 is where the fallout (broken +`Sdk.Project`/`Sdk.Fixtures` references, `Algebras/` removal) gets fixed. Do +not try to get `dhall type` green after this task alone — it won't be, and +that's expected (same as `gen-migration-plan.md`'s phase-0 commit 2 for +`java.gen`). Fold Task 1 and Task 2 into one commit if you'd rather not carry +a known-broken intermediate state. + +- [ ] **Step 1: Create `gen/Deps/Contract.dhall`** — the exact pin `java.gen` + and `gen-sdk` itself use: + +```dhall +https://raw.githubusercontent.com/pgenie-io/gen-contract/v4.0.1/src/package.dhall + sha256:4a130ba7fbaa152a776babbb1bf2994a4833931ca76bde9bf6930d354225651e +``` + +- [ ] **Step 2: Bump `gen/Deps/Sdk.dhall`** to `gen-sdk v2.0.0` (same pin + `java.gen`'s `src/Deps/Sdk.dhall` uses). Preserve the existing `as Source` + import mode (see `AGENTS.md`/CI comments on why `python.gen` uses it — + `java.gen` doesn't, but that's an intentional `python.gen`-specific RAM + optimization, not something this migration should undo): + +```dhall +https://raw.githubusercontent.com/pgenie-io/gen-sdk/v2.0.0/src/package.dhall + sha256:b9def6ab1179bc4aaae7fc6e91977f094f75934cd5755175c294a9e97ca71b15 + as Source +``` + + If keeping `as Source` here, its hash is a *source*-text hash, not the + semantic hash above copied from `java.gen` (which imports plainly). Verify + with `dhall hash` against the raw URL using the repo's pinned toolchain + (`mise x -- dhall hash <<< 'https://raw.githubusercontent.com/pgenie-io/gen-sdk/v2.0.0/src/package.dhall as Source'` + or equivalent) before trusting the semantic hash verbatim in `as Source` + mode — don't guess. + +- [ ] **Step 3: Delete `gen/Deps/package.dhall`.** + +- [ ] **Step 4: Commit** (or fold into Task 2's commit). + +--- + +### Task 2: Rewire `Structures/CustomKind.dhall` and all 11 `Interpreters/*.dhall` + +**Files:** +- Modify: `gen/Structures/CustomKind.dhall` +- Modify: `gen/Interpreters/{CustomType,Member,ParamsMember,Primitive,Project,Query,QueryFragments,Result,ResultColumns,Scalar,Value}.dhall` +- Delete: `gen/Algebras/` (all three files) + +**The mechanical recipe, applied to every file above:** + +1. Replace `let Deps = ../Deps/package.dhall` with direct imports of exactly + what the file uses. Every one of these files uses `Deps.Sdk.Project` + (→ becomes a direct `Deps/Contract.dhall` import) and `Deps.Lude`/`Deps.Prelude` + (→ direct imports). Concretely, replace: + ```dhall + let Deps = ../Deps/package.dhall + ``` + with (only the lines this particular file actually needs — check with + `grep -n 'Deps\.' ` first): + ```dhall + let Lude = ../Deps/Lude.dhall + + let Prelude = ../Deps/Prelude.dhall + + let Model = ../Deps/Contract.dhall + ``` + and change every remaining `Deps.Lude` → `Lude`, `Deps.Prelude` → `Prelude` + in the body. `QueryFragments.dhall` additionally has an unused + `let Sdk = Deps.Sdk` line (line 7) — drop it, nothing in the file + references the `Sdk` binding. + +2. Replace the line `let Model = Deps.Sdk.Project` (now redundant with step + 1's `Model` binding) — don't duplicate it, step 1 already introduces + `Model` pointed at `Deps/Contract.dhall`. + +3. Delete `let Algebra = ../Algebras/Interpreter.dhall`. + +4. Add a local `Config` type declaration (same 4 fields everywhere per the + Global Constraints note on deferred narrowing): + ```dhall + let Config = + { packageName : Text + , importName : Text + , emitSync : Bool + , onUnsupported : OnUnsupported.Mode + } + ``` + This needs `OnUnsupported = ../Structures/OnUnsupported.dhall` imported in + any file that doesn't already import it (check first — `Project.dhall` + already does). + +5. Change every `\(config : Algebra.Config) ->` to `\(config : Config) ->`. + +6. Change the tail: + - **10 of the 11 files** (`CustomType`, `Primitive`, `Project`, `Query`, + `QueryFragments`, `Result`, `ResultColumns`, `Scalar`, `Value` — 9 files, + not 10; `Member`/`ParamsMember` are the exception below) end with + `Algebra.module Input Output run` (or, for `Scalar.dhall`, + `Algebra.module Input Output run /\ { ScalarDecode }`). Change to: + ```dhall + Sdk.Sigs.interpreter Config Input Output run + ``` + (`Scalar.dhall`: `Sdk.Sigs.interpreter Config Input Output run /\ { ScalarDecode }`), + which needs `let Sdk = ../Deps/Sdk.dhall` imported (it isn't currently, + since `Deps.Sdk.Project` used to come through the barrel — add it). + - **`Member.dhall` and `ParamsMember.dhall`** keep their existing tail + verbatim: `in { Input, Output, Run, run }`, just with `Algebra.Config` → + `Config` in the `Run` type alias line + (`let Run = Config -> CustomKind.Lookup -> Input -> Lude.Compiled.Type Output`). + No `Sdk.Sigs.interpreter` here — see Global Constraints. + +7. `Interpreters/Project.dhall` specifically: its two `\(config : Algebra.Config) ->` + occurrences (the `combineOutputs` and `run` functions) both become + `\(config : Config) ->`; its `lookupConfig : Algebra.Config` type + annotation (used to type-check `Value.run` calls for composite-field + rendering) becomes `lookupConfig : Config`. No other logic in this file + changes — the `Skip`/`Fail` machinery is untouched per Global Constraints. + +- [ ] **Step 1: Apply the recipe to all 11 `Interpreters/*.dhall` files and `Structures/CustomKind.dhall`.** +- [ ] **Step 2: Delete `gen/Algebras/`.** +- [ ] **Step 3: Verify** (with the `mise`-pinned toolchain, not the bare local `dhall` — see Global Constraints): + ```bash + mise x -- dhall type --file gen/Interpreters/Project.dhall + ``` + Expected: prints the interpreter's type (a record with `Input`, `Output`, + `Result`, `Run`, `run` fields) with no error. This alone pulls in every + other `Interpreters/*.dhall` transitively, so it's a full check of this task. +- [ ] **Step 4: Commit.** + +--- + +### Task 3: Rewire the 10 `Templates/*.dhall` files + +**Files:** +- Modify: `gen/Templates/{CompositeModule,CoreModule,EnumModule,FacadeModule,InitModule,RegisterModule,RowsModule,RuntimeModule,StatementModule,TypesInit}.dhall` + +**Recipe:** + +1. `CoreModule.dhall`, `InitModule.dhall`, `RuntimeModule.dhall` don't import + `Deps` at all (no `Prelude`/`Lude` need) — only change: drop + `let Algebra = ../Algebras/Template.dhall`, add `let Sdk = ../Deps/Sdk.dhall`, + and change the tail. `CoreModule`/`RuntimeModule` end with + `Algebra.module {} (\(_ : {}) -> content)` → `Sdk.Sigs.template {} (\(_ : {}) -> content)`. + `InitModule` ends with `Algebra.module Params render` → + `Sdk.Sigs.template Params render`. + +2. The other 7 (`CompositeModule`, `EnumModule`, `FacadeModule`, + `RegisterModule`, `RowsModule`, `StatementModule`, `TypesInit`) currently + have `let Deps = ../Deps/package.dhall`, and only ever use + `Deps.Prelude.*` (all seven) and, additionally, `Deps.Lude.Text.indentNonEmpty` + (`RowsModule`, `StatementModule` only — confirmed by + `grep -n 'Deps\.' gen/Templates/*.dhall` during planning). Replace the + barrel import with: + ```dhall + let Prelude = ../Deps/Prelude.dhall + ``` + adding `let Lude = ../Deps/Lude.dhall` only in `RowsModule.dhall` and + `StatementModule.dhall`. Then replace `Deps.Prelude.` → `Prelude.` and + `Deps.Lude.` → `Lude.` throughout each file's body. Drop + `let Algebra = ../Algebras/Template.dhall`, add `let Sdk = ../Deps/Sdk.dhall`. + +3. Tails for these 7: `Algebra.module Params run` → + `Sdk.Sigs.template Params run` (`FacadeModule`, `RegisterModule`, + `StatementModule`); with a combined record for the other 4: + `Algebra.module Params run /\ { Field }` (`CompositeModule`) → + `Sdk.Sigs.template Params run /\ { Field }`; `/\ { Variant }` (`EnumModule`); + `/\ { StatementExport, TypeExport }` (`FacadeModule` — check which of + `FacadeModule`/others actually has this combinator vs a plain + `Algebra.module Params run`, per the earlier grep output, before editing — + don't assume, re-`grep -n 'Algebra.module' gen/Templates/*.dhall` and + match each file's exact current tail); `/\ { RowDef }` (`RowsModule`); + `/\ { Export }` (`TypesInit`). + +- [ ] **Step 1: Apply the recipe to all 10 files.** +- [ ] **Step 2: Verify:** + ```bash + mise x -- dhall type --file gen/Interpreters/Project.dhall + ``` + (Templates are only reachable transitively through `Interpreters/Project.dhall` + and its children, same as Task 2 — this single check covers both tasks once + both are done. If running Task 2 and 3 as separate commits, this step will + fail after Task 2 alone if any interpreter references a not-yet-updated + template's old shape; if so, do Tasks 2 and 3 as one commit instead.) +- [ ] **Step 3: Commit.** + +--- + +### Task 4: Rewrite the root entry point (`Config.dhall`, `compile.dhall` → `Interpret.dhall`, `Gen.dhall` → `package.dhall`) + +**Files:** +- Modify (move, content unchanged): `gen/Config.dhall` +- Modify (move + rewrite): `gen/compile.dhall` → `gen/Interpret.dhall` +- Modify (move + rewrite): `gen/Gen.dhall` → `gen/package.dhall` + +(Paths shown as `gen/...` here since Task 5 does the `gen/` → `src/` directory +move; do this task first, in place, then Task 5 is a pure `git mv` sweep with +no further content changes.) + +**Interfaces:** +- Consumes: `Interpreters/Project.dhall`'s `run` (produced by Task 2, + now `Sdk.Sigs.interpreter`-shaped: `.run : Config -> Contract.Project -> Compiled Output`). +- Produces: `package.dhall`'s `Sdk.Sigs.generator`-built value + (`{ contractVersion, Config, compile }`), consumed by Task 5's + `demos/Exhaustive.dhall` and by any pGenie project's `artifacts..gen` URL. + +`Sdk.Sigs.generator`'s shape (from the architecture doc): +```dhall +\(Config : Type) -> +\(defaultConfig : Config) -> +\(interpret : Config -> Contract.Project -> Contract.Output) -> + let compile = \(config : Optional Config) -> + merge { None = interpret defaultConfig, Some = interpret } config + in Contract.module Config compile +``` +Note `interpret` takes a **bare** `Config`, not `Optional Config` — the outer +"config block omitted entirely" case is handled once, by substituting +`defaultConfig`, not by `interpret` itself. `python.gen`'s current +`compile.dhall` handles *two* levels of optionality (the whole block, and +each field within it) with a doubled `Prelude.Optional.fold`. Only the outer +level goes away; each field inside `Config` stays individually `Optional` (so +a project can supply `emitSync: true` alone and still get default +`packageName`/`onUnsupported`) — that per-field defaulting is +`python.gen`-specific richness `java.gen` doesn't have (its `Config` has one +non-Optional `Bool` field), and this migration must not lose it. + +- [ ] **Step 1: `gen/Config.dhall`** — content unchanged, just confirm it + still reads (no edits needed here; listed for completeness since Task 5 + moves the file). + +- [ ] **Step 2: Rewrite `gen/compile.dhall` as `gen/Interpret.dhall`** — + drop the outer `Optional Config` unwrap (the two outermost + `Prelude.Optional.fold Config config Text (\(c : Config) -> ...)` / + `... Bool ...` / `... OnUnsupported.Mode ...` wrappers), keep everything + else (the per-field defaults, `importName` derivation) as is: + +```dhall +let Deps = ./Deps/package.dhall + +-- NOTE: Task 5 changes this to ./Deps/Contract.dhall / ./Deps/Prelude.dhall +-- directly once the Deps barrel is gone (Task 1) — write it that way now, +-- don't reintroduce the barrel: +let Contract = ./Deps/Contract.dhall + +let Prelude = ./Deps/Prelude.dhall + +let Config = ./Config.dhall + +let OnUnsupported = ./Structures/OnUnsupported.dhall + +let ProjectInterpreter = ./Interpreters/Project.dhall + +-- Entry point handed to gen-sdk's Sdk.Sigs.generator as `interpret`. Each +-- field of Config is independently Optional, so a project may omit the +-- whole config block (Sdk.Sigs.generator substitutes an all-None +-- defaultConfig, see package.dhall) 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). The async surface is always +-- emitted; emitSync adds the sync mirror. +in \(config : Config) -> + \(project : Contract.Project) -> + let defaults = + { packageName = project.name.inKebabCase + , emitSync = False + , onUnsupported = OnUnsupported.Mode.Fail + } + + let packageName = + Prelude.Optional.fold + Text + config.packageName + Text + (\(t : Text) -> t) + defaults.packageName + + let emitSync = + Prelude.Optional.fold + Bool + config.emitSync + Bool + (\(b : Bool) -> b) + defaults.emitSync + + let onUnsupported = + Prelude.Optional.fold + OnUnsupported.Mode + config.onUnsupported + OnUnsupported.Mode + (\(m : OnUnsupported.Mode) -> m) + defaults.onUnsupported + + let importName = Prelude.Text.replace "-" "_" packageName + + let interpreterConfig = { packageName, importName, emitSync, onUnsupported } + + in ProjectInterpreter.run interpreterConfig project +``` + +- [ ] **Step 3: Rewrite `gen/Gen.dhall` as `gen/package.dhall`:** + +```dhall +let Sdk = ./Deps/Sdk.dhall + +let OnUnsupported = ./Structures/OnUnsupported.dhall + +let Config = ./Config.dhall + +let Config/default + : Config + = { packageName = None Text + , emitSync = None Bool + , onUnsupported = None OnUnsupported.Mode + } + +let interpret = ./Interpret.dhall + +in Sdk.Sigs.generator Config Config/default interpret +``` + +- [ ] **Step 4: Verify:** + ```bash + mise x -- dhall type --file gen/package.dhall + ``` + Expected type: a record with `contractVersion`, `Config`, `compile` fields + (`compile : Optional Config -> Contract.Project -> Contract.Output`). +- [ ] **Step 5: Commit.** + +--- + +### Task 5: Move `gen/` → `src/`, `tests/Exhaustive.dhall` → `demos/Exhaustive.dhall` + +**Files:** +- Move: `gen/` → `src/` (whole tree, `git mv`) +- Move + rewrite: `tests/Exhaustive.dhall` → `demos/Exhaustive.dhall` + +- [ ] **Step 1:** + ```bash + git mv gen src + mkdir -p demos + git mv tests/Exhaustive.dhall demos/Exhaustive.dhall + ``` + All the `../Deps/...`, `./Interpreters/...`, `../Templates/...` style + relative imports inside `src/` are untouched by this move (they're relative + to their own file, not to the repo root), so no content changes are needed + inside `src/` itself from the move alone. + +- [ ] **Step 2: Rewrite `demos/Exhaustive.dhall`.** Its old body called + `Gen.compileToFileMap config project` — `Sdk.Sigs.generator`-built modules + don't have a `compileToFileMap` field (per the architecture doc: "there is + no `compileToFileMap` on the module — turning an `Output` into files is the + caller's job, via `Sdk.Output.toFileMap`"). New content: + +```dhall +-- Applies this generator to gen-sdk's shared cross-backend fixture project +-- (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 +-- 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: +-- those statements/types are dropped with a warning instead of aborting the +-- whole compile. +-- +-- Intended to be executed with: +-- +-- ```bash +-- dhall to-directory-tree --file demos/Exhaustive.dhall --output --allow-path-separators +-- ``` +let Sdk = ../src/Deps/Sdk.dhall + +let Gen = ../src/package.dhall + +let OnUnsupported = ../src/Structures/OnUnsupported.dhall + +let project = Sdk.Fixtures.Exhaustive + +let config = + Some + { packageName = None Text + , emitSync = Some True + , onUnsupported = Some OnUnsupported.Mode.Skip + } + +in Sdk.Output.toFileMap (Gen.compile config project) +``` + +- [ ] **Step 3: Verify:** + ```bash + mise x -- dhall type --file demos/Exhaustive.dhall + ``` + Expected: `List { mapKey : Text, mapValue : Text }` (or however this + fork/version of Dhall renders `Prelude.Map.Type Text Text`), no error. +- [ ] **Step 4: Commit.** + +--- + +### Task 6: Update every external reference to the old paths + +**Files:** +- Modify: `.github/workflows/ci.yml` +- Modify: `.github/workflows/release.yml` +- Modify: `.github/scripts/build-contract-shell.sh` +- Modify: `README.md` +- Modify: `AGENTS.md` +- Modify: `DESIGN.md` +- Modify: `build.bash` +- Modify: `bench/generate.sh`, `bench/as-source.sh` +- Modify: `mise.toml` (the `golden` task) + +**Path substitutions to apply everywhere they occur** (verify each hit with +`grep -rn` first — don't blind-sed across the whole repo, `tests/golden/` +contains generated Python that must NOT be touched): + +| Old | New | +|---|---| +| `gen/Gen.dhall` | `src/package.dhall` | +| `gen/Deps/*.dhall` | `src/Deps/*.dhall` | +| `tests/Exhaustive.dhall` | `demos/Exhaustive.dhall` | +| `gen/` (prose/dir references) | `src/` | + +Specific known hits (from `grep -rn "gen/Gen\.dhall\|gen/Deps\|tests/Exhaustive"` +run during planning): + +- `.github/workflows/ci.yml`: the `contract` job's "Strip `as Source`..." + step does `sed -i ... gen/Deps/*.dhall` → `src/Deps/*.dhall`; the + "Generate output from Dhall" step's `dhall_file: tests/Exhaustive.dhall` → + `demos/Exhaustive.dhall`. +- `.github/workflows/release.yml`: the "Resolve Dhall" step's + `file: gen/Gen.dhall` → `file: src/package.dhall`. +- `.github/scripts/build-contract-shell.sh`: comment references + `tests/Exhaustive.dhall` → `demos/Exhaustive.dhall` (comment only, verify + no functional path argument needs changing — it's invoked with + `contract-output` as a positional arg per `ci.yml`, not a hardcoded path). +- `README.md`: line ~38 `gen: https://raw.githubusercontent.com/slavashvets/python.gen/master/gen/Gen.dhall` + → `.../src/package.dhall`; lines ~64-66, the three example URLs + (`.../gen/Gen.dhall`) → `.../src/package.dhall`. +- `AGENTS.md`: line ~36 "`gen/` pins its remote imports by sha256 + (`gen/Deps/*.dhall`)" → "`src/` pins its remote imports by sha256 + (`src/Deps/*.dhall`)". +- `DESIGN.md`: line 4 (`gen/`), line 381 (`gen/Gen.dhall` "is the entry point + handed to gen-sdk"), line 397 (`gen/` mirrors...), line 401 (the `gen/` + tree diagram — replace with the new `src/` tree, matching Task 5's actual + post-move layout), lines 632/636 (`tests/Exhaustive.dhall` → `demos/Exhaustive.dhall`). +- `build.bash`: this is a scratch/dev script (mostly commented-out lines) — + update the live lines: `target=tests/Exhaustive.dhall` → + `target=demos/Exhaustive.dhall`; the commented `# target=gen/Gen.dhall` and + `# dhall freeze gen/Deps/*.dhall` lines → `src/` equivalents (keep them + commented, just fix the paths so they're not stale if uncommented later). +- `bench/generate.sh`, `bench/as-source.sh`: both `cp -R "$root/gen" "$out/gen"` / + `"$1/gen"` → `"$root/src" "$out/src"` (and update the `gen/Deps/*.dhall` + perl substitutions to `src/Deps/*.dhall`). **The `as Source` → plain-import + sha256 substitutions in both scripts' `strip_as_source` are pinned to the + *old* `gen-sdk v0.11.0`/`lude v5.1.0` source-vs-normalized hash pairs** + (`8d43544e...`→`b9f7bb84...` for Sdk, `46b527b0...`→`14c43eec...` for Lude). + Since `Task 1` bumps `gen-sdk` to `v2.0.0`, these substitution pairs are now + wrong and must be recomputed for the new pin using the repo's actual pinned + toolchain (`mise x -- dhall hash` on the plain, non-`as-Source` import) — + don't guess these; if the recompute can't happen in this pass, leave a + `# TODO` in the script rather than shipping a silently-wrong benchmark. +- `mise.toml`'s `golden` task: the `python3 - ... "$root/gen/Gen.dhall"` arg + and the fixture-project string replace target `"../../gen/Gen.dhall"` → + `"../../src/package.dhall"`. + +- [ ] **Step 1: Apply all substitutions above.** +- [ ] **Step 2: Confirm no stragglers:** + ```bash + grep -rn "gen/Gen\.dhall\|gen/Deps\|gen/Interpreters\|gen/Templates\|gen/Structures\|gen/Config\.dhall\|gen/compile\.dhall\|tests/Exhaustive" \ + --include="*.md" --include="*.yml" --include="*.yaml" --include="*.toml" --include="*.sh" --include="*.bash" . + ``` + Expected: no output (everything left under `tests/golden/` or `tests/fixture-project/` + that isn't a generator-path reference is fine and out of scope — check any + hit manually rather than assuming). +- [ ] **Step 3: Commit.** + +--- + +### Task 7: Format, verify end-to-end, update CHANGELOG + +- [ ] **Step 1: Format everything:** + ```bash + mise x -- dhall format --transitive src/package.dhall + mise x -- dhall format --transitive demos/Exhaustive.dhall + ``` + +- [ ] **Step 2: Full type-check:** + ```bash + mise x -- dhall type --file src/package.dhall + mise x -- dhall type --file demos/Exhaustive.dhall + ``` + +- [ ] **Step 3: Regenerate the Exhaustive fixture and confirm no diff in + output** (this is the load-bearing check — everything above only proves + the Dhall type-checks, not that it still produces the same files): + ```bash + mise x -- dhall to-directory-tree --allow-path-separators --file demos/Exhaustive.dhall --output /tmp/pygen-after + ``` + Compare against a snapshot taken from the pre-migration tree the same way + (`git stash`, regenerate to `/tmp/pygen-before`, `git stash pop`, `diff -rq + /tmp/pygen-before /tmp/pygen-after`). Expected: **no diff**. Any diff here + is a migration bug, not an intentional update — per Global Constraints, go + fix it rather than accepting the new output. + +- [ ] **Step 4: Run the Python harness:** + ```bash + mise x -- uv sync + mise x -- uv run pytest tests -v + ``` + (Needs a reachable Postgres — `PGN_TEST_DATABASE_URL`, see `ci.yml` for the + Docker Compose equivalent — and the `mise`-pinned `pgn 0.9.1`, since the + harness shells out to it.) Expected: all green, no new failures relative to + a pre-migration run. + +- [ ] **Step 5: Add a CHANGELOG.md entry** under `# Upcoming` (the file + already starts with that heading), non-breaking, modeled on `java.gen`'s + own `v1.1.0` entry: + ```markdown + - 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`). + ``` + Adjust the last parenthetical if no project in practice points at the + unresolved source path (check `README.md`'s own recommended usage — if it + only ever recommends the frozen `resolved.dhall` release asset, simplify + this to "no change to generated output or the public Dhall interface"). + +- [ ] **Step 6: Commit.** + +## Deferred / explicitly out of scope (record as follow-ups, don't do now) + +- Narrowing each interpreter's `Config` to only the fields it needs (see + Global Constraints). +- Giving `Member.dhall`/`ParamsMember.dhall` a real `Sdk.Sigs.interpreter` + shape by folding `CustomKind.Lookup` into `Config` or `Input` (see Global + Constraints). +- Adding a `Name` interpreter (`java.gen` has `Interpreters/Name.dhall` + centralizing identifier casing/escaping; `python.gen` calls + `Structures/PyIdent.dhall` ad hoc from several interpreters instead). This + is a real architecture-doc deviation but not something `java.gen`'s last + release changed — separate task if wanted. +- Recomputing the `as Source` bench-script hash pairs for `gen-sdk v2.0.0` / + `lude v5.1.0` (flagged inline in Task 6 — needs the real toolchain, not + guessable). From 81ea82c81dee169431839b06bfa530175aebf92f Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sat, 11 Jul 2026 17:31:09 +0300 Subject: [PATCH 09/41] Revert "The plan" This reverts commit 15c28361de3192aa2d0e896fa32076c5f841ece3. --- .../plans/2026-07-11-gen-sdk-v2-migration.md | 666 ------------------ 1 file changed, 666 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-11-gen-sdk-v2-migration.md diff --git a/docs/superpowers/plans/2026-07-11-gen-sdk-v2-migration.md b/docs/superpowers/plans/2026-07-11-gen-sdk-v2-migration.md deleted file mode 100644 index 5e0499c..0000000 --- a/docs/superpowers/plans/2026-07-11-gen-sdk-v2-migration.md +++ /dev/null @@ -1,666 +0,0 @@ -# python.gen: migrate to gen-contract v4.0.1 / gen-sdk v2.0.0, adopt architecture layout - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Bring `python.gen` up to the same structural shape as `java.gen`'s -last release (`v1.1.0`): pin `gen-contract`/`gen-sdk` directly (no bundled -SDK), adopt `Sdk.Sigs` in place of the local `Algebras/` folder, and move to -the `src/`-rooted repo layout the normative architecture doc describes — with -**zero change to generated output**. - -**Architecture:** Reference is -`gen-sdk/docs/generator-architecture.md` (already read in full) and -`java.gen`'s current `master` (tag `v1.1.0`, commit `658508c`). `python.gen` -is currently on pre-split `gen-sdk v0.11.0` (no `Sigs`, bundles `Project` -itself) with a hand-rolled `gen/Algebras/{Interpreter,Template}.dhall`, a -`gen/Deps/package.dhall` barrel, and the old `gen/` + `tests/Exhaustive.dhall` -layout — i.e. it has never done *any* of the steps `java.gen` went through -(`gen-migration-plan.md` at the pgenie repo root covers `rust.gen`/`haskell.gen`, -which already had `Sigs`; it does not cover `python.gen` at all). This plan -folds all of `java.gen`'s historical steps into one destination state, since -there is no reason to recreate `java.gen`'s intermediate commits. - -**Tech Stack:** Dhall (fork `pgn`'s dhall, package name `dhll`, needed for the -`Text/equal` builtin and `as Source` import mode — see Global Constraints), -Python 3.12 / `uv` / `pytest` for the harness, `mise` for tool pinning. - -## Global Constraints - -- **No behavior change.** This is a dependency/layout migration, not a - feature change. If `demos/Exhaustive.dhall` (see Task 5) produces different - file paths or content than `tests/Exhaustive.dhall` did before the move, - that is a bug in the migration, not an expected diff. -- **Do not touch interpreter algorithms.** `Interpreters/Project.dhall`'s - `Skip`/`Fail` logic (`typeSucceeds`/`queryChecks`/`effectiveQueries`) is - deliberately hand-rolled instead of using `Typeclasses.Classes.Alternative` - the way `java.gen` does, per an explicit in-file comment: multiple - `QueryGen.run`/`CustomTypeGen.run` call sites for the same query measurably - multiplied Dhall normalization time (seconds → minutes), confirmed by wall-time - bisection. This is orthogonal to the Deps/Sigs migration — leave it as is. - Do **not** add `src/Deps/Typeclasses.dhall` since nothing will use it. -- **`Interpreters/Member.dhall` and `Interpreters/ParamsMember.dhall` keep - their 3-argument `Run` type** (`Config -> CustomKind.Lookup -> Input -> - Compiled Output`) instead of conforming to `Sdk.Sigs.interpreter`'s fixed - 2-argument shape (`Config -> Input -> Result`). `java.gen`'s own - `Member.dhall` doesn't need a lookup table; `python.gen`'s does (custom-type - name resolution — see the `buildLookup`/`IndexedCustomType` comments in - `Interpreters/Project.dhall`). Bundling `Lookup` into `Config` or `Input` - to force-fit the Sig is a bigger, separate refactor — out of scope here. - These two files get the Deps-import and `Algebra.Config` → local `Config` - changes (Task 2) but keep their existing bare `{ Input, Output, Run, run }` - export, not `Sdk.Sigs.interpreter Config Input Output run`. -- **Config narrowing is deferred.** The architecture doc's ideal is each - interpreter declaring only the `Config` fields it (and its children) needs. - `java.gen` does this trivially (`{ useOptional : Bool }` everywhere, since - that's its whole config). `python.gen`'s config has 4 fields - (`packageName`, `importName`, `emitSync`, `onUnsupported`); auditing exactly - which fields each of the 11 interpreters actually reads and narrowing each - is real, separate work with real risk of missing a field some deeply nested - path needs. This plan keeps the **same 4-field `Config` record, declared - locally and verbatim in each interpreter module** (no more shared - `Algebra.Config` alias) — this satisfies "`Config` is a parameter each - module declares itself," just not narrowed. Note it as a follow-up, don't - do it now. -- **Local verification gap.** This sandbox has a global `dhall`/`pgn` (cabal - build, `dhll-1.42.3`) that is **not** the `mise`-pinned `pgn v0.9.1` this - repo's CI/tests actually use — a plain `dhall type --file tests/Exhaustive.dhall` - here fails on the *pre-migration* tree already, with an `as Source` - hash-integrity mismatch on `gen/Deps/Sdk.dhall` (confirmed during planning: - expected `8d43544e...`, actual `573b4655...`). This is a toolchain mismatch, - not evidence of a real problem. **Whoever executes this plan must run - verification through `mise x -- dhall ...` / `mise x -- pgn ...` / `mise x - -- uv run pytest`**, matching `mise.toml`'s pin, not a bare global `dhall`. - If `mise` isn't available in the execution environment either, at minimum - run `dhall format --transitive` (syntax-only, tool-version-agnostic) and - flag that deeper verification (`dhall type`, fixture diff, pytest, - basedpyright) still needs to happen on a properly provisioned machine/CI - before this is considered done. - ---- - -## File Structure - -``` -src/ (was gen/) - package.dhall (was Gen.dhall — now built via Sdk.Sigs.generator) - Config.dhall (unchanged content, moved) - Interpret.dhall (was compile.dhall — Config no longer Optional at top) - Deps/ - Contract.dhall (NEW — gen-contract v4.0.1 pin) - Sdk.dhall (bumped gen-sdk v0.11.0 → v2.0.0) - Lude.dhall (unchanged content, moved) - Prelude.dhall (unchanged content, moved) - (package.dhall barrel REMOVED) - Interpreters/ (11 files: Deps.Sdk.Project → Deps.Contract, Algebra → Sdk.Sigs) - Templates/ (10 files: Algebra → Sdk.Sigs; 7 also de-barrel Deps) - Structures/ (CustomKind.dhall: Deps.Sdk.Project → Deps.Contract; others untouched) - (Algebras/ REMOVED) -demos/ - Exhaustive.dhall (was tests/Exhaustive.dhall — rewritten for Sdk.Output.toFileMap) -tests/ (Python pytest harness — unchanged except any gen/-path references) -.github/workflows/{ci,release}.yml, .github/scripts/build-contract-shell.sh, -README.md, AGENTS.md, DESIGN.md, build.bash, bench/*.sh, mise.toml - (path references updated: gen/ → src/, tests/Exhaustive.dhall → demos/Exhaustive.dhall) -``` - ---- - -### Task 1: Add the new Deps pins and remove the `Deps/package.dhall` barrel - -**Files:** -- Create: `gen/Deps/Contract.dhall` -- Modify: `gen/Deps/Sdk.dhall` -- Delete: `gen/Deps/package.dhall` -- Modify (de-barrel): every file that currently has `let Deps = ../Deps/package.dhall` (see the full list in Tasks 2–3 — do this as part of those tasks, not twice) - -This task only stages the new pins; Task 2 is where the fallout (broken -`Sdk.Project`/`Sdk.Fixtures` references, `Algebras/` removal) gets fixed. Do -not try to get `dhall type` green after this task alone — it won't be, and -that's expected (same as `gen-migration-plan.md`'s phase-0 commit 2 for -`java.gen`). Fold Task 1 and Task 2 into one commit if you'd rather not carry -a known-broken intermediate state. - -- [ ] **Step 1: Create `gen/Deps/Contract.dhall`** — the exact pin `java.gen` - and `gen-sdk` itself use: - -```dhall -https://raw.githubusercontent.com/pgenie-io/gen-contract/v4.0.1/src/package.dhall - sha256:4a130ba7fbaa152a776babbb1bf2994a4833931ca76bde9bf6930d354225651e -``` - -- [ ] **Step 2: Bump `gen/Deps/Sdk.dhall`** to `gen-sdk v2.0.0` (same pin - `java.gen`'s `src/Deps/Sdk.dhall` uses). Preserve the existing `as Source` - import mode (see `AGENTS.md`/CI comments on why `python.gen` uses it — - `java.gen` doesn't, but that's an intentional `python.gen`-specific RAM - optimization, not something this migration should undo): - -```dhall -https://raw.githubusercontent.com/pgenie-io/gen-sdk/v2.0.0/src/package.dhall - sha256:b9def6ab1179bc4aaae7fc6e91977f094f75934cd5755175c294a9e97ca71b15 - as Source -``` - - If keeping `as Source` here, its hash is a *source*-text hash, not the - semantic hash above copied from `java.gen` (which imports plainly). Verify - with `dhall hash` against the raw URL using the repo's pinned toolchain - (`mise x -- dhall hash <<< 'https://raw.githubusercontent.com/pgenie-io/gen-sdk/v2.0.0/src/package.dhall as Source'` - or equivalent) before trusting the semantic hash verbatim in `as Source` - mode — don't guess. - -- [ ] **Step 3: Delete `gen/Deps/package.dhall`.** - -- [ ] **Step 4: Commit** (or fold into Task 2's commit). - ---- - -### Task 2: Rewire `Structures/CustomKind.dhall` and all 11 `Interpreters/*.dhall` - -**Files:** -- Modify: `gen/Structures/CustomKind.dhall` -- Modify: `gen/Interpreters/{CustomType,Member,ParamsMember,Primitive,Project,Query,QueryFragments,Result,ResultColumns,Scalar,Value}.dhall` -- Delete: `gen/Algebras/` (all three files) - -**The mechanical recipe, applied to every file above:** - -1. Replace `let Deps = ../Deps/package.dhall` with direct imports of exactly - what the file uses. Every one of these files uses `Deps.Sdk.Project` - (→ becomes a direct `Deps/Contract.dhall` import) and `Deps.Lude`/`Deps.Prelude` - (→ direct imports). Concretely, replace: - ```dhall - let Deps = ../Deps/package.dhall - ``` - with (only the lines this particular file actually needs — check with - `grep -n 'Deps\.' ` first): - ```dhall - let Lude = ../Deps/Lude.dhall - - let Prelude = ../Deps/Prelude.dhall - - let Model = ../Deps/Contract.dhall - ``` - and change every remaining `Deps.Lude` → `Lude`, `Deps.Prelude` → `Prelude` - in the body. `QueryFragments.dhall` additionally has an unused - `let Sdk = Deps.Sdk` line (line 7) — drop it, nothing in the file - references the `Sdk` binding. - -2. Replace the line `let Model = Deps.Sdk.Project` (now redundant with step - 1's `Model` binding) — don't duplicate it, step 1 already introduces - `Model` pointed at `Deps/Contract.dhall`. - -3. Delete `let Algebra = ../Algebras/Interpreter.dhall`. - -4. Add a local `Config` type declaration (same 4 fields everywhere per the - Global Constraints note on deferred narrowing): - ```dhall - let Config = - { packageName : Text - , importName : Text - , emitSync : Bool - , onUnsupported : OnUnsupported.Mode - } - ``` - This needs `OnUnsupported = ../Structures/OnUnsupported.dhall` imported in - any file that doesn't already import it (check first — `Project.dhall` - already does). - -5. Change every `\(config : Algebra.Config) ->` to `\(config : Config) ->`. - -6. Change the tail: - - **10 of the 11 files** (`CustomType`, `Primitive`, `Project`, `Query`, - `QueryFragments`, `Result`, `ResultColumns`, `Scalar`, `Value` — 9 files, - not 10; `Member`/`ParamsMember` are the exception below) end with - `Algebra.module Input Output run` (or, for `Scalar.dhall`, - `Algebra.module Input Output run /\ { ScalarDecode }`). Change to: - ```dhall - Sdk.Sigs.interpreter Config Input Output run - ``` - (`Scalar.dhall`: `Sdk.Sigs.interpreter Config Input Output run /\ { ScalarDecode }`), - which needs `let Sdk = ../Deps/Sdk.dhall` imported (it isn't currently, - since `Deps.Sdk.Project` used to come through the barrel — add it). - - **`Member.dhall` and `ParamsMember.dhall`** keep their existing tail - verbatim: `in { Input, Output, Run, run }`, just with `Algebra.Config` → - `Config` in the `Run` type alias line - (`let Run = Config -> CustomKind.Lookup -> Input -> Lude.Compiled.Type Output`). - No `Sdk.Sigs.interpreter` here — see Global Constraints. - -7. `Interpreters/Project.dhall` specifically: its two `\(config : Algebra.Config) ->` - occurrences (the `combineOutputs` and `run` functions) both become - `\(config : Config) ->`; its `lookupConfig : Algebra.Config` type - annotation (used to type-check `Value.run` calls for composite-field - rendering) becomes `lookupConfig : Config`. No other logic in this file - changes — the `Skip`/`Fail` machinery is untouched per Global Constraints. - -- [ ] **Step 1: Apply the recipe to all 11 `Interpreters/*.dhall` files and `Structures/CustomKind.dhall`.** -- [ ] **Step 2: Delete `gen/Algebras/`.** -- [ ] **Step 3: Verify** (with the `mise`-pinned toolchain, not the bare local `dhall` — see Global Constraints): - ```bash - mise x -- dhall type --file gen/Interpreters/Project.dhall - ``` - Expected: prints the interpreter's type (a record with `Input`, `Output`, - `Result`, `Run`, `run` fields) with no error. This alone pulls in every - other `Interpreters/*.dhall` transitively, so it's a full check of this task. -- [ ] **Step 4: Commit.** - ---- - -### Task 3: Rewire the 10 `Templates/*.dhall` files - -**Files:** -- Modify: `gen/Templates/{CompositeModule,CoreModule,EnumModule,FacadeModule,InitModule,RegisterModule,RowsModule,RuntimeModule,StatementModule,TypesInit}.dhall` - -**Recipe:** - -1. `CoreModule.dhall`, `InitModule.dhall`, `RuntimeModule.dhall` don't import - `Deps` at all (no `Prelude`/`Lude` need) — only change: drop - `let Algebra = ../Algebras/Template.dhall`, add `let Sdk = ../Deps/Sdk.dhall`, - and change the tail. `CoreModule`/`RuntimeModule` end with - `Algebra.module {} (\(_ : {}) -> content)` → `Sdk.Sigs.template {} (\(_ : {}) -> content)`. - `InitModule` ends with `Algebra.module Params render` → - `Sdk.Sigs.template Params render`. - -2. The other 7 (`CompositeModule`, `EnumModule`, `FacadeModule`, - `RegisterModule`, `RowsModule`, `StatementModule`, `TypesInit`) currently - have `let Deps = ../Deps/package.dhall`, and only ever use - `Deps.Prelude.*` (all seven) and, additionally, `Deps.Lude.Text.indentNonEmpty` - (`RowsModule`, `StatementModule` only — confirmed by - `grep -n 'Deps\.' gen/Templates/*.dhall` during planning). Replace the - barrel import with: - ```dhall - let Prelude = ../Deps/Prelude.dhall - ``` - adding `let Lude = ../Deps/Lude.dhall` only in `RowsModule.dhall` and - `StatementModule.dhall`. Then replace `Deps.Prelude.` → `Prelude.` and - `Deps.Lude.` → `Lude.` throughout each file's body. Drop - `let Algebra = ../Algebras/Template.dhall`, add `let Sdk = ../Deps/Sdk.dhall`. - -3. Tails for these 7: `Algebra.module Params run` → - `Sdk.Sigs.template Params run` (`FacadeModule`, `RegisterModule`, - `StatementModule`); with a combined record for the other 4: - `Algebra.module Params run /\ { Field }` (`CompositeModule`) → - `Sdk.Sigs.template Params run /\ { Field }`; `/\ { Variant }` (`EnumModule`); - `/\ { StatementExport, TypeExport }` (`FacadeModule` — check which of - `FacadeModule`/others actually has this combinator vs a plain - `Algebra.module Params run`, per the earlier grep output, before editing — - don't assume, re-`grep -n 'Algebra.module' gen/Templates/*.dhall` and - match each file's exact current tail); `/\ { RowDef }` (`RowsModule`); - `/\ { Export }` (`TypesInit`). - -- [ ] **Step 1: Apply the recipe to all 10 files.** -- [ ] **Step 2: Verify:** - ```bash - mise x -- dhall type --file gen/Interpreters/Project.dhall - ``` - (Templates are only reachable transitively through `Interpreters/Project.dhall` - and its children, same as Task 2 — this single check covers both tasks once - both are done. If running Task 2 and 3 as separate commits, this step will - fail after Task 2 alone if any interpreter references a not-yet-updated - template's old shape; if so, do Tasks 2 and 3 as one commit instead.) -- [ ] **Step 3: Commit.** - ---- - -### Task 4: Rewrite the root entry point (`Config.dhall`, `compile.dhall` → `Interpret.dhall`, `Gen.dhall` → `package.dhall`) - -**Files:** -- Modify (move, content unchanged): `gen/Config.dhall` -- Modify (move + rewrite): `gen/compile.dhall` → `gen/Interpret.dhall` -- Modify (move + rewrite): `gen/Gen.dhall` → `gen/package.dhall` - -(Paths shown as `gen/...` here since Task 5 does the `gen/` → `src/` directory -move; do this task first, in place, then Task 5 is a pure `git mv` sweep with -no further content changes.) - -**Interfaces:** -- Consumes: `Interpreters/Project.dhall`'s `run` (produced by Task 2, - now `Sdk.Sigs.interpreter`-shaped: `.run : Config -> Contract.Project -> Compiled Output`). -- Produces: `package.dhall`'s `Sdk.Sigs.generator`-built value - (`{ contractVersion, Config, compile }`), consumed by Task 5's - `demos/Exhaustive.dhall` and by any pGenie project's `artifacts..gen` URL. - -`Sdk.Sigs.generator`'s shape (from the architecture doc): -```dhall -\(Config : Type) -> -\(defaultConfig : Config) -> -\(interpret : Config -> Contract.Project -> Contract.Output) -> - let compile = \(config : Optional Config) -> - merge { None = interpret defaultConfig, Some = interpret } config - in Contract.module Config compile -``` -Note `interpret` takes a **bare** `Config`, not `Optional Config` — the outer -"config block omitted entirely" case is handled once, by substituting -`defaultConfig`, not by `interpret` itself. `python.gen`'s current -`compile.dhall` handles *two* levels of optionality (the whole block, and -each field within it) with a doubled `Prelude.Optional.fold`. Only the outer -level goes away; each field inside `Config` stays individually `Optional` (so -a project can supply `emitSync: true` alone and still get default -`packageName`/`onUnsupported`) — that per-field defaulting is -`python.gen`-specific richness `java.gen` doesn't have (its `Config` has one -non-Optional `Bool` field), and this migration must not lose it. - -- [ ] **Step 1: `gen/Config.dhall`** — content unchanged, just confirm it - still reads (no edits needed here; listed for completeness since Task 5 - moves the file). - -- [ ] **Step 2: Rewrite `gen/compile.dhall` as `gen/Interpret.dhall`** — - drop the outer `Optional Config` unwrap (the two outermost - `Prelude.Optional.fold Config config Text (\(c : Config) -> ...)` / - `... Bool ...` / `... OnUnsupported.Mode ...` wrappers), keep everything - else (the per-field defaults, `importName` derivation) as is: - -```dhall -let Deps = ./Deps/package.dhall - --- NOTE: Task 5 changes this to ./Deps/Contract.dhall / ./Deps/Prelude.dhall --- directly once the Deps barrel is gone (Task 1) — write it that way now, --- don't reintroduce the barrel: -let Contract = ./Deps/Contract.dhall - -let Prelude = ./Deps/Prelude.dhall - -let Config = ./Config.dhall - -let OnUnsupported = ./Structures/OnUnsupported.dhall - -let ProjectInterpreter = ./Interpreters/Project.dhall - --- Entry point handed to gen-sdk's Sdk.Sigs.generator as `interpret`. Each --- field of Config is independently Optional, so a project may omit the --- whole config block (Sdk.Sigs.generator substitutes an all-None --- defaultConfig, see package.dhall) 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). The async surface is always --- emitted; emitSync adds the sync mirror. -in \(config : Config) -> - \(project : Contract.Project) -> - let defaults = - { packageName = project.name.inKebabCase - , emitSync = False - , onUnsupported = OnUnsupported.Mode.Fail - } - - let packageName = - Prelude.Optional.fold - Text - config.packageName - Text - (\(t : Text) -> t) - defaults.packageName - - let emitSync = - Prelude.Optional.fold - Bool - config.emitSync - Bool - (\(b : Bool) -> b) - defaults.emitSync - - let onUnsupported = - Prelude.Optional.fold - OnUnsupported.Mode - config.onUnsupported - OnUnsupported.Mode - (\(m : OnUnsupported.Mode) -> m) - defaults.onUnsupported - - let importName = Prelude.Text.replace "-" "_" packageName - - let interpreterConfig = { packageName, importName, emitSync, onUnsupported } - - in ProjectInterpreter.run interpreterConfig project -``` - -- [ ] **Step 3: Rewrite `gen/Gen.dhall` as `gen/package.dhall`:** - -```dhall -let Sdk = ./Deps/Sdk.dhall - -let OnUnsupported = ./Structures/OnUnsupported.dhall - -let Config = ./Config.dhall - -let Config/default - : Config - = { packageName = None Text - , emitSync = None Bool - , onUnsupported = None OnUnsupported.Mode - } - -let interpret = ./Interpret.dhall - -in Sdk.Sigs.generator Config Config/default interpret -``` - -- [ ] **Step 4: Verify:** - ```bash - mise x -- dhall type --file gen/package.dhall - ``` - Expected type: a record with `contractVersion`, `Config`, `compile` fields - (`compile : Optional Config -> Contract.Project -> Contract.Output`). -- [ ] **Step 5: Commit.** - ---- - -### Task 5: Move `gen/` → `src/`, `tests/Exhaustive.dhall` → `demos/Exhaustive.dhall` - -**Files:** -- Move: `gen/` → `src/` (whole tree, `git mv`) -- Move + rewrite: `tests/Exhaustive.dhall` → `demos/Exhaustive.dhall` - -- [ ] **Step 1:** - ```bash - git mv gen src - mkdir -p demos - git mv tests/Exhaustive.dhall demos/Exhaustive.dhall - ``` - All the `../Deps/...`, `./Interpreters/...`, `../Templates/...` style - relative imports inside `src/` are untouched by this move (they're relative - to their own file, not to the repo root), so no content changes are needed - inside `src/` itself from the move alone. - -- [ ] **Step 2: Rewrite `demos/Exhaustive.dhall`.** Its old body called - `Gen.compileToFileMap config project` — `Sdk.Sigs.generator`-built modules - don't have a `compileToFileMap` field (per the architecture doc: "there is - no `compileToFileMap` on the module — turning an `Output` into files is the - caller's job, via `Sdk.Output.toFileMap`"). New content: - -```dhall --- Applies this generator to gen-sdk's shared cross-backend fixture project --- (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 --- 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: --- those statements/types are dropped with a warning instead of aborting the --- whole compile. --- --- Intended to be executed with: --- --- ```bash --- dhall to-directory-tree --file demos/Exhaustive.dhall --output --allow-path-separators --- ``` -let Sdk = ../src/Deps/Sdk.dhall - -let Gen = ../src/package.dhall - -let OnUnsupported = ../src/Structures/OnUnsupported.dhall - -let project = Sdk.Fixtures.Exhaustive - -let config = - Some - { packageName = None Text - , emitSync = Some True - , onUnsupported = Some OnUnsupported.Mode.Skip - } - -in Sdk.Output.toFileMap (Gen.compile config project) -``` - -- [ ] **Step 3: Verify:** - ```bash - mise x -- dhall type --file demos/Exhaustive.dhall - ``` - Expected: `List { mapKey : Text, mapValue : Text }` (or however this - fork/version of Dhall renders `Prelude.Map.Type Text Text`), no error. -- [ ] **Step 4: Commit.** - ---- - -### Task 6: Update every external reference to the old paths - -**Files:** -- Modify: `.github/workflows/ci.yml` -- Modify: `.github/workflows/release.yml` -- Modify: `.github/scripts/build-contract-shell.sh` -- Modify: `README.md` -- Modify: `AGENTS.md` -- Modify: `DESIGN.md` -- Modify: `build.bash` -- Modify: `bench/generate.sh`, `bench/as-source.sh` -- Modify: `mise.toml` (the `golden` task) - -**Path substitutions to apply everywhere they occur** (verify each hit with -`grep -rn` first — don't blind-sed across the whole repo, `tests/golden/` -contains generated Python that must NOT be touched): - -| Old | New | -|---|---| -| `gen/Gen.dhall` | `src/package.dhall` | -| `gen/Deps/*.dhall` | `src/Deps/*.dhall` | -| `tests/Exhaustive.dhall` | `demos/Exhaustive.dhall` | -| `gen/` (prose/dir references) | `src/` | - -Specific known hits (from `grep -rn "gen/Gen\.dhall\|gen/Deps\|tests/Exhaustive"` -run during planning): - -- `.github/workflows/ci.yml`: the `contract` job's "Strip `as Source`..." - step does `sed -i ... gen/Deps/*.dhall` → `src/Deps/*.dhall`; the - "Generate output from Dhall" step's `dhall_file: tests/Exhaustive.dhall` → - `demos/Exhaustive.dhall`. -- `.github/workflows/release.yml`: the "Resolve Dhall" step's - `file: gen/Gen.dhall` → `file: src/package.dhall`. -- `.github/scripts/build-contract-shell.sh`: comment references - `tests/Exhaustive.dhall` → `demos/Exhaustive.dhall` (comment only, verify - no functional path argument needs changing — it's invoked with - `contract-output` as a positional arg per `ci.yml`, not a hardcoded path). -- `README.md`: line ~38 `gen: https://raw.githubusercontent.com/slavashvets/python.gen/master/gen/Gen.dhall` - → `.../src/package.dhall`; lines ~64-66, the three example URLs - (`.../gen/Gen.dhall`) → `.../src/package.dhall`. -- `AGENTS.md`: line ~36 "`gen/` pins its remote imports by sha256 - (`gen/Deps/*.dhall`)" → "`src/` pins its remote imports by sha256 - (`src/Deps/*.dhall`)". -- `DESIGN.md`: line 4 (`gen/`), line 381 (`gen/Gen.dhall` "is the entry point - handed to gen-sdk"), line 397 (`gen/` mirrors...), line 401 (the `gen/` - tree diagram — replace with the new `src/` tree, matching Task 5's actual - post-move layout), lines 632/636 (`tests/Exhaustive.dhall` → `demos/Exhaustive.dhall`). -- `build.bash`: this is a scratch/dev script (mostly commented-out lines) — - update the live lines: `target=tests/Exhaustive.dhall` → - `target=demos/Exhaustive.dhall`; the commented `# target=gen/Gen.dhall` and - `# dhall freeze gen/Deps/*.dhall` lines → `src/` equivalents (keep them - commented, just fix the paths so they're not stale if uncommented later). -- `bench/generate.sh`, `bench/as-source.sh`: both `cp -R "$root/gen" "$out/gen"` / - `"$1/gen"` → `"$root/src" "$out/src"` (and update the `gen/Deps/*.dhall` - perl substitutions to `src/Deps/*.dhall`). **The `as Source` → plain-import - sha256 substitutions in both scripts' `strip_as_source` are pinned to the - *old* `gen-sdk v0.11.0`/`lude v5.1.0` source-vs-normalized hash pairs** - (`8d43544e...`→`b9f7bb84...` for Sdk, `46b527b0...`→`14c43eec...` for Lude). - Since `Task 1` bumps `gen-sdk` to `v2.0.0`, these substitution pairs are now - wrong and must be recomputed for the new pin using the repo's actual pinned - toolchain (`mise x -- dhall hash` on the plain, non-`as-Source` import) — - don't guess these; if the recompute can't happen in this pass, leave a - `# TODO` in the script rather than shipping a silently-wrong benchmark. -- `mise.toml`'s `golden` task: the `python3 - ... "$root/gen/Gen.dhall"` arg - and the fixture-project string replace target `"../../gen/Gen.dhall"` → - `"../../src/package.dhall"`. - -- [ ] **Step 1: Apply all substitutions above.** -- [ ] **Step 2: Confirm no stragglers:** - ```bash - grep -rn "gen/Gen\.dhall\|gen/Deps\|gen/Interpreters\|gen/Templates\|gen/Structures\|gen/Config\.dhall\|gen/compile\.dhall\|tests/Exhaustive" \ - --include="*.md" --include="*.yml" --include="*.yaml" --include="*.toml" --include="*.sh" --include="*.bash" . - ``` - Expected: no output (everything left under `tests/golden/` or `tests/fixture-project/` - that isn't a generator-path reference is fine and out of scope — check any - hit manually rather than assuming). -- [ ] **Step 3: Commit.** - ---- - -### Task 7: Format, verify end-to-end, update CHANGELOG - -- [ ] **Step 1: Format everything:** - ```bash - mise x -- dhall format --transitive src/package.dhall - mise x -- dhall format --transitive demos/Exhaustive.dhall - ``` - -- [ ] **Step 2: Full type-check:** - ```bash - mise x -- dhall type --file src/package.dhall - mise x -- dhall type --file demos/Exhaustive.dhall - ``` - -- [ ] **Step 3: Regenerate the Exhaustive fixture and confirm no diff in - output** (this is the load-bearing check — everything above only proves - the Dhall type-checks, not that it still produces the same files): - ```bash - mise x -- dhall to-directory-tree --allow-path-separators --file demos/Exhaustive.dhall --output /tmp/pygen-after - ``` - Compare against a snapshot taken from the pre-migration tree the same way - (`git stash`, regenerate to `/tmp/pygen-before`, `git stash pop`, `diff -rq - /tmp/pygen-before /tmp/pygen-after`). Expected: **no diff**. Any diff here - is a migration bug, not an intentional update — per Global Constraints, go - fix it rather than accepting the new output. - -- [ ] **Step 4: Run the Python harness:** - ```bash - mise x -- uv sync - mise x -- uv run pytest tests -v - ``` - (Needs a reachable Postgres — `PGN_TEST_DATABASE_URL`, see `ci.yml` for the - Docker Compose equivalent — and the `mise`-pinned `pgn 0.9.1`, since the - harness shells out to it.) Expected: all green, no new failures relative to - a pre-migration run. - -- [ ] **Step 5: Add a CHANGELOG.md entry** under `# Upcoming` (the file - already starts with that heading), non-breaking, modeled on `java.gen`'s - own `v1.1.0` entry: - ```markdown - - 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`). - ``` - Adjust the last parenthetical if no project in practice points at the - unresolved source path (check `README.md`'s own recommended usage — if it - only ever recommends the frozen `resolved.dhall` release asset, simplify - this to "no change to generated output or the public Dhall interface"). - -- [ ] **Step 6: Commit.** - -## Deferred / explicitly out of scope (record as follow-ups, don't do now) - -- Narrowing each interpreter's `Config` to only the fields it needs (see - Global Constraints). -- Giving `Member.dhall`/`ParamsMember.dhall` a real `Sdk.Sigs.interpreter` - shape by folding `CustomKind.Lookup` into `Config` or `Input` (see Global - Constraints). -- Adding a `Name` interpreter (`java.gen` has `Interpreters/Name.dhall` - centralizing identifier casing/escaping; `python.gen` calls - `Structures/PyIdent.dhall` ad hoc from several interpreters instead). This - is a real architecture-doc deviation but not something `java.gen`'s last - release changed — separate task if wanted. -- Recomputing the `as Source` bench-script hash pairs for `gen-sdk v2.0.0` / - `lude v5.1.0` (flagged inline in Task 6 — needs the real toolchain, not - guessable). From 7b3ef57f68105b5c5dbfee2a1e41117fea135e96 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sun, 12 Jul 2026 06:16:12 +0300 Subject: [PATCH 10/41] Fix --- CHANGELOG.md | 40 ++ DESIGN.md | 294 +++++--- ...26-07-11-encounter-order-custom-imports.md | 327 +++++++++ .../2026-07-11-reusable-custom-type-codecs.md | 633 ++++++++++++++++++ docs/upstream-asks.md | 12 + src/Config.dhall | 15 - src/Deps/Lude.dhall | 5 +- src/Deps/Prelude.dhall | 1 - src/Deps/Sdk.dhall | 1 - src/Interpret.dhall | 54 -- src/Interpreters/CustomType.dhall | 14 +- src/Interpreters/Member.dhall | 180 ++--- src/Interpreters/ParamsMember.dhall | 121 +--- src/Interpreters/Project.dhall | 180 ++--- src/Interpreters/Query.dhall | 11 +- src/Interpreters/Result.dhall | 27 +- src/Interpreters/ResultColumns.dhall | 10 +- src/Structures/CustomKind.dhall | 28 - src/Structures/ImportSet.dhall | 115 +--- src/Templates/CompositeModule.dhall | 42 +- src/Templates/EnumModule.dhall | 11 + src/Templates/RowsModule.dhall | 2 +- src/Templates/StatementModule.dhall | 2 +- src/package.dhall | 21 +- tests/test_config_variants.py | 2 +- 25 files changed, 1483 insertions(+), 665 deletions(-) create mode 100644 docs/plans/2026-07-11-encounter-order-custom-imports.md create mode 100644 docs/plans/2026-07-11-reusable-custom-type-codecs.md delete mode 100644 src/Config.dhall delete mode 100644 src/Interpret.dhall delete mode 100644 src/Structures/CustomKind.dhall diff --git a/CHANGELOG.md b/CHANGELOG.md index f98fe04..b55b2bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # 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 diff --git a/DESIGN.md b/DESIGN.md index 79e8a7c..952e4cc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. --- @@ -383,20 +420,27 @@ overwritten on every run. Do not hand-edit it. ```dhall let Sdk = ./Deps/Sdk.dhall -let Config = ./Config.dhall +let OnUnsupported = ./Structures/OnUnsupported.dhall + +let ProjectInterpreter = ./Interpreters/Project.dhall + +let Config = { packageName : Optional Text, emitSync : Optional Bool, onUnsupported : Optional OnUnsupported.Mode } -let interpret = ./Interpret.dhall +let Config/default = { packageName = None Text, emitSync = None Bool, onUnsupported = None OnUnsupported.Mode } -in Sdk.Sigs.generator Config Config/default interpret +in Sdk.Sigs.generator Config Config/default ProjectInterpreter.run ``` `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`. `Interpret.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 (`Contract.Output`). +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 @@ -405,13 +449,10 @@ algebra signatures themselves live in gen-sdk's `Sdk.Sigs` (`interpreter.dhall`/ ```text src/ - package.dhall # Sdk.Sigs.generator Config Config/default interpret (entry handed to gen-sdk) - Config.dhall # user config TYPE: { packageName, emitSync, onUnsupported } - Interpret.dhall # derive interpreter Config from user Config, call Project.run + 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) @@ -438,29 +479,39 @@ src/ 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. --- @@ -474,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 @@ -496,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. --- diff --git a/docs/plans/2026-07-11-encounter-order-custom-imports.md b/docs/plans/2026-07-11-encounter-order-custom-imports.md new file mode 100644 index 0000000..7d1c663 --- /dev/null +++ b/docs/plans/2026-07-11-encounter-order-custom-imports.md @@ -0,0 +1,327 @@ +# Encounter-Order Custom-Type Imports Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop requiring a project-wide `order : Natural` to sort and dedupe custom-type import lines in `ImportSet.dhall`. Emit them in encounter order (the order the referencing columns/params are declared) instead, and accept that de-duplicating two references to the *same* custom type within one file is not achievable in vanilla Dhall — verify it isn't actually needed by the current corpus, and document the tradeoff rather than reintroduing a lookup to avoid it. + +**Architecture:** `ImportSet.dhall` today dedupes and alphabetizes custom-type imports by carrying a `dedupKey : Natural` — the type's alphabetical index in `project.customTypes` — through every `CustomImport` value, specifically because Dhall has no `Text` comparison to sort or dedupe on `moduleName`/`className` directly (see the file's own header comment). That `order` value's only source was `buildLookup` (`Interpreters/Project.dhall`), which the companion plan (`2026-07-11-reusable-custom-type-codecs.md`) deletes. Once it's gone, `ImportSet.dhall` has nothing to key on. Rather than re-deriving a Natural surrogate some other way, this plan removes the sort/dedup step and lets `ImportSet.combine`'s existing `List/fold` order (already the query's declared column/param order — a real, already-computed, non-Text-comparison ordering) stand as the output order. + +**Tech Stack:** Dhall (dhall-lang 1.42), Python 3.12 generated output, pytest golden-file harness (`mise run test`, `mise run golden`). + +## Global Constraints + +- **Depends on** `docs/plans/2026-07-11-reusable-custom-type-codecs.md` — that plan's Task 3/4 call `ImportSet.custom customImport` (no `order` argument). Land this plan's Task 1 first, or in the same PR; `ImportSet.customEnum`/`customComposite` (which this plan deletes) are exactly what those tasks stop calling. +- No behavior change to the four already-Natural-keyed stdlib import flags (`uuid`, `datetime`, `date`, `time`, `timedelta`, `decimal`, `jsonb`, `json`, `jsonValue`, `enumArray`) — those are plain `Bool` OR's today and are untouched by this plan. +- Golden fixture output must be regenerated and diffed (`tests/golden/`), not hand-edited. +- Every Dhall file touched must independently type-check: `dhall type --file=`. + +--- + +## Why dedup can't be preserved without reintroducing a lookup + +Worth writing down since it's not obvious and the temptation to "just find a clever `Text/replace` trick" is real — this was checked directly against `PyIdent.dhall`'s working pattern (DESIGN.md section 13) before concluding it doesn't generalize: + +`PyIdent.dhall`'s `sanitizeAgainst` tests a runtime `Text` against a small, **fixed, compile-time-literal** candidate list (the 35 Python keywords) — one known literal at a time, folded. That's why the two-`Text/replace` trick works: one side of every comparison is always a literal. + +Import dedup needs the opposite shape: is this **runtime** `moduleName` (derived from a `Name` that varies per project) equal to any of the **other runtime** `moduleName`s already collected? Both sides are dynamic. No sequence of `Text/replace` calls can decide that, because deciding it requires producing a `Bool` from two non-literal `Text` values, which is exactly the operation Dhall doesn't have (and which pgn's forked `Text/equal` exists to provide). This isn't a missing trick — dedup of dynamically-computed `Text` is unconditionally impossible in vanilla Dhall. The only ways to get it back are: (a) a fork builtin (what we're removing), (b) a pre-assigned `Natural` id per distinct value (what `order` was — sourced from a project-wide search, i.e. `buildLookup`, also being removed), or (c) don't need it. + +This plan takes (c), having checked how much it costs to: + +```bash +grep -rl "^from \.\.types\." tests/golden/src 2>/dev/null | while read f; do + n=$(grep -c "^from \.\.types\." "$f") + [ "$n" -gt 1 ] && echo "$f: $n" +done +``` + +No output — verified during design (see conversation record / re-run before Task 2 to confirm it's still true after Doc 1's regeneration). No file in the current fixture corpus imports the same custom type twice. The risk is real but currently unexercised: if a future query selects the same composite/enum type via two different columns, the generated file will contain two identical `from ..types.X import Y` lines — syntactically valid, harmless to `basedpyright strict` and to Python's import system, just visually redundant. Task 2 adds a corpus case that exercises this on purpose so the tradeoff is documented against real output, not just asserted. + +--- + +## File Structure + +| File | Change | +|---|---| +| `src/Structures/ImportSet.dhall` | Drop `dedupKey` from `CustomImport`; delete `dedupCustoms`, `sortCustoms`, `eqNat`, `leNat`, `sortedCustoms`; collapse `customEnum`/`customComposite` into the existing `custom`; `combine` plain-concatenates `customTypes` instead of deduping. | +| `src/Templates/RowsModule.dhall`, `src/Templates/StatementModule.dhall` | Read `imports.customTypes` directly instead of `ImportSet.sortedCustoms imports`. | +| `tests/golden/` | Regenerate via `mise run golden`; review the (likely negligible) reordering of custom-type import lines from alphabetical to declaration order. | +| `python.gen/DESIGN.md` | Note the ordering change where section 12/13 currently describe the alphabetical-by-`order` scheme. | + +--- + +### Task 1: Simplify `ImportSet.dhall` + +**Files:** +- Modify: `src/Structures/ImportSet.dhall` + +**Interfaces:** +- `CustomImport` loses `dedupKey : Natural` → becomes `{ className : Text, moduleName : Text }`. +- `custom : CustomImport -> Self` — unchanged signature, now the only constructor (no more `customEnum`/`customComposite`). +- `combine : Self -> Self -> Self` — `customTypes` field is now a plain list concatenation. +- `sortedCustoms` is deleted. Callers read `.customTypes` directly. + +- [ ] **Step 1: Replace the file** + +```dhall +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 + } +``` + +- [ ] **Step 2: Type-check** + +Run: `dhall type --file=src/Structures/ImportSet.dhall` +Expected: prints the record-of-functions signature, no error. + +- [ ] **Step 3: Commit** + +```bash +git add src/Structures/ImportSet.dhall +git commit -m "python.gen: drop order-based sort/dedup from ImportSet, use encounter order" +``` + +--- + +### Task 2: Update the two render call sites + +**Files:** +- Modify: `src/Templates/RowsModule.dhall`, `src/Templates/StatementModule.dhall` + +- [ ] **Step 1: `RowsModule.dhall:58`** + +Change: +```dhall + (ImportSet.sortedCustoms imports) +``` +to: +```dhall + imports.customTypes +``` + +- [ ] **Step 2: `StatementModule.dhall:82`** + +Same change: +```dhall + imports.customTypes +``` + +- [ ] **Step 3: Type-check both** + +Run: `dhall type --file=src/Templates/RowsModule.dhall && dhall type --file=src/Templates/StatementModule.dhall` +Expected: both print their signatures, no error. + +- [ ] **Step 4: Commit** + +```bash +git add src/Templates/RowsModule.dhall src/Templates/StatementModule.dhall +git commit -m "python.gen: render custom-type imports in encounter order" +``` + +--- + +### Task 3: Regenerate golden fixtures and verify the dedup gap directly + +**Files:** +- Modify: `tests/fixture-project/` (temporary, to exercise the dedup gap — see Step 1) +- Regenerate: `tests/golden/` + +- [ ] **Step 1: Confirm today's corpus has no same-type-twice case** + +Run: +```bash +grep -rl "^from \.\.types\." tests/golden/src 2>/dev/null | while read f; do + n=$(grep -c "^from \.\.types\." "$f") + [ "$n" -gt 1 ] && echo "$f: $n" +done +``` +Expected: no output (re-confirms the design-time check above, against the *current* golden tree before this plan's regeneration). + +- [ ] **Step 2: Regenerate golden** + +Run: `mise run golden` +Expected: succeeds. `git diff tests/golden` shows custom-type import lines reordered from alphabetical to declaration order in files with 2+ distinct custom-type imports (e.g. wherever `Mood` and `Point2D` are both imported today — check whether the query's own column order already happens to be alphabetical for that file; if so the diff is empty there and this is confirmed low-risk for the current corpus). + +- [ ] **Step 3: Deliberately add a same-type-twice column to the fixture project** + +Add a query (or extend an existing one) whose result row or param list references the *same* composite or enum type through two different columns/params — e.g. two `mood`-typed columns in one query. This is new fixture surface, not present today; add it under `tests/fixture-project/queries/`. + +Run: `mise run golden` +Expected: succeeds; the regenerated file for that query contains **two** identical `from ..types.mood import Mood` lines (confirm with `grep -c` on the specific file). This is the one visible, accepted consequence of this plan — capture it in the diff review, don't silently let it slip into `tests/golden` unremarked. + +- [ ] **Step 4: Decide whether to keep the same-type-twice fixture case** + +Keeping it in the committed corpus makes the tradeoff a permanent, visible regression test (future readers see the duplicate import and the comment in `ImportSet.dhall` explaining it, instead of being surprised by it later). Removing it keeps the golden diff minimal for this change. Either is fine — this plan recommends **keeping it**, since an accepted-but-invisible tradeoff tends to resurface as a bug report; make the call and note it in the commit message either way. + +- [ ] **Step 5: Run the full test suite** + +Run: `mise run test` +Expected: all pass. Duplicate import lines don't fail `basedpyright strict` (Python tolerates redundant imports) or the golden byte-comparison (it compares against the freshly-committed golden, not some independent expectation). + +- [ ] **Step 6: Commit** + +```bash +git add tests/golden tests/fixture-project +git commit -m "python.gen: regenerate golden fixtures for encounter-order imports" +``` + +--- + +### Task 4: Update `DESIGN.md` + +**Files:** +- Modify: `python.gen/DESIGN.md` + +- [ ] **Step 1: Update section 12/13 cross-references** + +Wherever DESIGN.md currently describes `order`/alphabetical import sorting (it's referenced in passing around sections 12-13 and in code comments already updated by `docs/plans/2026-07-11-reusable-custom-type-codecs.md`'s Task 8), add a short note: custom-type imports are emitted in encounter (declaration) order, not sorted, since the `order` Natural no longer exists once `buildLookup` is gone; same-type-twice references are not deduped (a Dhall limitation, not an oversight — see `src/Structures/ImportSet.dhall`'s header comment for the full reasoning). Point at the fixture case from Task 3 if kept. + +- [ ] **Step 2: Commit** + +```bash +git add python.gen/DESIGN.md +git commit -m "python.gen: document encounter-order custom-type imports in DESIGN.md" +``` + +--- + +## Self-Review + +**Spec coverage:** Task 1 is the actual mechanism change (drop the Natural key, drop sort/dedup). Task 2 fixes the two render call sites that would otherwise reference a deleted `sortedCustoms`. Task 3 regenerates and — importantly — deliberately exercises the one behavior change (duplicate imports for a same-type-twice reference) instead of letting it go unverified. Task 4 keeps DESIGN.md truthful. + +**Placeholder scan:** no TBDs; every step names an exact file, an exact diff, or an exact command with expected output. + +**Dependency on the companion plan:** called out at the top (Global Constraints) and repeated in the companion plan's own self-review — `docs/plans/2026-07-11-reusable-custom-type-codecs.md` Task 3/4 call `ImportSet.custom` with no `order` argument, which only type-checks after this plan's Task 1. Sequence: this plan's Task 1 → companion plan's Tasks 3-6 → this plan's Tasks 2-4 (Task 2 touches templates the companion plan doesn't touch, so it can land anytime after Task 1, but golden regeneration in either plan's Task 8/3 should happen once, after both are code-complete, not twice). + +## Execution Handoff + +Plan complete and saved to `python.gen/docs/plans/2026-07-11-encounter-order-custom-imports.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach? diff --git a/docs/plans/2026-07-11-reusable-custom-type-codecs.md b/docs/plans/2026-07-11-reusable-custom-type-codecs.md new file mode 100644 index 0000000..b2f880c --- /dev/null +++ b/docs/plans/2026-07-11-reusable-custom-type-codecs.md @@ -0,0 +1,633 @@ +# Reusable Custom-Type Codecs Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Delete `buildLookup`/`CustomKind.Lookup` — and with it python.gen's last dependency on pgn's forked `Text/equal` builtin (DESIGN.md section 12) — by generating one `_decode`/`_encode` codec per custom type in `types/.py`, called by name from every reference site, instead of re-deriving decode/encode logic (field types, Composite-vs-Enum classification) at each call site. + +**Architecture:** `CustomType.dhall` already visits every custom type once with its full definition in hand (composite fields or enum variants) and emits `types/.py`. Today it stops at the dataclass/enum class. This plan makes it also emit a `_decode` staticmethod (and, for composites, an `_encode` instance method) on that same class. Every column/param reference site (`Member.dhall`, `ParamsMember.dhall`) currently has to search `project.customTypes` by name to find out whether it's looking at a composite or an enum, because it re-derives the decode/encode expression inline. Once decode/encode is a named method reachable from the `Name` the reference site already carries (`name.inPascalCase`), the reference site just calls it — no search, no classification, no `Text/equal`. + +**Tech Stack:** Dhall (dhall-lang 1.42 vendored via `dhall` CLI; pgn's forked interpreter for the parts still using it), Python 3.12 generated output (psycopg3), basedpyright strict, pytest golden-file harness (`mise run test`, `mise run golden`). + +## Global Constraints + +- No behavior change to non-custom-type (primitive) decode/encode paths. +- Golden fixture output (`tests/golden/`) must be regenerated and diffed, not hand-edited (per `tests/golden/README.md`). +- `mise run test` (pytest, includes `test_generated_passes_basedpyright_strict`) must pass after regeneration. +- Every Dhall file touched must independently type-check: `dhall type --file=`. +- Don't touch `Primitive.dhall`, `Scalar.dhall`, `Value.dhall`, or the `Model`/`Contract` dependency — this plan is scoped to what python.gen can do unilaterally, with the current, unmodified `Scalar = < Primitive : Primitive | Custom : Name >` contract. + +--- + +## Design decision needed before Task 3: array-of-custom-type behavior + +Today, `Member.dhall`'s Custom branch treats arrays differently by kind, because it already knows the kind from `lookup name`: +- **Enum arrays (1-D):** supported — `enumArrayDecode` emits an element-wise list comprehension. +- **Composite arrays:** rejected at generation time — `"Array of a composite type is not supported (element-wise decode is unimplemented)"` (Member.dhall:219) and, for params, `"Array of a composite type as a parameter is not supported"` (ParamsMember.dhall:359). + +Once decode/encode become named methods called unconditionally (no classification at the call site), there's no Dhall-level way to keep rejecting *only* composite arrays without reintroducing some form of lookup. Two ways forward: + +- **A — Recommended: uniform array codec, kind-specific availability.** Every custom type's template *may* define `_decode_array` (a staticmethod that turns the raw array value into a `list[...]`); `EnumModule.dhall` defines it, `CompositeModule.dhall` does not. `Member.dhall` always emits `f"{typeName}._decode_array({src})"` for a 1-D custom-type array, regardless of kind. If that's ever generated against a composite, `basedpyright strict` (already gating `mise run test`, see `tests/golden/README.md`) fails on the missing attribute — a real check, just moved from `pgn generate` time to CI time. Composite-array *params* work the same way: encode always calls `f"{fieldName}._encode()"`; if `fieldName`'s inferred type is `list[Point2D]`, basedpyright flags the missing `._encode` on `list`. This is a genuine, if later, safety net — not a silent hole. The `"Absent"` case (a `customRef` name matching no generated module) degrades the same way: the emitted `from ..types. import ` fails as an unresolved import, also caught by basedpyright strict. +- **B — Preserve today's exact rejection.** Keep a minimal kind signal alive somewhere reachable without a name search — no such source was found during design (see the exploration notes below); the only ones available (project-wide `customTypes` list, or a `Scalar.Custom` contract change) reintroduce either the search or the upstream ask this plan is explicitly trying to avoid. Pick this only if lifting the composite-array restriction is unacceptable without a live Postgres verification first. + +This plan is written for **Option A**. Composite-array support was never tested (DESIGN.md and the fixture corpus have no composite-array column — verified via `grep -rn "^from \.\.types\." tests/golden/`, no file references the same custom type twice, and none of the fixture's composite columns are arrays), so Task 8 includes adding one to the fixture project specifically to exercise this path before it ships. If that Postgres test reveals composite-array decode genuinely doesn't work end-to-end (not just an assumption), fall back to Option B and keep the `"not supported"` report, sourced from a small dedicated check rather than full `buildLookup` (open a follow-up plan; don't block this one on it). + +--- + +## File Structure + +| File | Change | +|---|---| +| `src/Templates/CompositeModule.dhall` | Add `_decode` (staticmethod) and `_encode` (instance method) to the generated dataclass. | +| `src/Templates/EnumModule.dhall` | Add `_decode` and `_decode_array` (staticmethods) and `_encode` (instance method, identity) to the generated `StrEnum`. | +| `src/Interpreters/Member.dhall` | Custom branch calls `{typeName}._decode(...)` / `{typeName}._decode_array(...)` unconditionally; drop the `lookup` parameter and the `Enum`/`Composite`/`Absent` merge. | +| `src/Interpreters/ParamsMember.dhall` | Custom branch calls `{fieldName}._encode()` unconditionally; drop `lookup` and the merge. | +| `src/Interpreters/ResultColumns.dhall`, `src/Interpreters/Result.dhall`, `src/Interpreters/Query.dhall` | Drop the `lookup : CustomKind.Lookup` parameter they only thread through. | +| `src/Interpreters/CustomType.dhall` | Drop `nestedLookup` (nothing left to pass it to). | +| `src/Interpreters/Project.dhall` | Delete `buildLookup`, `IndexedCustomType`, `compositeFields`, `memberPyType`, `lookupConfig` — all exist only to feed `buildLookup`. | +| `src/Structures/CustomKind.dhall` | Delete the file; nothing imports it after the above. | +| `python.gen/DESIGN.md` | Rewrite section 12 (no longer an accepted risk — resolved) and section 13 (unchanged content, but cross-reference updates). | +| `python.gen/docs/upstream-asks.md` | Remove ask 3 (resolved without the upstream change) or mark it withdrawn. | +| `tests/golden/` | Regenerate via `mise run golden`. | + +--- + +### Task 1: Add `_decode`/`_encode` to `CompositeModule.dhall` + +**Files:** +- Modify: `src/Templates/CompositeModule.dhall` + +**Interfaces:** +- Consumes: same `Params = { typeName : Text, extraImports : List Text, fields : List Field }` as today, `Field = { fieldName : Text, fieldType : Text }` — no signature change. +- Produces: the rendered module text now defines `_decode(src: object) -> ""` (staticmethod) and `_encode(self) -> tuple[...]` (instance method) on the dataclass, callable by any generator downstream as `TypeName._decode(x)` / `value._encode()`. + +This was prototyped and verified against `dhall type` and `dhall text` (rendered output checked with `python3 -c "compile(...)"` for both a two-field and a one-field composite — the one-field case needs the trailing-comma tuple, same edge case `compositeBind` already handles today). + +- [ ] **Step 1: Replace the template body** + +```dhall +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 } +``` + +- [ ] **Step 2: Type-check** + +Run: `dhall type --file=src/Templates/CompositeModule.dhall` +Expected: prints the `{ Field : Type, Params : Type, Run : Type, run : ... }` signature, no error. + +- [ ] **Step 3: Spot-render and validate as Python** + +```bash +cat > /tmp/render_composite.dhall <<'EOF' +let CompositeModule = ./src/Templates/CompositeModule.dhall +in CompositeModule.run + { typeName = "Point2D" + , extraImports = [] : List Text + , fields = + [ { fieldName = "x", fieldType = "int" } + , { fieldName = "y", fieldType = "int" } + ] + } +EOF +dhall text --file=/tmp/render_composite.dhall | python3 -c "import sys; compile(sys.stdin.read(), 'point2d.py', 'exec')" && echo OK +``` +Expected: `OK`, and eyeballing the output shows `_decode`/`_encode` indented as class members (4 spaces), not module-level. + +- [ ] **Step 4: Commit** + +```bash +git add src/Templates/CompositeModule.dhall +git commit -m "python.gen: emit _decode/_encode on generated composite dataclasses" +``` + +--- + +### Task 2: Add `_decode`/`_decode_array`/`_encode` to `EnumModule.dhall` + +**Files:** +- Modify: `src/Templates/EnumModule.dhall` + +**Interfaces:** +- Consumes: same `Params = { typeName : Text, variants : List Variant }` — no signature change. +- Produces: `_decode(src: object) -> ""`, `_decode_array(src: object) -> list[""]`, `_encode(self) -> ""` (identity — psycopg binds the `StrEnum` instance directly, matching today's `defaultBind`). + +The scalar/array decode bodies are copied verbatim from `Member.dhall`'s current `enumDecode`/`enumArrayDecode` (Member.dhall:61-82), just moved from "Dhall builds inline Python text at every call site" to "Dhall builds it once, into the class." + +- [ ] **Step 1: Replace the template body** + +```dhall +let Prelude = ../Deps/Prelude.dhall + +let Sdk = ../Deps/Sdk.dhall + +let Variant = { memberName : Text, pgValue : Text } + +let Params = { typeName : Text, variants : List Variant } + +let run = + \(params : Params) -> + -- pgValue is interpolated into a single-line double-quoted Python literal; a + -- label may legally contain a backslash, quote, or control character, so + -- escape them to keep the literal valid and value-equal to the DB label. + -- Order is load-bearing: backslash first (so the escapes added below are not + -- re-escaped), then the control chars, then the closing quote. + let escapeLabel + : Text -> Text + = \(raw : Text) -> + Prelude.Function.composeList + Text + [ Prelude.Text.replace "\\" "\\\\" + , Prelude.Text.replace "\r" "\\r" + , Prelude.Text.replace "\n" "\\n" + , Prelude.Text.replace "\t" "\\t" + , Prelude.Text.replace "\"" "\\\"" + ] + raw + + let memberLines = + Prelude.Text.concatMapSep + "\n" + Variant + ( \(variant : Variant) -> + " ${variant.memberName} = \"${escapeLabel variant.pgValue}\"" + ) + params.variants + + let codecMethods = + "\n" + ++ " @staticmethod\n" + ++ " def _decode(src: object) -> \"${params.typeName}\":\n" + ++ " return ${params.typeName}(cast(str, src))\n" + ++ "\n" + ++ " @staticmethod\n" + ++ " def _decode_array(src: object) -> list[\"${params.typeName}\"]:\n" + ++ " return [\n" + ++ " ${params.typeName}(v)\n" + ++ " for v in cast(list[str], require_array(src))\n" + ++ " ]\n" + ++ "\n" + ++ " def _encode(self) -> \"${params.typeName}\":\n" + ++ " return self" + + in '' + from enum import StrEnum + from typing import cast + + from .._runtime import require_array + + + class ${params.typeName}(StrEnum): + ${memberLines} + ${codecMethods} + '' + +in Sdk.Sigs.template Params run /\ { Variant } +``` + +> `require_array` must already be importable from `.._runtime` relative to `types/.py` — confirm the relative import depth matches `types/`'s actual nesting (one level under the package root per `CustomType.dhall`'s `modulePath = "types/${moduleName}.py"`) before running Step 2; adjust to `.._runtime` vs `..._runtime` to match what `Member.dhall`'s current `enumArrayDecode` assumes at its own call sites (check `ImportSet.dhall`'s handling of `require_array` imports today for the exact existing relative path convention, since this moves an existing import from call sites into the shared module). + +- [ ] **Step 2: Type-check** + +Run: `dhall type --file=src/Templates/EnumModule.dhall` +Expected: signature prints, no error. + +- [ ] **Step 3: Spot-render and validate as Python** + +```bash +cat > /tmp/render_enum.dhall <<'EOF' +let EnumModule = ./src/Templates/EnumModule.dhall +in EnumModule.run + { typeName = "Mood" + , variants = + [ { memberName = "HAPPY", pgValue = "happy" } + , { memberName = "SAD", pgValue = "sad" } + ] + } +EOF +dhall text --file=/tmp/render_enum.dhall | python3 -c "import sys; compile(sys.stdin.read(), 'mood.py', 'exec')" && echo OK +``` +Expected: `OK`. + +- [ ] **Step 4: Commit** + +```bash +git add src/Templates/EnumModule.dhall +git commit -m "python.gen: emit _decode/_decode_array/_encode on generated enum classes" +``` + +--- + +### Task 3: Simplify `Member.dhall`'s Custom branch + +**Files:** +- Modify: `src/Interpreters/Member.dhall` + +**Interfaces:** +- Consumes: `value.scalar.customRef : Optional Model.Name` (unchanged — still comes straight off `Scalar.run`, see `src/Interpreters/Scalar.dhall:44-52`), `value.dims : Natural` (unchanged). +- Produces: `Run = Config -> Input -> Lude.Compiled.Type Output` — **drops the `CustomKind.Lookup` parameter**. Every caller (`ResultColumns.dhall`, `CustomType.dhall`) updates in Task 5/6. + +- [ ] **Step 1: Replace the Custom branch (Member.dhall:129-232) and the `Run` alias (Member.dhall:245)** + +Delete the `merge { Enum = ...; Composite = ...; Absent = ... } (lookup name)` block and the `CustomKind.CompositeField`-typed `compositeDecode` helper (lines 100-116, now dead — the same logic lives in `CompositeModule.dhall`'s `_decode` now). Replace with: + +```dhall + , 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 Lude.Compiled.ok + Output + ( mkOutput + (ImportSet.custom customImport) + ( wrapNullable + ( \(src : Text) -> + "${typeName}._decode_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" + ) +``` + +Note this drops the `Enum`/`Composite` import-set split (`ImportSet.customEnum`/`ImportSet.customComposite`) in favor of one `ImportSet.custom` call — see Doc 2 (`2026-07-11-encounter-order-custom-imports.md`) for why `ImportSet.customEnum`/`customComposite` collapse into a single `ImportSet.custom` once `order` no longer exists. **Land Doc 2 in the same branch as this task, or `dhall type` will fail here** — Task 3 depends on `ImportSet.CustomImport` no longer requiring a `dedupKey`/`order` field. + +- [ ] **Step 2: Drop the `lookup` parameter from `Run`** + +Change: +```dhall +let Run = Config -> CustomKind.Lookup -> Input -> Lude.Compiled.Type Output +``` +to: +```dhall +let Run = Config -> Input -> Lude.Compiled.Type Output +``` +and the `run` definition's `\(lookup : CustomKind.Lookup) ->` (Member.dhall:40) — delete that line entirely, since `run` no longer takes it. + +- [ ] **Step 3: Delete the now-dead `CustomKind` import (Member.dhall:9) and the dead `compositeDecode` helper (Member.dhall:100-116)** + +- [ ] **Step 4: Type-check** + +Run: `dhall type --file=src/Interpreters/Member.dhall` +Expected: fails until Task 5 updates `ResultColumns.dhall` (Member's only caller) to stop passing `lookup` — that's fine, type-check `Member.dhall` standalone by temporarily checking `Sdk.Sigs.interpreter Config Input Output run` in isolation, or proceed straight to Task 5 and type-check the pair together. Don't commit Task 3 alone; commit Tasks 3+5+6 together (they're one type-checking unit — Dhall won't let you land a signature change without updating every call site in the same change). + +--- + +### Task 4: Simplify `ParamsMember.dhall`'s Custom branch + +**Files:** +- Modify: `src/Interpreters/ParamsMember.dhall` + +**Interfaces:** +- Consumes: same as Task 3. +- Produces: `Run = Config -> Input -> Lude.Compiled.Type Output` — drops `CustomKind.Lookup`. + +- [ ] **Step 1: Replace the Custom branch (ParamsMember.dhall:311-367)** + +Delete `compositeBind` (lines 258-288, now dead — logic moved into `CompositeModule.dhall`'s `_encode`) and the `merge { Enum = ...; Composite = ...; Absent = ... } (lookup name)` block. Replace with: + +```dhall + in Prelude.Optional.fold + Model.Name + value.scalar.customRef + (Lude.Compiled.Type Output) + ( \(name : Model.Name) -> + let customImport = + { className = name.inPascalCase + , moduleName = name.inSnakeCase + } + + let encodeExpr = + if input.isNullable + then "None if ${fieldName} is None else ${fieldName}._encode()" + else "${fieldName}._encode()" + + in Lude.Compiled.ok + Output + ( mkOutput + (ImportSet.combine value.imports (ImportSet.custom customImport)) + encodeExpr + ) + ) + ( if isJsonArrayParam + then Lude.Compiled.report + Output + [ input.pgName ] + "json/jsonb array as a parameter is not supported" + else Lude.Compiled.ok + Output + (mkOutput value.imports defaultBind) + ) +``` + +This drops the `Natural/isZero value.dims` guard that used to reject composite-array params — per the Design decision above (Option A), an array-of-composite param now generates `{fieldName}._encode()` where `fieldName`'s type is `list[Point2D]`; `list` has no `._encode`, so `basedpyright strict` catches it. Confirm this in Task 8's basedpyright run specifically, not just eyeball it. + +- [ ] **Step 2: Drop the `lookup` parameter from `Run` (ParamsMember.dhall:387) and `run`'s `\(lookup : CustomKind.Lookup) ->` (ParamsMember.dhall:235)** + +- [ ] **Step 3: Delete the dead `CustomKind` import (ParamsMember.dhall:9)** + +- [ ] **Step 4: Type-check together with Task 3** + +Run: `dhall type --file=src/Interpreters/ParamsMember.dhall` +Expected: same caveat as Task 3 Step 4 — its caller (`Query.dhall`) still passes `lookup` until Task 5. + +--- + +### Task 5: Drop `lookup` threading from `ResultColumns.dhall`, `Result.dhall`, `Query.dhall` + +**Files:** +- Modify: `src/Interpreters/ResultColumns.dhall`, `src/Interpreters/Result.dhall`, `src/Interpreters/Query.dhall` + +**Interfaces:** +- Each of these only forwards `lookup` to a callee; none inspect it. Removing it is mechanical. + +- [ ] **Step 1: `ResultColumns.dhall`** — delete `\(lookup : CustomKind.Lookup) ->` (line 62) and change `Member.run config lookup member` (line 76) to `Member.run config member`. Delete the `CustomKind` import (line 9) if nothing else in the file uses it — verify with `grep -n CustomKind src/Interpreters/ResultColumns.dhall` after editing. + +- [ ] **Step 2: `Result.dhall`** — delete both `\(lookup : CustomKind.Lookup) ->` occurrences (lines 66, 93), change `ResultColumns.run config lookup rowClassName columns` (line 89) to `ResultColumns.run config rowClassName columns`, and `rowsOutput config lookup rowClassName` (line 100) to `rowsOutput config rowClassName` — check `rowsOutput`'s own definition for a `lookup` parameter to drop too (it wasn't in the earlier grep excerpt; read the file before editing to confirm). Delete the `CustomKind` import (line 9) if unused after. + +- [ ] **Step 3: `Query.dhall`** — delete `\(lookup : CustomKind.Lookup) ->` (line 140), change `ResultModule.run config lookup rowClassName input.result` (line 156) to drop `lookup`, and `ParamsMember.run config lookup member` (line 173) to `ParamsMember.run config member`. Delete the `CustomKind` import (line 7) if unused after. + +- [ ] **Step 4: Type-check each file standalone** + +Run: `dhall type --file=src/Interpreters/ResultColumns.dhall && dhall type --file=src/Interpreters/Result.dhall && dhall type --file=src/Interpreters/Query.dhall` +Expected: all three print their signatures. `Query.dhall` will still fail until `Project.dhall` (Task 6) stops passing `lookup` to `QueryGen.run` — that's the last link. + +--- + +### Task 6: Delete `buildLookup` and its support code from `Project.dhall` + +**Files:** +- Modify: `src/Interpreters/Project.dhall` + +- [ ] **Step 1: Delete dead helpers** + +Delete, in order (they only exist to feed `buildLookup`, confirmed by re-reading the file top to bottom — nothing else calls `lookupConfig`, `memberPyType`, `compositeFields`, `IndexedCustomType`, or `buildLookup` itself): +- `lookupConfig` (lines 74-80) +- `memberPyType` (lines 85-96) +- `compositeFields` (lines 98-108) +- The local `CompositeField = { fieldName : Text, pyType : Text }` (line 7) — dead once `compositeFields`/`memberPyType` are gone +- `IndexedCustomType` (line 142) +- `buildLookup` (lines 147-181) + +- [ ] **Step 2: Update the call site** + +Find `let lookup = buildLookup effectiveCustomTypes` (line 479) — delete it, and change both call sites that pass `lookup`: +- `QueryGen.run config lookup query` (line 503) → `QueryGen.run config query` +- `(\(query : Model.Query) -> QueryGen.run config lookup query)` (line 524) → `(\(query : Model.Query) -> QueryGen.run config query)` + +- [ ] **Step 3: Delete the dead `CustomKind` import (line 9)** + +- [ ] **Step 4: Type-check the whole chain** + +Run: `dhall type --file=src/Interpreters/Project.dhall` +Expected: prints the module signature with no error — this is the point where Tasks 3-6 all click together (Project → Query → Result/ParamsMember → ResultColumns/Member all now agree on the lookup-free signatures). + +- [ ] **Step 5: Commit Tasks 3-6 together** + +```bash +git add src/Interpreters/Member.dhall src/Interpreters/ParamsMember.dhall \ + src/Interpreters/ResultColumns.dhall src/Interpreters/Result.dhall \ + src/Interpreters/Query.dhall src/Interpreters/Project.dhall +git commit -m "python.gen: call custom-type codecs by name instead of resolving via buildLookup" +``` + +--- + +### Task 7: Drop `nestedLookup` from `CustomType.dhall`, delete `CustomKind.dhall` + +**Files:** +- Modify: `src/Interpreters/CustomType.dhall` +- Delete: `src/Structures/CustomKind.dhall` + +- [ ] **Step 1: `CustomType.dhall`** — delete `nestedLookup` (lines 51-53) and its comment, and change `MemberGen.run config nestedLookup m` (line 127) to `MemberGen.run config m`. Delete the `CustomKind` import (line 11). + +- [ ] **Step 2: Confirm nothing else references `CustomKind`** + +Run: `grep -rln "CustomKind" src` +Expected: no output. + +- [ ] **Step 3: Delete the file** + +```bash +git rm src/Structures/CustomKind.dhall +``` + +- [ ] **Step 4: Type-check the full package entry point** + +Run: `dhall type --file=src/package.dhall` +Expected: prints the top-level module signature, no error. (This is the first point a full-package check is meaningful — earlier steps only checked individual interpreter files.) + +- [ ] **Step 5: Commit** + +```bash +git add src/Interpreters/CustomType.dhall +git commit -m "python.gen: delete CustomKind.dhall, the last remnant of buildLookup" +``` + +--- + +### Task 8: Regenerate golden fixtures, verify, update docs + +**Files:** +- Modify: `tests/fixture-project/` (add a composite-array column per the Design decision above) +- Regenerate: `tests/golden/` +- Modify: `python.gen/DESIGN.md`, `python.gen/docs/upstream-asks.md`, `python.gen/CHANGELOG.md` + +- [ ] **Step 1: Add a composite-array test column to the fixture project** + +Find the fixture's composite-type column definitions under `tests/fixture-project/` (query/table SQL referencing a composite type, e.g. the `Point2D`-typed column used in `insert_specimen`/`get_specimen` per the golden output). Add one query or column that selects/inserts an *array* of that composite type — this is the first real exercise of the Option A fallback path (`basedpyright strict` should catch it if `_decode_array`/`_encode` aren't defined on `Point2D`, since Task 1 deliberately didn't add them to `CompositeModule.dhall`). + +Confirm the expected failure mode first: + +Run: `mise run golden` (needs `PGN_TEST_DATABASE_URL` pointing at a live Postgres — see `tests/golden/README.md`) +Expected: generation succeeds (no Dhall-level rejection — that's the point of Option A), but the regenerated file calls `Point2D._decode_array(...)` or `list[Point2D]._encode()`-shaped code that doesn't exist. + +Run: `mise run test` +Expected: `test_generated_passes_basedpyright_strict` FAILS, citing the missing attribute. This confirms the safety net from the Design decision actually fires. Once confirmed, either: + - revert the fixture addition (if you don't want composite arrays in the committed golden corpus yet), or + - implement `_decode_array`/an array-aware `_encode` on `CompositeModule.dhall` for real and keep the fixture (a follow-up, out of this plan's scope — flag it, don't scope-creep this task). + +- [ ] **Step 2: Remove the composite-array addition (unless implementing it for real per Step 1)** + +- [ ] **Step 3: Regenerate golden for real** + +Run: `mise run golden` +Expected: succeeds, rewrites `tests/golden/src/specimen_client/_generated/**` and both facades. + +- [ ] **Step 4: Review the diff** + +Run: `git diff tests/golden` +Expected: every composite/enum decode/encode call site now reads `TypeName._decode(...)`/`TypeName._decode_array(...)`/`value._encode()` instead of the old inlined `cast(tuple[...], ...)`/`(x.a, x.b)` expressions; `types/point_2_d.py`, `types/mood.py`, `types/tag_value.py` each gain the new methods. No unrelated files change. + +- [ ] **Step 5: Run the full test suite** + +Run: `mise run test` +Expected: all pass, including `test_generated_passes_basedpyright_strict`. + +- [ ] **Step 6: Update `DESIGN.md`** + +Rewrite section 12 (`## 12. Forked-Dhall (Text/equal) dependency risk`) — it's no longer an accepted risk for python.gen; state plainly that `buildLookup` is gone and `Text/equal` is no longer used anywhere in this generator's own Dhall source (grep to confirm: `grep -rn "Text/equal" src` returns nothing). Keep a short note that `demos/Exhaustive.dhall`/`mise run golden` still needs the pinned pgn binary regardless, because `gen-sdk`'s own `Fixtures` module independently uses the fork builtin — this plan doesn't touch that, and it isn't blocked on it. Cross-reference section 13 (unchanged — `PyIdent.dhall`'s trick is unrelated and still in place). + +- [ ] **Step 7: Update `docs/upstream-asks.md`** + +Remove ask 3 (`## 3. gen-sdk: kind tag or Natural index on Scalar.Custom`) or mark it explicitly withdrawn with one line explaining why (`buildLookup`'s only consumer was resolved locally by generating named codecs instead of resolving structural type info by search — see `docs/plans/2026-07-11-reusable-custom-type-codecs.md`). Don't delete the file's other two asks (pragma parsing, warnings printing) — they're unrelated and still open. + +- [ ] **Step 8: Update `CHANGELOG.md`** + +Add an entry under the appropriate section describing the behavior change from the Design decision (composite-array columns/params no longer rejected at generation time; verify at basedpyright-strict time instead) if Step 1's fixture addition was kept, or note it as a documented-but-untested path if reverted. + +- [ ] **Step 9: Commit** + +```bash +git add tests/golden tests/fixture-project python.gen/DESIGN.md python.gen/docs/upstream-asks.md python.gen/CHANGELOG.md +git commit -m "python.gen: regenerate golden fixtures for reusable custom-type codecs" +``` + +--- + +## Self-Review + +**Spec coverage:** Task 1-2 build the reusable codecs (the actual "fix the root cause"). Tasks 3-4 make the two reference sites (decode, encode) call them. Tasks 5-7 remove the now-dead plumbing (`lookup` threading, `buildLookup`, `CustomKind.dhall`) so nothing is left half-migrated. Task 8 proves it against the real toolchain and updates the two docs (`DESIGN.md`, `upstream-asks.md`) that currently assert this is unfixable — both would otherwise go stale and mislead the next reader. + +**Open question carried forward, not silently resolved:** the array-of-composite behavior change (Design decision, Option A vs B) is a real product decision, flagged explicitly rather than picked unilaterally in the diff. Task 8 Step 1 is designed to surface the actual runtime behavior (does `basedpyright strict` really catch it, does composite-array decode actually work against real Postgres) before committing to either branch. + +**Dependency on the companion plan:** Task 3 explicitly calls out that it needs `docs/plans/2026-07-11-encounter-order-custom-imports.md` (`ImportSet.dhall`'s `order`/`dedupKey` removal) landed first or alongside — `ImportSet.customEnum`/`customComposite` currently *require* an `order : Natural` that only `buildLookup` produced. Implement that plan first, or fold both into one PR; don't land this plan's Task 3 against the unmodified `ImportSet.dhall`. + +## Execution Handoff + +Plan complete and saved to `python.gen/docs/plans/2026-07-11-reusable-custom-type-codecs.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach? 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/src/Config.dhall b/src/Config.dhall deleted file mode 100644 index 1377127..0000000 --- a/src/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; Interpret.dhall supplies the --- defaults. -let OnUnsupported = ./Structures/OnUnsupported.dhall - -in { packageName : Optional Text - , emitSync : Optional Bool - , onUnsupported : Optional OnUnsupported.Mode - } : Type diff --git a/src/Deps/Lude.dhall b/src/Deps/Lude.dhall index 30f1f5a..a4b5116 100644 --- a/src/Deps/Lude.dhall +++ b/src/Deps/Lude.dhall @@ -1,3 +1,2 @@ -https://raw.githubusercontent.com/codemine-io/lude.dhall/v5.1.0/src/package.dhall - sha256:46b527b071eba96a17e76b4bc5774645714dd5b4355974d221e705aa7c126e77 - as Source +https://raw.githubusercontent.com/codemine-io/lude.dhall/v5.2.0/src/package.dhall + sha256:b04ff9a38c8be087dfacfe76d057fc5272afec60cac28c9e6c1b78cade7ff6ef diff --git a/src/Deps/Prelude.dhall b/src/Deps/Prelude.dhall index 54107fc..76e2a7a 100644 --- a/src/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 index 5602c43..18f9fce 100644 --- a/src/Deps/Sdk.dhall +++ b/src/Deps/Sdk.dhall @@ -1,3 +1,2 @@ https://raw.githubusercontent.com/pgenie-io/gen-sdk/v2.0.0/src/package.dhall sha256:b9def6ab1179bc4aaae7fc6e91977f094f75934cd5755175c294a9e97ca71b15 - as Source diff --git a/src/Interpret.dhall b/src/Interpret.dhall deleted file mode 100644 index de4642f..0000000 --- a/src/Interpret.dhall +++ /dev/null @@ -1,54 +0,0 @@ -let Contract = ./Deps/Contract.dhall - -let Prelude = ./Deps/Prelude.dhall - -let Config = ./Config.dhall - -let OnUnsupported = ./Structures/OnUnsupported.dhall - -let ProjectInterpreter = ./Interpreters/Project.dhall - --- Entry point handed to gen-sdk's Sdk.Sigs.generator as `interpret`. Each --- field of Config is independently Optional, so a project may omit the --- whole config block (Sdk.Sigs.generator substitutes an all-None --- defaultConfig, see package.dhall) 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). The async surface is always --- emitted; emitSync adds the sync mirror. -in \(config : Config) -> - \(project : Contract.Project) -> - let defaults = - { packageName = project.name.inKebabCase - , emitSync = False - , onUnsupported = OnUnsupported.Mode.Fail - } - - let packageName = - Prelude.Optional.fold - Text - config.packageName - Text - (\(t : Text) -> t) - defaults.packageName - - let emitSync = - Prelude.Optional.fold - Bool - config.emitSync - Bool - (\(b : Bool) -> b) - defaults.emitSync - - let onUnsupported = - Prelude.Optional.fold - OnUnsupported.Mode - config.onUnsupported - OnUnsupported.Mode - (\(m : OnUnsupported.Mode) -> m) - defaults.onUnsupported - - let importName = Prelude.Text.replace "-" "_" packageName - - let interpreterConfig = { packageName, importName, emitSync, onUnsupported } - - in ProjectInterpreter.run interpreterConfig project diff --git a/src/Interpreters/CustomType.dhall b/src/Interpreters/CustomType.dhall index 676f9ab..abdf94f 100644 --- a/src/Interpreters/CustomType.dhall +++ b/src/Interpreters/CustomType.dhall @@ -8,8 +8,6 @@ let Sdk = ../Deps/Sdk.dhall let ImportSet = ../Structures/ImportSet.dhall -let CustomKind = ../Structures/CustomKind.dhall - let OnUnsupported = ../Structures/OnUnsupported.dhall let MemberGen = ./Member.dhall @@ -44,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). @@ -123,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 = diff --git a/src/Interpreters/Member.dhall b/src/Interpreters/Member.dhall index 8d2d27c..c4e8273 100644 --- a/src/Interpreters/Member.dhall +++ b/src/Interpreters/Member.dhall @@ -4,9 +4,9 @@ let Prelude = ../Deps/Prelude.dhall let Model = ../Deps/Contract.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 @@ -37,7 +37,6 @@ let Output = let run = \(config : 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 @@ -58,63 +57,6 @@ let run = 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 = - 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 @@ -135,11 +77,7 @@ let run = let typeName = name.inPascalCase let customImport = - \(order : Natural) -> - { className = typeName - , moduleName = name.inSnakeCase - , order - } + { className = typeName, moduleName = name.inSnakeCase } let mkOutput = \(customImports : ImportSet.Type) -> @@ -149,81 +87,57 @@ let run = , pyType , isNullable = input.isNullable , imports = - ImportSet.combine - baseImports - customImports + 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 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 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 + 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 a composite type is not supported (element-wise decode is unimplemented)" - , Absent = - Lude.Compiled.report + ) + else Lude.Compiled.report Output - [ name.inSnakeCase ] - "Custom type not found in project customTypes" - } - (lookup name) + [ input.pgName, name.inSnakeCase ] + "Array of dimensionality > 1 is not supported" ) ( Lude.Compiled.report Output @@ -242,6 +156,4 @@ let run = in Lude.Compiled.flatMap Value.Output Output buildOutput compiledValue -let Run = Config -> CustomKind.Lookup -> Input -> Lude.Compiled.Type Output - -in { Input, Output, Run, run } +in Sdk.Sigs.interpreter Config Input Output run diff --git a/src/Interpreters/ParamsMember.dhall b/src/Interpreters/ParamsMember.dhall index 2a4b469..457631d 100644 --- a/src/Interpreters/ParamsMember.dhall +++ b/src/Interpreters/ParamsMember.dhall @@ -4,9 +4,9 @@ let Prelude = ../Deps/Prelude.dhall let Model = ../Deps/Contract.dhall -let ImportSet = ../Structures/ImportSet.dhall +let Sdk = ../Deps/Sdk.dhall -let CustomKind = ../Structures/CustomKind.dhall +let ImportSet = ../Structures/ImportSet.dhall let OnUnsupported = ../Structures/OnUnsupported.dhall @@ -232,7 +232,6 @@ let isJsonArray = let run = \(config : Config) -> - \(lookup : CustomKind.Lookup) -> \(input : Input) -> let fieldName = pySafeName input.name.inSnakeCase @@ -253,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 = - 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 Prelude.Natural.equal - ( 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 = @@ -314,56 +279,36 @@ let run = (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 @@ -384,6 +329,4 @@ let run = in Lude.Compiled.flatMap Value.Output Output buildOutput compiledValue -let Run = Config -> CustomKind.Lookup -> Input -> Lude.Compiled.Type Output - -in { Input, Output, Run, run } +in Sdk.Sigs.interpreter Config Input Output run diff --git a/src/Interpreters/Project.dhall b/src/Interpreters/Project.dhall index d4fa6d6..f142a99 100644 --- a/src/Interpreters/Project.dhall +++ b/src/Interpreters/Project.dhall @@ -6,12 +6,6 @@ let Model = ../Deps/Contract.dhall let Sdk = ../Deps/Sdk.dhall -let CustomKind = ../Structures/CustomKind.dhall - -let PyIdent = ../Structures/PyIdent.dhall - -let Value = ./Value.dhall - let QueryGen = ./Query.dhall let CustomTypeGen = ./CustomType.dhall @@ -38,7 +32,20 @@ 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 @@ -69,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 - : 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 @@ -136,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 : 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 @@ -423,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 : 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 @@ -448,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 @@ -463,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 @@ -476,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 @@ -485,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 @@ -500,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 @@ -521,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 @@ -541,7 +501,7 @@ let run = (List QueryGen.Output) (List CustomTypeGen.Output) Output - (combineOutputs config input) + (combineOutputs resolvedConfig input) queriesForCombine typesForCombine diff --git a/src/Interpreters/Query.dhall b/src/Interpreters/Query.dhall index cce3fcd..b3e86cd 100644 --- a/src/Interpreters/Query.dhall +++ b/src/Interpreters/Query.dhall @@ -2,9 +2,9 @@ let Prelude = ../Deps/Prelude.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 @@ -137,7 +137,6 @@ let render = let run = \(config : Config) -> - \(lookup : CustomKind.Lookup) -> \(input : Input) -> let rowClassName = input.name.inPascalCase ++ "Row" @@ -153,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 @@ -170,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/src/Interpreters/Result.dhall b/src/Interpreters/Result.dhall index 5d652a0..b20190d 100644 --- a/src/Interpreters/Result.dhall +++ b/src/Interpreters/Result.dhall @@ -4,9 +4,9 @@ let Lude = ../Deps/Lude.dhall let Model = ../Deps/Contract.dhall -let ImportSet = ../Structures/ImportSet.dhall +let Sdk = ../Deps/Sdk.dhall -let CustomKind = ../Structures/CustomKind.dhall +let ImportSet = ../Structures/ImportSet.dhall let OnUnsupported = ../Structures/OnUnsupported.dhall @@ -14,11 +14,17 @@ let ResultColumns = ./ResultColumns.dhall let Compiled = Lude.Compiled +-- 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 @@ -63,10 +69,8 @@ let cardinalityShape let rowsOutput = \(config : Config) -> - \(lookup : CustomKind.Lookup) -> - \(rowClassName : Text) -> \(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 @@ -78,7 +82,7 @@ let rowsOutput = { returnType = shape.returnType , helperName = shape.helperName , rowClass = Some - { name = rowClassName + { name = config.rowClassName , fieldsBlock = cols.fieldsBlock , decodeBlock = cols.decodeBlock } @@ -86,19 +90,20 @@ let rowsOutput = , callsDecode = True } ) - (ResultColumns.run config lookup rowClassName columns) + ( ResultColumns.run + config.{ packageName, importName, emitSync, onUnsupported } + columns + ) let run = \(config : Config) -> - \(lookup : CustomKind.Lookup) -> - \(rowClassName : Text) -> \(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/src/Interpreters/ResultColumns.dhall b/src/Interpreters/ResultColumns.dhall index e0bbfaa..6a589a2 100644 --- a/src/Interpreters/ResultColumns.dhall +++ b/src/Interpreters/ResultColumns.dhall @@ -4,9 +4,9 @@ let Lude = ../Deps/Lude.dhall let Model = ../Deps/Contract.dhall -let ImportSet = ../Structures/ImportSet.dhall +let Sdk = ../Deps/Sdk.dhall -let CustomKind = ../Structures/CustomKind.dhall +let ImportSet = ../Structures/ImportSet.dhall let OnUnsupported = ../Structures/OnUnsupported.dhall @@ -59,8 +59,6 @@ let assemble let run = \(config : Config) -> - \(lookup : CustomKind.Lookup) -> - \(rowClassName : Text) -> \(input : Input) -> Compiled.map (List Member.Output) @@ -73,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/src/Structures/CustomKind.dhall b/src/Structures/CustomKind.dhall deleted file mode 100644 index f2c4a1b..0000000 --- a/src/Structures/CustomKind.dhall +++ /dev/null @@ -1,28 +0,0 @@ -let Model = ../Deps/Contract.dhall - --- 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/src/Structures/ImportSet.dhall b/src/Structures/ImportSet.dhall index 04fc239..b5c7b81 100644 --- a/src/Structures/ImportSet.dhall +++ b/src/Structures/ImportSet.dhall @@ -1,11 +1,16 @@ 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 } +-- 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 @@ -83,97 +88,6 @@ 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) -> @@ -187,17 +101,13 @@ let combine = , json = left.json || right.json , jsonValue = left.jsonValue || right.jsonValue , enumArray = left.enumArray || right.enumArray - , customTypes = dedupCustoms (left.customTypes # right.customTypes) + , customTypes = 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 @@ -212,9 +122,6 @@ in { Type = Self , jsonValue , enumArray , custom - , customEnum - , customComposite , combine , combineAll - , sortedCustoms } diff --git a/src/Templates/CompositeModule.dhall b/src/Templates/CompositeModule.dhall index b1ab3e0..32c4ee5 100644 --- a/src/Templates/CompositeModule.dhall +++ b/src/Templates/CompositeModule.dhall @@ -20,11 +20,50 @@ let run = ) 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" + then "from dataclasses import dataclass\nfrom typing import cast" else '' from dataclasses import dataclass + from typing import cast ${Prelude.Text.concatSep "\n" params.extraImports}'' @@ -41,6 +80,7 @@ let run = """ ${fieldLines} + ${codecMethods} '' in Sdk.Sigs.template Params run /\ { Field } diff --git a/src/Templates/EnumModule.dhall b/src/Templates/EnumModule.dhall index 1ebf486..04c4bfe 100644 --- a/src/Templates/EnumModule.dhall +++ b/src/Templates/EnumModule.dhall @@ -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 Sdk.Sigs.template Params run /\ { Variant } diff --git a/src/Templates/RowsModule.dhall b/src/Templates/RowsModule.dhall index 18da31b..b1d5b13 100644 --- a/src/Templates/RowsModule.dhall +++ b/src/Templates/RowsModule.dhall @@ -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 diff --git a/src/Templates/StatementModule.dhall b/src/Templates/StatementModule.dhall index f2a6f61..325e21f 100644 --- a/src/Templates/StatementModule.dhall +++ b/src/Templates/StatementModule.dhall @@ -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 diff --git a/src/package.dhall b/src/package.dhall index 3d07cf7..5161367 100644 --- a/src/package.dhall +++ b/src/package.dhall @@ -2,7 +2,22 @@ let Sdk = ./Deps/Sdk.dhall let OnUnsupported = ./Structures/OnUnsupported.dhall -let Config = ./Config.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 @@ -11,6 +26,4 @@ let Config/default , onUnsupported = None OnUnsupported.Mode } -let interpret = ./Interpret.dhall - -in Sdk.Sigs.generator Config Config/default interpret +in Sdk.Sigs.generator Config Config/default ProjectInterpreter.run 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() From 76255508f57f17230e1a8f153810ca234db3582b Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sun, 12 Jul 2026 06:29:44 +0300 Subject: [PATCH 11/41] Drop the plans --- ...26-07-11-encounter-order-custom-imports.md | 327 --------- .../2026-07-11-reusable-custom-type-codecs.md | 633 ------------------ 2 files changed, 960 deletions(-) delete mode 100644 docs/plans/2026-07-11-encounter-order-custom-imports.md delete mode 100644 docs/plans/2026-07-11-reusable-custom-type-codecs.md diff --git a/docs/plans/2026-07-11-encounter-order-custom-imports.md b/docs/plans/2026-07-11-encounter-order-custom-imports.md deleted file mode 100644 index 7d1c663..0000000 --- a/docs/plans/2026-07-11-encounter-order-custom-imports.md +++ /dev/null @@ -1,327 +0,0 @@ -# Encounter-Order Custom-Type Imports Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Stop requiring a project-wide `order : Natural` to sort and dedupe custom-type import lines in `ImportSet.dhall`. Emit them in encounter order (the order the referencing columns/params are declared) instead, and accept that de-duplicating two references to the *same* custom type within one file is not achievable in vanilla Dhall — verify it isn't actually needed by the current corpus, and document the tradeoff rather than reintroduing a lookup to avoid it. - -**Architecture:** `ImportSet.dhall` today dedupes and alphabetizes custom-type imports by carrying a `dedupKey : Natural` — the type's alphabetical index in `project.customTypes` — through every `CustomImport` value, specifically because Dhall has no `Text` comparison to sort or dedupe on `moduleName`/`className` directly (see the file's own header comment). That `order` value's only source was `buildLookup` (`Interpreters/Project.dhall`), which the companion plan (`2026-07-11-reusable-custom-type-codecs.md`) deletes. Once it's gone, `ImportSet.dhall` has nothing to key on. Rather than re-deriving a Natural surrogate some other way, this plan removes the sort/dedup step and lets `ImportSet.combine`'s existing `List/fold` order (already the query's declared column/param order — a real, already-computed, non-Text-comparison ordering) stand as the output order. - -**Tech Stack:** Dhall (dhall-lang 1.42), Python 3.12 generated output, pytest golden-file harness (`mise run test`, `mise run golden`). - -## Global Constraints - -- **Depends on** `docs/plans/2026-07-11-reusable-custom-type-codecs.md` — that plan's Task 3/4 call `ImportSet.custom customImport` (no `order` argument). Land this plan's Task 1 first, or in the same PR; `ImportSet.customEnum`/`customComposite` (which this plan deletes) are exactly what those tasks stop calling. -- No behavior change to the four already-Natural-keyed stdlib import flags (`uuid`, `datetime`, `date`, `time`, `timedelta`, `decimal`, `jsonb`, `json`, `jsonValue`, `enumArray`) — those are plain `Bool` OR's today and are untouched by this plan. -- Golden fixture output must be regenerated and diffed (`tests/golden/`), not hand-edited. -- Every Dhall file touched must independently type-check: `dhall type --file=`. - ---- - -## Why dedup can't be preserved without reintroducing a lookup - -Worth writing down since it's not obvious and the temptation to "just find a clever `Text/replace` trick" is real — this was checked directly against `PyIdent.dhall`'s working pattern (DESIGN.md section 13) before concluding it doesn't generalize: - -`PyIdent.dhall`'s `sanitizeAgainst` tests a runtime `Text` against a small, **fixed, compile-time-literal** candidate list (the 35 Python keywords) — one known literal at a time, folded. That's why the two-`Text/replace` trick works: one side of every comparison is always a literal. - -Import dedup needs the opposite shape: is this **runtime** `moduleName` (derived from a `Name` that varies per project) equal to any of the **other runtime** `moduleName`s already collected? Both sides are dynamic. No sequence of `Text/replace` calls can decide that, because deciding it requires producing a `Bool` from two non-literal `Text` values, which is exactly the operation Dhall doesn't have (and which pgn's forked `Text/equal` exists to provide). This isn't a missing trick — dedup of dynamically-computed `Text` is unconditionally impossible in vanilla Dhall. The only ways to get it back are: (a) a fork builtin (what we're removing), (b) a pre-assigned `Natural` id per distinct value (what `order` was — sourced from a project-wide search, i.e. `buildLookup`, also being removed), or (c) don't need it. - -This plan takes (c), having checked how much it costs to: - -```bash -grep -rl "^from \.\.types\." tests/golden/src 2>/dev/null | while read f; do - n=$(grep -c "^from \.\.types\." "$f") - [ "$n" -gt 1 ] && echo "$f: $n" -done -``` - -No output — verified during design (see conversation record / re-run before Task 2 to confirm it's still true after Doc 1's regeneration). No file in the current fixture corpus imports the same custom type twice. The risk is real but currently unexercised: if a future query selects the same composite/enum type via two different columns, the generated file will contain two identical `from ..types.X import Y` lines — syntactically valid, harmless to `basedpyright strict` and to Python's import system, just visually redundant. Task 2 adds a corpus case that exercises this on purpose so the tradeoff is documented against real output, not just asserted. - ---- - -## File Structure - -| File | Change | -|---|---| -| `src/Structures/ImportSet.dhall` | Drop `dedupKey` from `CustomImport`; delete `dedupCustoms`, `sortCustoms`, `eqNat`, `leNat`, `sortedCustoms`; collapse `customEnum`/`customComposite` into the existing `custom`; `combine` plain-concatenates `customTypes` instead of deduping. | -| `src/Templates/RowsModule.dhall`, `src/Templates/StatementModule.dhall` | Read `imports.customTypes` directly instead of `ImportSet.sortedCustoms imports`. | -| `tests/golden/` | Regenerate via `mise run golden`; review the (likely negligible) reordering of custom-type import lines from alphabetical to declaration order. | -| `python.gen/DESIGN.md` | Note the ordering change where section 12/13 currently describe the alphabetical-by-`order` scheme. | - ---- - -### Task 1: Simplify `ImportSet.dhall` - -**Files:** -- Modify: `src/Structures/ImportSet.dhall` - -**Interfaces:** -- `CustomImport` loses `dedupKey : Natural` → becomes `{ className : Text, moduleName : Text }`. -- `custom : CustomImport -> Self` — unchanged signature, now the only constructor (no more `customEnum`/`customComposite`). -- `combine : Self -> Self -> Self` — `customTypes` field is now a plain list concatenation. -- `sortedCustoms` is deleted. Callers read `.customTypes` directly. - -- [ ] **Step 1: Replace the file** - -```dhall -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 - } -``` - -- [ ] **Step 2: Type-check** - -Run: `dhall type --file=src/Structures/ImportSet.dhall` -Expected: prints the record-of-functions signature, no error. - -- [ ] **Step 3: Commit** - -```bash -git add src/Structures/ImportSet.dhall -git commit -m "python.gen: drop order-based sort/dedup from ImportSet, use encounter order" -``` - ---- - -### Task 2: Update the two render call sites - -**Files:** -- Modify: `src/Templates/RowsModule.dhall`, `src/Templates/StatementModule.dhall` - -- [ ] **Step 1: `RowsModule.dhall:58`** - -Change: -```dhall - (ImportSet.sortedCustoms imports) -``` -to: -```dhall - imports.customTypes -``` - -- [ ] **Step 2: `StatementModule.dhall:82`** - -Same change: -```dhall - imports.customTypes -``` - -- [ ] **Step 3: Type-check both** - -Run: `dhall type --file=src/Templates/RowsModule.dhall && dhall type --file=src/Templates/StatementModule.dhall` -Expected: both print their signatures, no error. - -- [ ] **Step 4: Commit** - -```bash -git add src/Templates/RowsModule.dhall src/Templates/StatementModule.dhall -git commit -m "python.gen: render custom-type imports in encounter order" -``` - ---- - -### Task 3: Regenerate golden fixtures and verify the dedup gap directly - -**Files:** -- Modify: `tests/fixture-project/` (temporary, to exercise the dedup gap — see Step 1) -- Regenerate: `tests/golden/` - -- [ ] **Step 1: Confirm today's corpus has no same-type-twice case** - -Run: -```bash -grep -rl "^from \.\.types\." tests/golden/src 2>/dev/null | while read f; do - n=$(grep -c "^from \.\.types\." "$f") - [ "$n" -gt 1 ] && echo "$f: $n" -done -``` -Expected: no output (re-confirms the design-time check above, against the *current* golden tree before this plan's regeneration). - -- [ ] **Step 2: Regenerate golden** - -Run: `mise run golden` -Expected: succeeds. `git diff tests/golden` shows custom-type import lines reordered from alphabetical to declaration order in files with 2+ distinct custom-type imports (e.g. wherever `Mood` and `Point2D` are both imported today — check whether the query's own column order already happens to be alphabetical for that file; if so the diff is empty there and this is confirmed low-risk for the current corpus). - -- [ ] **Step 3: Deliberately add a same-type-twice column to the fixture project** - -Add a query (or extend an existing one) whose result row or param list references the *same* composite or enum type through two different columns/params — e.g. two `mood`-typed columns in one query. This is new fixture surface, not present today; add it under `tests/fixture-project/queries/`. - -Run: `mise run golden` -Expected: succeeds; the regenerated file for that query contains **two** identical `from ..types.mood import Mood` lines (confirm with `grep -c` on the specific file). This is the one visible, accepted consequence of this plan — capture it in the diff review, don't silently let it slip into `tests/golden` unremarked. - -- [ ] **Step 4: Decide whether to keep the same-type-twice fixture case** - -Keeping it in the committed corpus makes the tradeoff a permanent, visible regression test (future readers see the duplicate import and the comment in `ImportSet.dhall` explaining it, instead of being surprised by it later). Removing it keeps the golden diff minimal for this change. Either is fine — this plan recommends **keeping it**, since an accepted-but-invisible tradeoff tends to resurface as a bug report; make the call and note it in the commit message either way. - -- [ ] **Step 5: Run the full test suite** - -Run: `mise run test` -Expected: all pass. Duplicate import lines don't fail `basedpyright strict` (Python tolerates redundant imports) or the golden byte-comparison (it compares against the freshly-committed golden, not some independent expectation). - -- [ ] **Step 6: Commit** - -```bash -git add tests/golden tests/fixture-project -git commit -m "python.gen: regenerate golden fixtures for encounter-order imports" -``` - ---- - -### Task 4: Update `DESIGN.md` - -**Files:** -- Modify: `python.gen/DESIGN.md` - -- [ ] **Step 1: Update section 12/13 cross-references** - -Wherever DESIGN.md currently describes `order`/alphabetical import sorting (it's referenced in passing around sections 12-13 and in code comments already updated by `docs/plans/2026-07-11-reusable-custom-type-codecs.md`'s Task 8), add a short note: custom-type imports are emitted in encounter (declaration) order, not sorted, since the `order` Natural no longer exists once `buildLookup` is gone; same-type-twice references are not deduped (a Dhall limitation, not an oversight — see `src/Structures/ImportSet.dhall`'s header comment for the full reasoning). Point at the fixture case from Task 3 if kept. - -- [ ] **Step 2: Commit** - -```bash -git add python.gen/DESIGN.md -git commit -m "python.gen: document encounter-order custom-type imports in DESIGN.md" -``` - ---- - -## Self-Review - -**Spec coverage:** Task 1 is the actual mechanism change (drop the Natural key, drop sort/dedup). Task 2 fixes the two render call sites that would otherwise reference a deleted `sortedCustoms`. Task 3 regenerates and — importantly — deliberately exercises the one behavior change (duplicate imports for a same-type-twice reference) instead of letting it go unverified. Task 4 keeps DESIGN.md truthful. - -**Placeholder scan:** no TBDs; every step names an exact file, an exact diff, or an exact command with expected output. - -**Dependency on the companion plan:** called out at the top (Global Constraints) and repeated in the companion plan's own self-review — `docs/plans/2026-07-11-reusable-custom-type-codecs.md` Task 3/4 call `ImportSet.custom` with no `order` argument, which only type-checks after this plan's Task 1. Sequence: this plan's Task 1 → companion plan's Tasks 3-6 → this plan's Tasks 2-4 (Task 2 touches templates the companion plan doesn't touch, so it can land anytime after Task 1, but golden regeneration in either plan's Task 8/3 should happen once, after both are code-complete, not twice). - -## Execution Handoff - -Plan complete and saved to `python.gen/docs/plans/2026-07-11-encounter-order-custom-imports.md`. Two execution options: - -**1. Subagent-Driven (recommended)** - dispatch a fresh subagent per task, review between tasks, fast iteration - -**2. Inline Execution** - execute tasks in this session using executing-plans, batch execution with checkpoints - -Which approach? diff --git a/docs/plans/2026-07-11-reusable-custom-type-codecs.md b/docs/plans/2026-07-11-reusable-custom-type-codecs.md deleted file mode 100644 index b2f880c..0000000 --- a/docs/plans/2026-07-11-reusable-custom-type-codecs.md +++ /dev/null @@ -1,633 +0,0 @@ -# Reusable Custom-Type Codecs Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Delete `buildLookup`/`CustomKind.Lookup` — and with it python.gen's last dependency on pgn's forked `Text/equal` builtin (DESIGN.md section 12) — by generating one `_decode`/`_encode` codec per custom type in `types/.py`, called by name from every reference site, instead of re-deriving decode/encode logic (field types, Composite-vs-Enum classification) at each call site. - -**Architecture:** `CustomType.dhall` already visits every custom type once with its full definition in hand (composite fields or enum variants) and emits `types/.py`. Today it stops at the dataclass/enum class. This plan makes it also emit a `_decode` staticmethod (and, for composites, an `_encode` instance method) on that same class. Every column/param reference site (`Member.dhall`, `ParamsMember.dhall`) currently has to search `project.customTypes` by name to find out whether it's looking at a composite or an enum, because it re-derives the decode/encode expression inline. Once decode/encode is a named method reachable from the `Name` the reference site already carries (`name.inPascalCase`), the reference site just calls it — no search, no classification, no `Text/equal`. - -**Tech Stack:** Dhall (dhall-lang 1.42 vendored via `dhall` CLI; pgn's forked interpreter for the parts still using it), Python 3.12 generated output (psycopg3), basedpyright strict, pytest golden-file harness (`mise run test`, `mise run golden`). - -## Global Constraints - -- No behavior change to non-custom-type (primitive) decode/encode paths. -- Golden fixture output (`tests/golden/`) must be regenerated and diffed, not hand-edited (per `tests/golden/README.md`). -- `mise run test` (pytest, includes `test_generated_passes_basedpyright_strict`) must pass after regeneration. -- Every Dhall file touched must independently type-check: `dhall type --file=`. -- Don't touch `Primitive.dhall`, `Scalar.dhall`, `Value.dhall`, or the `Model`/`Contract` dependency — this plan is scoped to what python.gen can do unilaterally, with the current, unmodified `Scalar = < Primitive : Primitive | Custom : Name >` contract. - ---- - -## Design decision needed before Task 3: array-of-custom-type behavior - -Today, `Member.dhall`'s Custom branch treats arrays differently by kind, because it already knows the kind from `lookup name`: -- **Enum arrays (1-D):** supported — `enumArrayDecode` emits an element-wise list comprehension. -- **Composite arrays:** rejected at generation time — `"Array of a composite type is not supported (element-wise decode is unimplemented)"` (Member.dhall:219) and, for params, `"Array of a composite type as a parameter is not supported"` (ParamsMember.dhall:359). - -Once decode/encode become named methods called unconditionally (no classification at the call site), there's no Dhall-level way to keep rejecting *only* composite arrays without reintroducing some form of lookup. Two ways forward: - -- **A — Recommended: uniform array codec, kind-specific availability.** Every custom type's template *may* define `_decode_array` (a staticmethod that turns the raw array value into a `list[...]`); `EnumModule.dhall` defines it, `CompositeModule.dhall` does not. `Member.dhall` always emits `f"{typeName}._decode_array({src})"` for a 1-D custom-type array, regardless of kind. If that's ever generated against a composite, `basedpyright strict` (already gating `mise run test`, see `tests/golden/README.md`) fails on the missing attribute — a real check, just moved from `pgn generate` time to CI time. Composite-array *params* work the same way: encode always calls `f"{fieldName}._encode()"`; if `fieldName`'s inferred type is `list[Point2D]`, basedpyright flags the missing `._encode` on `list`. This is a genuine, if later, safety net — not a silent hole. The `"Absent"` case (a `customRef` name matching no generated module) degrades the same way: the emitted `from ..types. import ` fails as an unresolved import, also caught by basedpyright strict. -- **B — Preserve today's exact rejection.** Keep a minimal kind signal alive somewhere reachable without a name search — no such source was found during design (see the exploration notes below); the only ones available (project-wide `customTypes` list, or a `Scalar.Custom` contract change) reintroduce either the search or the upstream ask this plan is explicitly trying to avoid. Pick this only if lifting the composite-array restriction is unacceptable without a live Postgres verification first. - -This plan is written for **Option A**. Composite-array support was never tested (DESIGN.md and the fixture corpus have no composite-array column — verified via `grep -rn "^from \.\.types\." tests/golden/`, no file references the same custom type twice, and none of the fixture's composite columns are arrays), so Task 8 includes adding one to the fixture project specifically to exercise this path before it ships. If that Postgres test reveals composite-array decode genuinely doesn't work end-to-end (not just an assumption), fall back to Option B and keep the `"not supported"` report, sourced from a small dedicated check rather than full `buildLookup` (open a follow-up plan; don't block this one on it). - ---- - -## File Structure - -| File | Change | -|---|---| -| `src/Templates/CompositeModule.dhall` | Add `_decode` (staticmethod) and `_encode` (instance method) to the generated dataclass. | -| `src/Templates/EnumModule.dhall` | Add `_decode` and `_decode_array` (staticmethods) and `_encode` (instance method, identity) to the generated `StrEnum`. | -| `src/Interpreters/Member.dhall` | Custom branch calls `{typeName}._decode(...)` / `{typeName}._decode_array(...)` unconditionally; drop the `lookup` parameter and the `Enum`/`Composite`/`Absent` merge. | -| `src/Interpreters/ParamsMember.dhall` | Custom branch calls `{fieldName}._encode()` unconditionally; drop `lookup` and the merge. | -| `src/Interpreters/ResultColumns.dhall`, `src/Interpreters/Result.dhall`, `src/Interpreters/Query.dhall` | Drop the `lookup : CustomKind.Lookup` parameter they only thread through. | -| `src/Interpreters/CustomType.dhall` | Drop `nestedLookup` (nothing left to pass it to). | -| `src/Interpreters/Project.dhall` | Delete `buildLookup`, `IndexedCustomType`, `compositeFields`, `memberPyType`, `lookupConfig` — all exist only to feed `buildLookup`. | -| `src/Structures/CustomKind.dhall` | Delete the file; nothing imports it after the above. | -| `python.gen/DESIGN.md` | Rewrite section 12 (no longer an accepted risk — resolved) and section 13 (unchanged content, but cross-reference updates). | -| `python.gen/docs/upstream-asks.md` | Remove ask 3 (resolved without the upstream change) or mark it withdrawn. | -| `tests/golden/` | Regenerate via `mise run golden`. | - ---- - -### Task 1: Add `_decode`/`_encode` to `CompositeModule.dhall` - -**Files:** -- Modify: `src/Templates/CompositeModule.dhall` - -**Interfaces:** -- Consumes: same `Params = { typeName : Text, extraImports : List Text, fields : List Field }` as today, `Field = { fieldName : Text, fieldType : Text }` — no signature change. -- Produces: the rendered module text now defines `_decode(src: object) -> ""` (staticmethod) and `_encode(self) -> tuple[...]` (instance method) on the dataclass, callable by any generator downstream as `TypeName._decode(x)` / `value._encode()`. - -This was prototyped and verified against `dhall type` and `dhall text` (rendered output checked with `python3 -c "compile(...)"` for both a two-field and a one-field composite — the one-field case needs the trailing-comma tuple, same edge case `compositeBind` already handles today). - -- [ ] **Step 1: Replace the template body** - -```dhall -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 } -``` - -- [ ] **Step 2: Type-check** - -Run: `dhall type --file=src/Templates/CompositeModule.dhall` -Expected: prints the `{ Field : Type, Params : Type, Run : Type, run : ... }` signature, no error. - -- [ ] **Step 3: Spot-render and validate as Python** - -```bash -cat > /tmp/render_composite.dhall <<'EOF' -let CompositeModule = ./src/Templates/CompositeModule.dhall -in CompositeModule.run - { typeName = "Point2D" - , extraImports = [] : List Text - , fields = - [ { fieldName = "x", fieldType = "int" } - , { fieldName = "y", fieldType = "int" } - ] - } -EOF -dhall text --file=/tmp/render_composite.dhall | python3 -c "import sys; compile(sys.stdin.read(), 'point2d.py', 'exec')" && echo OK -``` -Expected: `OK`, and eyeballing the output shows `_decode`/`_encode` indented as class members (4 spaces), not module-level. - -- [ ] **Step 4: Commit** - -```bash -git add src/Templates/CompositeModule.dhall -git commit -m "python.gen: emit _decode/_encode on generated composite dataclasses" -``` - ---- - -### Task 2: Add `_decode`/`_decode_array`/`_encode` to `EnumModule.dhall` - -**Files:** -- Modify: `src/Templates/EnumModule.dhall` - -**Interfaces:** -- Consumes: same `Params = { typeName : Text, variants : List Variant }` — no signature change. -- Produces: `_decode(src: object) -> ""`, `_decode_array(src: object) -> list[""]`, `_encode(self) -> ""` (identity — psycopg binds the `StrEnum` instance directly, matching today's `defaultBind`). - -The scalar/array decode bodies are copied verbatim from `Member.dhall`'s current `enumDecode`/`enumArrayDecode` (Member.dhall:61-82), just moved from "Dhall builds inline Python text at every call site" to "Dhall builds it once, into the class." - -- [ ] **Step 1: Replace the template body** - -```dhall -let Prelude = ../Deps/Prelude.dhall - -let Sdk = ../Deps/Sdk.dhall - -let Variant = { memberName : Text, pgValue : Text } - -let Params = { typeName : Text, variants : List Variant } - -let run = - \(params : Params) -> - -- pgValue is interpolated into a single-line double-quoted Python literal; a - -- label may legally contain a backslash, quote, or control character, so - -- escape them to keep the literal valid and value-equal to the DB label. - -- Order is load-bearing: backslash first (so the escapes added below are not - -- re-escaped), then the control chars, then the closing quote. - let escapeLabel - : Text -> Text - = \(raw : Text) -> - Prelude.Function.composeList - Text - [ Prelude.Text.replace "\\" "\\\\" - , Prelude.Text.replace "\r" "\\r" - , Prelude.Text.replace "\n" "\\n" - , Prelude.Text.replace "\t" "\\t" - , Prelude.Text.replace "\"" "\\\"" - ] - raw - - let memberLines = - Prelude.Text.concatMapSep - "\n" - Variant - ( \(variant : Variant) -> - " ${variant.memberName} = \"${escapeLabel variant.pgValue}\"" - ) - params.variants - - let codecMethods = - "\n" - ++ " @staticmethod\n" - ++ " def _decode(src: object) -> \"${params.typeName}\":\n" - ++ " return ${params.typeName}(cast(str, src))\n" - ++ "\n" - ++ " @staticmethod\n" - ++ " def _decode_array(src: object) -> list[\"${params.typeName}\"]:\n" - ++ " return [\n" - ++ " ${params.typeName}(v)\n" - ++ " for v in cast(list[str], require_array(src))\n" - ++ " ]\n" - ++ "\n" - ++ " def _encode(self) -> \"${params.typeName}\":\n" - ++ " return self" - - in '' - from enum import StrEnum - from typing import cast - - from .._runtime import require_array - - - class ${params.typeName}(StrEnum): - ${memberLines} - ${codecMethods} - '' - -in Sdk.Sigs.template Params run /\ { Variant } -``` - -> `require_array` must already be importable from `.._runtime` relative to `types/.py` — confirm the relative import depth matches `types/`'s actual nesting (one level under the package root per `CustomType.dhall`'s `modulePath = "types/${moduleName}.py"`) before running Step 2; adjust to `.._runtime` vs `..._runtime` to match what `Member.dhall`'s current `enumArrayDecode` assumes at its own call sites (check `ImportSet.dhall`'s handling of `require_array` imports today for the exact existing relative path convention, since this moves an existing import from call sites into the shared module). - -- [ ] **Step 2: Type-check** - -Run: `dhall type --file=src/Templates/EnumModule.dhall` -Expected: signature prints, no error. - -- [ ] **Step 3: Spot-render and validate as Python** - -```bash -cat > /tmp/render_enum.dhall <<'EOF' -let EnumModule = ./src/Templates/EnumModule.dhall -in EnumModule.run - { typeName = "Mood" - , variants = - [ { memberName = "HAPPY", pgValue = "happy" } - , { memberName = "SAD", pgValue = "sad" } - ] - } -EOF -dhall text --file=/tmp/render_enum.dhall | python3 -c "import sys; compile(sys.stdin.read(), 'mood.py', 'exec')" && echo OK -``` -Expected: `OK`. - -- [ ] **Step 4: Commit** - -```bash -git add src/Templates/EnumModule.dhall -git commit -m "python.gen: emit _decode/_decode_array/_encode on generated enum classes" -``` - ---- - -### Task 3: Simplify `Member.dhall`'s Custom branch - -**Files:** -- Modify: `src/Interpreters/Member.dhall` - -**Interfaces:** -- Consumes: `value.scalar.customRef : Optional Model.Name` (unchanged — still comes straight off `Scalar.run`, see `src/Interpreters/Scalar.dhall:44-52`), `value.dims : Natural` (unchanged). -- Produces: `Run = Config -> Input -> Lude.Compiled.Type Output` — **drops the `CustomKind.Lookup` parameter**. Every caller (`ResultColumns.dhall`, `CustomType.dhall`) updates in Task 5/6. - -- [ ] **Step 1: Replace the Custom branch (Member.dhall:129-232) and the `Run` alias (Member.dhall:245)** - -Delete the `merge { Enum = ...; Composite = ...; Absent = ... } (lookup name)` block and the `CustomKind.CompositeField`-typed `compositeDecode` helper (lines 100-116, now dead — the same logic lives in `CompositeModule.dhall`'s `_decode` now). Replace with: - -```dhall - , 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 Lude.Compiled.ok - Output - ( mkOutput - (ImportSet.custom customImport) - ( wrapNullable - ( \(src : Text) -> - "${typeName}._decode_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" - ) -``` - -Note this drops the `Enum`/`Composite` import-set split (`ImportSet.customEnum`/`ImportSet.customComposite`) in favor of one `ImportSet.custom` call — see Doc 2 (`2026-07-11-encounter-order-custom-imports.md`) for why `ImportSet.customEnum`/`customComposite` collapse into a single `ImportSet.custom` once `order` no longer exists. **Land Doc 2 in the same branch as this task, or `dhall type` will fail here** — Task 3 depends on `ImportSet.CustomImport` no longer requiring a `dedupKey`/`order` field. - -- [ ] **Step 2: Drop the `lookup` parameter from `Run`** - -Change: -```dhall -let Run = Config -> CustomKind.Lookup -> Input -> Lude.Compiled.Type Output -``` -to: -```dhall -let Run = Config -> Input -> Lude.Compiled.Type Output -``` -and the `run` definition's `\(lookup : CustomKind.Lookup) ->` (Member.dhall:40) — delete that line entirely, since `run` no longer takes it. - -- [ ] **Step 3: Delete the now-dead `CustomKind` import (Member.dhall:9) and the dead `compositeDecode` helper (Member.dhall:100-116)** - -- [ ] **Step 4: Type-check** - -Run: `dhall type --file=src/Interpreters/Member.dhall` -Expected: fails until Task 5 updates `ResultColumns.dhall` (Member's only caller) to stop passing `lookup` — that's fine, type-check `Member.dhall` standalone by temporarily checking `Sdk.Sigs.interpreter Config Input Output run` in isolation, or proceed straight to Task 5 and type-check the pair together. Don't commit Task 3 alone; commit Tasks 3+5+6 together (they're one type-checking unit — Dhall won't let you land a signature change without updating every call site in the same change). - ---- - -### Task 4: Simplify `ParamsMember.dhall`'s Custom branch - -**Files:** -- Modify: `src/Interpreters/ParamsMember.dhall` - -**Interfaces:** -- Consumes: same as Task 3. -- Produces: `Run = Config -> Input -> Lude.Compiled.Type Output` — drops `CustomKind.Lookup`. - -- [ ] **Step 1: Replace the Custom branch (ParamsMember.dhall:311-367)** - -Delete `compositeBind` (lines 258-288, now dead — logic moved into `CompositeModule.dhall`'s `_encode`) and the `merge { Enum = ...; Composite = ...; Absent = ... } (lookup name)` block. Replace with: - -```dhall - in Prelude.Optional.fold - Model.Name - value.scalar.customRef - (Lude.Compiled.Type Output) - ( \(name : Model.Name) -> - let customImport = - { className = name.inPascalCase - , moduleName = name.inSnakeCase - } - - let encodeExpr = - if input.isNullable - then "None if ${fieldName} is None else ${fieldName}._encode()" - else "${fieldName}._encode()" - - in Lude.Compiled.ok - Output - ( mkOutput - (ImportSet.combine value.imports (ImportSet.custom customImport)) - encodeExpr - ) - ) - ( if isJsonArrayParam - then Lude.Compiled.report - Output - [ input.pgName ] - "json/jsonb array as a parameter is not supported" - else Lude.Compiled.ok - Output - (mkOutput value.imports defaultBind) - ) -``` - -This drops the `Natural/isZero value.dims` guard that used to reject composite-array params — per the Design decision above (Option A), an array-of-composite param now generates `{fieldName}._encode()` where `fieldName`'s type is `list[Point2D]`; `list` has no `._encode`, so `basedpyright strict` catches it. Confirm this in Task 8's basedpyright run specifically, not just eyeball it. - -- [ ] **Step 2: Drop the `lookup` parameter from `Run` (ParamsMember.dhall:387) and `run`'s `\(lookup : CustomKind.Lookup) ->` (ParamsMember.dhall:235)** - -- [ ] **Step 3: Delete the dead `CustomKind` import (ParamsMember.dhall:9)** - -- [ ] **Step 4: Type-check together with Task 3** - -Run: `dhall type --file=src/Interpreters/ParamsMember.dhall` -Expected: same caveat as Task 3 Step 4 — its caller (`Query.dhall`) still passes `lookup` until Task 5. - ---- - -### Task 5: Drop `lookup` threading from `ResultColumns.dhall`, `Result.dhall`, `Query.dhall` - -**Files:** -- Modify: `src/Interpreters/ResultColumns.dhall`, `src/Interpreters/Result.dhall`, `src/Interpreters/Query.dhall` - -**Interfaces:** -- Each of these only forwards `lookup` to a callee; none inspect it. Removing it is mechanical. - -- [ ] **Step 1: `ResultColumns.dhall`** — delete `\(lookup : CustomKind.Lookup) ->` (line 62) and change `Member.run config lookup member` (line 76) to `Member.run config member`. Delete the `CustomKind` import (line 9) if nothing else in the file uses it — verify with `grep -n CustomKind src/Interpreters/ResultColumns.dhall` after editing. - -- [ ] **Step 2: `Result.dhall`** — delete both `\(lookup : CustomKind.Lookup) ->` occurrences (lines 66, 93), change `ResultColumns.run config lookup rowClassName columns` (line 89) to `ResultColumns.run config rowClassName columns`, and `rowsOutput config lookup rowClassName` (line 100) to `rowsOutput config rowClassName` — check `rowsOutput`'s own definition for a `lookup` parameter to drop too (it wasn't in the earlier grep excerpt; read the file before editing to confirm). Delete the `CustomKind` import (line 9) if unused after. - -- [ ] **Step 3: `Query.dhall`** — delete `\(lookup : CustomKind.Lookup) ->` (line 140), change `ResultModule.run config lookup rowClassName input.result` (line 156) to drop `lookup`, and `ParamsMember.run config lookup member` (line 173) to `ParamsMember.run config member`. Delete the `CustomKind` import (line 7) if unused after. - -- [ ] **Step 4: Type-check each file standalone** - -Run: `dhall type --file=src/Interpreters/ResultColumns.dhall && dhall type --file=src/Interpreters/Result.dhall && dhall type --file=src/Interpreters/Query.dhall` -Expected: all three print their signatures. `Query.dhall` will still fail until `Project.dhall` (Task 6) stops passing `lookup` to `QueryGen.run` — that's the last link. - ---- - -### Task 6: Delete `buildLookup` and its support code from `Project.dhall` - -**Files:** -- Modify: `src/Interpreters/Project.dhall` - -- [ ] **Step 1: Delete dead helpers** - -Delete, in order (they only exist to feed `buildLookup`, confirmed by re-reading the file top to bottom — nothing else calls `lookupConfig`, `memberPyType`, `compositeFields`, `IndexedCustomType`, or `buildLookup` itself): -- `lookupConfig` (lines 74-80) -- `memberPyType` (lines 85-96) -- `compositeFields` (lines 98-108) -- The local `CompositeField = { fieldName : Text, pyType : Text }` (line 7) — dead once `compositeFields`/`memberPyType` are gone -- `IndexedCustomType` (line 142) -- `buildLookup` (lines 147-181) - -- [ ] **Step 2: Update the call site** - -Find `let lookup = buildLookup effectiveCustomTypes` (line 479) — delete it, and change both call sites that pass `lookup`: -- `QueryGen.run config lookup query` (line 503) → `QueryGen.run config query` -- `(\(query : Model.Query) -> QueryGen.run config lookup query)` (line 524) → `(\(query : Model.Query) -> QueryGen.run config query)` - -- [ ] **Step 3: Delete the dead `CustomKind` import (line 9)** - -- [ ] **Step 4: Type-check the whole chain** - -Run: `dhall type --file=src/Interpreters/Project.dhall` -Expected: prints the module signature with no error — this is the point where Tasks 3-6 all click together (Project → Query → Result/ParamsMember → ResultColumns/Member all now agree on the lookup-free signatures). - -- [ ] **Step 5: Commit Tasks 3-6 together** - -```bash -git add src/Interpreters/Member.dhall src/Interpreters/ParamsMember.dhall \ - src/Interpreters/ResultColumns.dhall src/Interpreters/Result.dhall \ - src/Interpreters/Query.dhall src/Interpreters/Project.dhall -git commit -m "python.gen: call custom-type codecs by name instead of resolving via buildLookup" -``` - ---- - -### Task 7: Drop `nestedLookup` from `CustomType.dhall`, delete `CustomKind.dhall` - -**Files:** -- Modify: `src/Interpreters/CustomType.dhall` -- Delete: `src/Structures/CustomKind.dhall` - -- [ ] **Step 1: `CustomType.dhall`** — delete `nestedLookup` (lines 51-53) and its comment, and change `MemberGen.run config nestedLookup m` (line 127) to `MemberGen.run config m`. Delete the `CustomKind` import (line 11). - -- [ ] **Step 2: Confirm nothing else references `CustomKind`** - -Run: `grep -rln "CustomKind" src` -Expected: no output. - -- [ ] **Step 3: Delete the file** - -```bash -git rm src/Structures/CustomKind.dhall -``` - -- [ ] **Step 4: Type-check the full package entry point** - -Run: `dhall type --file=src/package.dhall` -Expected: prints the top-level module signature, no error. (This is the first point a full-package check is meaningful — earlier steps only checked individual interpreter files.) - -- [ ] **Step 5: Commit** - -```bash -git add src/Interpreters/CustomType.dhall -git commit -m "python.gen: delete CustomKind.dhall, the last remnant of buildLookup" -``` - ---- - -### Task 8: Regenerate golden fixtures, verify, update docs - -**Files:** -- Modify: `tests/fixture-project/` (add a composite-array column per the Design decision above) -- Regenerate: `tests/golden/` -- Modify: `python.gen/DESIGN.md`, `python.gen/docs/upstream-asks.md`, `python.gen/CHANGELOG.md` - -- [ ] **Step 1: Add a composite-array test column to the fixture project** - -Find the fixture's composite-type column definitions under `tests/fixture-project/` (query/table SQL referencing a composite type, e.g. the `Point2D`-typed column used in `insert_specimen`/`get_specimen` per the golden output). Add one query or column that selects/inserts an *array* of that composite type — this is the first real exercise of the Option A fallback path (`basedpyright strict` should catch it if `_decode_array`/`_encode` aren't defined on `Point2D`, since Task 1 deliberately didn't add them to `CompositeModule.dhall`). - -Confirm the expected failure mode first: - -Run: `mise run golden` (needs `PGN_TEST_DATABASE_URL` pointing at a live Postgres — see `tests/golden/README.md`) -Expected: generation succeeds (no Dhall-level rejection — that's the point of Option A), but the regenerated file calls `Point2D._decode_array(...)` or `list[Point2D]._encode()`-shaped code that doesn't exist. - -Run: `mise run test` -Expected: `test_generated_passes_basedpyright_strict` FAILS, citing the missing attribute. This confirms the safety net from the Design decision actually fires. Once confirmed, either: - - revert the fixture addition (if you don't want composite arrays in the committed golden corpus yet), or - - implement `_decode_array`/an array-aware `_encode` on `CompositeModule.dhall` for real and keep the fixture (a follow-up, out of this plan's scope — flag it, don't scope-creep this task). - -- [ ] **Step 2: Remove the composite-array addition (unless implementing it for real per Step 1)** - -- [ ] **Step 3: Regenerate golden for real** - -Run: `mise run golden` -Expected: succeeds, rewrites `tests/golden/src/specimen_client/_generated/**` and both facades. - -- [ ] **Step 4: Review the diff** - -Run: `git diff tests/golden` -Expected: every composite/enum decode/encode call site now reads `TypeName._decode(...)`/`TypeName._decode_array(...)`/`value._encode()` instead of the old inlined `cast(tuple[...], ...)`/`(x.a, x.b)` expressions; `types/point_2_d.py`, `types/mood.py`, `types/tag_value.py` each gain the new methods. No unrelated files change. - -- [ ] **Step 5: Run the full test suite** - -Run: `mise run test` -Expected: all pass, including `test_generated_passes_basedpyright_strict`. - -- [ ] **Step 6: Update `DESIGN.md`** - -Rewrite section 12 (`## 12. Forked-Dhall (Text/equal) dependency risk`) — it's no longer an accepted risk for python.gen; state plainly that `buildLookup` is gone and `Text/equal` is no longer used anywhere in this generator's own Dhall source (grep to confirm: `grep -rn "Text/equal" src` returns nothing). Keep a short note that `demos/Exhaustive.dhall`/`mise run golden` still needs the pinned pgn binary regardless, because `gen-sdk`'s own `Fixtures` module independently uses the fork builtin — this plan doesn't touch that, and it isn't blocked on it. Cross-reference section 13 (unchanged — `PyIdent.dhall`'s trick is unrelated and still in place). - -- [ ] **Step 7: Update `docs/upstream-asks.md`** - -Remove ask 3 (`## 3. gen-sdk: kind tag or Natural index on Scalar.Custom`) or mark it explicitly withdrawn with one line explaining why (`buildLookup`'s only consumer was resolved locally by generating named codecs instead of resolving structural type info by search — see `docs/plans/2026-07-11-reusable-custom-type-codecs.md`). Don't delete the file's other two asks (pragma parsing, warnings printing) — they're unrelated and still open. - -- [ ] **Step 8: Update `CHANGELOG.md`** - -Add an entry under the appropriate section describing the behavior change from the Design decision (composite-array columns/params no longer rejected at generation time; verify at basedpyright-strict time instead) if Step 1's fixture addition was kept, or note it as a documented-but-untested path if reverted. - -- [ ] **Step 9: Commit** - -```bash -git add tests/golden tests/fixture-project python.gen/DESIGN.md python.gen/docs/upstream-asks.md python.gen/CHANGELOG.md -git commit -m "python.gen: regenerate golden fixtures for reusable custom-type codecs" -``` - ---- - -## Self-Review - -**Spec coverage:** Task 1-2 build the reusable codecs (the actual "fix the root cause"). Tasks 3-4 make the two reference sites (decode, encode) call them. Tasks 5-7 remove the now-dead plumbing (`lookup` threading, `buildLookup`, `CustomKind.dhall`) so nothing is left half-migrated. Task 8 proves it against the real toolchain and updates the two docs (`DESIGN.md`, `upstream-asks.md`) that currently assert this is unfixable — both would otherwise go stale and mislead the next reader. - -**Open question carried forward, not silently resolved:** the array-of-composite behavior change (Design decision, Option A vs B) is a real product decision, flagged explicitly rather than picked unilaterally in the diff. Task 8 Step 1 is designed to surface the actual runtime behavior (does `basedpyright strict` really catch it, does composite-array decode actually work against real Postgres) before committing to either branch. - -**Dependency on the companion plan:** Task 3 explicitly calls out that it needs `docs/plans/2026-07-11-encounter-order-custom-imports.md` (`ImportSet.dhall`'s `order`/`dedupKey` removal) landed first or alongside — `ImportSet.customEnum`/`customComposite` currently *require* an `order : Natural` that only `buildLookup` produced. Implement that plan first, or fold both into one PR; don't land this plan's Task 3 against the unmodified `ImportSet.dhall`. - -## Execution Handoff - -Plan complete and saved to `python.gen/docs/plans/2026-07-11-reusable-custom-type-codecs.md`. Two execution options: - -**1. Subagent-Driven (recommended)** - dispatch a fresh subagent per task, review between tasks, fast iteration - -**2. Inline Execution** - execute tasks in this session using executing-plans, batch execution with checkpoints - -Which approach? From bd42d46cdbf74c2fe0841c773d2989be83af97c4 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sun, 12 Jul 2026 08:08:42 +0300 Subject: [PATCH 12/41] Add plans --- .../2026-07-12-configurable-sync-output.md | 1367 +++++++++++++++++ ...026-07-12-distribute-rows-per-statement.md | 996 ++++++++++++ 2 files changed, 2363 insertions(+) create mode 100644 docs/plans/2026-07-12-configurable-sync-output.md create mode 100644 docs/plans/2026-07-12-distribute-rows-per-statement.md diff --git a/docs/plans/2026-07-12-configurable-sync-output.md b/docs/plans/2026-07-12-configurable-sync-output.md new file mode 100644 index 0000000..5c047b4 --- /dev/null +++ b/docs/plans/2026-07-12-configurable-sync-output.md @@ -0,0 +1,1367 @@ +# Configurable Sync Output Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the additive `emitSync : Optional Bool` config knob (which always emits async and optionally bolts a second `sync/` tree on top) with an exclusive `sync : Optional Bool` knob, default `False`, that selects exactly one surface — async or sync — and emits it at the same unified file paths either way, so flipping the flag never changes the shape of the output tree or any import path, only file contents. + +**Architecture:** `Interpreters/Project.dhall` picks one `Surface.Type` value (`Surface.async` when `config.sync` is `False`, `Surface.sync` when `True`) and threads it through a single render pass instead of today's two passes (always-async + conditionally-additive-sync). `Interpreters/Query.dhall`'s per-query `Output` collapses its `asyncModulePath`/`asyncContent`/`syncModulePath`/`syncContent` two-surface duplication down to one `modulePath`/`content` pair. `Structures/Surface.dhall`'s import-depth tokens (`rowsImport`, `corePrefix`, `typesPrefix`) collapse to the same value for both surfaces, since sync statement modules no longer live one package deeper under `sync/statements/` — they live at the exact path async statements use today. `_rows.py` and `types/` are untouched by this plan (still surface-agnostic, still emitted once); splitting `_rows.py` per statement is a separate, later plan (`docs/plans/2026-07-12-distribute-rows-per-statement.md`) that depends on this one landing first. + +**Tech Stack:** Dhall (dhall-lang 1.42 vendored via `dhall` CLI; pgn's forked interpreter for the parts still using it), Python 3.12 generated output (psycopg3), basedpyright strict, pytest golden-file harness (`mise run test`, `mise run golden`), pgn 0.9.1 (mise-pinned). + +## Global Constraints + +- No behavior change to param/result type mapping, custom-type codecs, or SQL rendering — this plan is scoped to the sync/async surface-selection mechanism only. +- Default behavior when `config.sync` is omitted must be byte-identical to today's `emitSync` omitted/false default (async only, same paths) — verified by golden diff. +- Golden fixture output (`tests/golden/`, new `tests/golden_sync/`) must be regenerated via `mise run golden`, not hand-edited (per `tests/golden/README.md`). +- `mise run test` (pytest, includes `test_generated_passes_basedpyright_strict`) must pass after regeneration, for both the async and sync golden trees. +- Every Dhall file touched must independently type-check via the whole-generator check: `dhall type --file=src/package.dhall`. +- This is a breaking config change (the `emitSync` key stops existing). Per this repo's convention (see `CHANGELOG.md`'s "Upcoming" section and the "Drop the plans" precedent), document it there — no deprecation shim, no dual-key transition period. +- CI's memory-reduction script (`.github/workflows/ci.yml`, "Reduce the fixture project to the python artifact") truncates `tests/fixture-project/project1.pgn.yaml` at the `# The variants below` marker. Any new artifact that must run in CI (not just locally) has to be placed *before* that marker. + +--- + +## File Structure + +| File | Change | +|---|---| +| `src/package.dhall` | Public `Config`/`Config/default`: rename `emitSync` field to `sync`; rewrite doc comment. | +| `src/Interpreters/Project.dhall` | `Config`/`ResolvedConfig`: rename field. `run`: rename resolve binding. `combineOutputs`: replace always-async-plus-optional-sync emission with single-surface emission at unified paths. | +| `src/Interpreters/Query.dhall` | `Config`: rename field. `Output`: collapse `asyncModulePath`/`asyncContent`/`syncModulePath`/`syncContent` to `modulePath`/`content`. `render`: pick one surface, call `StatementModule.run` once instead of twice. | +| `src/Structures/Surface.dhall` | Collapse `sync`'s `rowsImport`/`corePrefix`/`typesPrefix` to the same depth as `async`'s (no more extra-dot nesting). Rewrite header comment. | +| `src/Templates/RuntimeModule.dhall` | Update the `syncContent` doc comment (no longer describes a `sync/_runtime.py` path). | +| `src/Interpreters/{Value,ResultColumns,Member,Scalar,QueryFragments,ParamsMember,CustomType,Primitive,Result}.dhall` | Mechanical rename: local `Config` type's `emitSync : Bool` field → `sync : Bool` (each is a narrowing projection of `ResolvedConfig`, never itself branches on the value except `Result.dhall`'s field-selection literal). | +| `demos/Exhaustive.dhall` | Rename `emitSync = Some True` → `sync = Some True`. | +| `tests/fixture-project/project1.pgn.yaml` | Main `python:` artifact: drop `emitSync: true` (default `sync: false` going forward). Add new `python-sync:` artifact (`sync: true`), placed before the CI truncation marker. Rename `emitSync` → `sync` in the `python-sync-only` and `python-null` variants. | +| `tests/golden_sync/` (new) | Hand-written shell (`pyproject.toml`, `README.md`, `src/specimen_sync_client/py.typed`) mirroring `tests/golden/`'s shell, for the new `python-sync` artifact's committed golden tree. | +| `tests/_harness.py` | Add `GOLDEN_DIR_SYNC` path constant. | +| `tests/conftest.py` | Add a `full_package_sync` fixture mirroring `full_package`, sourced from the `python_sync` artifact directory and `GOLDEN_DIR_SYNC`. | +| `tests/test_generated.py` | Drop `SYNC_FACADE_INIT` and the two-facade loop in `test_generated_matches_golden`. Add `test_generated_sync_matches_golden` and `test_generated_sync_passes_basedpyright_strict`. Delete `test_roundtrip_sync_and_cross_surface_identity` (cross-surface identity no longer exists once surfaces are exclusive); replace with `test_roundtrip_sync_surface` against `full_package_sync`. Move `test_roundtrip_single_field_composite_sync` onto `full_package_sync` with unified (non-`.sync.`) import paths. | +| `tests/test_config_variants.py` | Replace the `(package / "sync").exists()` presence check (no longer meaningful once paths are unified) with a content-based signal read from `_generated/_runtime.py`. Rename `emitSync` → `sync` throughout prose and assertions. | +| `mise.toml` | `golden` task: also generate and rsync the `python-sync` artifact into `tests/golden_sync/`. | +| `README.md` | Config reference table, quickstart example, "Key features," "Using the generated code" section. | +| `DESIGN.md` | Sections 1 ("What the generator emits"), 3 (runtime import example), 4 ("Surface mechanism"), 9 (facade), 10 (Config flow) — rewrite to describe exclusive single-surface selection at unified paths. | +| `CHANGELOG.md` | New "Upcoming" entry documenting the breaking config change. | + +--- + +### Task 1: Rename `emitSync` to `sync` across the Config-threading chain (no behavior change) + +Pure rename first, isolated from the behavior change in Task 2, so it is independently verifiable: after this task, generation is byte-identical to before, just keyed on a renamed config field. + +**Files:** +- Modify: `src/package.dhall` +- Modify: `src/Interpreters/Project.dhall` +- Modify: `src/Interpreters/Query.dhall` +- Modify: `src/Interpreters/Value.dhall`, `ResultColumns.dhall`, `Member.dhall`, `Scalar.dhall`, `QueryFragments.dhall`, `ParamsMember.dhall`, `CustomType.dhall`, `Primitive.dhall`, `Result.dhall` +- Modify: `demos/Exhaustive.dhall` + +**Interfaces:** +- Produces: every interpreter's `Config` type now has a `sync : Bool` field (was `emitSync : Bool`); `Project.dhall`'s public `Config`/`ResolvedConfig` and `src/package.dhall`'s `Config`/`Config/default` match. Task 2 reads `config.sync` / `resolvedConfig.sync`. + +- [ ] **Step 1: Rename in `src/package.dhall`** + +```dhall +let Sdk = ./Deps/Sdk.dhall + +let OnUnsupported = ./Structures/OnUnsupported.dhall + +let ProjectInterpreter = ./Interpreters/Project.dhall + +-- User-facing config for this generator. `sync` picks which single surface +-- the generator emits: `False` (default) emits the async surface +-- (psycopg.AsyncConnection); `True` emits the sync surface +-- (psycopg.Connection) instead, at the exact same paths — flipping this +-- flag never changes the output tree's shape or any import path, only file +-- contents. `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 + , sync : Optional Bool + , onUnsupported : Optional OnUnsupported.Mode + } : Type + +let Config/default + : Config + = { packageName = None Text + , sync = None Bool + , onUnsupported = None OnUnsupported.Mode + } + +in Sdk.Sigs.generator Config Config/default ProjectInterpreter.run +``` + +- [ ] **Step 2: Rename in `src/Interpreters/Project.dhall`** + +Replace lines 35-53 (the `Config` and `ResolvedConfig` declarations): + +```dhall +-- 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, sync off (async), onUnsupported Fail); there is no separate config +-- type or resolve step between package.dhall and here. +let Config = + { packageName : Optional Text + , sync : 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 + , sync : Bool + , onUnsupported : OnUnsupported.Mode + } +``` + +Replace the `emitSync` resolve binding in `run` (around line 380-386): + +```dhall + let sync = + Prelude.Optional.fold + Bool + config.sync + Bool + (\(b : Bool) -> b) + False +``` + +And its use in building `resolvedConfig` (around line 398-400): + +```dhall + let resolvedConfig + : ResolvedConfig + = { packageName, importName, sync, onUnsupported } +``` + +Leave the rest of `combineOutputs`'s body as-is for this task — Task 2 rewrites that function's structure anyway — but rename the two literal occurrences of the field name inside it so Step 7's type-check passes. Line 272 (doc comment): + +```dhall + -- config.sync. It reuses the shared `_rows.py` and `types/`, so only +``` + +Line 325 (the conditional gating `syncFiles`): + +```dhall + let syncFiles = + if config.sync + then [ syncSubpackageInit +``` + +- [ ] **Step 3: Rename in `src/Interpreters/Query.dhall`** + +Line 28 (`Config` record): `, emitSync : Bool` → `, sync : Bool`. + +Line 40 comment ("emitted only when config.emitSync") → "emitted only when config.sync" (Task 2 rewrites this comment fully; this step is the minimal rename). + +- [ ] **Step 4: Rename the narrowing `Config` field in the nine passthrough interpreters** + +Each of these files declares a local `Config` type that is a narrowing projection of `ResolvedConfig`, purely so later Dhall record-selection syntax can pick the field out by name. None of them branch on the value except `Result.dhall`'s selection literal (next step). In each file, change the line `, emitSync : Bool` to `, sync : Bool`: + +| File | Line | +|---|---| +| `src/Interpreters/Value.dhall` | 18 | +| `src/Interpreters/ResultColumns.dhall` | 20 | +| `src/Interpreters/Member.dhall` | 20 | +| `src/Interpreters/Scalar.dhall` | 16 | +| `src/Interpreters/QueryFragments.dhall` | 16 | +| `src/Interpreters/ParamsMember.dhall` | 18 | +| `src/Interpreters/CustomType.dhall` | 22 | +| `src/Interpreters/Primitive.dhall` | 14 | +| `src/Interpreters/Result.dhall` | 25 | + +- [ ] **Step 5: Rename the field-selection literal in `src/Interpreters/Result.dhall`** + +Line 94: + +```dhall + config.{ packageName, importName, sync, onUnsupported } +``` + +- [ ] **Step 6: Rename in `demos/Exhaustive.dhall`** + +Line 29: + +```dhall + { packageName = None Text + , sync = Some True + , onUnsupported = Some OnUnsupported.Mode.Skip + } +``` + +- [ ] **Step 7: Type-check the whole generator** + +Run: `cd python.gen && dhall type --file=src/package.dhall` +Expected: prints the generator's function type, no errors. (This alone proves every renamed `Config` record still lines up across every call site — Dhall's structural typing would reject a mismatched field name.) + +- [ ] **Step 8: Confirm no `emitSync` references remain in `src/`** + +Run: `cd python.gen && grep -rn "emitSync" src/ demos/` +Expected: no output. + +- [ ] **Step 9: Commit** + +```bash +git add src/package.dhall src/Interpreters/Project.dhall src/Interpreters/Query.dhall \ + src/Interpreters/Value.dhall src/Interpreters/ResultColumns.dhall src/Interpreters/Member.dhall \ + src/Interpreters/Scalar.dhall src/Interpreters/QueryFragments.dhall src/Interpreters/ParamsMember.dhall \ + src/Interpreters/CustomType.dhall src/Interpreters/Primitive.dhall src/Interpreters/Result.dhall \ + demos/Exhaustive.dhall +git commit -m "python.gen: rename emitSync config field to sync (no behavior change)" +``` + +--- + +### Task 2: Make `sync` exclusive — unify output paths, single-surface emission + +**Files:** +- Modify: `src/Structures/Surface.dhall` +- Modify: `src/Interpreters/Query.dhall` +- Modify: `src/Interpreters/Project.dhall` +- Modify: `src/Templates/RuntimeModule.dhall` (comment only) +- Modify: `src/Templates/FacadeModule.dhall` (drop dead surface-selection params) + +**Interfaces:** +- Consumes: `resolvedConfig.sync : Bool` (from Task 1). +- Produces: `Query.dhall`'s `Output` record now has `modulePath : Text` and `content : Text` (replacing the four async/sync-prefixed fields) — any code outside this task that referenced `query.asyncModulePath` etc. must be updated in this same task, since Dhall's structural typing will fail closed on the old field names. + +- [ ] **Step 1: Collapse the import-depth tokens in `src/Structures/Surface.dhall`** + +Sync statement modules no longer live one package deeper (`sync/statements/`) — they live at the exact same `statements/` path async uses. Both surfaces now reach `_rows`, `_core`, and `types/` at the same relative depth. + +```dhall +-- A code-generation surface: the async or sync flavour of a statement module. +-- Exactly one surface is emitted per generate (Interpreters/Project.dhall +-- picks async or sync from config.sync and renders everything at the same +-- unified paths), so the two Surface values differ only in the tokens that +-- vary between `async def`/`def`, `AsyncConnection`/`Connection`, and +-- `await `/``. Both reach the shared `_rows`/`_core`/`types` modules at the +-- same relative import depth, since neither surface is nested under a +-- surface-named subdirectory. +let Surface = + { defKeyword : Text + , connType : Text + , awaitKw : Text + , rowsImport : Text + , corePrefix : Text + , typesPrefix : Text + } + +let async + : Surface + = { defKeyword = "async def" + , connType = "AsyncConnection" + , awaitKw = "await " + , rowsImport = ".._rows" + , corePrefix = ".._core" + , typesPrefix = "..types" + } + +let sync + : Surface + = { defKeyword = "def" + , connType = "Connection" + , awaitKw = "" + , rowsImport = ".._rows" + , corePrefix = ".._core" + , typesPrefix = "..types" + } + +in { Type = Surface, async, sync } +``` + +- [ ] **Step 2: Collapse `Query.dhall`'s per-surface duplication** + +Replace the `Output` record (lines 42-51): + +```dhall +-- A query contributes a shared Row (assembled into `_rows.py` by Project) plus +-- one thin statement module, rendered for whichever surface config.sync picked. +let Output = + { functionName : Text + , rowClassName : Optional Text + , rowDef : Optional RowsModule.RowDef + , rowImports : ImportSet.Type + , modulePath : Text + , content : Text + } +``` + +Replace the tail of `render` (from `let mkModule = ...` through the end of the function, lines 112-136): + +```dhall + let surface = if config.sync then Surface.sync else Surface.async + + let content = + StatementModule.run + { functionName + , returnType = result.returnType + , helperName = result.helperName + , callsDecode = result.callsDecode + , sqlLiteral = fragments.sqlLiteral + , rowClassName + , decodeName + , paramSigLines + , paramDictEntries + , imports = paramImports + , surface + } + + in { functionName + , rowClassName + , rowDef + , rowImports = result.imports + , modulePath = "statements/${functionName}.py" + , content + } +``` + +- [ ] **Step 3: Rewrite `Project.dhall`'s `combineOutputs` for single-surface emission** + +Replace the whole body from the `let asyncFacade = ...` binding (line 157) through `let allFiles = ...` (line 347) with: + +```dhall + let surface = if config.sync then Surface.sync else Surface.async + + let facade = + { path = packagePrefix ++ "__init__.py" + , content = + FacadeModule.run + { generatedPrefix = "._generated" + , statementsPath = "statements" + , statements = facadeStatements + , types = facadeTypes + } + } + + -- Surface-agnostic; performs no I/O, so exactly one copy is emitted + -- regardless of surface. Both runtime bodies re-export from it. + let coreModule = + { path = srcPrefix ++ "_core.py", content = CoreModule.run {=} } + + let runtimeModule = + { path = srcPrefix ++ "_runtime.py" + , content = + if config.sync then RuntimeModule.runSync {=} else RuntimeModule.run {=} + } + + let statementsInit = + { path = srcPrefix ++ "statements/__init__.py" + , content = + InitModule.run { docstring = "Generated SQL statements." } + } + + -- The shared Row dataclasses + decode functions, imported by the + -- statement modules of whichever surface was selected. + let rowDefs = + Prelude.List.concatMap + QueryGen.Output + RowsModule.RowDef + ( \(query : QueryGen.Output) -> + Prelude.Optional.toList RowsModule.RowDef query.rowDef + ) + queries + + let rowImports = + ImportSet.combineAll + ( Prelude.List.map + QueryGen.Output + ImportSet.Type + (\(query : QueryGen.Output) -> query.rowImports) + queries + ) + + let rowsFiles = + if Prelude.List.null RowsModule.RowDef rowDefs + then [] : List Lude.File.Type + else [ { path = srcPrefix ++ "_rows.py" + , content = + RowsModule.run { rows = rowDefs, imports = rowImports } + } + ] + + let statementFiles = + Prelude.List.map + QueryGen.Output + Lude.File.Type + ( \(query : QueryGen.Output) -> + { path = srcPrefix ++ query.modulePath + , content = query.content + } + ) + queries + + let typeFiles = + Prelude.List.map + CustomTypeGen.Output + Lude.File.Type + ( \(ct : CustomTypeGen.Output) -> + { path = srcPrefix ++ ct.modulePath + , content = ct.moduleContent + } + ) + customTypes + + let typesInitExports = + Prelude.List.map + CustomTypeGen.Output + TypesInit.Export + (\(ct : CustomTypeGen.Output) -> { moduleName = ct.moduleName, typeName = ct.typeName }) + customTypes + + let typesInitFiles = + if Prelude.List.null CustomTypeGen.Output customTypes + then [] : List Lude.File.Type + else [ { path = srcPrefix ++ "types/__init__.py" + , content = TypesInit.run { exports = typesInitExports } + } + ] + + let compositeNames = compositePgNames customTypes + + let enumNames = enumPgNames customTypes + + let hasCustomRegistration = + Prelude.Bool.not + ( Prelude.Bool.and + [ Prelude.List.null Text compositeNames + , Prelude.List.null Text enumNames + ] + ) + + let registerFiles = + if hasCustomRegistration + then [ { path = srcPrefix ++ "_register.py" + , content = + RegisterModule.run { compositeNames, enumNames, surface } + } + ] + else [] : List Lude.File.Type + + let staticFiles = + [ facade, topInit, coreModule, runtimeModule, statementsInit ] + + let allFiles = + staticFiles + # registerFiles + # rowsFiles + # typesInitFiles + # typeFiles + # statementFiles + + in Prelude.List.map Lude.File.Type Lude.File.Type withHeader allFiles + : Output +``` + +Note `topInit` (the `_generated/__init__.py` facade docstring file, defined earlier in the function and unchanged) is still referenced in `staticFiles` — leave its definition (around line 128-135 in the original) exactly as-is; only the block below it changes. + +- [ ] **Step 4: Drop `FacadeModule.dhall`'s dead surface-selection parameters** + +`generatedPrefix` and `statementsPath` exist only because the old design called `FacadeModule.run` twice with different values (once for the async facade at the package root, once for the sync facade one level down at `sync/__init__.py`). Now there is exactly one facade, always at the package root, so both become constants — hardcode them and drop them from `Params`. + +Replace the header comment and `Params` declaration (lines 13-24): + +```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. +let StatementExport = + { functionName : Text, rowClassName : Optional Text } + +-- A custom type re-exported from types/: the leaf module name plus the class. +let TypeExport = { moduleName : Text, className : Text } + +-- The facade always lives at the package root, importing from `_generated` +-- (prefix `._generated`) and `_generated/statements` (statementsPath +-- `statements`) — both constant now that exactly one surface is emitted per +-- generate (Interpreters/Project.dhall picks it via config.sync). +let generatedPrefix = "._generated" + +let statementsPath = "statements" + +let Params = + { statements : List StatementExport + , types : List TypeExport + } +``` + +Replace every remaining `params.generatedPrefix` with `generatedPrefix` and every `params.statementsPath` with `statementsPath` in the rest of the file (the `runtimeBlock`, `typeBlock`, `rowBlock`, and `statementBlock` bindings inside `run`). + +Then, back in `Interpreters/Project.dhall`, simplify the `facade` binding from Step 3 above to drop the now-nonexistent fields: + +```dhall + let facade = + { path = packagePrefix ++ "__init__.py" + , content = + FacadeModule.run + { statements = facadeStatements, types = facadeTypes } + } +``` + +- [ ] **Step 5: Update the `syncContent` doc comment in `src/Templates/RuntimeModule.dhall`** + +Replace the comment at lines 84-88: + +```dhall +-- The sync surface's body, selected in place of `content` at the same +-- `_runtime.py` path when config.sync is True (Interpreters/Project.dhall). +-- The five helpers are the same shape with `def`/`Connection`/`with`/no- +-- `await`. JsonValue/NoRowError/require_array are re-exported from _core so +-- both surfaces share one canonical identity rather than two equal-but- +-- distinct definitions. +``` + +- [ ] **Step 6: Type-check the whole generator** + +Run: `cd python.gen && dhall type --file=src/package.dhall` +Expected: prints the generator's function type, no errors. + +- [ ] **Step 7: Delete the stale freeze file so the next `pgn generate` re-resolves the working tree** + +Run: `rm -f tests/fixture-project/freeze1.pgn.yaml` +Expected: file removed (pgn's freeze cache does not know the generator source changed underneath a stable relative path; a stale freeze would silently keep emitting the old two-tree output). + +- [ ] **Step 8: Commit** + +```bash +git add src/Structures/Surface.dhall src/Interpreters/Query.dhall src/Interpreters/Project.dhall \ + src/Templates/RuntimeModule.dhall src/Templates/FacadeModule.dhall +git commit -m "python.gen: make sync/async mutually exclusive at unified output paths" +``` + +(This task intentionally does not update `tests/fixture-project/project1.pgn.yaml` yet — the main `python:` artifact still has `sync: true` from before Task 1's rename, so at this point in the plan, generating against it produces sync-surface content at the paths async used to occupy, and `tests/golden/` has not been refreshed yet, so `test_generated_matches_golden` and the sync-specific round-trip tests are expected to fail until Task 3 and Task 4 land. This is fine mid-plan; the full suite is the gate at the end, not after every task.) + +--- + +### Task 3: Update the fixture project config + +**Files:** +- Modify: `tests/fixture-project/project1.pgn.yaml` + +**Interfaces:** +- Produces: a `python-sync` artifact (packageName `specimen-sync-client`) alongside the existing `python` artifact (now `sync` omitted, i.e. async by default), both ahead of the CI truncation marker. Task 4/5 consume `artifacts/python_sync` and the `specimen_sync_client` package name. + +- [ ] **Step 1: Rewrite `tests/fixture-project/project1.pgn.yaml`** + +```yaml +space: python-gen +name: fixture +version: 0.0.0 +postgres: 18 +artifacts: + python: + gen: ../../src/package.dhall + config: + packageName: specimen-client + python-sync: + gen: ../../src/package.dhall + config: + packageName: specimen-sync-client + sync: true + # The variants below pin pgn's actual YAML->Dhall decode semantics for the + # Optional Config knobs (undocumented by pgn itself); see + # tests/test_config_variants.py for the assertions. + python-name-only: + gen: ../../src/package.dhall + config: + packageName: name-only-client + python-sync-only: + gen: ../../src/package.dhall + config: + sync: true + python-empty: + gen: ../../src/package.dhall + config: {} + python-bare: + gen: ../../src/package.dhall + python-unknown-key: + gen: ../../src/package.dhall + config: + packageName: unknown-key-client + bogusField: 1 + python-null: + gen: ../../src/package.dhall + config: + packageName: null-client + sync: null +``` + +Note `python:` drops the `emitSync: true` key entirely (was: `packageName: specimen-client`, `emitSync: true`) — the primary golden fixture now represents the default (`sync` omitted → async), matching what most of `tests/test_generated.py` exercises. `python-sync` is new and sits *before* the `# The variants below` marker, so CI's truncation step (`.github/workflows/ci.yml`) keeps it alongside `python`. + +- [ ] **Step 2: Commit** + +```bash +git add tests/fixture-project/project1.pgn.yaml +git commit -m "python.gen: add python-sync fixture artifact, drop emitSync from main artifact" +``` + +--- + +### Task 4: Add the `tests/golden_sync/` shell and wire up golden regeneration for both artifacts + +**Files:** +- Create: `tests/golden_sync/pyproject.toml` +- Create: `tests/golden_sync/README.md` +- Create: `tests/golden_sync/src/specimen_sync_client/py.typed` +- Modify: `tests/_harness.py` +- Modify: `mise.toml` +- Modify: `tests/golden/pyproject.toml` (drop the now-absent `emitSync`-driven `sync/` package reference — see Step 5) + +**Interfaces:** +- Produces: `GOLDEN_DIR_SYNC` (a `Path` constant in `tests/_harness.py`), importable by `tests/conftest.py` and `tests/test_generated.py`. + +- [ ] **Step 1: Create `tests/golden_sync/pyproject.toml`** + +```toml +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "specimen-sync-client" +version = "0.0.0" +requires-python = ">=3.12" +dependencies = ["psycopg>=3.2"] + +[tool.hatch.build.targets.wheel] +packages = ["src/specimen_sync_client"] + +[tool.ruff] +exclude = ["src/specimen_sync_client/_generated"] +``` + +- [ ] **Step 2: Create `tests/golden_sync/src/specimen_sync_client/py.typed`** + +Empty file (PEP 561 marker, matching `tests/golden/src/specimen_client/py.typed`). + +```bash +mkdir -p tests/golden_sync/src/specimen_sync_client +touch tests/golden_sync/src/specimen_sync_client/py.typed +``` + +- [ ] **Step 3: Create `tests/golden_sync/README.md`** + +```markdown +# Golden files (sync surface) + +The sync-surface counterpart of `tests/golden/`: a committed full fixture +package generated with `config: { sync: true }`, package name +`specimen_sync_client`. Same structure and purpose as `tests/golden/README.md` +describes for the async (default) surface — the hand-written shell here +(`pyproject.toml`, `py.typed`) plus the generated `_generated/` subtree and +the generated package-root facade `src/specimen_sync_client/__init__.py`. + +`test_generated_sync_matches_golden` regenerates the `python-sync` fixture +artifact into a temp tree and asserts every file under the fresh +`_generated/` plus the facade equals its golden twin here, both ways (no +missing, no extra). `test_generated_sync_passes_basedpyright_strict` runs +basedpyright strict on this full golden package. + +## Updating the golden + +Run only when a generator change legitimately alters the sync surface's +output, and review the resulting diff before committing. + +```bash +mise run golden +``` + +`mise run golden` refreshes both `tests/golden/` (the `python` artifact) and +this directory (the `python-sync` artifact) in one pass — see `mise.toml`. +``` + +- [ ] **Step 4: Add `GOLDEN_DIR_SYNC` to `tests/_harness.py`** + +Line 28, immediately after `GOLDEN_DIR = HERE / "golden"`: + +```python +GOLDEN_DIR = HERE / "golden" +GOLDEN_DIR_SYNC = HERE / "golden_sync" +``` + +- [ ] **Step 5: Confirm `tests/golden/pyproject.toml` needs no change** + +`tests/golden/pyproject.toml`'s `[tool.ruff] exclude` and `packages` entries already reference only `src/specimen_client` (not a `sync/` sub-path), so this file needs no edit — the sync surface's shell lives entirely under the new `tests/golden_sync/`, a sibling directory, not nested inside the existing golden tree. (Listed in File Structure above out of caution; this step is a no-op confirmation, not an edit.) + +- [ ] **Step 6: Rewrite the `golden` task in `mise.toml`** + +Replace the `[tasks.golden]` block: + +```toml +# Regenerates the "python" and "python-sync" artifacts: a full 7-artifact +# generate peaks at ~31 GB RSS (memory goes to normalizing the generator +# closure per artifact, see ci.yml), so this keeps only the two artifacts +# golden actually needs instead of all 7. +[tasks.golden] +description = "Refresh tests/golden and tests/golden_sync from the working-tree src/" +run = ''' +#!/usr/bin/env bash +set -euo pipefail +root="$(pwd)" +tmp="$(mktemp -d)" +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/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("../../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 +rsync -a --delete artifacts/python/src/specimen_client/_generated/ "$root/tests/golden/src/specimen_client/_generated/" +cp artifacts/python/src/specimen_client/__init__.py "$root/tests/golden/src/specimen_client/__init__.py" +rsync -a --delete artifacts/python_sync/src/specimen_sync_client/_generated/ "$root/tests/golden_sync/src/specimen_sync_client/_generated/" +cp artifacts/python_sync/src/specimen_sync_client/__init__.py "$root/tests/golden_sync/src/specimen_sync_client/__init__.py" +echo "golden refreshed; review with: git diff tests/golden tests/golden_sync" +''' +``` + +Note the `cp .../sync/__init__.py` line from the original task is gone (no sync facade sub-path exists anymore); the sync surface's facade is now at the same top-level `__init__.py` position as async's, just in the sibling `python_sync` artifact directory. + +- [ ] **Step 7: Run the golden refresh** + +Run: `cd python.gen && mise run golden` +Expected: exits 0, prints `golden refreshed; review with: git diff tests/golden tests/golden_sync`. Needs a reachable Postgres (`PGN_TEST_DATABASE_URL`, default `postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable`). + +- [ ] **Step 8: Review the diff** + +Run: `cd python.gen && git status tests/golden tests/golden_sync && git diff tests/golden` +Expected: `tests/golden/src/specimen_client/_generated/sync/` and `tests/golden/src/specimen_client/sync/__init__.py` are deleted (14 files: `sync/__init__.py`, `sync/_register.py`, `sync/_runtime.py`, `sync/statements/__init__.py`, 10 `sync/statements/*.py`, package-root `sync/__init__.py`). `tests/golden_sync/src/specimen_sync_client/_generated/` is new and structurally mirrors what `tests/golden/src/specimen_client/_generated/` looked like *before* this plan minus the `sync/` nesting, with `def`/`Connection`/no-`await` content instead of `async def`/`AsyncConnection`/`await `. No other content in either tree should have changed (params, SQL, custom types, row shapes are untouched by this plan). + +- [ ] **Step 9: Commit** + +```bash +git add tests/golden tests/golden_sync tests/_harness.py mise.toml +git commit -m "python.gen: add tests/golden_sync, regenerate both golden trees for exclusive sync" +``` + +--- + +### Task 5: Update the pytest harness for two independent golden trees + +**Files:** +- Modify: `tests/conftest.py` +- Modify: `tests/test_generated.py` + +**Interfaces:** +- Consumes: `GOLDEN_DIR_SYNC` (Task 4), `artifacts/python_sync` (Task 3), package name `specimen_sync_client`. +- Produces: `full_package_sync` fixture (mirrors `full_package`), usable by any test needing an importable sync-surface package. + +- [ ] **Step 1: Add a `generated_tree_sync`-adjacent path helper and `full_package_sync` fixture to `tests/conftest.py`** + +Add after the existing `full_package` fixture (end of file): + +```python +@pytest.fixture(scope="session") +def full_package_sync(generated_tree: Path, tmp_path_factory: pytest.TempPathFactory) -> Path: + """The sync-surface counterpart of `full_package`. + + `generated_tree` already ran `pgn generate` against the full project + (all artifacts, including `python-sync`), so this reuses that one + subprocess call rather than invoking pgn again: it just points at the + sibling `python_sync` artifact directory instead of `python`. + """ + generated_tree_sync = generated_tree.parent.parent / "artifacts" / "python_sync" + root = tmp_path_factory.mktemp("pkg-sync") + shell_src = GOLDEN_DIR_SYNC / "src" + generated_src = generated_tree_sync / "src" + _ = shutil.copytree(shell_src, root / "src", ignore=shutil.ignore_patterns("_generated", "__init__.py")) + for pkg_dir in generated_src.iterdir(): + dest_pkg = root / "src" / pkg_dir.name + _ = shutil.copytree(pkg_dir / "_generated", dest_pkg / "_generated") + _ = shutil.copy2(pkg_dir / "__init__.py", dest_pkg / "__init__.py") + return root +``` + +Add `GOLDEN_DIR_SYNC` to the existing `from tests._harness import (...)` block at the top of the file: + +```python +from tests._harness import ( + FIXTURE_PROJECT, + GOLDEN_DIR, + GOLDEN_DIR_SYNC, + HERE, + SRC_DIR, + admin_database_url, + effective_database_name, + run_pgn, +) +``` + +- [ ] **Step 2: Update `tests/test_generated.py`'s module-level constants** + +Replace lines 33-39: + +```python +# The generator emits the /_generated subtree plus the package-root +# __init__.py facade; the rest of the shell (pyproject.toml, py.typed) is +# hand-written and lives in golden as a committed fixture, not produced by +# generate. +GENERATED_SUBTREE = Path("src/specimen_client/_generated") +FACADE_INIT = Path("src/specimen_client/__init__.py") +GENERATED_SUBTREE_SYNC = Path("src/specimen_sync_client/_generated") +FACADE_INIT_SYNC = Path("src/specimen_sync_client/__init__.py") +``` + +Update the import line to include `GOLDEN_DIR_SYNC`: + +```python +from tests._harness import FIXTURE_PROJECT, GOLDEN_DIR, GOLDEN_DIR_SYNC, HERE, ensure_droppable, run_pgn +``` + +- [ ] **Step 3: Simplify `test_generated_matches_golden`, add its sync counterpart** + +Replace the `for facade in (FACADE_INIT, SYNC_FACADE_INIT):` loop (lines 116-118) with a single check: + +```python + if (generated_tree / FACADE_INIT).read_text() != (GOLDEN_DIR / FACADE_INIT).read_text(): + mismatched.append(str(FACADE_INIT)) +``` + +Add a new test after `test_generated_matches_golden`: + +```python +def test_generated_sync_matches_golden(generated_tree: Path) -> None: + """Sync-surface counterpart of test_generated_matches_golden. + + generated_tree points at the "python" artifact; the sync surface's + output lives in the sibling "python_sync" artifact directory produced by + the same pgn generate call (see the generated_tree fixture). + """ + generated_tree_sync = generated_tree.parent.parent / "artifacts" / "python_sync" + produced_root = generated_tree_sync / GENERATED_SUBTREE_SYNC + golden_root = GOLDEN_DIR_SYNC / GENERATED_SUBTREE_SYNC + + produced = _relative_files(produced_root) + golden = _relative_files(golden_root) + + missing = sorted(str(p) for p in golden - produced) + extra = sorted(str(p) for p in produced - golden) + assert not missing, f"golden files not produced by the generator: {missing}" + assert not extra, f"generator emitted files absent from golden: {extra}" + + mismatched: list[str] = [] + for rel in sorted(produced, key=str): + if (produced_root / rel).read_text() != (golden_root / rel).read_text(): + mismatched.append(str(rel)) + + if (generated_tree_sync / FACADE_INIT_SYNC).read_text() != (GOLDEN_DIR_SYNC / FACADE_INIT_SYNC).read_text(): + mismatched.append(str(FACADE_INIT_SYNC)) + + assert not mismatched, ( + "generated sync output drifted from golden in: " + + ", ".join(mismatched) + + "\nupdate via: mise run golden (see tests/golden_sync/README.md)" + ) +``` + +- [ ] **Step 4: Add `test_generated_sync_passes_basedpyright_strict`** + +Add after `test_generated_passes_basedpyright_strict`: + +```python +def test_generated_sync_passes_basedpyright_strict(tmp_path: Path) -> None: + """basedpyright strict on the full sync-surface golden package.""" + config = tmp_path / "pyrightconfig.json" + _ = config.write_text( + json.dumps( + { + "pythonVersion": "3.12", + "typeCheckingMode": "strict", + "include": [str(GOLDEN_DIR_SYNC / "src")], + "venvPath": str(HARNESS_ROOT), + "venv": ".venv", + "reportMissingModuleSource": False, + } + ) + ) + result = subprocess.run( + ["basedpyright", "--project", str(config), "--outputjson"], + capture_output=True, + text=True, + ) + + if not result.stdout.strip(): + pytest.fail(f"basedpyright produced no JSON (exit {result.returncode}):\n{result.stderr}") + + summary = json.loads(result.stdout)["summary"] + assert summary["filesAnalyzed"] > 0, f"basedpyright analyzed no files; bad include path?\n{result.stdout}" + assert summary["errorCount"] == 0 and summary["warningCount"] == 0, ( + f"basedpyright strict reported issues: {summary}\n{result.stdout}" + ) +``` + +- [ ] **Step 5: Delete `test_roundtrip_sync_and_cross_surface_identity`, replace with `test_roundtrip_sync_surface`** + +Delete the whole function (lines 392-479 in the original — cross-surface identity is not a meaningful concept once each surface is its own independent generate with its own `_rows.py`/package). Replace with: + +```python +def test_roundtrip_sync_surface(full_package_sync: Path, roundtrip_db: str) -> None: + """The sync surface, generated independently with config.sync = true. + + Drives the generated sync functions (psycopg.Connection, no await) end + to end, at the same unified paths the async surface uses (no `.sync.` + sub-path) — this package was generated standalone, not alongside async. + """ + _apply_migrations(roundtrip_db) + import_module = _import_client_sync(full_package_sync) + + register = import_module("specimen_sync_client._generated._register") + mood_mod = import_module("specimen_sync_client._generated.types.mood") + point_mod = import_module("specimen_sync_client._generated.types.point_2_d") + insert = import_module("specimen_sync_client._generated.statements.insert_specimen") + get = import_module("specimen_sync_client._generated.statements.get_specimen") + by_moods = import_module("specimen_sync_client._generated.statements.list_specimens_by_moods") + bump = import_module("specimen_sync_client._generated.statements.bump_specimen_revision") + + Mood = mood_mod.Mood + Point2D = point_mod.Point2D + + conn = psycopg.connect(roundtrip_db, autocommit=True) + try: + register.register_types(conn) + + inserted = insert.insert_specimen( + conn, + doc_jsonb={"k": "v", "n": 1}, + feeling=Mood.HAPPY, + origin=Point2D(x=1.5, y=2.5), + flag=True, + small=1, + medium=2, + large=3, + ratio=0.5, + precise=0.25, + title="alpha", + code="C-1", + letter="x", + born_on=date(2020, 1, 2), + amount=Decimal("12.34"), + blob=b"\x00\x01", + doc_json=[1, 2, 3], + maybe_text=None, + maybe_int=None, + maybe_uuid=None, + maybe_ts=None, + maybe_num=None, + tags=["a", None, "b"], + related_ids=None, + grid=None, + moods=[Mood.HAPPY, None, Mood.SAD], + ) + assert isinstance(inserted.feeling, Mood) + assert inserted.feeling is Mood.HAPPY + assert isinstance(inserted.origin, Point2D) + assert inserted.doc_jsonb == {"k": "v", "n": 1} + assert inserted.moods == [Mood.HAPPY, None, Mood.SAD] + assert inserted.moods is not None + assert inserted.moods[0] is Mood.HAPPY + + specimen_id = inserted.id + hit = get.get_specimen(conn, id=specimen_id) + assert hit is not None + assert hit.id == specimen_id + assert get.get_specimen(conn, id=specimen_id + 10_000) is None + + mood_rows = by_moods.list_specimens_by_moods(conn, moods=[Mood.HAPPY]) + assert [r.id for r in mood_rows] == [specimen_id] + assert by_moods.list_specimens_by_moods(conn, moods=[Mood.SAD]) == [] + + affected = bump.bump_specimen_revision(conn, id=specimen_id) + assert affected == 1 + bumped = get.get_specimen(conn, id=specimen_id) + assert bumped is not None + assert bumped.rev == 2 + finally: + conn.close() +``` + +Add the `_import_client_sync` helper next to `_import_client` (same shape, different module-name prefix so `sys.modules` cache invalidation targets the right package): + +```python +def _import_client_sync(full_package_sync: Path): # noqa: ANN202 - dynamic module set + src = str(full_package_sync / "src") + if src not in sys.path: + sys.path.insert(0, src) + for name in list(sys.modules): + if name == "specimen_sync_client" or name.startswith("specimen_sync_client."): + del sys.modules[name] + return importlib.import_module +``` + +- [ ] **Step 6: Move `test_roundtrip_single_field_composite_sync` onto the sync-only package** + +Replace the function body: + +```python +def test_roundtrip_single_field_composite_sync(full_package_sync: Path, roundtrip_db: str) -> None: + """Sync-surface counterpart of test_roundtrip_single_field_composite.""" + _apply_migrations(roundtrip_db) + import_module = _import_client_sync(full_package_sync) + + register = import_module("specimen_sync_client._generated._register") + tag_mod = import_module("specimen_sync_client._generated.types.tag_value") + insert = import_module("specimen_sync_client._generated.statements.insert_tagged_item") + get = import_module("specimen_sync_client._generated.statements.get_tagged_item") + + TagValue = tag_mod.TagValue + + conn = psycopg.connect(roundtrip_db, autocommit=True) + try: + register.register_types(conn) + + inserted = insert.insert_tagged_item(conn, name="widget", tag=TagValue(value="blue")) + assert isinstance(inserted.tag, TagValue) + assert inserted.tag == TagValue(value="blue") + + hit = get.get_tagged_item(conn, id=inserted.id) + assert hit is not None + assert hit.tag == TagValue(value="blue") + finally: + conn.close() +``` + +- [ ] **Step 7: Run the generated-output test module** + +Run: `cd python.gen && mise run test -- tests/test_generated.py -v` +Expected: all tests pass, including the new `test_generated_sync_matches_golden`, `test_generated_sync_passes_basedpyright_strict`, `test_roundtrip_sync_surface`, and `test_roundtrip_single_field_composite_sync`. + +- [ ] **Step 8: Commit** + +```bash +git add tests/conftest.py tests/test_generated.py +git commit -m "python.gen: split sync-surface tests onto their own golden fixture" +``` + +--- + +### Task 6: Fix the config-variant tests' sync-detection signal + +**Files:** +- Modify: `tests/test_config_variants.py` + +**Interfaces:** +- Consumes: `_generated/_runtime.py` content (Task 2 made `def fetch_many`/`async def fetch_many` the sync/async tell, since there is no more `sync/` subdirectory to check for existence). + +- [ ] **Step 1: Rewrite `tests/test_config_variants.py`** + +```python +"""Pins pgn's actual YAML->Dhall decode semantics for the Optional Config knobs. + +pgn's decode behavior for record types is undocumented, so each artifact in +project1.pgn.yaml drives a different subset of `config` keys through the same +compile.dhall and the assertions below record what pgn 0.6.5 was observed to do, +not a documented contract. These variants are pinned by the directory/package +name and which surface (sync or async) they produce, not a full golden tree. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.skipif( + os.environ.get("HARNESS_CI_REDUCED") == "1", + reason=( + "variant decode semantics are pinned locally; CI runs the reduced " + "single-artifact project because pgn generate of all 7 artifacts " + "exhausts GitHub-hosted runner memory" + ), +) + + +def _artifact_src(generated_tree: Path, artifact_key: str) -> Path: + # generated_tree is /artifacts/python; artifact directories are + # named after the artifact key with "-" replaced by "_" (pgn's own doing, + # independent of the Dhall config below). + project_root = generated_tree.parent.parent + artifact_dir = artifact_key.replace("-", "_") + return project_root / "artifacts" / artifact_dir / "src" + + +def _package_dir(generated_tree: Path, artifact_key: str) -> Path: + src = _artifact_src(generated_tree, artifact_key) + packages = [p for p in src.iterdir() if p.is_dir()] + assert len(packages) == 1, f"expected exactly one package under {src}, found {packages}" + return packages[0] + + +def _is_sync(package: Path) -> bool: + """Whether the generated package's runtime module is the sync surface. + + There is no `sync/` subdirectory to check anymore (config.sync selects + which content is rendered at the *same* `_runtime.py` path); the async + body defines `async def fetch_many`, the sync body defines `def + fetch_many` with no `async`. + """ + runtime = (package / "_generated" / "_runtime.py").read_text() + return "async def fetch_many" not in runtime + + +def test_name_only_config_derives_package_and_defaults_sync_off(generated_tree: Path) -> None: + package = _package_dir(generated_tree, "python-name-only") + assert package.name == "name_only_client" + assert not _is_sync(package) + + +def test_sync_only_config_defaults_package_name_from_project(generated_tree: Path) -> None: + package = _package_dir(generated_tree, "python-sync-only") + assert package.name == "fixture" + assert _is_sync(package) + + +def test_empty_config_object_defaults_both_fields(generated_tree: Path) -> None: + package = _package_dir(generated_tree, "python-empty") + assert package.name == "fixture" + assert not _is_sync(package) + + +def test_absent_config_key_defaults_both_fields(generated_tree: Path) -> None: + """No `config:` key at all decodes the same as `config: {}` (None Config).""" + package = _package_dir(generated_tree, "python-bare") + assert package.name == "fixture" + assert not _is_sync(package) + + +def test_unknown_config_key_is_ignored_not_rejected(generated_tree: Path) -> None: + """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 _is_sync(package) + + +def test_null_value_decodes_as_absent_field(generated_tree: Path) -> None: + """`sync: null` decodes to None, same as omitting the key (default False).""" + package = _package_dir(generated_tree, "python-null") + assert package.name == "null_client" + assert not _is_sync(package) +``` + +- [ ] **Step 2: Run the config-variant tests** + +Run: `cd python.gen && mise run test -- tests/test_config_variants.py -v` +Expected: all six tests pass (unless `HARNESS_CI_REDUCED=1` is set locally, in which case they skip — matches existing behavior). + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_config_variants.py +git commit -m "python.gen: fix config-variant sync detection for unified output paths" +``` + +--- + +### Task 7: Update documentation + +**Files:** +- Modify: `README.md` +- Modify: `DESIGN.md` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Update `README.md`'s config reference table** + +Replace the table at lines 76-80: + +```markdown +| key | type | default | +| --------------- | ------------------ | ------------------------------ | +| `packageName` | `Text` | the project name, kebab-cased | +| `sync` | `Bool` | `False` | +| `onUnsupported` | `"Fail" \| "Skip"` | `"Fail"` | +``` + +- [ ] **Step 2: Update the quickstart example (lines 36-42)** + +```yaml +artifacts: + python: + gen: https://raw.githubusercontent.com/slavashvets/python.gen/master/src/package.dhall + config: + packageName: my-db-client + sync: true +``` + +- [ ] **Step 3: Update "Key features" (line 21-23)** + +```markdown +- **Async or sync client over psycopg 3.** `config.sync` (default `False`) + picks exactly one surface — the async client (`psycopg.AsyncConnection`) or + the sync one (`psycopg.Connection`) — at the same output paths either way; + install `psycopg[binary]` for the fast C implementation. +``` + +- [ ] **Step 4: Update "Using the generated code" (lines 220-240)** + +Replace the closing paragraph and code block: + +```markdown +## Using the generated code + +Import from the flat facade, not from `_generated` directly: + +```python +from my_db_client import get_specimen, GetSpecimenRow, Mood +``` + +The facade re-exports every Row dataclass, every enum/composite, and every +statement function with `X as X` markers and a matching `__all__`. If the +project has composites or enums, call `register_types(conn)` once per +connection before relying on native composite decode or enum-array results; +scalar enums do not need it. + +With `sync: true`, the entire client is generated against +`psycopg.Connection` instead — same import path, same facade shape, only the +function signatures and I/O become synchronous: + +```python +from my_db_client import get_specimen, GetSpecimenRow, Mood + +def handler(conn): + row = get_specimen(conn, id=42) +``` + +A project that needs both an async backend and a sync consumer (e.g. a +Dagster pipeline) generates two artifacts pointed at this same `gen:`, one +with `sync: true` and one without, each with its own `packageName` — not one +artifact with both surfaces bundled together. +``` + +- [ ] **Step 5: Update `DESIGN.md` sections describing the surface mechanism** + +Rewrite the "What the generator emits" / "What the generator adds when `emitSync`" split (roughly lines 43-71) into a single section describing unconditional, surface-selected emission: + +```markdown +### What the generator emits + +``` +src//__init__.py # generated facade (re-exports everything) +src//_generated/__init__.py +src//_generated/_core.py # surface-agnostic: JsonValue, NoRowError, require_array +src//_generated/_runtime.py # I/O helpers for whichever surface config.sync picked +src//_generated/_register.py # only if composites or enums +src//_generated/_rows.py # shared Row dataclasses + decode functions +src//_generated/statements/__init__.py +src//_generated/statements/.py # one thin wrapper per query, def or async def +src//_generated/types/__init__.py # only if custom types exist +src//_generated/types/.py +``` + +Exactly one surface is emitted per generate, picked by `config.sync` +(`False`, the default, emits async; `True` emits sync) — the tree shape and +every import path are identical either way; only the statement modules' +`def`/`async def`, `Connection`/`AsyncConnection`, and `_runtime.py`'s body +change. A project that needs both surfaces generates two artifacts against +this same `gen:` with different `packageName`s, one with `sync: true` and +one without. +``` + +Rewrite the "Surface mechanism" section (roughly lines 192-217) to drop the "emits both, gated by config.emitSync" framing: + +```markdown +## 4. Surface mechanism (async / sync) + +```dhall +{ defKeyword : Text -- "async def" | "def" +, connType : Text -- "AsyncConnection" | "Connection" +, awaitKw : Text -- "await " | "" +, rowsImport : Text -- ".._rows" (same depth for both surfaces) +, corePrefix : Text -- ".._core" (same depth for both surfaces) +, typesPrefix : Text -- "..types" (same depth for both surfaces) +} +``` + +`Surface.async` and `Surface.sync` are the two values. `Interpreters/ +Project.dhall` picks one — `if config.sync then Surface.sync else +Surface.async` — and threads it through a single render pass: +`Interpreters/Query.dhall` calls the statement-module template once per +query (not twice), and `Interpreters/Project.dhall` picks the matching +`_runtime.py` body and, when custom types exist, the matching `_register.py` +body. `_rows.py` and `types/` are surface-agnostic and always render exactly +once, so switching `config.sync` costs only the thin I/O wrappers. +``` + +Update the facade section (roughly lines 400-409) to remove the two-facade description: + +```markdown +- every statement function from `_generated/statements/`. + +The facade lives at `/__init__.py` (prefix `._generated`, statements +under `statements`) regardless of which surface `config.sync` selected. +``` + +Update the Config-flow section's example (roughly lines 427-429, 503-509) to use `sync` in place of `emitSync`, matching Task 1/2's rename, e.g.: + +```dhall +let Config = { packageName : Optional Text, sync : Optional Bool, onUnsupported : Optional OnUnsupported.Mode } + +let Config/default = { packageName = None Text, sync = None Bool, onUnsupported = None OnUnsupported.Mode } +``` + +And the prose right below the second code block (roughly lines 500-514, "### Config flow"): replace every `emitSync` with `sync`, and `` `emitSync` to `False` `` with `` `sync` to `False` ``. + +Also update the "Generator decomposition" file-tree block (roughly lines 450-480): `Structures/Surface.dhall`'s comment changes from "async/sync token table (section 4)" to "async/sync token table, both surfaces at the same import depth (section 4)"; `Interpreters/Query.dhall`'s comment changes from "assemble one query: shared rowDef + async/sync statement modules" to "assemble one query: shared rowDef + one statement module for whichever surface config.sync picked". + +- [ ] **Step 6: Add a `CHANGELOG.md` "Upcoming" entry** + +Add to the top of the `# Upcoming` section: + +```markdown +- **Breaking:** `emitSync` is gone. In its place, `sync : Optional Bool` + (default `False`) picks exactly one surface per generate — async or sync — + emitted at the same unified paths either way (no more `sync/` subdirectory, + no more second package-root facade). Previously `emitSync: true` added a + second, nested sync tree alongside the always-emitted async one; a project + that needs both surfaces now generates two artifacts against this same + `gen:` with different `packageName`s, one with `sync: true` and one + without. See `docs/plans/2026-07-12-configurable-sync-output.md` for the + full rationale and migration shape. `tests/golden_sync/` is a new committed + golden fixture (`specimen_sync_client`) exercising the sync surface + end-to-end (basedpyright strict + round-trip), alongside the existing + `tests/golden/` (`specimen_client`, now async-only). +``` + +- [ ] **Step 7: Commit** + +```bash +git add README.md DESIGN.md CHANGELOG.md +git commit -m "python.gen: document the exclusive sync/async config change" +``` + +--- + +### Task 8: Full verification pass + +**Files:** none (verification only). + +- [ ] **Step 1: Run the full harness** + +Run: `cd python.gen && mise run test` +Expected: all tests pass (allow ~10 minutes; most of it is pgn's own type inference against Postgres). + +- [ ] **Step 2: Confirm no stray `emitSync` references anywhere in the repo** + +Run: `cd python.gen && grep -rn "emitSync" . --include="*.dhall" --include="*.md" --include="*.py" --include="*.yaml" --include="*.toml" | grep -v "^CHANGELOG.md:"` +Expected: no output (the `CHANGELOG.md` exclusion is because older, already-shipped entries legitimately still mention the old field name as history — only today's new entry from Task 7 should be free of it going forward, and that new entry doesn't mention `emitSync` at all so the grep with the exclusion is really just a safety margin, not an expected hit). + +- [ ] **Step 3: Confirm both golden trees are internally consistent with the new fixture config** + +Run: `cd python.gen && git status --short tests/golden tests/golden_sync` +Expected: clean (everything from Task 4's regeneration already committed). + +- [ ] **Step 4: Manually skim one generated statement file from each surface** + +Run: `head -30 tests/golden/src/specimen_client/_generated/statements/get_specimen.py tests/golden_sync/src/specimen_sync_client/_generated/statements/get_specimen.py` +Expected: the async file shows `from psycopg import AsyncConnection` and `async def get_specimen(...)`; the sync file shows `from psycopg import Connection` and `def get_specimen(...)` (no `async`), both importing from `.._rows`/`.._core` (not `..._rows`/`..._core`) — confirming Task 2's depth collapse took effect. diff --git a/docs/plans/2026-07-12-distribute-rows-per-statement.md b/docs/plans/2026-07-12-distribute-rows-per-statement.md new file mode 100644 index 0000000..6e909eb --- /dev/null +++ b/docs/plans/2026-07-12-distribute-rows-per-statement.md @@ -0,0 +1,996 @@ +# Distribute Rows Per Statement Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Delete the shared `_rows.py` god-module. Each query's Row dataclass and `decode_` function move verbatim into that query's own statement module (`statements/.py`), since there is already a strict 1:1 relationship between a query and its Row (no cross-statement sharing, no deduplication — confirmed empirically: 227 queries, 227 distinct Row classes, one dataclass and one decode function per query, always named after the query itself). + +**Architecture:** `Templates/RowsModule.dhall` is deleted; its `RowDef` type and `renderRow` function move into `Templates/StatementModule.dhall`, the file's sole remaining consumer. `StatementModule.dhall`'s `Params` gains `rowDef : Optional RowDef` (replacing the old `rowClassName`-only field, which existed just to build an `import ... from _rows` line that no longer exists) and renders the Row class + decode function inline, directly above the `SQL = ...` constant. `Interpreters/Query.dhall` merges its param-side and result-side `ImportSet`s into one combined set per statement file (previously kept separate: param imports fed the statement module, result/row imports fed a project-wide accumulator for `_rows.py`). `Interpreters/Project.dhall` drops the `rowDefs`/`rowImports`/`rowsFiles` machinery entirely. `Templates/FacadeModule.dhall`'s per-statement import line grows an optional Row-class alias, so the facade imports a query's Row class from that query's own statement module instead of a shared `_rows` import block. + +**Tech Stack:** Dhall (dhall-lang 1.42 vendored via `dhall` CLI; pgn's forked interpreter for the parts still using it), Python 3.12 generated output (psycopg3), basedpyright strict, pytest golden-file harness (`mise run test`, `mise run golden`), pgn 0.9.1 (mise-pinned). + +## Global Constraints + +- **Depends on `docs/plans/2026-07-12-configurable-sync-output.md` landing first.** This plan assumes exactly one statement tree per generate at unified paths (`statements/.py`, no `sync/` nesting) — it touches the same statement-module template that plan collapsed to a single surface. Do not start this plan until that one is merged and both `tests/golden/` and `tests/golden_sync/` are green. +- No change to decode LOGIC (the `cast(...)` expressions, enum/composite decode bodies, field ordering) — this plan only relocates already-correct rendering code from one file to another. `Interpreters/Member.dhall`, `Result.dhall`, `ResultColumns.dhall` are untouched. +- Row class and decode function names are unchanged (`SelectMood000Row`, `decode_select_mood_0_0_0`, etc.) — this is a pure code-motion plan, not a renaming plan (renaming to something shorter given the new file-local context, e.g. `Row`/`decode`, is an explicitly deferred, separate concern). +- Golden fixture output (`tests/golden/`, `tests/golden_sync/`) must be regenerated via `mise run golden`, not hand-edited. +- `mise run test` must pass after regeneration, for both golden trees. +- Every Dhall file touched must independently type-check via `dhall type --file=src/package.dhall`. +- This is a breaking change for any consumer importing `from ._generated._rows import X` directly. The README already tells consumers not to do that ("Import from the flat facade, not from `_generated` directly"), so this is "you were told" breakage, not a silent one — still worth a CHANGELOG line since `_rows.py` is a real, previously-stable module path someone could have depended on anyway. + +--- + +## File Structure + +| File | Change | +|---|---| +| `src/Templates/StatementModule.dhall` | Absorb `RowDef`/`renderRow` from `RowsModule.dhall`. `Params`: replace `rowClassName : Optional Text` with `rowDef : Optional RowDef`. Drop `rowsImportLine`. Add row-driven stdlib imports (`Mapping`, `dataclass`, `cast`) and a `require_array` import line, both gated on whether the query has a row. Splice the rendered row block between the import section and the `SQL = ...` constant. | +| `src/Templates/RowsModule.dhall` | Delete. | +| `src/Interpreters/Query.dhall` | Drop the `RowsModule` import; merge param and result `ImportSet`s into one; retarget the row's type to `StatementModule.RowDef`; drop `rowDef`/`rowImports` from `Output` (both become `render`-internal only). | +| `src/Interpreters/Project.dhall` | Drop the `RowsModule` import and the `rowDefs`/`rowImports`/`rowsFiles` bindings; drop `# rowsFiles` from `allFiles`. | +| `src/Templates/FacadeModule.dhall` | Drop the separate `_rows` import block (`rowBlock`); fold each statement's Row-class alias onto its own statement import line. | +| `tests/test_unsupported_types.py` | Drop the `_rows.py` orphan-reference check and the `_rows` import-smoke-test line (row content is now private to each statement file, already covered by the existing "skipped statement file doesn't exist" checks). | +| `tests/golden/`, `tests/golden_sync/` | Regenerate via `mise run golden`; `_rows.py` disappears from both, its content redistributed into each `statements/*.py` file. | +| `README.md` | "Using the generated code" no longer needs updating (already says "import from the flat facade," unaffected by where Rows physically live) — no change needed; confirmed in Task 3. | +| `DESIGN.md` | Section "## 2. Shared type layer: `_rows.py` and `types/`" rewritten (rows are no longer shared/centralized); "Generated package layout" tree (as left by the sync-output plan) drops the `_rows.py` line; "Generator decomposition" file tree drops the `RowsModule.dhall` line and updates `StatementModule.dhall`'s description; the byte-offset example referencing `tests/golden/.../_rows.py`'s `moods` column is repointed at the new location. | +| `CHANGELOG.md` | New "Upcoming" entry documenting the removal of `_rows.py`. | + +--- + +### Task 1: Move `RowDef`/`renderRow` into `StatementModule.dhall` and render the row inline + +**Files:** +- Modify: `src/Templates/StatementModule.dhall` +- Delete: `src/Templates/RowsModule.dhall` + +**Interfaces:** +- Produces: `StatementModule.RowDef = { className : Text, fieldsBlock : Text, decodeBlock : Text, decodeName : Text }` (moved verbatim from `RowsModule.RowDef`) and `StatementModule.Params.rowDef : Optional RowDef` (replacing `rowClassName : Optional Text`). Task 2 (`Query.dhall`) is the consumer. + +- [ ] **Step 1: Read `src/Templates/RowsModule.dhall`'s `RowDef` and `renderRow` one more time to confirm the exact text being moved** + +Run: `cat src/Templates/RowsModule.dhall` +Expected (for reference — this exact text is being relocated, not rewritten): + +```dhall +let RowDef = + { className : Text + , fieldsBlock : Text + , decodeBlock : Text + , decodeName : Text + } +``` + +```dhall +let renderRow + : RowDef -> Text + = \(row : RowDef) -> + "@dataclass(frozen=True, slots=True)\n" + ++ "class " + ++ row.className + ++ ":\n" + ++ indentAll 4 row.fieldsBlock + ++ "\n\n\n" + ++ "def " + ++ row.decodeName + ++ "(row: Mapping[str, object]) -> " + ++ row.className + ++ ":\n" + ++ " return " + ++ row.className + ++ "(\n" + ++ indentAll 8 row.decodeBlock + ++ "\n )" +``` + +- [ ] **Step 2: Rewrite `src/Templates/StatementModule.dhall` in full** + +```dhall +let Prelude = ../Deps/Prelude.dhall + +let Lude = ../Deps/Lude.dhall + +let Sdk = ../Deps/Sdk.dhall + +let ImportSet = ../Structures/ImportSet.dhall + +let Surface = ../Structures/Surface.dhall + +-- A query's frozen Row dataclass plus its module-level decode function, +-- rendered directly into that query's own statement module (there is a +-- strict 1:1 relationship between a query and its Row — no cross-statement +-- sharing — so co-locating them costs nothing and removes the need for a +-- shared _rows.py import). +let RowDef = + { className : Text + , fieldsBlock : Text + , decodeBlock : Text + , decodeName : Text + } + +-- Prefix every line (including the first) with `n` spaces, leaving blank lines +-- untouched so trailing whitespace never appears. +let indentAll + : Natural -> Text -> Text + = \(n : Natural) -> + \(text : Text) -> + let pad = Prelude.Text.replicate n " " + + in pad ++ Lude.Text.indentNonEmpty n text + +let renderRow + : RowDef -> Text + = \(row : RowDef) -> + "@dataclass(frozen=True, slots=True)\n" + ++ "class " + ++ row.className + ++ ":\n" + ++ indentAll 4 row.fieldsBlock + ++ "\n\n\n" + ++ "def " + ++ row.decodeName + ++ "(row: Mapping[str, object]) -> " + ++ row.className + ++ ":\n" + ++ " return " + ++ row.className + ++ "(\n" + ++ indentAll 8 row.decodeBlock + ++ "\n )" + +-- "" when the query returns no rows (Void/RowsAffected); otherwise the +-- rendered class + decode function, framed with the same "\n\n\n" (two +-- blank lines) PEP8 spacing _rows.py used between consecutive row defs, on +-- both sides — matching the two-blank-lines-before/after convention for a +-- top-level class or function. +let renderRowBlock + : Optional RowDef -> Text + = \(rowDef : Optional RowDef) -> + merge + { None = "" + , Some = \(row : RowDef) -> "\n" ++ renderRow row ++ "\n\n\n" + } + rowDef + +let hasRow + : Optional RowDef -> Bool + = \(rowDef : Optional RowDef) -> + merge { None = False, Some = \(_ : RowDef) -> True } rowDef + +-- A statement module is the thin per-surface I/O wrapper: it renders its own +-- Row dataclass and decode function (when the query returns rows) and one +-- `async def`/`def` over a connection. `imports` carries BOTH the parameter +-- type imports and the result-column imports merged into one set +-- (Interpreters/Query.dhall combines them), since both now live in this one +-- file. +let Params = + { functionName : Text + , returnType : Text + , helperName : Text + , callsDecode : Bool + , sqlLiteral : Text + , rowDef : Optional RowDef + , decodeName : Text + , paramSigLines : List Text + , paramDictEntries : List Text + , imports : ImportSet.Type + , surface : Surface.Type + } + +let importLineIf + : Bool -> Text -> List Text + = \(cond : Bool) -> \(line : Text) -> if cond then [ line ] else [] : List Text + +-- "from datetime import ..." collapses the four datetime members into one line. +let datetimeImport + : ImportSet.Type -> List Text + = \(imports : ImportSet.Type) -> + let names = + importLineIf imports.date "date" + # importLineIf imports.datetime "datetime" + # importLineIf imports.time "time" + # importLineIf imports.timedelta "timedelta" + + in if Prelude.Bool.not (Prelude.List.null Text names) + then [ "from datetime import " ++ Prelude.Text.concatSep ", " names ] + else [] : List Text + +-- The I/O helper (fetch_*/execute_*) always comes from _runtime; JsonValue and +-- require_array, when used, come from _core via the surface's corePrefix. +let runtimeImport + : Text -> Text + = \(helperName : Text) -> "from .._runtime import " ++ helperName + +let coreImport + : Text -> Bool -> List Text + = \(corePrefix : Text) -> + \(jsonValue : Bool) -> + if jsonValue + then [ "from ${corePrefix} import JsonValue" ] + else [] : List Text + +let customImportLines + : Text -> ImportSet.Type -> List Text + = \(typesPrefix : Text) -> + \(imports : ImportSet.Type) -> + Prelude.List.map + ImportSet.CustomImport + Text + ( \(c : ImportSet.CustomImport) -> + "from ${typesPrefix}.${c.moduleName} import ${c.className}" + ) + imports.customTypes + +let renderImports + : Params -> Text + = \(params : Params) -> + let imports = params.imports + + let rowIsPresent = hasRow params.rowDef + + let stdlibBlock = + importLineIf rowIsPresent "from collections.abc import Mapping" + # importLineIf rowIsPresent "from dataclasses import dataclass" + # datetimeImport imports + # importLineIf imports.decimal "from decimal import Decimal" + # importLineIf rowIsPresent "from typing import cast" + # importLineIf imports.uuid "from uuid import UUID" + + let psycopgBlock = + [ "from psycopg import ${params.surface.connType}" ] + # importLineIf + imports.json + "from psycopg.types.json import Json" + # importLineIf + imports.jsonb + "from psycopg.types.json import Jsonb" + + let localBlock = + coreImport params.surface.corePrefix imports.jsonValue + # importLineIf + imports.enumArray + "from ${params.surface.corePrefix} import require_array" + # [ runtimeImport params.helperName ] + # customImportLines params.surface.typesPrefix imports + + let groups = + [ [ "from __future__ import annotations" ] + , stdlibBlock + , psycopgBlock + , localBlock + ] + + let nonEmptyGroups = + Prelude.List.filter + (List Text) + ( \(g : List Text) -> + Prelude.Bool.not (Prelude.List.null Text g) + ) + groups + + in Prelude.Text.concatMapSep + "\n\n" + (List Text) + (\(g : List Text) -> Prelude.Text.concatSep "\n" g) + nonEmptyGroups + +let renderSignature + : Params -> Text + = \(params : Params) -> + let hasParams = + Prelude.Bool.not (Prelude.List.null Text params.paramSigLines) + + let kwMarker = if hasParams then " *,\n" else "" + + let paramBlock = + Prelude.Text.concatMap + Text + (\(line : Text) -> " " ++ line ++ ",\n") + params.paramSigLines + + in params.surface.defKeyword + ++ " " + ++ params.functionName + ++ "(\n" + ++ " conn: ${params.surface.connType}[object],\n" + ++ kwMarker + ++ paramBlock + ++ ") -> " + ++ params.returnType + ++ ":" + +-- Emit the dict multi-line with a magic trailing comma so ruff keeps it +-- expanded at any width, which keeps the generated file format-stable. +let renderParamsDict + : Params -> Text + = \(params : Params) -> + if Prelude.List.null Text params.paramDictEntries + then "params: dict[str, object] = {}" + else "params: dict[str, object] = {\n" + ++ Prelude.Text.concatMap + Text + (\(entry : Text) -> " " ++ entry ++ ",\n") + params.paramDictEntries + ++ "}" + +let renderCall + : Params -> Text + = \(params : Params) -> + let await = params.surface.awaitKw + + in if params.callsDecode + then "return ${await}${params.helperName}(conn, _SQL, params, ${params.decodeName})" + else "return ${await}${params.helperName}(conn, _SQL, params)" + +in Sdk.Sigs.template + Params + ( \(params : Params) -> + renderImports params + ++ "\n\n" + ++ renderRowBlock params.rowDef + -- The leading backslash after the opening quotes keeps the first SQL + -- line flush (no blank line); the newline before the closing quotes is + -- the only deviation from the raw text, a harmless trailing newline for + -- psycopg. + ++ "SQL = \"\"\"\\\n" + ++ params.sqlLiteral + -- Encode once at import; the helpers take bytes so each call skips a + -- per-query str->bytes allocation (psycopg auto-prepare keys on the + -- bytes value, so equal bytes still hit the prepared-statement cache). + ++ "\n\"\"\"\n\n_SQL = SQL.encode()\n\n\n" + ++ renderSignature params + ++ "\n" + ++ indentAll 4 (renderParamsDict params) + ++ "\n" + ++ indentAll 4 (renderCall params) + ++ "\n" + ) + /\ { RowDef } +``` + +Note the final `/\ { RowDef }` — `Query.dhall` (Task 2) needs to reference `StatementModule.RowDef` as a type, the same way it used to reference `RowsModule.RowDef`, so `RowDef` must be exported from the module's record the same way `RowsModule.dhall` did (`Sdk.Sigs.template Params run /\ { RowDef }`). + +- [ ] **Step 3: Delete `src/Templates/RowsModule.dhall`** + +Run: `git rm src/Templates/RowsModule.dhall` + +- [ ] **Step 4: Confirm the file is self-contained** + +Run: `cd python.gen && grep -n "RowsModule" src/Templates/StatementModule.dhall` +Expected: no output (the new file must not reference the deleted module). + +- [ ] **Step 5: Commit** + +```bash +git add src/Templates/StatementModule.dhall +git commit -m "python.gen: move RowDef/renderRow into StatementModule, render rows inline" +``` + +(This task alone does not yet type-check the whole generator — `Query.dhall` and `Project.dhall` still reference the now-deleted `RowsModule` and the old `Params.rowClassName` field. Task 2 fixes that; run `dhall type --file=src/package.dhall` at the end of Task 2, not this one.) + +--- + +### Task 2: Merge imports and retarget `Query.dhall` and `Project.dhall` + +**Files:** +- Modify: `src/Interpreters/Query.dhall` +- Modify: `src/Interpreters/Project.dhall` + +**Interfaces:** +- Consumes: `StatementModule.RowDef` (Task 1). +- Produces: `Query.dhall`'s `Output` shrinks to `{ functionName : Text, rowClassName : Optional Text, modulePath : Text, content : Text }` (drops `rowDef` and `rowImports`, both now `render`-internal only). `Project.dhall`'s `combineOutputs` no longer builds `_rows.py`. + +- [ ] **Step 1: Rewrite `src/Interpreters/Query.dhall` in full** + +```dhall +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 Surface = ../Structures/Surface.dhall + +let OnUnsupported = ../Structures/OnUnsupported.dhall + +let ResultModule = ./Result.dhall + +let QueryFragmentsModule = ./QueryFragments.dhall + +let ParamsMember = ./ParamsMember.dhall + +let StatementModule = ../Templates/StatementModule.dhall + +let Config = + { packageName : Text + , importName : Text + , sync : Bool + , onUnsupported : OnUnsupported.Mode + } + +let Compiled = Lude.Compiled + +let Input = Model.Query + +-- A query renders to one thin statement module: its own Row dataclass and +-- decode function (when it returns rows) plus the I/O wrapper for whichever +-- surface config.sync picked. rowClassName is still surfaced here (not just +-- internal to the rendered content) because Project.dhall's facade needs the +-- name to build the re-export line; the Row's full definition does not +-- leave this module. +let Output = + { functionName : Text + , rowClassName : Optional Text + , modulePath : Text + , content : Text + } + +let render = + \(config : Config) -> + \(input : Input) -> + \(result : ResultModule.Output) -> + \(fragments : QueryFragmentsModule.Output) -> + \(params : List ParamsMember.Output) -> + -- The function name is also the module filename and the facade import name; + -- a query named like a Python keyword would emit `def class(...)`, a module + -- `class.py`, and `from ... import class` (all SyntaxErrors), so sanitize it + -- like params and result columns. SQL/dict/row lookups key off raw names. + let functionName = PyIdent.pySafeName input.name.inSnakeCase + + let decodeName = "decode_${functionName}" + + let paramSigLines = + Prelude.List.map + ParamsMember.Output + Text + (\(p : ParamsMember.Output) -> p.fieldName ++ ": " ++ p.pyType) + params + + let paramDictEntries = + Prelude.List.map + ParamsMember.Output + Text + ( \(p : ParamsMember.Output) -> + "\"" ++ p.pgName ++ "\": " ++ p.bindExpr + ) + params + + let paramImports = + ImportSet.combineAll + ( Prelude.List.map + ParamsMember.Output + ImportSet.Type + (\(p : ParamsMember.Output) -> p.imports) + params + ) + + let rowClassName = + Prelude.Optional.map + ResultModule.RowClass + Text + (\(rc : ResultModule.RowClass) -> rc.name) + result.rowClass + + let rowDef = + Prelude.Optional.map + ResultModule.RowClass + StatementModule.RowDef + ( \(rc : ResultModule.RowClass) -> + { className = rc.name + , fieldsBlock = rc.fieldsBlock + , decodeBlock = rc.decodeBlock + , decodeName + } + ) + result.rowClass + + -- The Row's own imports (JsonValue, Decimal, custom types, ...) and + -- the parameters' imports both land in this one file now, so they + -- merge into a single ImportSet instead of flowing to two separate + -- consumers (the statement module and, formerly, _rows.py). + let mergedImports = ImportSet.combine paramImports result.imports + + let surface = if config.sync then Surface.sync else Surface.async + + let content = + StatementModule.run + { functionName + , returnType = result.returnType + , helperName = result.helperName + , callsDecode = result.callsDecode + , sqlLiteral = fragments.sqlLiteral + , rowDef + , decodeName + , paramSigLines + , paramDictEntries + , imports = mergedImports + , surface + } + + in { functionName + , rowClassName + , modulePath = "statements/${functionName}.py" + , content + } + +let run = + \(config : Config) -> + \(input : Input) -> + let rowClassName = input.name.inPascalCase ++ "Row" + + in Compiled.nest + Output + input.srcPath + ( Compiled.map3 + ResultModule.Output + QueryFragmentsModule.Output + (List ParamsMember.Output) + Output + (render config input) + ( Compiled.nest + ResultModule.Output + "result" + (ResultModule.run (config /\ { rowClassName }) input.result) + ) + ( Compiled.nest + QueryFragmentsModule.Output + "sql" + (QueryFragmentsModule.run config input.fragments) + ) + ( Compiled.nest + (List ParamsMember.Output) + "params" + ( Compiled.traverseList + Model.Member + ParamsMember.Output + ( \(member : Model.Member) -> + Compiled.nest + ParamsMember.Output + member.pgName + (ParamsMember.run config member) + ) + input.params + ) + ) + ) + +in Sdk.Sigs.interpreter Config Input Output run +``` + +- [ ] **Step 2: Drop the `RowsModule` import and the `_rows.py` machinery from `src/Interpreters/Project.dhall`** + +Delete line 25 (`let RowsModule = ../Templates/RowsModule.dhall`). + +Delete the `rowDefs`, `rowImports`, and `rowsFiles` bindings from `combineOutputs` (these sat between the `runtimeModule`/`statementsInit` bindings and `statementFiles`, per the sync-output plan's Task 2 Step 3 rewrite): + +```dhall + let rowDefs = + Prelude.List.concatMap + QueryGen.Output + RowsModule.RowDef + ( \(query : QueryGen.Output) -> + Prelude.Optional.toList RowsModule.RowDef query.rowDef + ) + queries + + let rowImports = + ImportSet.combineAll + ( Prelude.List.map + QueryGen.Output + ImportSet.Type + (\(query : QueryGen.Output) -> query.rowImports) + queries + ) + + let rowsFiles = + if Prelude.List.null RowsModule.RowDef rowDefs + then [] : List Lude.File.Type + else [ { path = srcPrefix ++ "_rows.py" + , content = + RowsModule.run { rows = rowDefs, imports = rowImports } + } + ] + +``` + +(all three bindings, deleted in full — `statementFiles` becomes the very next binding after `statementsInit`.) + +Update `allFiles` to drop the now-nonexistent `rowsFiles`: + +```dhall + let allFiles = + staticFiles + # registerFiles + # typesInitFiles + # typeFiles + # statementFiles +``` + +- [ ] **Step 3: Type-check the whole generator** + +Run: `cd python.gen && dhall type --file=src/package.dhall` +Expected: prints the generator's function type, no errors. + +- [ ] **Step 4: Confirm no `RowsModule` references remain** + +Run: `cd python.gen && grep -rn "RowsModule" src/` +Expected: no output. + +- [ ] **Step 5: Commit** + +```bash +git add src/Interpreters/Query.dhall src/Interpreters/Project.dhall +git commit -m "python.gen: merge row and param imports per statement, delete _rows.py emission" +``` + +--- + +### Task 3: Update `FacadeModule.dhall`'s Row re-export + +**Files:** +- Modify: `src/Templates/FacadeModule.dhall` +- Modify: `src/Templates/CoreModule.dhall` (comment only) + +- [ ] **Step 0: Fix the stale `_rows.py` mention in `src/Templates/CoreModule.dhall`'s header comment** + +Line 9, "and _rows.py, the statement modules, and the facades import these" → "and the statement modules and facades import these" (the full comment, lines 3-10): + +```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 +-- async and sync surfaces: the JsonValue alias, the NoRowError/DecodeError +-- exceptions, and the require_array decode guard. It performs no I/O, so there is +-- exactly one copy regardless of surface. The two _runtime.py modules re-export +-- JsonValue/NoRowError/require_array from here so off-contract imports keep +-- working, and the statement modules and facades import these names from +-- _core directly. +``` + +- [ ] **Step 1: Fold the Row-class alias onto each statement's own import line** + +Replace the body of `run` (everything from `let runtimeBlock = ...` through `let allNames = ...`, i.e. lines 49-112 of the pre-this-plan file): + +```dhall +let run = + \(params : Params) -> + let runtimeBlock = + "from ${generatedPrefix}._core import " + ++ Prelude.Text.concatMapSep + ", " + Text + alias + runtimeNames + + let typeBlock = + Prelude.Text.concatMapSep + "\n" + TypeExport + ( \(t : TypeExport) -> + "from ${generatedPrefix}.types.${t.moduleName} import ${alias t.className}" + ) + params.types + + let rows = rowNames params.statements + + -- A query's Row class now lives in that query's own statement + -- module (there is no shared _rows module anymore), so the row + -- alias, when present, rides on the same import line as the + -- statement function itself: "from ...statements.fn import fn as + -- fn, RowCls as RowCls" — one line per statement, not two separate + -- import blocks. + let statementBlock = + Prelude.Text.concatMapSep + "\n" + StatementExport + ( \(s : StatementExport) -> + let rowSuffix = + merge + { None = "", Some = \(row : Text) -> ", ${alias row}" } + s.rowClassName + + in "from ${generatedPrefix}.${statementsPath}.${s.functionName} import " + ++ alias s.functionName + ++ rowSuffix + ) + params.statements + + let importGroups = + [ runtimeBlock ] + # ( if Prelude.List.null TypeExport params.types + then [] : List Text + else [ typeBlock ] + ) + # ( if Prelude.List.null StatementExport params.statements + then [] : List Text + else [ statementBlock ] + ) + + let importSection = Prelude.Text.concatSep "\n\n" importGroups + + let allNames = + runtimeNames + # Prelude.List.map + TypeExport + Text + (\(t : TypeExport) -> t.className) + params.types + # rows + # Prelude.List.map + StatementExport + Text + (\(s : StatementExport) -> s.functionName) + params.statements + + let allEntries = + Prelude.Text.concatMap + Text + (\(name : Text) -> " \"${name}\",\n") + allNames + + in '' + ${importSection} + + __all__ = [ + ${allEntries}] + '' +``` + +Note `rowNames`/`rowNames params.statements` (the `rows` binding) is still needed — it still feeds `allNames` for `__all__` — only the separate `rowBlock` import section it used to also build is gone. Leave the `rowNames` function definition itself untouched. + +- [ ] **Step 2: Type-check the whole generator** + +Run: `cd python.gen && dhall type --file=src/package.dhall` +Expected: prints the generator's function type, no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/Templates/FacadeModule.dhall src/Templates/CoreModule.dhall +git commit -m "python.gen: facade imports each Row class from its own statement module" +``` + +--- + +### Task 4: Regenerate both golden trees and fix the unsupported-types test + +**Files:** +- Modify: `tests/test_unsupported_types.py` +- Regenerate: `tests/golden/`, `tests/golden_sync/` + +**Interfaces:** +- Consumes: `mise run golden` (unchanged mechanism from the sync-output plan's Task 4 — that plan already made it regenerate both trees in one pass). + +- [ ] **Step 1: Delete the stale freeze file** + +Run: `rm -f tests/fixture-project/freeze1.pgn.yaml` + +- [ ] **Step 2: Update `tests/test_unsupported_types.py`** + +Delete the `rows = (src / "_rows.py").read_text()` line and its assertion. Before: + +```python + facade = (package_src / "__init__.py").read_text() + rows = (src / "_rows.py").read_text() + register = (src / "_register.py").read_text() + types_init = (src / "types" / "__init__.py").read_text() + for orphan in ( + "probe_unsupported", + "probe_json_array", + "probe_nested_composite", + "wrapped_point", + "WrappedPoint", + ): + assert orphan not in facade, f"facade references skipped {orphan}" + assert orphan not in rows, f"_rows references skipped {orphan}" + assert orphan not in register, f"_register references skipped {orphan}" + assert orphan not in types_init, f"types/__init__ references skipped {orphan}" +``` + +After: + +```python + facade = (package_src / "__init__.py").read_text() + register = (src / "_register.py").read_text() + types_init = (src / "types" / "__init__.py").read_text() + for orphan in ( + "probe_unsupported", + "probe_json_array", + "probe_nested_composite", + "wrapped_point", + "WrappedPoint", + ): + assert orphan not in facade, f"facade references skipped {orphan}" + assert orphan not in register, f"_register references skipped {orphan}" + assert orphan not in types_init, f"types/__init__ references skipped {orphan}" +``` + +(A skipped query's Row now lives only inside that query's own statement file, which the adjacent assertion at line 171 — `assert not (src / "statements" / f"{name}.py").exists()` — already proves never got written; there is no longer a shared file where an orphaned Row reference could leak.) + +Delete the `importlib.import_module("fixture._generated._rows")` line. Before: + +```python + importlib.import_module("fixture") + importlib.import_module("fixture._generated._register") + importlib.import_module("fixture._generated._rows") + for name in kept_statements: + importlib.import_module(f"fixture._generated.statements.{name}") +``` + +After: + +```python + importlib.import_module("fixture") + importlib.import_module("fixture._generated._register") + for name in kept_statements: + importlib.import_module(f"fixture._generated.statements.{name}") +``` + +- [ ] **Step 3: Run the golden refresh** + +Run: `cd python.gen && mise run golden` +Expected: exits 0. Needs a reachable Postgres (`PGN_TEST_DATABASE_URL`). + +- [ ] **Step 4: Review the diff** + +Run: `cd python.gen && git status tests/golden tests/golden_sync && git diff --stat tests/golden tests/golden_sync` +Expected: `tests/golden/src/specimen_client/_generated/_rows.py` and `tests/golden_sync/src/specimen_sync_client/_generated/_rows.py` are deleted. Every `_generated/statements/*.py` file in both trees grows (gains its own Row dataclass + decode function, plus the new `Mapping`/`dataclass`/`typing.cast` imports where it returns rows). `src/specimen_client/__init__.py` and `src/specimen_sync_client/__init__.py` (the facades) change: each statement's import line grows a `, RowCls as RowCls` suffix, and the old separate `from ._generated._rows import (...)` block disappears. No SQL, param, or decode-expression content should differ from before this plan — only file placement and import statements. + +- [ ] **Step 5: Spot-check one regenerated statement file** + +Run: `cat tests/golden/src/specimen_client/_generated/statements/get_specimen.py` +Expected: imports (including `from collections.abc import Mapping`, `from dataclasses import dataclass`, `from typing import cast`), then `@dataclass(frozen=True, slots=True)\nclass GetSpecimenRow:`, then `def decode_get_specimen(row: Mapping[str, object]) -> GetSpecimenRow:`, then two blank lines, then `SQL = """...`, then `async def get_specimen(...)`. No `from .._rows import` line anywhere in the file. + +- [ ] **Step 6: Commit** + +```bash +git add tests/golden tests/golden_sync tests/test_unsupported_types.py +git commit -m "python.gen: regenerate golden trees with rows distributed per statement" +``` + +--- + +### Task 5: Run the full pytest harness + +**Files:** none (verification only — Tasks 1-4 already touched every file the harness exercises; this task's job is to confirm they cohere). + +- [ ] **Step 1: Run the full harness** + +Run: `cd python.gen && mise run test` +Expected: all tests pass, including `test_generated_matches_golden`, `test_generated_sync_matches_golden`, both basedpyright-strict tests, all round-trip tests, and `test_unsupported_types.py`'s skip-mode tests. + +- [ ] **Step 2: Confirm no stray `_rows`/`RowsModule` references remain in source or tests** + +Run: `cd python.gen && grep -rln "_rows\|RowsModule" src/ tests/*.py demos/ 2>/dev/null` +Expected: no output. + +- [ ] **Step 3: Commit if Steps 1-2 required any fixes** + +If the full-suite run surfaced anything Tasks 1-4 missed, fix it here and commit; otherwise this task is a clean pass-through and needs no commit of its own. + +--- + +### Task 6: Update documentation + +**Files:** +- Modify: `DESIGN.md` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Rewrite `DESIGN.md`'s "Generated package layout" tree** + +Starting from the tree the sync-output plan (`docs/plans/2026-07-12-configurable-sync-output.md`, Task 7 Step 5) already left behind, drop the `_rows.py` line: + +```text +src//__init__.py # generated facade (re-exports everything) +src//_generated/__init__.py +src//_generated/_core.py # surface-agnostic: JsonValue, NoRowError, require_array +src//_generated/_runtime.py # I/O helpers for whichever surface config.sync picked +src//_generated/_register.py # only if composites or enums +src//_generated/statements/__init__.py +src//_generated/statements/.py # one wrapper per query: its own Row + decode + def, def or async def +src//_generated/types/__init__.py # only if custom types exist +src//_generated/types/.py +``` + +- [ ] **Step 2: Rewrite `## 2. Shared type layer: `_rows.py` and `types/`` (renumber/retitle to "## 2. Per-statement rows and the shared `types/` layer")** + +```markdown +## 2. Per-statement rows and the shared `types/` layer + +Decode is LOCAL to each statement now, not centralized. For each query that +returns rows, its own statement module holds one +`@dataclass(frozen=True, slots=True)` Row plus a module-level +`decode_(row: Mapping[str, object]) -> ` function, immediately +above the `SQL = ...` constant. There is a strict 1:1 relationship between a +query and its Row (no two queries share a Row class, even when their result +shapes are identical), so co-locating them costs nothing: + +```python +# statements/get_specimen.py +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast + +from psycopg import AsyncConnection + +from .._runtime import fetch_single + + +@dataclass(frozen=True, slots=True) +class GetSpecimenRow: + ... + + +def decode_get_specimen(row: Mapping[str, object]) -> GetSpecimenRow: + return GetSpecimenRow(...) + + +SQL = """...""" + +_SQL = SQL.encode() + + +async def get_specimen(conn: AsyncConnection[object], *, id: int) -> GetSpecimenRow | None: + ... + return await fetch_single(conn, _SQL, params, decode_get_specimen) +``` + +`Templates/StatementModule.dhall` renders both the Row and the wrapper in one +pass; `Interpreters/Query.dhall` merges the parameter-side and result-side +`ImportSet`s into one set per file, since both now live in the same module. +The package-root facade re-exports every Row from its own statement module +(`from ._generated.statements.get_specimen import get_specimen as +get_specimen, GetSpecimenRow as GetSpecimenRow`), not from a shared module. + +`types/.py` still holds the enum / composite class, unchanged by this +— custom types genuinely are shared across every query that references them, +unlike Rows. A statement module's custom-type imports (whether the type +appears in a param or a result column) all use the same `${typesPrefix}` +prefix (`..types`) now, since both originate from the same file. +``` + +- [ ] **Step 3: Update the "Generator decomposition" file tree** + +Drop the `RowsModule.dhall` line and update `StatementModule.dhall`'s description: + +```text + Templates/ + CoreModule.dhall # shared _core.py: JsonValue, NoRowError/DecodeError, require_array + RuntimeModule.dhall # async + sync _runtime.py bodies + StatementModule.dhall # one wrapper per query: its own Row dataclass + decode fn, plus the I/O def + RegisterModule.dhall # _register.py (per surface) + FacadeModule.dhall # the flat package-root facade + EnumModule.dhall / CompositeModule.dhall / TypesInit.dhall / InitModule.dhall +``` + +- [ ] **Step 4: Repoint the byte-offset example that referenced `_rows.py`** + +Run: `grep -n "_rows.py" DESIGN.md` and update the surrounding sentence (around what was originally line 600, describing the `moods` column) to reference `tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py` (or whichever statement file the example was illustrating) instead of `_rows.py`. + +- [ ] **Step 5: Add a `CHANGELOG.md` "Upcoming" entry** + +```markdown +- **Breaking:** `_rows.py` is gone. Each query's Row dataclass and + `decode_` function now live directly in that query's own statement + module (`statements/.py`), rendered immediately above the + `SQL = ...` constant, instead of in one shared, project-wide file. This + only affects code importing `from ._generated._rows import ...` + directly — the README has always said to import from the flat facade + instead, and the facade's re-exported names (`GetSpecimenRow`, + `decode_get_specimen`, etc.) are unchanged. See + `docs/plans/2026-07-12-distribute-rows-per-statement.md` for the full + rationale. +``` + +- [ ] **Step 6: Commit** + +```bash +git add DESIGN.md CHANGELOG.md +git commit -m "python.gen: document per-statement row distribution" +``` From c30e2127bf6d2b70e6c5c30c7fa1ac91af99ec5b Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sun, 12 Jul 2026 08:17:53 +0300 Subject: [PATCH 13/41] python.gen: rename emitSync config field to sync (no behavior change) --- demos/Exhaustive.dhall | 2 +- src/Interpreters/CustomType.dhall | 2 +- src/Interpreters/Member.dhall | 2 +- src/Interpreters/ParamsMember.dhall | 2 +- src/Interpreters/Primitive.dhall | 2 +- src/Interpreters/Project.dhall | 16 ++++++++-------- src/Interpreters/Query.dhall | 4 ++-- src/Interpreters/QueryFragments.dhall | 2 +- src/Interpreters/Result.dhall | 4 ++-- src/Interpreters/ResultColumns.dhall | 2 +- src/Interpreters/Scalar.dhall | 2 +- src/Interpreters/Value.dhall | 2 +- src/Templates/RuntimeModule.dhall | 2 +- src/package.dhall | 22 ++++++++++++---------- 14 files changed, 34 insertions(+), 32 deletions(-) diff --git a/demos/Exhaustive.dhall b/demos/Exhaustive.dhall index 35e8f28..584f943 100644 --- a/demos/Exhaustive.dhall +++ b/demos/Exhaustive.dhall @@ -26,7 +26,7 @@ let project = Sdk.Fixtures.Exhaustive let config = Some { packageName = None Text - , emitSync = Some True + , sync = Some True , onUnsupported = Some OnUnsupported.Mode.Skip } diff --git a/src/Interpreters/CustomType.dhall b/src/Interpreters/CustomType.dhall index abdf94f..dbde176 100644 --- a/src/Interpreters/CustomType.dhall +++ b/src/Interpreters/CustomType.dhall @@ -19,7 +19,7 @@ let CompositeModule = ../Templates/CompositeModule.dhall let Config = { packageName : Text , importName : Text - , emitSync : Bool + , sync : Bool , onUnsupported : OnUnsupported.Mode } diff --git a/src/Interpreters/Member.dhall b/src/Interpreters/Member.dhall index c4e8273..a9acd0a 100644 --- a/src/Interpreters/Member.dhall +++ b/src/Interpreters/Member.dhall @@ -17,7 +17,7 @@ let Value = ./Value.dhall let Config = { packageName : Text , importName : Text - , emitSync : Bool + , sync : Bool , onUnsupported : OnUnsupported.Mode } diff --git a/src/Interpreters/ParamsMember.dhall b/src/Interpreters/ParamsMember.dhall index 457631d..6d7b794 100644 --- a/src/Interpreters/ParamsMember.dhall +++ b/src/Interpreters/ParamsMember.dhall @@ -15,7 +15,7 @@ let Value = ./Value.dhall let Config = { packageName : Text , importName : Text - , emitSync : Bool + , sync : Bool , onUnsupported : OnUnsupported.Mode } diff --git a/src/Interpreters/Primitive.dhall b/src/Interpreters/Primitive.dhall index 052ef1b..d58e07f 100644 --- a/src/Interpreters/Primitive.dhall +++ b/src/Interpreters/Primitive.dhall @@ -11,7 +11,7 @@ let OnUnsupported = ../Structures/OnUnsupported.dhall let Config = { packageName : Text , importName : Text - , emitSync : Bool + , sync : Bool , onUnsupported : OnUnsupported.Mode } diff --git a/src/Interpreters/Project.dhall b/src/Interpreters/Project.dhall index f142a99..e2dc47b 100644 --- a/src/Interpreters/Project.dhall +++ b/src/Interpreters/Project.dhall @@ -35,11 +35,11 @@ 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 +-- name, sync 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 + , sync : Optional Bool , onUnsupported : Optional OnUnsupported.Mode } @@ -48,7 +48,7 @@ let Config = let ResolvedConfig = { packageName : Text , importName : Text - , emitSync : Bool + , sync : Bool , onUnsupported : OnUnsupported.Mode } @@ -269,7 +269,7 @@ let combineOutputs = else [] : List Lude.File.Type -- The sync surface mirrors the async one under `sync/`, gated on - -- config.emitSync. It reuses the shared `_rows.py` and `types/`, so only + -- config.sync. It reuses the shared `_rows.py` and `types/`, so only -- the I/O wrappers (statements, runtime, register) and the sync facade -- are sync-specific. let syncStatementFiles = @@ -322,7 +322,7 @@ let combineOutputs = } let syncFiles = - if config.emitSync + if config.sync then [ syncSubpackageInit , syncRuntime , syncStatementsInit @@ -377,10 +377,10 @@ let run = (\(t : Text) -> t) input.name.inKebabCase - let emitSync = + let sync = Prelude.Optional.fold Bool - config.emitSync + config.sync Bool (\(b : Bool) -> b) False @@ -397,7 +397,7 @@ let run = let resolvedConfig : ResolvedConfig - = { packageName, importName, emitSync, onUnsupported } + = { packageName, importName, sync, onUnsupported } let skip = merge { Fail = False, Skip = True } resolvedConfig.onUnsupported diff --git a/src/Interpreters/Query.dhall b/src/Interpreters/Query.dhall index b3e86cd..caa7c5e 100644 --- a/src/Interpreters/Query.dhall +++ b/src/Interpreters/Query.dhall @@ -25,7 +25,7 @@ let StatementModule = ../Templates/StatementModule.dhall let Config = { packageName : Text , importName : Text - , emitSync : Bool + , sync : Bool , onUnsupported : OnUnsupported.Mode } @@ -37,7 +37,7 @@ let Input = Model.Query -- A query contributes a shared Row (assembled into `_rows.py` by Project) plus a -- thin statement module per surface. asyncModule is always emitted; syncModule is --- emitted only when config.emitSync. rowImports are the result-column imports, +-- emitted only when config.sync. rowImports are the result-column imports, -- folded into `_rows.py`. let Output = { functionName : Text diff --git a/src/Interpreters/QueryFragments.dhall b/src/Interpreters/QueryFragments.dhall index 7813900..a8a3cf0 100644 --- a/src/Interpreters/QueryFragments.dhall +++ b/src/Interpreters/QueryFragments.dhall @@ -13,7 +13,7 @@ let OnUnsupported = ../Structures/OnUnsupported.dhall let Config = { packageName : Text , importName : Text - , emitSync : Bool + , sync : Bool , onUnsupported : OnUnsupported.Mode } diff --git a/src/Interpreters/Result.dhall b/src/Interpreters/Result.dhall index b20190d..a54bd6e 100644 --- a/src/Interpreters/Result.dhall +++ b/src/Interpreters/Result.dhall @@ -22,7 +22,7 @@ let Compiled = Lude.Compiled let Config = { packageName : Text , importName : Text - , emitSync : Bool + , sync : Bool , onUnsupported : OnUnsupported.Mode , rowClassName : Text } @@ -91,7 +91,7 @@ let rowsOutput = } ) ( ResultColumns.run - config.{ packageName, importName, emitSync, onUnsupported } + config.{ packageName, importName, sync, onUnsupported } columns ) diff --git a/src/Interpreters/ResultColumns.dhall b/src/Interpreters/ResultColumns.dhall index 6a589a2..e6be05e 100644 --- a/src/Interpreters/ResultColumns.dhall +++ b/src/Interpreters/ResultColumns.dhall @@ -17,7 +17,7 @@ let Compiled = Lude.Compiled let Config = { packageName : Text , importName : Text - , emitSync : Bool + , sync : Bool , onUnsupported : OnUnsupported.Mode } diff --git a/src/Interpreters/Scalar.dhall b/src/Interpreters/Scalar.dhall index 63ec788..78939f4 100644 --- a/src/Interpreters/Scalar.dhall +++ b/src/Interpreters/Scalar.dhall @@ -13,7 +13,7 @@ let Primitive = ./Primitive.dhall let Config = { packageName : Text , importName : Text - , emitSync : Bool + , sync : Bool , onUnsupported : OnUnsupported.Mode } diff --git a/src/Interpreters/Value.dhall b/src/Interpreters/Value.dhall index e1c8d6a..b9ff26f 100644 --- a/src/Interpreters/Value.dhall +++ b/src/Interpreters/Value.dhall @@ -15,7 +15,7 @@ let Scalar = ./Scalar.dhall let Config = { packageName : Text , importName : Text - , emitSync : Bool + , sync : Bool , onUnsupported : OnUnsupported.Mode } diff --git a/src/Templates/RuntimeModule.dhall b/src/Templates/RuntimeModule.dhall index baa56bc..fa7e3f0 100644 --- a/src/Templates/RuntimeModule.dhall +++ b/src/Templates/RuntimeModule.dhall @@ -81,7 +81,7 @@ let content = _ = await cur.execute(sql, params) '' --- The sync mirror, emitted at _generated/sync/_runtime.py when emitSync. The +-- The sync mirror, emitted at _generated/sync/_runtime.py when config.sync is True. The -- five helpers are the same shape with `def`/`Connection`/`with`/no-`await`. -- JsonValue/NoRowError/require_array are re-exported from _core (two levels up) -- so both surfaces share one canonical identity rather than two equal-but- diff --git a/src/package.dhall b/src/package.dhall index 5161367..b3c282e 100644 --- a/src/package.dhall +++ b/src/package.dhall @@ -4,25 +4,27 @@ 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; +-- User-facing config for this generator. `sync` picks which single surface +-- the generator emits: `False` (default) emits the async surface +-- (psycopg.AsyncConnection); `True` emits the sync surface +-- (psycopg.Connection) instead, at the exact same paths — flipping this +-- flag never changes the output tree's shape or any import path, only file +-- contents. `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 + , sync : Optional Bool , onUnsupported : Optional OnUnsupported.Mode } : Type let Config/default : Config = { packageName = None Text - , emitSync = None Bool + , sync = None Bool , onUnsupported = None OnUnsupported.Mode } From be513908512a07dd93c6e11da570ab0c4d210556 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sun, 12 Jul 2026 08:32:42 +0300 Subject: [PATCH 14/41] python.gen: make sync/async mutually exclusive at unified output paths --- src/Interpreters/Project.dhall | 104 ++++++------------------------ src/Interpreters/Query.dhall | 49 +++++++------- src/Structures/Surface.dhall | 24 +++---- src/Templates/FacadeModule.dhall | 25 ++++--- src/Templates/RuntimeModule.dhall | 11 ++-- 5 files changed, 69 insertions(+), 144 deletions(-) diff --git a/src/Interpreters/Project.dhall b/src/Interpreters/Project.dhall index e2dc47b..cbdbcaa 100644 --- a/src/Interpreters/Project.dhall +++ b/src/Interpreters/Project.dhall @@ -154,24 +154,25 @@ let combineOutputs = ) customTypes - let asyncFacade = + let surface = if config.sync then Surface.sync else Surface.async + + let facade = { path = packagePrefix ++ "__init__.py" , content = FacadeModule.run - { generatedPrefix = "._generated" - , statementsPath = "statements" - , statements = facadeStatements - , types = facadeTypes - } + { statements = facadeStatements, types = facadeTypes } } -- Surface-agnostic; performs no I/O, so exactly one copy is emitted - -- regardless of surface. Both _runtime.py modules re-export from it. + -- regardless of surface. Both runtime bodies re-export from it. let coreModule = { path = srcPrefix ++ "_core.py", content = CoreModule.run {=} } let runtimeModule = - { path = srcPrefix ++ "_runtime.py", content = RuntimeModule.run {=} } + { path = srcPrefix ++ "_runtime.py" + , content = + if config.sync then RuntimeModule.runSync {=} else RuntimeModule.run {=} + } let statementsInit = { path = srcPrefix ++ "statements/__init__.py" @@ -179,9 +180,8 @@ let combineOutputs = InitModule.run { docstring = "Generated SQL statements." } } - -- The shared Row dataclasses + decode functions, imported by both the - -- async and sync statement modules so the two surfaces share one set of - -- types (cross-surface identity). + -- The shared Row dataclasses + decode functions, imported by the + -- statement modules of whichever surface was selected. let rowDefs = Prelude.List.concatMap QueryGen.Output @@ -209,13 +209,13 @@ let combineOutputs = } ] - let asyncStatementFiles = + let statementFiles = Prelude.List.map QueryGen.Output Lude.File.Type ( \(query : QueryGen.Output) -> - { path = srcPrefix ++ query.asyncModulePath - , content = query.asyncContent + { path = srcPrefix ++ query.modulePath + , content = query.content } ) queries @@ -262,87 +262,21 @@ let combineOutputs = if hasCustomRegistration then [ { path = srcPrefix ++ "_register.py" , content = - RegisterModule.run - { compositeNames, enumNames, surface = Surface.async } + RegisterModule.run { compositeNames, enumNames, surface } } ] else [] : List Lude.File.Type - -- The sync surface mirrors the async one under `sync/`, gated on - -- config.sync. It reuses the shared `_rows.py` and `types/`, so only - -- the I/O wrappers (statements, runtime, register) and the sync facade - -- are sync-specific. - let syncStatementFiles = - Prelude.List.map - QueryGen.Output - Lude.File.Type - ( \(query : QueryGen.Output) -> - { path = srcPrefix ++ query.syncModulePath - , content = query.syncContent - } - ) - queries - - let syncSubpackageInit = - { path = srcPrefix ++ "sync/__init__.py" - , content = - InitModule.run { docstring = "Generated sync database client." } - } - - let syncStatementsInit = - { path = srcPrefix ++ "sync/statements/__init__.py" - , content = - InitModule.run { docstring = "Generated SQL statements (sync)." } - } - - let syncRuntime = - { path = srcPrefix ++ "sync/_runtime.py" - , content = RuntimeModule.runSync {=} - } - - let syncRegisterFiles = - if hasCustomRegistration - then [ { path = srcPrefix ++ "sync/_register.py" - , content = - RegisterModule.run - { compositeNames, enumNames, surface = Surface.sync } - } - ] - else [] : List Lude.File.Type - - let syncFacade = - { path = packagePrefix ++ "sync/__init__.py" - , content = - FacadeModule.run - { generatedPrefix = ".._generated" - , statementsPath = "sync.statements" - , statements = facadeStatements - , types = facadeTypes - } - } - - let syncFiles = - if config.sync - then [ syncSubpackageInit - , syncRuntime - , syncStatementsInit - , syncFacade - ] - # syncRegisterFiles - # syncStatementFiles - else [] : List Lude.File.Type - - let asyncStaticFiles = - [ asyncFacade, topInit, coreModule, runtimeModule, statementsInit ] + let staticFiles = + [ facade, topInit, coreModule, runtimeModule, statementsInit ] let allFiles = - asyncStaticFiles + staticFiles # registerFiles # rowsFiles # typesInitFiles # typeFiles - # asyncStatementFiles - # syncFiles + # statementFiles in Prelude.List.map Lude.File.Type Lude.File.Type withHeader allFiles : Output diff --git a/src/Interpreters/Query.dhall b/src/Interpreters/Query.dhall index caa7c5e..e5e3aa7 100644 --- a/src/Interpreters/Query.dhall +++ b/src/Interpreters/Query.dhall @@ -35,19 +35,15 @@ let Model = ../Deps/Contract.dhall let Input = Model.Query --- A query contributes a shared Row (assembled into `_rows.py` by Project) plus a --- thin statement module per surface. asyncModule is always emitted; syncModule is --- emitted only when config.sync. rowImports are the result-column imports, --- folded into `_rows.py`. +-- A query contributes a shared Row (assembled into `_rows.py` by Project) plus +-- one thin statement module, rendered for whichever surface config.sync picked. let Output = { functionName : Text , rowClassName : Optional Text , rowDef : Optional RowsModule.RowDef , rowImports : ImportSet.Type - , asyncModulePath : Text - , asyncContent : Text - , syncModulePath : Text - , syncContent : Text + , modulePath : Text + , content : Text } let render = @@ -109,30 +105,29 @@ let render = ) result.rowClass - let mkModule = - \(surface : Surface.Type) -> - StatementModule.run - { functionName - , returnType = result.returnType - , helperName = result.helperName - , callsDecode = result.callsDecode - , sqlLiteral = fragments.sqlLiteral - , rowClassName - , decodeName - , paramSigLines - , paramDictEntries - , imports = paramImports - , surface - } + let surface = if config.sync then Surface.sync else Surface.async + + let content = + StatementModule.run + { functionName + , returnType = result.returnType + , helperName = result.helperName + , callsDecode = result.callsDecode + , sqlLiteral = fragments.sqlLiteral + , rowClassName + , decodeName + , paramSigLines + , paramDictEntries + , imports = paramImports + , surface + } in { functionName , rowClassName , rowDef , rowImports = result.imports - , asyncModulePath = "statements/${functionName}.py" - , asyncContent = mkModule Surface.async - , syncModulePath = "sync/statements/${functionName}.py" - , syncContent = mkModule Surface.sync + , modulePath = "statements/${functionName}.py" + , content } let run = diff --git a/src/Structures/Surface.dhall b/src/Structures/Surface.dhall index 50019bc..fc8552e 100644 --- a/src/Structures/Surface.dhall +++ b/src/Structures/Surface.dhall @@ -1,15 +1,11 @@ -- A code-generation surface: the async or sync flavour of a statement module. --- Backend (async) and ingest (sync) share one project, one generate, and the --- same Row dataclasses + enums; only the I/O wrapper differs per surface. The --- fields are the exact tokens that vary between `async def`/`def`, --- `AsyncConnection`/`Connection`, `await `/``, and the relative-import depth of --- the shared `_rows`/`types` modules (sync statement modules sit one package --- deeper under `sync/statements/`, so they reach the shared modules with an --- extra dot). The runtime import is `.._runtime` for both surfaces (async from --- `statements/`, sync from `sync/statements/`), so it needs no field here. --- corePrefix mirrors rowsImport's depth: statement modules import JsonValue from --- `_core` directly, so sync reaches it with an extra dot (`..._core`) exactly --- like it reaches `_rows` (`..._rows`). +-- Exactly one surface is emitted per generate (Interpreters/Project.dhall +-- picks async or sync from config.sync and renders everything at the same +-- unified paths), so the two Surface values differ only in the tokens that +-- vary between `async def`/`def`, `AsyncConnection`/`Connection`, and +-- `await `/``. Both reach the shared `_rows`/`_core`/`types` modules at the +-- same relative import depth, since neither surface is nested under a +-- surface-named subdirectory. let Surface = { defKeyword : Text , connType : Text @@ -34,9 +30,9 @@ let sync = { defKeyword = "def" , connType = "Connection" , awaitKw = "" - , rowsImport = "..._rows" - , corePrefix = "..._core" - , typesPrefix = "...types" + , rowsImport = ".._rows" + , corePrefix = ".._core" + , typesPrefix = "..types" } in { Type = Surface, async, sync } diff --git a/src/Templates/FacadeModule.dhall b/src/Templates/FacadeModule.dhall index 18d6f2e..b1b0d44 100644 --- a/src/Templates/FacadeModule.dhall +++ b/src/Templates/FacadeModule.dhall @@ -10,16 +10,15 @@ let StatementExport = -- A custom type re-exported from types/: the leaf module name plus the class. let TypeExport = { moduleName : Text, className : Text } --- generatedPrefix and statementsPath select the surface: the async facade lives --- at the package root (prefix `._generated`, statements under `statements`); the --- sync facade lives one level down at `sync/__init__.py` (prefix `.._generated`, --- statements under `sync.statements`). Both re-export the SAME Row dataclasses --- from `_rows` and the SAME enums/composites from `types`, so the two surfaces --- share one set of types. +-- The facade always lives at the package root, importing from `_generated` +-- (prefix `._generated`) and `_generated/statements` (statementsPath +-- `statements`) — both constant now that exactly one surface is emitted per +-- generate (Interpreters/Project.dhall picks it via config.sync). +let generatedPrefix = "._generated" +let statementsPath = "statements" + let Params = - { generatedPrefix : Text - , statementsPath : Text - , statements : List StatementExport + { statements : List StatementExport , types : List TypeExport } @@ -48,7 +47,7 @@ let runtimeNames = [ "JsonValue", "NoRowError" ] let run = \(params : Params) -> let runtimeBlock = - "from ${params.generatedPrefix}._core import " + "from ${generatedPrefix}._core import " ++ Prelude.Text.concatMapSep ", " Text @@ -60,14 +59,14 @@ let run = "\n" TypeExport ( \(t : TypeExport) -> - "from ${params.generatedPrefix}.types.${t.moduleName} import ${alias t.className}" + "from ${generatedPrefix}.types.${t.moduleName} import ${alias t.className}" ) params.types let rows = rowNames params.statements let rowBlock = - "from ${params.generatedPrefix}._rows import (\n" + "from ${generatedPrefix}._rows import (\n" ++ Prelude.Text.concatMap Text (\(name : Text) -> " ${alias name},\n") @@ -79,7 +78,7 @@ let run = "\n" StatementExport ( \(s : StatementExport) -> - "from ${params.generatedPrefix}.${params.statementsPath}.${s.functionName} import ${alias s.functionName}" + "from ${generatedPrefix}.${statementsPath}.${s.functionName} import ${alias s.functionName}" ) params.statements diff --git a/src/Templates/RuntimeModule.dhall b/src/Templates/RuntimeModule.dhall index fa7e3f0..e13a2d0 100644 --- a/src/Templates/RuntimeModule.dhall +++ b/src/Templates/RuntimeModule.dhall @@ -81,10 +81,11 @@ let content = _ = await cur.execute(sql, params) '' --- The sync mirror, emitted at _generated/sync/_runtime.py when config.sync is True. The --- five helpers are the same shape with `def`/`Connection`/`with`/no-`await`. --- JsonValue/NoRowError/require_array are re-exported from _core (two levels up) --- so both surfaces share one canonical identity rather than two equal-but- +-- The sync surface's body, selected in place of `content` at the same +-- `_runtime.py` path when config.sync is True (Interpreters/Project.dhall). +-- The five helpers are the same shape with `def`/`Connection`/`with`/no- +-- `await`. JsonValue/NoRowError/require_array are re-exported from _core so +-- both surfaces share one canonical identity rather than two equal-but- -- distinct definitions. let syncContent = '' @@ -96,7 +97,7 @@ let syncContent = from psycopg import Connection from psycopg.rows import dict_row - from .._core import JsonValue as JsonValue, NoRowError as NoRowError, require_array as require_array + from ._core import JsonValue as JsonValue, NoRowError as NoRowError, require_array as require_array _T = TypeVar("_T") _Row = Mapping[str, object] From cf6b165b84eb7e410a37a806972938c35104ff50 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sun, 12 Jul 2026 08:36:12 +0300 Subject: [PATCH 15/41] python.gen: add python-sync fixture artifact, drop emitSync from main artifact --- tests/fixture-project/project1.pgn.yaml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/fixture-project/project1.pgn.yaml b/tests/fixture-project/project1.pgn.yaml index ff75999..c6fc885 100644 --- a/tests/fixture-project/project1.pgn.yaml +++ b/tests/fixture-project/project1.pgn.yaml @@ -7,7 +7,11 @@ artifacts: gen: ../../src/package.dhall config: packageName: specimen-client - emitSync: true + python-sync: + gen: ../../src/package.dhall + config: + packageName: specimen-sync-client + sync: true # The variants below pin pgn's actual YAML->Dhall decode semantics for the # Optional Config knobs (undocumented by pgn itself); see # tests/test_config_variants.py for the assertions. @@ -18,7 +22,7 @@ artifacts: python-sync-only: gen: ../../src/package.dhall config: - emitSync: true + sync: true python-empty: gen: ../../src/package.dhall config: {} @@ -33,4 +37,4 @@ artifacts: gen: ../../src/package.dhall config: packageName: null-client - emitSync: null + sync: null From fe5f82b84be8d04c5436fdb18928f5cc883ef910 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sun, 12 Jul 2026 08:57:59 +0300 Subject: [PATCH 16/41] python.gen: add tests/golden_sync, regenerate both golden trees for exclusive sync --- mise.toml | 11 +- tests/_harness.py | 1 + .../src/specimen_client/_generated/_rows.py | 33 +- .../_generated/statements/insert_specimen.py | 7 +- .../statements/insert_tagged_item.py | 2 +- .../statements/list_specimens_by_feeling.py | 2 +- .../statements/list_specimens_by_moods.py | 2 +- .../specimen_client/_generated/types/mood.py | 8 + .../_generated/types/point_2_d.py | 8 + .../_generated/types/tag_value.py | 8 + .../src/specimen_client/sync/__init__.py | 63 ---- tests/golden_sync/README.md | 26 ++ tests/golden_sync/pyproject.toml | 15 + .../src/specimen_sync_client/__init__.py | 63 ++++ .../_generated}/__init__.py | 2 +- .../specimen_sync_client/_generated/_core.py | 35 ++ .../_generated}/_register.py | 0 .../specimen_sync_client/_generated/_rows.py | 303 ++++++++++++++++++ .../_generated}/_runtime.py | 2 +- .../_generated/statements}/__init__.py | 2 +- .../statements/bump_specimen_revision.py | 0 .../_generated}/statements/get_specimen.py | 2 +- .../_generated}/statements/get_tagged_item.py | 2 +- .../_generated}/statements/insert_specimen.py | 15 +- .../statements/insert_tagged_item.py | 6 +- .../statements/list_specimens_by_class.py | 2 +- .../statements/list_specimens_by_feeling.py | 6 +- .../statements/list_specimens_by_ids.py | 2 +- .../statements/list_specimens_by_moods.py | 6 +- .../list_specimens_keyword_column.py | 2 +- .../statements/search_specimens.py | 4 +- .../_generated/types/__init__.py | 7 + .../_generated/types/mood.py | 19 ++ .../_generated/types/point_2_d.py | 25 ++ .../_generated/types/tag_value.py | 24 ++ .../src/specimen_sync_client/py.typed | 0 36 files changed, 605 insertions(+), 110 deletions(-) delete mode 100644 tests/golden/src/specimen_client/sync/__init__.py create mode 100644 tests/golden_sync/README.md create mode 100644 tests/golden_sync/pyproject.toml create mode 100644 tests/golden_sync/src/specimen_sync_client/__init__.py rename tests/{golden/src/specimen_client/_generated/sync/statements => golden_sync/src/specimen_sync_client/_generated}/__init__.py (73%) create mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/_core.py rename tests/{golden/src/specimen_client/_generated/sync => golden_sync/src/specimen_sync_client/_generated}/_register.py (100%) create mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/_rows.py rename tests/{golden/src/specimen_client/_generated/sync => golden_sync/src/specimen_sync_client/_generated}/_runtime.py (94%) rename tests/{golden/src/specimen_client/_generated/sync => golden_sync/src/specimen_sync_client/_generated/statements}/__init__.py (80%) rename tests/{golden/src/specimen_client/_generated/sync => golden_sync/src/specimen_sync_client/_generated}/statements/bump_specimen_revision.py (100%) rename tests/{golden/src/specimen_client/_generated/sync => golden_sync/src/specimen_sync_client/_generated}/statements/get_specimen.py (93%) rename tests/{golden/src/specimen_client/_generated/sync => golden_sync/src/specimen_sync_client/_generated}/statements/get_tagged_item.py (91%) rename tests/{golden/src/specimen_client/_generated/sync => golden_sync/src/specimen_sync_client/_generated}/statements/insert_specimen.py (89%) rename tests/{golden/src/specimen_client/_generated/sync => golden_sync/src/specimen_sync_client/_generated}/statements/insert_tagged_item.py (84%) rename tests/{golden/src/specimen_client/_generated/sync => golden_sync/src/specimen_sync_client/_generated}/statements/list_specimens_by_class.py (92%) rename tests/{golden/src/specimen_client/_generated/sync => golden_sync/src/specimen_sync_client/_generated}/statements/list_specimens_by_feeling.py (79%) rename tests/{golden/src/specimen_client/_generated/sync => golden_sync/src/specimen_sync_client/_generated}/statements/list_specimens_by_ids.py (90%) rename tests/{golden/src/specimen_client/_generated/sync => golden_sync/src/specimen_sync_client/_generated}/statements/list_specimens_by_moods.py (78%) rename tests/{golden/src/specimen_client/_generated/sync => golden_sync/src/specimen_sync_client/_generated}/statements/list_specimens_keyword_column.py (91%) rename tests/{golden/src/specimen_client/_generated/sync => golden_sync/src/specimen_sync_client/_generated}/statements/search_specimens.py (92%) create mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/types/__init__.py create mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/types/mood.py create mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/types/point_2_d.py create mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/types/tag_value.py create mode 100644 tests/golden_sync/src/specimen_sync_client/py.typed diff --git a/mise.toml b/mise.toml index 68903bf..3855c94 100644 --- a/mise.toml +++ b/mise.toml @@ -37,8 +37,12 @@ run = "bench/generate.sh without" # see ci.yml), a single-artifact one at ~10 GB. The temp copy guarantees a # fresh resolve of the working-tree src/ (no stale freeze) and keeps pgn's # scratch files out of the repo. +# Regenerates the "python" and "python-sync" artifacts: a full 7-artifact +# generate peaks at ~31 GB RSS (memory goes to normalizing the generator +# closure per artifact, see ci.yml), so this keeps only the two artifacts +# golden actually needs instead of all 7. [tasks.golden] -description = "Refresh tests/golden from the working-tree src/ (single-artifact run)" +description = "Refresh tests/golden and tests/golden_sync from the working-tree src/" run = ''' #!/usr/bin/env bash set -euo pipefail @@ -61,6 +65,7 @@ cd "$tmp/fixture" pgn --database-url "${PGN_TEST_DATABASE_URL:-postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable}" generate rsync -a --delete artifacts/python/src/specimen_client/_generated/ "$root/tests/golden/src/specimen_client/_generated/" cp artifacts/python/src/specimen_client/__init__.py "$root/tests/golden/src/specimen_client/__init__.py" -cp artifacts/python/src/specimen_client/sync/__init__.py "$root/tests/golden/src/specimen_client/sync/__init__.py" -echo "golden refreshed; review with: git diff tests/golden" +rsync -a --delete artifacts/python_sync/src/specimen_sync_client/_generated/ "$root/tests/golden_sync/src/specimen_sync_client/_generated/" +cp artifacts/python_sync/src/specimen_sync_client/__init__.py "$root/tests/golden_sync/src/specimen_sync_client/__init__.py" +echo "golden refreshed; review with: git diff tests/golden tests/golden_sync" ''' diff --git a/tests/_harness.py b/tests/_harness.py index 9e5ffbd..9b90292 100644 --- a/tests/_harness.py +++ b/tests/_harness.py @@ -26,6 +26,7 @@ SRC_DIR = HERE.parent / "src" FIXTURE_PROJECT = HERE / "fixture-project" GOLDEN_DIR = HERE / "golden" +GOLDEN_DIR_SYNC = HERE / "golden_sync" # pgn creates its own temp database from this admin URL; we never write into the # target database itself. Default points at a local Postgres on the standard port; diff --git a/tests/golden/src/specimen_client/_generated/_rows.py b/tests/golden/src/specimen_client/_generated/_rows.py index e5e586d..b413258 100644 --- a/tests/golden/src/specimen_client/_generated/_rows.py +++ b/tests/golden/src/specimen_client/_generated/_rows.py @@ -16,6 +16,15 @@ from .types.mood import Mood from .types.point_2_d import Point2D from .types.tag_value import TagValue +from .types.mood import Mood +from .types.mood import Mood +from .types.point_2_d import Point2D +from .types.tag_value import TagValue +from .types.mood import Mood +from .types.point_2_d import Point2D +from .types.mood import Mood +from .types.mood import Mood +from .types.mood import Mood @dataclass(frozen=True, slots=True) @@ -79,8 +88,8 @@ def decode_get_specimen(row: Mapping[str, object]) -> GetSpecimenRow: tags=cast(list[str | None], row["tags"]), related_ids=cast(list[UUID | None] | None, row["related_ids"]), grid=cast(list[int | None] | None, row["grid"]), - feeling=Mood(cast(str, row["feeling"])), - origin=None if row["origin"] is None else Point2D(*cast(tuple[float | None, float | None], row["origin"])), + feeling=Mood._decode(row["feeling"]), + origin=None if row["origin"] is None else Point2D._decode(row["origin"]), label=cast(str, row["label"]), rev=cast(int, row["rev"]), meta=cast(JsonValue, row["meta"]), @@ -98,7 +107,7 @@ def decode_get_tagged_item(row: Mapping[str, object]) -> GetTaggedItemRow: return GetTaggedItemRow( id=cast(int, row["id"]), name=cast(str, row["name"]), - tag=TagValue(*cast(tuple[str | None], row["tag"])), + tag=TagValue._decode(row["tag"]), ) @@ -164,9 +173,9 @@ def decode_insert_specimen(row: Mapping[str, object]) -> InsertSpecimenRow: tags=cast(list[str | None], row["tags"]), related_ids=cast(list[UUID | None] | None, row["related_ids"]), grid=cast(list[int | None] | None, row["grid"]), - feeling=Mood(cast(str, row["feeling"])), - moods=None if row["moods"] is None else [None if v is None else Mood(v) for v in cast(list[str | None], require_array(row["moods"]))], - origin=None if row["origin"] is None else Point2D(*cast(tuple[float | None, float | None], row["origin"])), + feeling=Mood._decode(row["feeling"]), + moods=None if row["moods"] is None else [None if v is None else Mood._decode(v) for v in cast(list[str | None], require_array(row["moods"]))], + origin=None if row["origin"] is None else Point2D._decode(row["origin"]), label=cast(str, row["label"]), rev=cast(int, row["rev"]), meta=cast(JsonValue, row["meta"]), @@ -184,7 +193,7 @@ def decode_insert_tagged_item(row: Mapping[str, object]) -> InsertTaggedItemRow: return InsertTaggedItemRow( id=cast(int, row["id"]), name=cast(str, row["name"]), - tag=TagValue(*cast(tuple[str | None], row["tag"])), + tag=TagValue._decode(row["tag"]), ) @@ -218,11 +227,11 @@ def decode_list_specimens_by_feeling(row: Mapping[str, object]) -> ListSpecimens return ListSpecimensByFeelingRow( id=cast(int, row["id"]), pub_id=cast(UUID, row["pub_id"]), - feeling=Mood(cast(str, row["feeling"])), + feeling=Mood._decode(row["feeling"]), title=cast(str, row["title"]), label=cast(str, row["label"]), rev=cast(int, row["rev"]), - origin=None if row["origin"] is None else Point2D(*cast(tuple[float | None, float | None], row["origin"])), + origin=None if row["origin"] is None else Point2D._decode(row["origin"]), tags=cast(list[str | None], row["tags"]), meta=cast(JsonValue, row["meta"]), ) @@ -240,7 +249,7 @@ def decode_list_specimens_by_ids(row: Mapping[str, object]) -> ListSpecimensById return ListSpecimensByIdsRow( id=cast(int, row["id"]), pub_id=cast(UUID, row["pub_id"]), - feeling=Mood(cast(str, row["feeling"])), + feeling=Mood._decode(row["feeling"]), title=cast(str, row["title"]), ) @@ -258,8 +267,8 @@ def decode_list_specimens_by_moods(row: Mapping[str, object]) -> ListSpecimensBy return ListSpecimensByMoodsRow( id=cast(int, row["id"]), pub_id=cast(UUID, row["pub_id"]), - feeling=Mood(cast(str, row["feeling"])), - moods=None if row["moods"] is None else [None if v is None else Mood(v) for v in cast(list[str | None], require_array(row["moods"]))], + feeling=Mood._decode(row["feeling"]), + moods=None if row["moods"] is None else [None if v is None else Mood._decode(v) for v in cast(list[str | None], require_array(row["moods"]))], title=cast(str, row["title"]), ) diff --git a/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py b/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py index f97eb70..9f1b2e5 100644 --- a/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py +++ b/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py @@ -16,6 +16,7 @@ from .._rows import InsertSpecimenRow, decode_insert_specimen from .._runtime import fetch_single from ..types.mood import Mood +from ..types.mood import Mood from ..types.point_2_d import Point2D SQL = """\ @@ -110,8 +111,8 @@ async def insert_specimen( "tags": tags, "related_ids": related_ids, "grid": grid, - "feeling": feeling, - "moods": moods, - "origin": None if origin is None else (origin.x, origin.y), + "feeling": feeling._encode(), + "moods": None if moods is None else [None if x is None else x._encode() for x in moods], + "origin": None if origin is None else origin._encode(), } return await fetch_single(conn, _SQL, params, decode_insert_specimen) diff --git a/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py b/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py index b2fff1c..912594e 100644 --- a/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py +++ b/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py @@ -29,6 +29,6 @@ async def insert_tagged_item( ) -> InsertTaggedItemRow: params: dict[str, object] = { "name": name, - "tag": (tag.value,), + "tag": tag._encode(), } return await fetch_single(conn, _SQL, params, decode_insert_tagged_item) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py index 2173d01..73699d7 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py @@ -28,6 +28,6 @@ async def list_specimens_by_feeling( feeling: Mood | None, ) -> list[ListSpecimensByFeelingRow]: params: dict[str, object] = { - "feeling": feeling, + "feeling": None if feeling is None else feeling._encode(), } return await fetch_many(conn, _SQL, params, decode_list_specimens_by_feeling) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py index 644d601..1d5ba33 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py @@ -28,6 +28,6 @@ async def list_specimens_by_moods( moods: list[Mood | None] | None, ) -> list[ListSpecimensByMoodsRow]: params: dict[str, object] = { - "moods": moods, + "moods": None if moods is None else [None if x is None else x._encode() for x in moods], } return await fetch_many(conn, _SQL, params, decode_list_specimens_by_moods) diff --git a/tests/golden/src/specimen_client/_generated/types/mood.py b/tests/golden/src/specimen_client/_generated/types/mood.py index ece63e0..a91c901 100644 --- a/tests/golden/src/specimen_client/_generated/types/mood.py +++ b/tests/golden/src/specimen_client/_generated/types/mood.py @@ -3,9 +3,17 @@ # SPDX-License-Identifier: MIT-0 from enum import StrEnum +from typing import cast class Mood(StrEnum): HAPPY = "happy" SAD = "sad" MEH = "meh" + + @staticmethod + def _decode(src: object) -> "Mood": + return Mood(cast(str, src)) + + def _encode(self) -> "Mood": + return self diff --git a/tests/golden/src/specimen_client/_generated/types/point_2_d.py b/tests/golden/src/specimen_client/_generated/types/point_2_d.py index 1874b55..9bf2915 100644 --- a/tests/golden/src/specimen_client/_generated/types/point_2_d.py +++ b/tests/golden/src/specimen_client/_generated/types/point_2_d.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: MIT-0 from dataclasses import dataclass +from typing import cast @dataclass(frozen=True, slots=True) @@ -15,3 +16,10 @@ class Point2D: x: float | None y: float | None + + @staticmethod + def _decode(src: object) -> "Point2D": + return Point2D(*cast(tuple[float | None, float | None], src)) + + def _encode(self) -> tuple[float | None, float | None]: + return (self.x, self.y) diff --git a/tests/golden/src/specimen_client/_generated/types/tag_value.py b/tests/golden/src/specimen_client/_generated/types/tag_value.py index 13814f9..bfdbbfc 100644 --- a/tests/golden/src/specimen_client/_generated/types/tag_value.py +++ b/tests/golden/src/specimen_client/_generated/types/tag_value.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: MIT-0 from dataclasses import dataclass +from typing import cast @dataclass(frozen=True, slots=True) @@ -14,3 +15,10 @@ class TagValue: """ value: str | None + + @staticmethod + def _decode(src: object) -> "TagValue": + return TagValue(*cast(tuple[str | None], src)) + + def _encode(self) -> tuple[str | None]: + return (self.value,) diff --git a/tests/golden/src/specimen_client/sync/__init__.py b/tests/golden/src/specimen_client/sync/__init__.py deleted file mode 100644 index 18b75c1..0000000 --- a/tests/golden/src/specimen_client/sync/__init__.py +++ /dev/null @@ -1,63 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from .._generated._core import JsonValue as JsonValue, NoRowError as NoRowError - -from .._generated.types.mood import Mood as Mood -from .._generated.types.point_2_d import Point2D as Point2D -from .._generated.types.tag_value import TagValue as TagValue - -from .._generated._rows import ( - GetSpecimenRow as GetSpecimenRow, - GetTaggedItemRow as GetTaggedItemRow, - InsertSpecimenRow as InsertSpecimenRow, - InsertTaggedItemRow as InsertTaggedItemRow, - ListSpecimensByClassRow as ListSpecimensByClassRow, - ListSpecimensByFeelingRow as ListSpecimensByFeelingRow, - ListSpecimensByIdsRow as ListSpecimensByIdsRow, - ListSpecimensByMoodsRow as ListSpecimensByMoodsRow, - ListSpecimensKeywordColumnRow as ListSpecimensKeywordColumnRow, - SearchSpecimensRow as SearchSpecimensRow, -) - -from .._generated.sync.statements.bump_specimen_revision import bump_specimen_revision as bump_specimen_revision -from .._generated.sync.statements.get_specimen import get_specimen as get_specimen -from .._generated.sync.statements.get_tagged_item import get_tagged_item as get_tagged_item -from .._generated.sync.statements.insert_specimen import insert_specimen as insert_specimen -from .._generated.sync.statements.insert_tagged_item import insert_tagged_item as insert_tagged_item -from .._generated.sync.statements.list_specimens_by_class import list_specimens_by_class as list_specimens_by_class -from .._generated.sync.statements.list_specimens_by_feeling import list_specimens_by_feeling as list_specimens_by_feeling -from .._generated.sync.statements.list_specimens_by_ids import list_specimens_by_ids as list_specimens_by_ids -from .._generated.sync.statements.list_specimens_by_moods import list_specimens_by_moods as list_specimens_by_moods -from .._generated.sync.statements.list_specimens_keyword_column import list_specimens_keyword_column as list_specimens_keyword_column -from .._generated.sync.statements.search_specimens import search_specimens as search_specimens - -__all__ = [ - "JsonValue", - "NoRowError", - "Mood", - "Point2D", - "TagValue", - "GetSpecimenRow", - "GetTaggedItemRow", - "InsertSpecimenRow", - "InsertTaggedItemRow", - "ListSpecimensByClassRow", - "ListSpecimensByFeelingRow", - "ListSpecimensByIdsRow", - "ListSpecimensByMoodsRow", - "ListSpecimensKeywordColumnRow", - "SearchSpecimensRow", - "bump_specimen_revision", - "get_specimen", - "get_tagged_item", - "insert_specimen", - "insert_tagged_item", - "list_specimens_by_class", - "list_specimens_by_feeling", - "list_specimens_by_ids", - "list_specimens_by_moods", - "list_specimens_keyword_column", - "search_specimens", -] diff --git a/tests/golden_sync/README.md b/tests/golden_sync/README.md new file mode 100644 index 0000000..6f6d14b --- /dev/null +++ b/tests/golden_sync/README.md @@ -0,0 +1,26 @@ +# Golden files (sync surface) + +The sync-surface counterpart of `tests/golden/`: a committed full fixture +package generated with `config: { sync: true }`, package name +`specimen_sync_client`. Same structure and purpose as `tests/golden/README.md` +describes for the async (default) surface — the hand-written shell here +(`pyproject.toml`, `py.typed`) plus the generated `_generated/` subtree and +the generated package-root facade `src/specimen_sync_client/__init__.py`. + +`test_generated_sync_matches_golden` regenerates the `python-sync` fixture +artifact into a temp tree and asserts every file under the fresh +`_generated/` plus the facade equals its golden twin here, both ways (no +missing, no extra). `test_generated_sync_passes_basedpyright_strict` runs +basedpyright strict on this full golden package. + +## Updating the golden + +Run only when a generator change legitimately alters the sync surface's +output, and review the resulting diff before committing. + +```bash +mise run golden +``` + +`mise run golden` refreshes both `tests/golden/` (the `python` artifact) and +this directory (the `python-sync` artifact) in one pass — see `mise.toml`. \ No newline at end of file diff --git a/tests/golden_sync/pyproject.toml b/tests/golden_sync/pyproject.toml new file mode 100644 index 0000000..26a00bf --- /dev/null +++ b/tests/golden_sync/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "specimen-sync-client" +version = "0.0.0" +requires-python = ">=3.12" +dependencies = ["psycopg>=3.2"] + +[tool.hatch.build.targets.wheel] +packages = ["src/specimen_sync_client"] + +[tool.ruff] +exclude = ["src/specimen_sync_client/_generated"] \ No newline at end of file diff --git a/tests/golden_sync/src/specimen_sync_client/__init__.py b/tests/golden_sync/src/specimen_sync_client/__init__.py new file mode 100644 index 0000000..c34dbc1 --- /dev/null +++ b/tests/golden_sync/src/specimen_sync_client/__init__.py @@ -0,0 +1,63 @@ +# @generated by python.gen (pGenie). DO NOT EDIT. +# SPDX-FileCopyrightText: 2026 Viacheslav Shvets +# SPDX-License-Identifier: MIT-0 + +from ._generated._core import JsonValue as JsonValue, NoRowError as NoRowError + +from ._generated.types.mood import Mood as Mood +from ._generated.types.point_2_d import Point2D as Point2D +from ._generated.types.tag_value import TagValue as TagValue + +from ._generated._rows import ( + GetSpecimenRow as GetSpecimenRow, + GetTaggedItemRow as GetTaggedItemRow, + InsertSpecimenRow as InsertSpecimenRow, + InsertTaggedItemRow as InsertTaggedItemRow, + ListSpecimensByClassRow as ListSpecimensByClassRow, + ListSpecimensByFeelingRow as ListSpecimensByFeelingRow, + ListSpecimensByIdsRow as ListSpecimensByIdsRow, + ListSpecimensByMoodsRow as ListSpecimensByMoodsRow, + ListSpecimensKeywordColumnRow as ListSpecimensKeywordColumnRow, + SearchSpecimensRow as SearchSpecimensRow, +) + +from ._generated.statements.bump_specimen_revision import bump_specimen_revision as bump_specimen_revision +from ._generated.statements.get_specimen import get_specimen as get_specimen +from ._generated.statements.get_tagged_item import get_tagged_item as get_tagged_item +from ._generated.statements.insert_specimen import insert_specimen as insert_specimen +from ._generated.statements.insert_tagged_item import insert_tagged_item as insert_tagged_item +from ._generated.statements.list_specimens_by_class import list_specimens_by_class as list_specimens_by_class +from ._generated.statements.list_specimens_by_feeling import list_specimens_by_feeling as list_specimens_by_feeling +from ._generated.statements.list_specimens_by_ids import list_specimens_by_ids as list_specimens_by_ids +from ._generated.statements.list_specimens_by_moods import list_specimens_by_moods as list_specimens_by_moods +from ._generated.statements.list_specimens_keyword_column import list_specimens_keyword_column as list_specimens_keyword_column +from ._generated.statements.search_specimens import search_specimens as search_specimens + +__all__ = [ + "JsonValue", + "NoRowError", + "Mood", + "Point2D", + "TagValue", + "GetSpecimenRow", + "GetTaggedItemRow", + "InsertSpecimenRow", + "InsertTaggedItemRow", + "ListSpecimensByClassRow", + "ListSpecimensByFeelingRow", + "ListSpecimensByIdsRow", + "ListSpecimensByMoodsRow", + "ListSpecimensKeywordColumnRow", + "SearchSpecimensRow", + "bump_specimen_revision", + "get_specimen", + "get_tagged_item", + "insert_specimen", + "insert_tagged_item", + "list_specimens_by_class", + "list_specimens_by_feeling", + "list_specimens_by_ids", + "list_specimens_by_moods", + "list_specimens_keyword_column", + "search_specimens", +] diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/__init__.py b/tests/golden_sync/src/specimen_sync_client/_generated/__init__.py similarity index 73% rename from tests/golden/src/specimen_client/_generated/sync/statements/__init__.py rename to tests/golden_sync/src/specimen_sync_client/_generated/__init__.py index 3f0a22b..3117013 100644 --- a/tests/golden/src/specimen_client/_generated/sync/statements/__init__.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/__init__.py @@ -2,6 +2,6 @@ # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 -"""Generated SQL statements (sync).""" +"""Generated database client for specimen-sync-client.""" __all__: list[str] = [] diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/_core.py b/tests/golden_sync/src/specimen_sync_client/_generated/_core.py new file mode 100644 index 0000000..8927292 --- /dev/null +++ b/tests/golden_sync/src/specimen_sync_client/_generated/_core.py @@ -0,0 +1,35 @@ +# @generated by python.gen (pGenie). DO NOT EDIT. +# SPDX-FileCopyrightText: 2026 Viacheslav Shvets +# SPDX-License-Identifier: MIT-0 + +"""Shared types and decode helpers, surface-agnostic; no I/O.""" + +from __future__ import annotations + +from typing import cast + +type JsonValue = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] + + +class NoRowError(RuntimeError): + """A single-row query returned no rows.""" + + +class DecodeError(RuntimeError): + """A row value failed to decode into its target type.""" + + +def require_array(value: object) -> list[object]: + """Guard an enum-array column decode. + + psycopg returns an enum array as a Python list only when the enum type is + registered on the connection (register_types); without it the value comes + back as the raw array text, which would iterate into bogus members. Fail + clearly instead. + """ + if isinstance(value, list): + return cast(list[object], value) + raise RuntimeError( + "enum array decoded as text; call register_types() on the connection " + "before decoding enum-array columns" + ) diff --git a/tests/golden/src/specimen_client/_generated/sync/_register.py b/tests/golden_sync/src/specimen_sync_client/_generated/_register.py similarity index 100% rename from tests/golden/src/specimen_client/_generated/sync/_register.py rename to tests/golden_sync/src/specimen_sync_client/_generated/_register.py diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/_rows.py b/tests/golden_sync/src/specimen_sync_client/_generated/_rows.py new file mode 100644 index 0000000..b413258 --- /dev/null +++ b/tests/golden_sync/src/specimen_sync_client/_generated/_rows.py @@ -0,0 +1,303 @@ +# @generated by python.gen (pGenie). DO NOT EDIT. +# SPDX-FileCopyrightText: 2026 Viacheslav Shvets +# SPDX-License-Identifier: MIT-0 + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal +from typing import cast +from uuid import UUID + +from ._core import JsonValue +from ._core import require_array +from .types.mood import Mood +from .types.point_2_d import Point2D +from .types.tag_value import TagValue +from .types.mood import Mood +from .types.mood import Mood +from .types.point_2_d import Point2D +from .types.tag_value import TagValue +from .types.mood import Mood +from .types.point_2_d import Point2D +from .types.mood import Mood +from .types.mood import Mood +from .types.mood import Mood + + +@dataclass(frozen=True, slots=True) +class GetSpecimenRow: + id: int + pub_id: UUID + flag: bool + small: int + medium: int + large: int + ratio: float + precise: float + title: str + code: str + letter: str + born_on: date + created_at: datetime + amount: Decimal + blob: bytes + doc_json: JsonValue + doc_jsonb: JsonValue + maybe_text: str | None + maybe_int: int | None + maybe_uuid: UUID | None + maybe_ts: datetime | None + maybe_num: Decimal | None + tags: list[str | None] + related_ids: list[UUID | None] | None + grid: list[int | None] | None + feeling: Mood + origin: Point2D | None + label: str + rev: int + meta: JsonValue + + +def decode_get_specimen(row: Mapping[str, object]) -> GetSpecimenRow: + return GetSpecimenRow( + id=cast(int, row["id"]), + pub_id=cast(UUID, row["pub_id"]), + flag=cast(bool, row["flag"]), + small=cast(int, row["small"]), + medium=cast(int, row["medium"]), + large=cast(int, row["large"]), + ratio=cast(float, row["ratio"]), + precise=cast(float, row["precise"]), + title=cast(str, row["title"]), + code=cast(str, row["code"]), + letter=cast(str, row["letter"]), + born_on=cast(date, row["born_on"]), + created_at=cast(datetime, row["created_at"]), + amount=cast(Decimal, row["amount"]), + blob=cast(bytes, row["blob"]), + doc_json=cast(JsonValue, row["doc_json"]), + doc_jsonb=cast(JsonValue, row["doc_jsonb"]), + maybe_text=cast(str | None, row["maybe_text"]), + maybe_int=cast(int | None, row["maybe_int"]), + maybe_uuid=cast(UUID | None, row["maybe_uuid"]), + maybe_ts=cast(datetime | None, row["maybe_ts"]), + maybe_num=cast(Decimal | None, row["maybe_num"]), + tags=cast(list[str | None], row["tags"]), + related_ids=cast(list[UUID | None] | None, row["related_ids"]), + grid=cast(list[int | None] | None, row["grid"]), + feeling=Mood._decode(row["feeling"]), + origin=None if row["origin"] is None else Point2D._decode(row["origin"]), + label=cast(str, row["label"]), + rev=cast(int, row["rev"]), + meta=cast(JsonValue, row["meta"]), + ) + + +@dataclass(frozen=True, slots=True) +class GetTaggedItemRow: + id: int + name: str + tag: TagValue + + +def decode_get_tagged_item(row: Mapping[str, object]) -> GetTaggedItemRow: + return GetTaggedItemRow( + id=cast(int, row["id"]), + name=cast(str, row["name"]), + tag=TagValue._decode(row["tag"]), + ) + + +@dataclass(frozen=True, slots=True) +class InsertSpecimenRow: + id: int + pub_id: UUID + flag: bool + small: int + medium: int + large: int + ratio: float + precise: float + title: str + code: str + letter: str + born_on: date + created_at: datetime + amount: Decimal + blob: bytes + doc_json: JsonValue + doc_jsonb: JsonValue + maybe_text: str | None + maybe_int: int | None + maybe_uuid: UUID | None + maybe_ts: datetime | None + maybe_num: Decimal | None + tags: list[str | None] + related_ids: list[UUID | None] | None + grid: list[int | None] | None + feeling: Mood + moods: list[Mood | None] | None + origin: Point2D | None + label: str + rev: int + meta: JsonValue + + +def decode_insert_specimen(row: Mapping[str, object]) -> InsertSpecimenRow: + return InsertSpecimenRow( + id=cast(int, row["id"]), + pub_id=cast(UUID, row["pub_id"]), + flag=cast(bool, row["flag"]), + small=cast(int, row["small"]), + medium=cast(int, row["medium"]), + large=cast(int, row["large"]), + ratio=cast(float, row["ratio"]), + precise=cast(float, row["precise"]), + title=cast(str, row["title"]), + code=cast(str, row["code"]), + letter=cast(str, row["letter"]), + born_on=cast(date, row["born_on"]), + created_at=cast(datetime, row["created_at"]), + amount=cast(Decimal, row["amount"]), + blob=cast(bytes, row["blob"]), + doc_json=cast(JsonValue, row["doc_json"]), + doc_jsonb=cast(JsonValue, row["doc_jsonb"]), + maybe_text=cast(str | None, row["maybe_text"]), + maybe_int=cast(int | None, row["maybe_int"]), + maybe_uuid=cast(UUID | None, row["maybe_uuid"]), + maybe_ts=cast(datetime | None, row["maybe_ts"]), + maybe_num=cast(Decimal | None, row["maybe_num"]), + tags=cast(list[str | None], row["tags"]), + related_ids=cast(list[UUID | None] | None, row["related_ids"]), + grid=cast(list[int | None] | None, row["grid"]), + feeling=Mood._decode(row["feeling"]), + moods=None if row["moods"] is None else [None if v is None else Mood._decode(v) for v in cast(list[str | None], require_array(row["moods"]))], + origin=None if row["origin"] is None else Point2D._decode(row["origin"]), + label=cast(str, row["label"]), + rev=cast(int, row["rev"]), + meta=cast(JsonValue, row["meta"]), + ) + + +@dataclass(frozen=True, slots=True) +class InsertTaggedItemRow: + id: int + name: str + tag: TagValue + + +def decode_insert_tagged_item(row: Mapping[str, object]) -> InsertTaggedItemRow: + return InsertTaggedItemRow( + id=cast(int, row["id"]), + name=cast(str, row["name"]), + tag=TagValue._decode(row["tag"]), + ) + + +@dataclass(frozen=True, slots=True) +class ListSpecimensByClassRow: + id: int + title: str + + +def decode_list_specimens_by_class(row: Mapping[str, object]) -> ListSpecimensByClassRow: + return ListSpecimensByClassRow( + id=cast(int, row["id"]), + title=cast(str, row["title"]), + ) + + +@dataclass(frozen=True, slots=True) +class ListSpecimensByFeelingRow: + id: int + pub_id: UUID + feeling: Mood + title: str + label: str + rev: int + origin: Point2D | None + tags: list[str | None] + meta: JsonValue + + +def decode_list_specimens_by_feeling(row: Mapping[str, object]) -> ListSpecimensByFeelingRow: + return ListSpecimensByFeelingRow( + id=cast(int, row["id"]), + pub_id=cast(UUID, row["pub_id"]), + feeling=Mood._decode(row["feeling"]), + title=cast(str, row["title"]), + label=cast(str, row["label"]), + rev=cast(int, row["rev"]), + origin=None if row["origin"] is None else Point2D._decode(row["origin"]), + tags=cast(list[str | None], row["tags"]), + meta=cast(JsonValue, row["meta"]), + ) + + +@dataclass(frozen=True, slots=True) +class ListSpecimensByIdsRow: + id: int + pub_id: UUID + feeling: Mood + title: str + + +def decode_list_specimens_by_ids(row: Mapping[str, object]) -> ListSpecimensByIdsRow: + return ListSpecimensByIdsRow( + id=cast(int, row["id"]), + pub_id=cast(UUID, row["pub_id"]), + feeling=Mood._decode(row["feeling"]), + title=cast(str, row["title"]), + ) + + +@dataclass(frozen=True, slots=True) +class ListSpecimensByMoodsRow: + id: int + pub_id: UUID + feeling: Mood + moods: list[Mood | None] | None + title: str + + +def decode_list_specimens_by_moods(row: Mapping[str, object]) -> ListSpecimensByMoodsRow: + return ListSpecimensByMoodsRow( + id=cast(int, row["id"]), + pub_id=cast(UUID, row["pub_id"]), + feeling=Mood._decode(row["feeling"]), + moods=None if row["moods"] is None else [None if v is None else Mood._decode(v) for v in cast(list[str | None], require_array(row["moods"]))], + title=cast(str, row["title"]), + ) + + +@dataclass(frozen=True, slots=True) +class ListSpecimensKeywordColumnRow: + id: int + class_: str + + +def decode_list_specimens_keyword_column(row: Mapping[str, object]) -> ListSpecimensKeywordColumnRow: + return ListSpecimensKeywordColumnRow( + id=cast(int, row["id"]), + class_=cast(str, row["class"]), + ) + + +@dataclass(frozen=True, slots=True) +class SearchSpecimensRow: + id: int + title: str + label: str + meta: JsonValue + + +def decode_search_specimens(row: Mapping[str, object]) -> SearchSpecimensRow: + return SearchSpecimensRow( + id=cast(int, row["id"]), + title=cast(str, row["title"]), + label=cast(str, row["label"]), + meta=cast(JsonValue, row["meta"]), + ) diff --git a/tests/golden/src/specimen_client/_generated/sync/_runtime.py b/tests/golden_sync/src/specimen_sync_client/_generated/_runtime.py similarity index 94% rename from tests/golden/src/specimen_client/_generated/sync/_runtime.py rename to tests/golden_sync/src/specimen_sync_client/_generated/_runtime.py index 312a249..ced0afa 100644 --- a/tests/golden/src/specimen_client/_generated/sync/_runtime.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/_runtime.py @@ -10,7 +10,7 @@ from psycopg import Connection from psycopg.rows import dict_row -from .._core import JsonValue as JsonValue, NoRowError as NoRowError, require_array as require_array +from ._core import JsonValue as JsonValue, NoRowError as NoRowError, require_array as require_array _T = TypeVar("_T") _Row = Mapping[str, object] diff --git a/tests/golden/src/specimen_client/_generated/sync/__init__.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/__init__.py similarity index 80% rename from tests/golden/src/specimen_client/_generated/sync/__init__.py rename to tests/golden_sync/src/specimen_sync_client/_generated/statements/__init__.py index 47a8df4..3c3ae7f 100644 --- a/tests/golden/src/specimen_client/_generated/sync/__init__.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/__init__.py @@ -2,6 +2,6 @@ # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 -"""Generated sync database client.""" +"""Generated SQL statements.""" __all__: list[str] = [] diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/bump_specimen_revision.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/bump_specimen_revision.py similarity index 100% rename from tests/golden/src/specimen_client/_generated/sync/statements/bump_specimen_revision.py rename to tests/golden_sync/src/specimen_sync_client/_generated/statements/bump_specimen_revision.py diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/get_specimen.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_specimen.py similarity index 93% rename from tests/golden/src/specimen_client/_generated/sync/statements/get_specimen.py rename to tests/golden_sync/src/specimen_sync_client/_generated/statements/get_specimen.py index 748445d..d6a598b 100644 --- a/tests/golden/src/specimen_client/_generated/sync/statements/get_specimen.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_specimen.py @@ -6,7 +6,7 @@ from psycopg import Connection -from ..._rows import GetSpecimenRow, decode_get_specimen +from .._rows import GetSpecimenRow, decode_get_specimen from .._runtime import fetch_optional SQL = """\ diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/get_tagged_item.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_tagged_item.py similarity index 91% rename from tests/golden/src/specimen_client/_generated/sync/statements/get_tagged_item.py rename to tests/golden_sync/src/specimen_sync_client/_generated/statements/get_tagged_item.py index 08757e6..29c06f3 100644 --- a/tests/golden/src/specimen_client/_generated/sync/statements/get_tagged_item.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_tagged_item.py @@ -6,7 +6,7 @@ from psycopg import Connection -from ..._rows import GetTaggedItemRow, decode_get_tagged_item +from .._rows import GetTaggedItemRow, decode_get_tagged_item from .._runtime import fetch_optional SQL = """\ diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/insert_specimen.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py similarity index 89% rename from tests/golden/src/specimen_client/_generated/sync/statements/insert_specimen.py rename to tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py index e8ea5f5..455abe9 100644 --- a/tests/golden/src/specimen_client/_generated/sync/statements/insert_specimen.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py @@ -12,11 +12,12 @@ from psycopg.types.json import Json from psycopg.types.json import Jsonb -from ..._core import JsonValue -from ..._rows import InsertSpecimenRow, decode_insert_specimen +from .._core import JsonValue +from .._rows import InsertSpecimenRow, decode_insert_specimen from .._runtime import fetch_single -from ...types.mood import Mood -from ...types.point_2_d import Point2D +from ..types.mood import Mood +from ..types.mood import Mood +from ..types.point_2_d import Point2D SQL = """\ -- single row: insert ... returning the full type surface. @@ -110,8 +111,8 @@ def insert_specimen( "tags": tags, "related_ids": related_ids, "grid": grid, - "feeling": feeling, - "moods": moods, - "origin": None if origin is None else (origin.x, origin.y), + "feeling": feeling._encode(), + "moods": None if moods is None else [None if x is None else x._encode() for x in moods], + "origin": None if origin is None else origin._encode(), } return fetch_single(conn, _SQL, params, decode_insert_specimen) diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/insert_tagged_item.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py similarity index 84% rename from tests/golden/src/specimen_client/_generated/sync/statements/insert_tagged_item.py rename to tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py index 6d1f35a..c02fcd2 100644 --- a/tests/golden/src/specimen_client/_generated/sync/statements/insert_tagged_item.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py @@ -6,9 +6,9 @@ from psycopg import Connection -from ..._rows import InsertTaggedItemRow, decode_insert_tagged_item +from .._rows import InsertTaggedItemRow, decode_insert_tagged_item from .._runtime import fetch_single -from ...types.tag_value import TagValue +from ..types.tag_value import TagValue SQL = """\ -- single row: insert exercising a single-field composite as a parameter and @@ -29,6 +29,6 @@ def insert_tagged_item( ) -> InsertTaggedItemRow: params: dict[str, object] = { "name": name, - "tag": (tag.value,), + "tag": tag._encode(), } return fetch_single(conn, _SQL, params, decode_insert_tagged_item) diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_class.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_class.py similarity index 92% rename from tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_class.py rename to tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_class.py index a5309e4..eb32126 100644 --- a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_class.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_class.py @@ -6,7 +6,7 @@ from psycopg import Connection -from ..._rows import ListSpecimensByClassRow, decode_list_specimens_by_class +from .._rows import ListSpecimensByClassRow, decode_list_specimens_by_class from .._runtime import fetch_many SQL = """\ diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_feeling.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py similarity index 79% rename from tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_feeling.py rename to tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py index 4b5f2cd..449865a 100644 --- a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_feeling.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py @@ -6,9 +6,9 @@ from psycopg import Connection -from ..._rows import ListSpecimensByFeelingRow, decode_list_specimens_by_feeling +from .._rows import ListSpecimensByFeelingRow, decode_list_specimens_by_feeling from .._runtime import fetch_many -from ...types.mood import Mood +from ..types.mood import Mood SQL = """\ -- many: select with order by. Enum parameter ($feeling). @@ -28,6 +28,6 @@ def list_specimens_by_feeling( feeling: Mood | None, ) -> list[ListSpecimensByFeelingRow]: params: dict[str, object] = { - "feeling": feeling, + "feeling": None if feeling is None else feeling._encode(), } return fetch_many(conn, _SQL, params, decode_list_specimens_by_feeling) diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_ids.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_ids.py similarity index 90% rename from tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_ids.py rename to tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_ids.py index 94c6299..0512032 100644 --- a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_ids.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_ids.py @@ -8,7 +8,7 @@ from psycopg import Connection -from ..._rows import ListSpecimensByIdsRow, decode_list_specimens_by_ids +from .._rows import ListSpecimensByIdsRow, decode_list_specimens_by_ids from .._runtime import fetch_many SQL = """\ diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_moods.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py similarity index 78% rename from tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_moods.py rename to tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py index 5aaebd0..32f03ea 100644 --- a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_moods.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py @@ -6,9 +6,9 @@ from psycopg import Connection -from ..._rows import ListSpecimensByMoodsRow, decode_list_specimens_by_moods +from .._rows import ListSpecimensByMoodsRow, decode_list_specimens_by_moods from .._runtime import fetch_many -from ...types.mood import Mood +from ..types.mood import Mood SQL = """\ -- many: enum array parameter via = any($moods::mood[]); returns the enum array column. @@ -28,6 +28,6 @@ def list_specimens_by_moods( moods: list[Mood | None] | None, ) -> list[ListSpecimensByMoodsRow]: params: dict[str, object] = { - "moods": moods, + "moods": None if moods is None else [None if x is None else x._encode() for x in moods], } return fetch_many(conn, _SQL, params, decode_list_specimens_by_moods) diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_keyword_column.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_keyword_column.py similarity index 91% rename from tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_keyword_column.py rename to tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_keyword_column.py index 1b36708..0a7b949 100644 --- a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_keyword_column.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_keyword_column.py @@ -6,7 +6,7 @@ from psycopg import Connection -from ..._rows import ListSpecimensKeywordColumnRow, decode_list_specimens_keyword_column +from .._rows import ListSpecimensKeywordColumnRow, decode_list_specimens_keyword_column from .._runtime import fetch_many SQL = """\ diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/search_specimens.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/search_specimens.py similarity index 92% rename from tests/golden/src/specimen_client/_generated/sync/statements/search_specimens.py rename to tests/golden_sync/src/specimen_sync_client/_generated/statements/search_specimens.py index 73006c1..42bb069 100644 --- a/tests/golden/src/specimen_client/_generated/sync/statements/search_specimens.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/search_specimens.py @@ -7,8 +7,8 @@ from psycopg import Connection from psycopg.types.json import Jsonb -from ..._core import JsonValue -from ..._rows import SearchSpecimensRow, decode_search_specimens +from .._core import JsonValue +from .._rows import SearchSpecimensRow, decode_search_specimens from .._runtime import fetch_many SQL = """\ diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/types/__init__.py b/tests/golden_sync/src/specimen_sync_client/_generated/types/__init__.py new file mode 100644 index 0000000..c8a6ba8 --- /dev/null +++ b/tests/golden_sync/src/specimen_sync_client/_generated/types/__init__.py @@ -0,0 +1,7 @@ +# @generated by python.gen (pGenie). DO NOT EDIT. +# SPDX-FileCopyrightText: 2026 Viacheslav Shvets +# SPDX-License-Identifier: MIT-0 + +from .mood import Mood as Mood +from .point_2_d import Point2D as Point2D +from .tag_value import TagValue as TagValue diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/types/mood.py b/tests/golden_sync/src/specimen_sync_client/_generated/types/mood.py new file mode 100644 index 0000000..a91c901 --- /dev/null +++ b/tests/golden_sync/src/specimen_sync_client/_generated/types/mood.py @@ -0,0 +1,19 @@ +# @generated by python.gen (pGenie). DO NOT EDIT. +# SPDX-FileCopyrightText: 2026 Viacheslav Shvets +# SPDX-License-Identifier: MIT-0 + +from enum import StrEnum +from typing import cast + + +class Mood(StrEnum): + HAPPY = "happy" + SAD = "sad" + MEH = "meh" + + @staticmethod + def _decode(src: object) -> "Mood": + return Mood(cast(str, src)) + + def _encode(self) -> "Mood": + return self diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/types/point_2_d.py b/tests/golden_sync/src/specimen_sync_client/_generated/types/point_2_d.py new file mode 100644 index 0000000..9bf2915 --- /dev/null +++ b/tests/golden_sync/src/specimen_sync_client/_generated/types/point_2_d.py @@ -0,0 +1,25 @@ +# @generated by python.gen (pGenie). DO NOT EDIT. +# SPDX-FileCopyrightText: 2026 Viacheslav Shvets +# SPDX-License-Identifier: MIT-0 + +from dataclasses import dataclass +from typing import cast + + +@dataclass(frozen=True, slots=True) +class Point2D: + """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. + """ + + x: float | None + y: float | None + + @staticmethod + def _decode(src: object) -> "Point2D": + return Point2D(*cast(tuple[float | None, float | None], src)) + + def _encode(self) -> tuple[float | None, float | None]: + return (self.x, self.y) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/types/tag_value.py b/tests/golden_sync/src/specimen_sync_client/_generated/types/tag_value.py new file mode 100644 index 0000000..bfdbbfc --- /dev/null +++ b/tests/golden_sync/src/specimen_sync_client/_generated/types/tag_value.py @@ -0,0 +1,24 @@ +# @generated by python.gen (pGenie). DO NOT EDIT. +# SPDX-FileCopyrightText: 2026 Viacheslav Shvets +# SPDX-License-Identifier: MIT-0 + +from dataclasses import dataclass +from typing import cast + + +@dataclass(frozen=True, slots=True) +class TagValue: + """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. + """ + + value: str | None + + @staticmethod + def _decode(src: object) -> "TagValue": + return TagValue(*cast(tuple[str | None], src)) + + def _encode(self) -> tuple[str | None]: + return (self.value,) diff --git a/tests/golden_sync/src/specimen_sync_client/py.typed b/tests/golden_sync/src/specimen_sync_client/py.typed new file mode 100644 index 0000000..e69de29 From 3008b708e6b1c45743eb3a3603f5397e3ef00935 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sun, 12 Jul 2026 09:05:02 +0300 Subject: [PATCH 17/41] python.gen: split sync-surface tests onto their own golden fixture --- tests/conftest.py | 22 +++++++ tests/test_generated.py | 133 ++++++++++++++++++++++++++++++---------- 2 files changed, 121 insertions(+), 34 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5bac571..719c227 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,7 @@ from tests._harness import ( FIXTURE_PROJECT, GOLDEN_DIR, + GOLDEN_DIR_SYNC, HERE, SRC_DIR, admin_database_url, @@ -114,3 +115,24 @@ def full_package(generated_tree: Path, tmp_path_factory: pytest.TempPathFactory) (dest_pkg / "sync").mkdir(parents=True, exist_ok=True) _ = shutil.copy2(sync_facade, dest_pkg / "sync" / "__init__.py") return root + + +@pytest.fixture(scope="session") +def full_package_sync(generated_tree: Path, tmp_path_factory: pytest.TempPathFactory) -> Path: + """The sync-surface counterpart of `full_package`. + + `generated_tree` already ran `pgn generate` against the full project + (all artifacts, including `python-sync`), so this reuses that one + subprocess call rather than invoking pgn again: it just points at the + sibling `python_sync` artifact directory instead of `python`. + """ + generated_tree_sync = generated_tree.parent.parent / "artifacts" / "python_sync" + root = tmp_path_factory.mktemp("pkg-sync") + shell_src = GOLDEN_DIR_SYNC / "src" + generated_src = generated_tree_sync / "src" + _ = shutil.copytree(shell_src, root / "src", ignore=shutil.ignore_patterns("_generated", "__init__.py")) + for pkg_dir in generated_src.iterdir(): + dest_pkg = root / "src" / pkg_dir.name + _ = shutil.copytree(pkg_dir / "_generated", dest_pkg / "_generated") + _ = shutil.copy2(pkg_dir / "__init__.py", dest_pkg / "__init__.py") + return root diff --git a/tests/test_generated.py b/tests/test_generated.py index b671193..7675c71 100644 --- a/tests/test_generated.py +++ b/tests/test_generated.py @@ -26,7 +26,7 @@ import pytest from psycopg.conninfo import make_conninfo -from tests._harness import FIXTURE_PROJECT, GOLDEN_DIR, HERE, ensure_droppable, run_pgn +from tests._harness import FIXTURE_PROJECT, GOLDEN_DIR, GOLDEN_DIR_SYNC, HERE, ensure_droppable, run_pgn HARNESS_ROOT = HERE.parent @@ -36,7 +36,8 @@ # generate. GENERATED_SUBTREE = Path("src/specimen_client/_generated") FACADE_INIT = Path("src/specimen_client/__init__.py") -SYNC_FACADE_INIT = Path("src/specimen_client/sync/__init__.py") +GENERATED_SUBTREE_SYNC = Path("src/specimen_sync_client/_generated") +FACADE_INIT_SYNC = Path("src/specimen_sync_client/__init__.py") def test_fixture_project_analyses_clean(pgn_bin: str, pgn_admin_url: str, fixture_copy: Path) -> None: @@ -113,9 +114,8 @@ def test_generated_matches_golden(generated_tree: Path) -> None: if (produced_root / rel).read_text() != (golden_root / rel).read_text(): mismatched.append(str(rel)) - for facade in (FACADE_INIT, SYNC_FACADE_INIT): - if (generated_tree / facade).read_text() != (GOLDEN_DIR / facade).read_text(): - mismatched.append(str(facade)) + if (generated_tree / FACADE_INIT).read_text() != (GOLDEN_DIR / FACADE_INIT).read_text(): + mismatched.append(str(FACADE_INIT)) assert not mismatched, ( "generated output drifted from golden in: " @@ -124,6 +124,40 @@ def test_generated_matches_golden(generated_tree: Path) -> None: ) +def test_generated_sync_matches_golden(generated_tree: Path) -> None: + """Sync-surface counterpart of test_generated_matches_golden. + + generated_tree points at the "python" artifact; the sync surface's + output lives in the sibling "python_sync" artifact directory produced by + the same pgn generate call (see the generated_tree fixture). + """ + generated_tree_sync = generated_tree.parent.parent / "artifacts" / "python_sync" + produced_root = generated_tree_sync / GENERATED_SUBTREE_SYNC + golden_root = GOLDEN_DIR_SYNC / GENERATED_SUBTREE_SYNC + + produced = _relative_files(produced_root) + golden = _relative_files(golden_root) + + missing = sorted(str(p) for p in golden - produced) + extra = sorted(str(p) for p in produced - golden) + assert not missing, f"golden files not produced by the generator: {missing}" + assert not extra, f"generator emitted files absent from golden: {extra}" + + mismatched: list[str] = [] + for rel in sorted(produced, key=str): + if (produced_root / rel).read_text() != (golden_root / rel).read_text(): + mismatched.append(str(rel)) + + if (generated_tree_sync / FACADE_INIT_SYNC).read_text() != (GOLDEN_DIR_SYNC / FACADE_INIT_SYNC).read_text(): + mismatched.append(str(FACADE_INIT_SYNC)) + + assert not mismatched, ( + "generated sync output drifted from golden in: " + + ", ".join(mismatched) + + "\nupdate via: mise run golden (see tests/golden_sync/README.md)" + ) + + def test_generated_passes_basedpyright_strict(tmp_path: Path) -> None: """basedpyright strict on the FULL golden package: zero errors and warnings. @@ -166,6 +200,37 @@ def test_generated_passes_basedpyright_strict(tmp_path: Path) -> None: ) +def test_generated_sync_passes_basedpyright_strict(tmp_path: Path) -> None: + """basedpyright strict on the full sync-surface golden package.""" + config = tmp_path / "pyrightconfig.json" + _ = config.write_text( + json.dumps( + { + "pythonVersion": "3.12", + "typeCheckingMode": "strict", + "include": [str(GOLDEN_DIR_SYNC / "src")], + "venvPath": str(HARNESS_ROOT), + "venv": ".venv", + "reportMissingModuleSource": False, + } + ) + ) + result = subprocess.run( + ["basedpyright", "--project", str(config), "--outputjson"], + capture_output=True, + text=True, + ) + + if not result.stdout.strip(): + pytest.fail(f"basedpyright produced no JSON (exit {result.returncode}):\n{result.stderr}") + + summary = json.loads(result.stdout)["summary"] + assert summary["filesAnalyzed"] > 0, f"basedpyright analyzed no files; bad include path?\n{result.stdout}" + assert summary["errorCount"] == 0 and summary["warningCount"] == 0, ( + f"basedpyright strict reported issues: {summary}\n{result.stdout}" + ) + + @pytest.fixture def roundtrip_db(pgn_admin_url: str) -> Iterator[str]: """A uniquely named throwaway database on pg0, dropped on teardown.""" @@ -215,6 +280,16 @@ def _import_client(full_package: Path): # noqa: ANN202 - dynamic module set return importlib.import_module +def _import_client_sync(full_package_sync: Path): # noqa: ANN202 - dynamic module set + src = str(full_package_sync / "src") + if src not in sys.path: + sys.path.insert(0, src) + for name in list(sys.modules): + if name == "specimen_sync_client" or name.startswith("specimen_sync_client."): + del sys.modules[name] + return importlib.import_module + + def test_roundtrip_type_mappings(full_package: Path, roundtrip_db: str) -> None: """INSERT then SELECT through the generated client, asserting the mappings. @@ -389,37 +464,27 @@ def test_require_array_rejects_unregistered_enum_array(full_package: Path) -> No _ = runtime.require_array("{happy,sad}") -def test_roundtrip_sync_and_cross_surface_identity(full_package: Path, roundtrip_db: str) -> None: - """The sync surface decodes through the same shared Row types as async. +def test_roundtrip_sync_surface(full_package_sync: Path, roundtrip_db: str) -> None: + """The sync surface, generated independently with config.sync = true. - Drives the generated sync functions (psycopg.Connection, no await) end to end, - and asserts the Row dataclasses are shared across surfaces: both facades and - both statement modules expose the same class object (defined once in - _generated._rows), so a value typed against one surface is the other's too. + Drives the generated sync functions (psycopg.Connection, no await) end + to end, at the same unified paths the async surface uses (no `.sync.` + sub-path) — this package was generated standalone, not alongside async. """ _apply_migrations(roundtrip_db) - import_module = _import_client(full_package) + import_module = _import_client_sync(full_package_sync) - register = import_module("specimen_client._generated.sync._register") - mood_mod = import_module("specimen_client._generated.types.mood") - point_mod = import_module("specimen_client._generated.types.point_2_d") - insert = import_module("specimen_client._generated.sync.statements.insert_specimen") - get = import_module("specimen_client._generated.sync.statements.get_specimen") - by_moods = import_module("specimen_client._generated.sync.statements.list_specimens_by_moods") - bump = import_module("specimen_client._generated.sync.statements.bump_specimen_revision") + register = import_module("specimen_sync_client._generated._register") + mood_mod = import_module("specimen_sync_client._generated.types.mood") + point_mod = import_module("specimen_sync_client._generated.types.point_2_d") + insert = import_module("specimen_sync_client._generated.statements.insert_specimen") + get = import_module("specimen_sync_client._generated.statements.get_specimen") + by_moods = import_module("specimen_sync_client._generated.statements.list_specimens_by_moods") + bump = import_module("specimen_sync_client._generated.statements.bump_specimen_revision") Mood = mood_mod.Mood Point2D = point_mod.Point2D - # Cross-surface identity: the async facade, the sync facade, the sync - # statement module, and the shared _rows module all expose the same object. - async_facade = import_module("specimen_client") - sync_facade = import_module("specimen_client.sync") - rows_mod = import_module("specimen_client._generated._rows") - assert async_facade.InsertSpecimenRow is sync_facade.InsertSpecimenRow - assert insert.InsertSpecimenRow is rows_mod.InsertSpecimenRow - assert async_facade.InsertSpecimenRow is rows_mod.InsertSpecimenRow - conn = psycopg.connect(roundtrip_db, autocommit=True) try: register.register_types(conn) @@ -518,15 +583,15 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_roundtrip_single_field_composite_sync(full_package: Path, roundtrip_db: str) -> None: +def test_roundtrip_single_field_composite_sync(full_package_sync: Path, roundtrip_db: str) -> None: """Sync-surface counterpart of test_roundtrip_single_field_composite.""" _apply_migrations(roundtrip_db) - import_module = _import_client(full_package) + import_module = _import_client_sync(full_package_sync) - register = import_module("specimen_client._generated.sync._register") - tag_mod = import_module("specimen_client._generated.types.tag_value") - insert = import_module("specimen_client._generated.sync.statements.insert_tagged_item") - get = import_module("specimen_client._generated.sync.statements.get_tagged_item") + register = import_module("specimen_sync_client._generated._register") + tag_mod = import_module("specimen_sync_client._generated.types.tag_value") + insert = import_module("specimen_sync_client._generated.statements.insert_tagged_item") + get = import_module("specimen_sync_client._generated.statements.get_tagged_item") TagValue = tag_mod.TagValue From 5bab734da5014e4c26046ffa1570d1091f117081 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sun, 12 Jul 2026 09:15:02 +0300 Subject: [PATCH 18/41] python.gen: fix config-variant sync detection for unified output paths --- tests/test_config_variants.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/tests/test_config_variants.py b/tests/test_config_variants.py index e0d3ecf..9a46b07 100644 --- a/tests/test_config_variants.py +++ b/tests/test_config_variants.py @@ -4,7 +4,7 @@ project1.pgn.yaml drives a different subset of `config` keys through the same compile.dhall and the assertions below record what pgn 0.6.5 was observed to do, not a documented contract. These variants are pinned by the directory/package -name and sync-surface presence they produce, not a full golden tree. +name and which surface (sync or async) they produce, not a full golden tree. """ from __future__ import annotations @@ -40,40 +40,52 @@ def _package_dir(generated_tree: Path, artifact_key: str) -> Path: return packages[0] +def _is_sync(package: Path) -> bool: + """Whether the generated package's runtime module is the sync surface. + + There is no `sync/` subdirectory to check anymore (config.sync selects + which content is rendered at the *same* `_runtime.py` path); the async + body defines `async def fetch_many`, the sync body defines `def + fetch_many` with no `async`. + """ + runtime = (package / "_generated" / "_runtime.py").read_text() + return "async def fetch_many" not in runtime + + def test_name_only_config_derives_package_and_defaults_sync_off(generated_tree: Path) -> None: package = _package_dir(generated_tree, "python-name-only") assert package.name == "name_only_client" - assert not (package / "sync").exists() + assert not _is_sync(package) def test_sync_only_config_defaults_package_name_from_project(generated_tree: Path) -> None: package = _package_dir(generated_tree, "python-sync-only") assert package.name == "fixture" - assert (package / "sync" / "__init__.py").is_file() + assert _is_sync(package) def test_empty_config_object_defaults_both_fields(generated_tree: Path) -> None: package = _package_dir(generated_tree, "python-empty") assert package.name == "fixture" - assert not (package / "sync").exists() + assert not _is_sync(package) def test_absent_config_key_defaults_both_fields(generated_tree: Path) -> None: """No `config:` key at all decodes the same as `config: {}` (None Config).""" package = _package_dir(generated_tree, "python-bare") assert package.name == "fixture" - assert not (package / "sync").exists() + assert not _is_sync(package) def test_unknown_config_key_is_ignored_not_rejected(generated_tree: Path) -> None: """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() + assert not _is_sync(package) def test_null_value_decodes_as_absent_field(generated_tree: Path) -> None: - """`emitSync: null` decodes to None, same as omitting the key (default False).""" + """`sync: null` decodes to None, same as omitting the key (default False).""" package = _package_dir(generated_tree, "python-null") assert package.name == "null_client" - assert not (package / "sync").exists() + assert not _is_sync(package) From 28928023edcca13158062cb1f05ea7eb0faecc6c Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sun, 12 Jul 2026 09:25:23 +0300 Subject: [PATCH 19/41] python.gen: document the exclusive sync/async config change --- CHANGELOG.md | 13 ++++++ DESIGN.md | 117 +++++++++++++++++++++------------------------------ README.md | 37 ++++++++++------ 3 files changed, 84 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b55b2bf..a56b709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Upcoming +- **Breaking:** `emitSync` is gone. In its place, `sync : Optional Bool` + (default `False`) picks exactly one surface per generate — async or sync — + emitted at the same unified paths either way (no more `sync/` subdirectory, + no more second package-root facade). Previously `emitSync: true` added a + second, nested sync tree alongside the always-emitted async one; a project + that needs both surfaces now generates two artifacts against this same + `gen:` with different `packageName`s, one with `sync: true` and one + without. See `docs/plans/2026-07-12-configurable-sync-output.md` for the + full rationale and migration shape. `tests/golden_sync/` is a new committed + golden fixture (`specimen_sync_client`) exercising the sync surface + end-to-end (basedpyright strict + round-trip), alongside the existing + `tests/golden/` (`specimen_client`, now async-only). + - `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 diff --git a/DESIGN.md b/DESIGN.md index 952e4cc..734a0b0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -6,9 +6,10 @@ and golden output under `tests/`. When this doc and the tree disagree, the tree wins. The generator turns a pgn project (queries and custom types, analyzed against -a live PostgreSQL) into a typed Python database client. It emits two parallel -surfaces, async (`psycopg.AsyncConnection`) and sync (`psycopg.Connection`), -over one shared set of Row dataclasses and decode functions. +a live PostgreSQL) into a typed Python database client. It emits exactly one +surface per generate — async (`psycopg.AsyncConnection`) by default, or sync +(`psycopg.Connection`) when `config.sync` is `True` — at the same unified +output paths either way. Constraints that bound every decision below: @@ -30,47 +31,37 @@ artifact's own root. Every `path` is relative to that root and starts with by `_` (`my-db-client` -> `my_db_client`). The generator emits the entire `src//_generated/` subtree plus -the two package-root facade modules (`__init__.py` and, when sync is on, -`sync/__init__.py`). Everything else, a `pyproject.toml` and a `py.typed` -marker, is the consumer's own hand-written shell; the generator never -touches it. +the package-root facade module (`__init__.py`). Everything else, a +`pyproject.toml` and a `py.typed` marker, is the consumer's own hand-written +shell; the generator never touches it. Every emitted `.py` file starts with the exact first line `# @generated by python.gen (pGenie). DO NOT EDIT.` followed by a blank line. The header is prepended once in `Interpreters/Project.dhall` (`withHeader`) so it applies to all modules. -### What the generator emits (async surface, always) +### What the generator emits -```text -src//__init__.py # generated flat facade (re-exports everything) -src//_generated/__init__.py # docstring + __all__: list[str] = [] -src//_generated/_runtime.py # async I/O helpers + JsonValue + NoRowError -src//_generated/_rows.py # SHARED Row dataclasses + decode_ fns -src//_generated/_register.py # only if the project has composites or enums -src//_generated/types/__init__.py # only if customTypes is non-empty -src//_generated/types/.py # one per custom type (enum / composite) -src//_generated/statements/__init__.py -src//_generated/statements/.py # one thin async wrapper per query ``` - -### What the generator adds when `emitSync` - -```text -src//sync/__init__.py # generated sync facade -src//_generated/sync/__init__.py -src//_generated/sync/_runtime.py # sync I/O helpers; re-exports JsonValue/NoRowError -src//_generated/sync/_register.py # only if composites or enums -src//_generated/sync/statements/__init__.py -src//_generated/sync/statements/.py # one thin sync wrapper per query +src//__init__.py # generated facade (re-exports everything) +src//_generated/__init__.py +src//_generated/_core.py # surface-agnostic: JsonValue, NoRowError, require_array +src//_generated/_runtime.py # I/O helpers for whichever surface config.sync picked +src//_generated/_register.py # only if composites or enums +src//_generated/_rows.py # shared Row dataclasses + decode functions +src//_generated/statements/__init__.py +src//_generated/statements/.py # one thin wrapper per query, def or async def +src//_generated/types/__init__.py # only if custom types exist +src//_generated/types/.py ``` -The sync surface reuses the SAME `_rows.py` and `types/` modules. Only the -I/O wrappers (statements, runtime, register) and the sync facade are -sync-specific. Sync statement modules sit one package deeper, so they reach -the shared modules with an extra dot (`..._rows`, `...types`); the async -ones use `.._rows`, `..types`. This dot-depth difference is exactly what -`Structures/Surface.dhall` parameterizes (section 4). +Exactly one surface is emitted per generate, picked by `config.sync` +(`False`, the default, emits async; `True` emits sync) — the tree shape and +every import path are identical either way; only the statement modules' +`def`/`async def`, `Connection`/`AsyncConnection`, and `_runtime.py`'s body +change. A project that needs both surfaces generates two artifacts against +this same `gen:` with different `packageName`s, one with `sync: true` and +one without. ### Naming (all from the pgn `Name` object, never recomputed) @@ -164,8 +155,8 @@ Self-contained, emitted once per surface from `Templates/RuntimeModule.dhall` | `Void` | `execute_void` | `None` | The async runtime defines `JsonValue` and `NoRowError`; the sync runtime -re-exports both from the async one (`from .._runtime import JsonValue, -NoRowError`) so there is one canonical identity. The missing-single-row case +re-exports both from `_core` (`from ._core import JsonValue, NoRowError`) +so there is one canonical identity. The missing-single-row case raises `NoRowError(RuntimeError)`. Result helpers open `conn.cursor(row_factory=dict_row)` and pass each row @@ -191,31 +182,24 @@ narrows at its own boundary. ## 4. Surface mechanism (async / sync) -`Structures/Surface.dhall` is a five-field token table that captures -everything that varies between the two surfaces: - ```dhall { defKeyword : Text -- "async def" | "def" , connType : Text -- "AsyncConnection" | "Connection" , awaitKw : Text -- "await " | "" -, rowsImport : Text -- ".._rows" | "..._rows" -, typesPrefix: Text -- "..types" | "...types" +, rowsImport : Text -- ".._rows" (same depth for both surfaces) +, corePrefix : Text -- ".._core" (same depth for both surfaces) +, typesPrefix : Text -- "..types" (same depth for both surfaces) } ``` -`Surface.async` and `Surface.sync` are the two values. The statement-module -template (`Templates/StatementModule.dhall`) and the register template take -a `surface` field and render `${surface.defKeyword} ${functionName}(... conn: -${surface.connType}[object] ...)`, `return ${surface.awaitKw}${helperName}(...)`, -and the surface-correct relative imports. `Interpreters/Query.dhall` renders -each query twice (`mkModule Surface.async`, `mkModule Surface.sync`) and -returns both an `asyncContent`/`asyncModulePath` and a -`syncContent`/`syncModulePath`. `Interpreters/Project.dhall` always emits the -async files; the sync files are emitted only `if config.emitSync`. - -The type layer (`_rows.py`, `types/`) is rendered once and is -surface-agnostic, so adding the sync surface costs only the thin wrappers, -not a second copy of the types or decode. +`Surface.async` and `Surface.sync` are the two values. `Interpreters/ +Project.dhall` picks one — `if config.sync then Surface.sync else +Surface.async` — and threads it through a single render pass: +`Interpreters/Query.dhall` calls the statement-module template once per +query (not twice), and `Interpreters/Project.dhall` picks the matching +`_runtime.py` body and, when custom types exist, the matching `_register.py` +body. `_rows.py` and `types/` are surface-agnostic and always render exactly +once, so switching `config.sync` costs only the thin I/O wrappers. --- @@ -398,15 +382,10 @@ matching `__all__`: - every custom type from `_generated/types/`, - every Row dataclass from `_generated/_rows`, -- every statement function from `_generated/statements/` (async) or - `_generated/sync/statements/` (sync). +- every statement function from `_generated/statements/`. -So consumers do `from my_db_client import get_specimen, GetSpecimenRow, -Mood` against the flat root, and `from my_db_client.sync import ...` for the -sync surface. The async facade lives at `/__init__.py` (prefix -`._generated`, statements under `statements`); the sync facade lives at -`/sync/__init__.py` (prefix `.._generated`, statements under -`sync.statements`). Both re-export the SAME Rows and enums. +The facade lives at `/__init__.py` (prefix `._generated`, statements +under `statements`) regardless of which surface `config.sync` selected. This file is GENERATED: it carries the `@generated` header and is overwritten on every run. Do not hand-edit it. @@ -424,9 +403,9 @@ let OnUnsupported = ./Structures/OnUnsupported.dhall let ProjectInterpreter = ./Interpreters/Project.dhall -let Config = { packageName : Optional Text, emitSync : Optional Bool, onUnsupported : Optional OnUnsupported.Mode } +let Config = { packageName : Optional Text, sync : Optional Bool, onUnsupported : Optional OnUnsupported.Mode } -let Config/default = { packageName = None Text, emitSync = None Bool, onUnsupported = None OnUnsupported.Mode } +let Config/default = { packageName = None Text, sync = None Bool, onUnsupported = None OnUnsupported.Mode } in Sdk.Sigs.generator Config Config/default ProjectInterpreter.run ``` @@ -452,7 +431,7 @@ 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) + Surface.dhall # async/sync token table, both surfaces at the same import depth (section 4) ImportSet.dhall # per-module import flags + combine PyIdent.dhall # sanitize names that become Python identifiers (keyword -> name_) OnUnsupported.dhall # < Fail | Skip > (section 11) @@ -466,7 +445,7 @@ src/ QueryFragments.dhall # fragments -> escaped, %%-doubled, named-style SQL literal ResultColumns.dhall # Rows columns -> Row field lines + decode body Result.dhall # Void|RowsAffected|Rows -> return type + helper + rowClass - Query.dhall # assemble one query: shared rowDef + async/sync statement modules + Query.dhall # assemble one query: shared rowDef + one statement module for whichever surface config.sync picked Project.dhall # traverse queries+customTypes, assemble all files + facade + header, # apply the Skip filter (section 11) Templates/ @@ -500,13 +479,13 @@ renders custom-type imports in encounter order, unsorted and undeduped ### Config flow `package.dhall`'s `Config` is the user-facing type `{ packageName : Optional -Text, emitSync : Optional Bool, onUnsupported : Optional OnUnsupported.Mode +Text, sync : 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 +below it: `{ packageName, importName, sync, onUnsupported }`, with +`packageName` falling back to the project name in kebab case, `sync` 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 diff --git a/README.md b/README.md index 9e9a17f..a72eb9e 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@ A Dhall-authored code generator for [pgenie](https://github.com/pgenie-io/pgenie pgn reads a project's SQL queries and custom types from a live PostgreSQL, infers parameter and result types, then hands them to this generator, which -emits a typed Python client: one async client (`psycopg.AsyncConnection`), -optionally a parallel sync one (`psycopg.Connection`), sharing a single set -of Row dataclasses, decode functions, and enum/composite types. Generated -code depends on `psycopg>=3.2` and the stdlib only, nothing else. +emits a typed Python client: async (`psycopg.AsyncConnection`) by default, +or sync (`psycopg.Connection`) when `config.sync` is `True`, at the same +unified output paths either way. Generated code depends on `psycopg>=3.2` +and the stdlib only, nothing else. Every emitted file starts with `# @generated by python.gen (pGenie). DO NOT EDIT.` and an `SPDX-License-Identifier: MIT-0` header: the generated code is @@ -18,9 +18,10 @@ yours to use under your own project's terms, no attribution required. ## Key features -- **Async and sync clients over psycopg 3.** One shared set of Row dataclasses, - decode functions, and enum/composite types serves both surfaces; install - `psycopg[binary]` for the fast C implementation. +- **Async or sync client over psycopg 3.** `config.sync` (default `False`) + picks exactly one surface — the async client (`psycopg.AsyncConnection`) or + the sync one (`psycopg.Connection`) — at the same output paths either way; + install `psycopg[binary]` for the fast C implementation. - **Fully typed, end to end.** Parameter and result types are inferred from your live PostgreSQL by preparing the real queries, and every emitted file passes `basedpyright` strict with zero errors or warnings. @@ -38,7 +39,7 @@ artifacts: gen: https://raw.githubusercontent.com/slavashvets/python.gen/master/src/package.dhall config: packageName: my-db-client - emitSync: true + sync: true ``` `master` moves; once a `v0.1.0` tag exists, pin to the tagged raw URL instead @@ -76,7 +77,7 @@ as long as they resolve to the same source. | key | type | default | | --------------- | ------------------ | ------------------------------ | | `packageName` | `Text` | the project name, kebab-cased | -| `emitSync` | `Bool` | `False` | +| `sync` | `Bool` | `False` | | `onUnsupported` | `"Fail" \| "Skip"` | `"Fail"` | All three keys are optional, and so is the `config:` block itself. Decode @@ -87,7 +88,7 @@ generator's own test suite rather than assumed): - a key set to `null` decodes as absent, i.e. its default. - an unknown key is ignored, not rejected as a schema error. - each key falls back to its own default independently, so a partial config - (e.g. only `emitSync`) works field by field. + (e.g. only `sync`) works field by field. `packageName` becomes the Python import name by replacing `-` with `_` (`my-db-client` -> `my_db_client`). @@ -231,14 +232,22 @@ project has composites or enums, call `register_types(conn)` once per connection before relying on native composite decode or enum-array results; scalar enums do not need it. -With `emitSync: true`, a second surface appears under `my_db_client.sync`, -built from the exact same Row types and custom types as the async one, only -the I/O wrappers differ: +With `sync: true`, the entire client is generated against +`psycopg.Connection` instead — same import path, same facade shape, only the +function signatures and I/O become synchronous: ```python -from my_db_client.sync import get_specimen, GetSpecimenRow +from my_db_client import get_specimen, GetSpecimenRow, Mood + +def handler(conn): + row = get_specimen(conn, id=42) ``` +A project that needs both an async backend and a sync consumer (e.g. a +Dagster pipeline) generates two artifacts pointed at this same `gen:`, one +with `sync: true` and one without, each with its own `packageName` — not one +artifact with both surfaces bundled together. + ## Development This repo's own harness (`tests/`) needs a live PostgreSQL: it runs `pgn From a0b527e7c66b5f96bc79dd5f741086b0944aeb3e Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sun, 12 Jul 2026 10:09:00 +0300 Subject: [PATCH 20/41] Add demos to fixtures task --- docs/tasks/demos-to-fixtures.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/tasks/demos-to-fixtures.md diff --git a/docs/tasks/demos-to-fixtures.md b/docs/tasks/demos-to-fixtures.md new file mode 100644 index 0000000..fe5b6dd --- /dev/null +++ b/docs/tasks/demos-to-fixtures.md @@ -0,0 +1,32 @@ +# python.gen: `demos/` → `fixtures/` rename + +Postponed from the cross-generator rename. Apply when ready. + +## Directory rename + +```bash +cd /Users/mojojojo/repos/pgenie/python.gen +git mv demos/ fixtures/ +``` + +## File edits + +| File | Changes | +|---|---| +| `build.bash` | `demos/Exhaustive.dhall` → `fixtures/Exhaustive.dhall`; `regenerate_demo_output` → `regenerate_fixture_output` | +| `.github/workflows/ci.yml` | `demos/Exhaustive.dhall` → `fixtures/Exhaustive.dhall` | +| `.github/scripts/build-contract-shell.sh` | comment: `demos/Exhaustive.dhall` → `fixtures/Exhaustive.dhall` | +| `DESIGN.md` | 3 references: lines 646, 766, 770 — `demos/Exhaustive.dhall` → `fixtures/Exhaustive.dhall` | +| `CHANGELOG.md` | Add Upcoming entry documenting the rename | + +## Cleanup + +```bash +rm -rf .superpowers/sdd/ +``` + +## Left as-is + +- `Sdk.Fixtures.Exhaustive` import (stays) +- `tests/fixture-project/` (stays — separate concept) +- `notes.md` (historical) \ No newline at end of file From 82bd4ade5e159e0df0a3c71d2983fa554301de2e Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sun, 12 Jul 2026 10:09:34 +0300 Subject: [PATCH 21/41] Drop the task doc --- .../2026-07-12-configurable-sync-output.md | 1367 ----------------- 1 file changed, 1367 deletions(-) delete mode 100644 docs/plans/2026-07-12-configurable-sync-output.md diff --git a/docs/plans/2026-07-12-configurable-sync-output.md b/docs/plans/2026-07-12-configurable-sync-output.md deleted file mode 100644 index 5c047b4..0000000 --- a/docs/plans/2026-07-12-configurable-sync-output.md +++ /dev/null @@ -1,1367 +0,0 @@ -# Configurable Sync Output Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the additive `emitSync : Optional Bool` config knob (which always emits async and optionally bolts a second `sync/` tree on top) with an exclusive `sync : Optional Bool` knob, default `False`, that selects exactly one surface — async or sync — and emits it at the same unified file paths either way, so flipping the flag never changes the shape of the output tree or any import path, only file contents. - -**Architecture:** `Interpreters/Project.dhall` picks one `Surface.Type` value (`Surface.async` when `config.sync` is `False`, `Surface.sync` when `True`) and threads it through a single render pass instead of today's two passes (always-async + conditionally-additive-sync). `Interpreters/Query.dhall`'s per-query `Output` collapses its `asyncModulePath`/`asyncContent`/`syncModulePath`/`syncContent` two-surface duplication down to one `modulePath`/`content` pair. `Structures/Surface.dhall`'s import-depth tokens (`rowsImport`, `corePrefix`, `typesPrefix`) collapse to the same value for both surfaces, since sync statement modules no longer live one package deeper under `sync/statements/` — they live at the exact path async statements use today. `_rows.py` and `types/` are untouched by this plan (still surface-agnostic, still emitted once); splitting `_rows.py` per statement is a separate, later plan (`docs/plans/2026-07-12-distribute-rows-per-statement.md`) that depends on this one landing first. - -**Tech Stack:** Dhall (dhall-lang 1.42 vendored via `dhall` CLI; pgn's forked interpreter for the parts still using it), Python 3.12 generated output (psycopg3), basedpyright strict, pytest golden-file harness (`mise run test`, `mise run golden`), pgn 0.9.1 (mise-pinned). - -## Global Constraints - -- No behavior change to param/result type mapping, custom-type codecs, or SQL rendering — this plan is scoped to the sync/async surface-selection mechanism only. -- Default behavior when `config.sync` is omitted must be byte-identical to today's `emitSync` omitted/false default (async only, same paths) — verified by golden diff. -- Golden fixture output (`tests/golden/`, new `tests/golden_sync/`) must be regenerated via `mise run golden`, not hand-edited (per `tests/golden/README.md`). -- `mise run test` (pytest, includes `test_generated_passes_basedpyright_strict`) must pass after regeneration, for both the async and sync golden trees. -- Every Dhall file touched must independently type-check via the whole-generator check: `dhall type --file=src/package.dhall`. -- This is a breaking config change (the `emitSync` key stops existing). Per this repo's convention (see `CHANGELOG.md`'s "Upcoming" section and the "Drop the plans" precedent), document it there — no deprecation shim, no dual-key transition period. -- CI's memory-reduction script (`.github/workflows/ci.yml`, "Reduce the fixture project to the python artifact") truncates `tests/fixture-project/project1.pgn.yaml` at the `# The variants below` marker. Any new artifact that must run in CI (not just locally) has to be placed *before* that marker. - ---- - -## File Structure - -| File | Change | -|---|---| -| `src/package.dhall` | Public `Config`/`Config/default`: rename `emitSync` field to `sync`; rewrite doc comment. | -| `src/Interpreters/Project.dhall` | `Config`/`ResolvedConfig`: rename field. `run`: rename resolve binding. `combineOutputs`: replace always-async-plus-optional-sync emission with single-surface emission at unified paths. | -| `src/Interpreters/Query.dhall` | `Config`: rename field. `Output`: collapse `asyncModulePath`/`asyncContent`/`syncModulePath`/`syncContent` to `modulePath`/`content`. `render`: pick one surface, call `StatementModule.run` once instead of twice. | -| `src/Structures/Surface.dhall` | Collapse `sync`'s `rowsImport`/`corePrefix`/`typesPrefix` to the same depth as `async`'s (no more extra-dot nesting). Rewrite header comment. | -| `src/Templates/RuntimeModule.dhall` | Update the `syncContent` doc comment (no longer describes a `sync/_runtime.py` path). | -| `src/Interpreters/{Value,ResultColumns,Member,Scalar,QueryFragments,ParamsMember,CustomType,Primitive,Result}.dhall` | Mechanical rename: local `Config` type's `emitSync : Bool` field → `sync : Bool` (each is a narrowing projection of `ResolvedConfig`, never itself branches on the value except `Result.dhall`'s field-selection literal). | -| `demos/Exhaustive.dhall` | Rename `emitSync = Some True` → `sync = Some True`. | -| `tests/fixture-project/project1.pgn.yaml` | Main `python:` artifact: drop `emitSync: true` (default `sync: false` going forward). Add new `python-sync:` artifact (`sync: true`), placed before the CI truncation marker. Rename `emitSync` → `sync` in the `python-sync-only` and `python-null` variants. | -| `tests/golden_sync/` (new) | Hand-written shell (`pyproject.toml`, `README.md`, `src/specimen_sync_client/py.typed`) mirroring `tests/golden/`'s shell, for the new `python-sync` artifact's committed golden tree. | -| `tests/_harness.py` | Add `GOLDEN_DIR_SYNC` path constant. | -| `tests/conftest.py` | Add a `full_package_sync` fixture mirroring `full_package`, sourced from the `python_sync` artifact directory and `GOLDEN_DIR_SYNC`. | -| `tests/test_generated.py` | Drop `SYNC_FACADE_INIT` and the two-facade loop in `test_generated_matches_golden`. Add `test_generated_sync_matches_golden` and `test_generated_sync_passes_basedpyright_strict`. Delete `test_roundtrip_sync_and_cross_surface_identity` (cross-surface identity no longer exists once surfaces are exclusive); replace with `test_roundtrip_sync_surface` against `full_package_sync`. Move `test_roundtrip_single_field_composite_sync` onto `full_package_sync` with unified (non-`.sync.`) import paths. | -| `tests/test_config_variants.py` | Replace the `(package / "sync").exists()` presence check (no longer meaningful once paths are unified) with a content-based signal read from `_generated/_runtime.py`. Rename `emitSync` → `sync` throughout prose and assertions. | -| `mise.toml` | `golden` task: also generate and rsync the `python-sync` artifact into `tests/golden_sync/`. | -| `README.md` | Config reference table, quickstart example, "Key features," "Using the generated code" section. | -| `DESIGN.md` | Sections 1 ("What the generator emits"), 3 (runtime import example), 4 ("Surface mechanism"), 9 (facade), 10 (Config flow) — rewrite to describe exclusive single-surface selection at unified paths. | -| `CHANGELOG.md` | New "Upcoming" entry documenting the breaking config change. | - ---- - -### Task 1: Rename `emitSync` to `sync` across the Config-threading chain (no behavior change) - -Pure rename first, isolated from the behavior change in Task 2, so it is independently verifiable: after this task, generation is byte-identical to before, just keyed on a renamed config field. - -**Files:** -- Modify: `src/package.dhall` -- Modify: `src/Interpreters/Project.dhall` -- Modify: `src/Interpreters/Query.dhall` -- Modify: `src/Interpreters/Value.dhall`, `ResultColumns.dhall`, `Member.dhall`, `Scalar.dhall`, `QueryFragments.dhall`, `ParamsMember.dhall`, `CustomType.dhall`, `Primitive.dhall`, `Result.dhall` -- Modify: `demos/Exhaustive.dhall` - -**Interfaces:** -- Produces: every interpreter's `Config` type now has a `sync : Bool` field (was `emitSync : Bool`); `Project.dhall`'s public `Config`/`ResolvedConfig` and `src/package.dhall`'s `Config`/`Config/default` match. Task 2 reads `config.sync` / `resolvedConfig.sync`. - -- [ ] **Step 1: Rename in `src/package.dhall`** - -```dhall -let Sdk = ./Deps/Sdk.dhall - -let OnUnsupported = ./Structures/OnUnsupported.dhall - -let ProjectInterpreter = ./Interpreters/Project.dhall - --- User-facing config for this generator. `sync` picks which single surface --- the generator emits: `False` (default) emits the async surface --- (psycopg.AsyncConnection); `True` emits the sync surface --- (psycopg.Connection) instead, at the exact same paths — flipping this --- flag never changes the output tree's shape or any import path, only file --- contents. `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 - , sync : Optional Bool - , onUnsupported : Optional OnUnsupported.Mode - } : Type - -let Config/default - : Config - = { packageName = None Text - , sync = None Bool - , onUnsupported = None OnUnsupported.Mode - } - -in Sdk.Sigs.generator Config Config/default ProjectInterpreter.run -``` - -- [ ] **Step 2: Rename in `src/Interpreters/Project.dhall`** - -Replace lines 35-53 (the `Config` and `ResolvedConfig` declarations): - -```dhall --- 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, sync off (async), onUnsupported Fail); there is no separate config --- type or resolve step between package.dhall and here. -let Config = - { packageName : Optional Text - , sync : 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 - , sync : Bool - , onUnsupported : OnUnsupported.Mode - } -``` - -Replace the `emitSync` resolve binding in `run` (around line 380-386): - -```dhall - let sync = - Prelude.Optional.fold - Bool - config.sync - Bool - (\(b : Bool) -> b) - False -``` - -And its use in building `resolvedConfig` (around line 398-400): - -```dhall - let resolvedConfig - : ResolvedConfig - = { packageName, importName, sync, onUnsupported } -``` - -Leave the rest of `combineOutputs`'s body as-is for this task — Task 2 rewrites that function's structure anyway — but rename the two literal occurrences of the field name inside it so Step 7's type-check passes. Line 272 (doc comment): - -```dhall - -- config.sync. It reuses the shared `_rows.py` and `types/`, so only -``` - -Line 325 (the conditional gating `syncFiles`): - -```dhall - let syncFiles = - if config.sync - then [ syncSubpackageInit -``` - -- [ ] **Step 3: Rename in `src/Interpreters/Query.dhall`** - -Line 28 (`Config` record): `, emitSync : Bool` → `, sync : Bool`. - -Line 40 comment ("emitted only when config.emitSync") → "emitted only when config.sync" (Task 2 rewrites this comment fully; this step is the minimal rename). - -- [ ] **Step 4: Rename the narrowing `Config` field in the nine passthrough interpreters** - -Each of these files declares a local `Config` type that is a narrowing projection of `ResolvedConfig`, purely so later Dhall record-selection syntax can pick the field out by name. None of them branch on the value except `Result.dhall`'s selection literal (next step). In each file, change the line `, emitSync : Bool` to `, sync : Bool`: - -| File | Line | -|---|---| -| `src/Interpreters/Value.dhall` | 18 | -| `src/Interpreters/ResultColumns.dhall` | 20 | -| `src/Interpreters/Member.dhall` | 20 | -| `src/Interpreters/Scalar.dhall` | 16 | -| `src/Interpreters/QueryFragments.dhall` | 16 | -| `src/Interpreters/ParamsMember.dhall` | 18 | -| `src/Interpreters/CustomType.dhall` | 22 | -| `src/Interpreters/Primitive.dhall` | 14 | -| `src/Interpreters/Result.dhall` | 25 | - -- [ ] **Step 5: Rename the field-selection literal in `src/Interpreters/Result.dhall`** - -Line 94: - -```dhall - config.{ packageName, importName, sync, onUnsupported } -``` - -- [ ] **Step 6: Rename in `demos/Exhaustive.dhall`** - -Line 29: - -```dhall - { packageName = None Text - , sync = Some True - , onUnsupported = Some OnUnsupported.Mode.Skip - } -``` - -- [ ] **Step 7: Type-check the whole generator** - -Run: `cd python.gen && dhall type --file=src/package.dhall` -Expected: prints the generator's function type, no errors. (This alone proves every renamed `Config` record still lines up across every call site — Dhall's structural typing would reject a mismatched field name.) - -- [ ] **Step 8: Confirm no `emitSync` references remain in `src/`** - -Run: `cd python.gen && grep -rn "emitSync" src/ demos/` -Expected: no output. - -- [ ] **Step 9: Commit** - -```bash -git add src/package.dhall src/Interpreters/Project.dhall src/Interpreters/Query.dhall \ - src/Interpreters/Value.dhall src/Interpreters/ResultColumns.dhall src/Interpreters/Member.dhall \ - src/Interpreters/Scalar.dhall src/Interpreters/QueryFragments.dhall src/Interpreters/ParamsMember.dhall \ - src/Interpreters/CustomType.dhall src/Interpreters/Primitive.dhall src/Interpreters/Result.dhall \ - demos/Exhaustive.dhall -git commit -m "python.gen: rename emitSync config field to sync (no behavior change)" -``` - ---- - -### Task 2: Make `sync` exclusive — unify output paths, single-surface emission - -**Files:** -- Modify: `src/Structures/Surface.dhall` -- Modify: `src/Interpreters/Query.dhall` -- Modify: `src/Interpreters/Project.dhall` -- Modify: `src/Templates/RuntimeModule.dhall` (comment only) -- Modify: `src/Templates/FacadeModule.dhall` (drop dead surface-selection params) - -**Interfaces:** -- Consumes: `resolvedConfig.sync : Bool` (from Task 1). -- Produces: `Query.dhall`'s `Output` record now has `modulePath : Text` and `content : Text` (replacing the four async/sync-prefixed fields) — any code outside this task that referenced `query.asyncModulePath` etc. must be updated in this same task, since Dhall's structural typing will fail closed on the old field names. - -- [ ] **Step 1: Collapse the import-depth tokens in `src/Structures/Surface.dhall`** - -Sync statement modules no longer live one package deeper (`sync/statements/`) — they live at the exact same `statements/` path async uses. Both surfaces now reach `_rows`, `_core`, and `types/` at the same relative depth. - -```dhall --- A code-generation surface: the async or sync flavour of a statement module. --- Exactly one surface is emitted per generate (Interpreters/Project.dhall --- picks async or sync from config.sync and renders everything at the same --- unified paths), so the two Surface values differ only in the tokens that --- vary between `async def`/`def`, `AsyncConnection`/`Connection`, and --- `await `/``. Both reach the shared `_rows`/`_core`/`types` modules at the --- same relative import depth, since neither surface is nested under a --- surface-named subdirectory. -let Surface = - { defKeyword : Text - , connType : Text - , awaitKw : Text - , rowsImport : Text - , corePrefix : Text - , typesPrefix : Text - } - -let async - : Surface - = { defKeyword = "async def" - , connType = "AsyncConnection" - , awaitKw = "await " - , rowsImport = ".._rows" - , corePrefix = ".._core" - , typesPrefix = "..types" - } - -let sync - : Surface - = { defKeyword = "def" - , connType = "Connection" - , awaitKw = "" - , rowsImport = ".._rows" - , corePrefix = ".._core" - , typesPrefix = "..types" - } - -in { Type = Surface, async, sync } -``` - -- [ ] **Step 2: Collapse `Query.dhall`'s per-surface duplication** - -Replace the `Output` record (lines 42-51): - -```dhall --- A query contributes a shared Row (assembled into `_rows.py` by Project) plus --- one thin statement module, rendered for whichever surface config.sync picked. -let Output = - { functionName : Text - , rowClassName : Optional Text - , rowDef : Optional RowsModule.RowDef - , rowImports : ImportSet.Type - , modulePath : Text - , content : Text - } -``` - -Replace the tail of `render` (from `let mkModule = ...` through the end of the function, lines 112-136): - -```dhall - let surface = if config.sync then Surface.sync else Surface.async - - let content = - StatementModule.run - { functionName - , returnType = result.returnType - , helperName = result.helperName - , callsDecode = result.callsDecode - , sqlLiteral = fragments.sqlLiteral - , rowClassName - , decodeName - , paramSigLines - , paramDictEntries - , imports = paramImports - , surface - } - - in { functionName - , rowClassName - , rowDef - , rowImports = result.imports - , modulePath = "statements/${functionName}.py" - , content - } -``` - -- [ ] **Step 3: Rewrite `Project.dhall`'s `combineOutputs` for single-surface emission** - -Replace the whole body from the `let asyncFacade = ...` binding (line 157) through `let allFiles = ...` (line 347) with: - -```dhall - let surface = if config.sync then Surface.sync else Surface.async - - let facade = - { path = packagePrefix ++ "__init__.py" - , content = - FacadeModule.run - { generatedPrefix = "._generated" - , statementsPath = "statements" - , statements = facadeStatements - , types = facadeTypes - } - } - - -- Surface-agnostic; performs no I/O, so exactly one copy is emitted - -- regardless of surface. Both runtime bodies re-export from it. - let coreModule = - { path = srcPrefix ++ "_core.py", content = CoreModule.run {=} } - - let runtimeModule = - { path = srcPrefix ++ "_runtime.py" - , content = - if config.sync then RuntimeModule.runSync {=} else RuntimeModule.run {=} - } - - let statementsInit = - { path = srcPrefix ++ "statements/__init__.py" - , content = - InitModule.run { docstring = "Generated SQL statements." } - } - - -- The shared Row dataclasses + decode functions, imported by the - -- statement modules of whichever surface was selected. - let rowDefs = - Prelude.List.concatMap - QueryGen.Output - RowsModule.RowDef - ( \(query : QueryGen.Output) -> - Prelude.Optional.toList RowsModule.RowDef query.rowDef - ) - queries - - let rowImports = - ImportSet.combineAll - ( Prelude.List.map - QueryGen.Output - ImportSet.Type - (\(query : QueryGen.Output) -> query.rowImports) - queries - ) - - let rowsFiles = - if Prelude.List.null RowsModule.RowDef rowDefs - then [] : List Lude.File.Type - else [ { path = srcPrefix ++ "_rows.py" - , content = - RowsModule.run { rows = rowDefs, imports = rowImports } - } - ] - - let statementFiles = - Prelude.List.map - QueryGen.Output - Lude.File.Type - ( \(query : QueryGen.Output) -> - { path = srcPrefix ++ query.modulePath - , content = query.content - } - ) - queries - - let typeFiles = - Prelude.List.map - CustomTypeGen.Output - Lude.File.Type - ( \(ct : CustomTypeGen.Output) -> - { path = srcPrefix ++ ct.modulePath - , content = ct.moduleContent - } - ) - customTypes - - let typesInitExports = - Prelude.List.map - CustomTypeGen.Output - TypesInit.Export - (\(ct : CustomTypeGen.Output) -> { moduleName = ct.moduleName, typeName = ct.typeName }) - customTypes - - let typesInitFiles = - if Prelude.List.null CustomTypeGen.Output customTypes - then [] : List Lude.File.Type - else [ { path = srcPrefix ++ "types/__init__.py" - , content = TypesInit.run { exports = typesInitExports } - } - ] - - let compositeNames = compositePgNames customTypes - - let enumNames = enumPgNames customTypes - - let hasCustomRegistration = - Prelude.Bool.not - ( Prelude.Bool.and - [ Prelude.List.null Text compositeNames - , Prelude.List.null Text enumNames - ] - ) - - let registerFiles = - if hasCustomRegistration - then [ { path = srcPrefix ++ "_register.py" - , content = - RegisterModule.run { compositeNames, enumNames, surface } - } - ] - else [] : List Lude.File.Type - - let staticFiles = - [ facade, topInit, coreModule, runtimeModule, statementsInit ] - - let allFiles = - staticFiles - # registerFiles - # rowsFiles - # typesInitFiles - # typeFiles - # statementFiles - - in Prelude.List.map Lude.File.Type Lude.File.Type withHeader allFiles - : Output -``` - -Note `topInit` (the `_generated/__init__.py` facade docstring file, defined earlier in the function and unchanged) is still referenced in `staticFiles` — leave its definition (around line 128-135 in the original) exactly as-is; only the block below it changes. - -- [ ] **Step 4: Drop `FacadeModule.dhall`'s dead surface-selection parameters** - -`generatedPrefix` and `statementsPath` exist only because the old design called `FacadeModule.run` twice with different values (once for the async facade at the package root, once for the sync facade one level down at `sync/__init__.py`). Now there is exactly one facade, always at the package root, so both become constants — hardcode them and drop them from `Params`. - -Replace the header comment and `Params` declaration (lines 13-24): - -```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. -let StatementExport = - { functionName : Text, rowClassName : Optional Text } - --- A custom type re-exported from types/: the leaf module name plus the class. -let TypeExport = { moduleName : Text, className : Text } - --- The facade always lives at the package root, importing from `_generated` --- (prefix `._generated`) and `_generated/statements` (statementsPath --- `statements`) — both constant now that exactly one surface is emitted per --- generate (Interpreters/Project.dhall picks it via config.sync). -let generatedPrefix = "._generated" - -let statementsPath = "statements" - -let Params = - { statements : List StatementExport - , types : List TypeExport - } -``` - -Replace every remaining `params.generatedPrefix` with `generatedPrefix` and every `params.statementsPath` with `statementsPath` in the rest of the file (the `runtimeBlock`, `typeBlock`, `rowBlock`, and `statementBlock` bindings inside `run`). - -Then, back in `Interpreters/Project.dhall`, simplify the `facade` binding from Step 3 above to drop the now-nonexistent fields: - -```dhall - let facade = - { path = packagePrefix ++ "__init__.py" - , content = - FacadeModule.run - { statements = facadeStatements, types = facadeTypes } - } -``` - -- [ ] **Step 5: Update the `syncContent` doc comment in `src/Templates/RuntimeModule.dhall`** - -Replace the comment at lines 84-88: - -```dhall --- The sync surface's body, selected in place of `content` at the same --- `_runtime.py` path when config.sync is True (Interpreters/Project.dhall). --- The five helpers are the same shape with `def`/`Connection`/`with`/no- --- `await`. JsonValue/NoRowError/require_array are re-exported from _core so --- both surfaces share one canonical identity rather than two equal-but- --- distinct definitions. -``` - -- [ ] **Step 6: Type-check the whole generator** - -Run: `cd python.gen && dhall type --file=src/package.dhall` -Expected: prints the generator's function type, no errors. - -- [ ] **Step 7: Delete the stale freeze file so the next `pgn generate` re-resolves the working tree** - -Run: `rm -f tests/fixture-project/freeze1.pgn.yaml` -Expected: file removed (pgn's freeze cache does not know the generator source changed underneath a stable relative path; a stale freeze would silently keep emitting the old two-tree output). - -- [ ] **Step 8: Commit** - -```bash -git add src/Structures/Surface.dhall src/Interpreters/Query.dhall src/Interpreters/Project.dhall \ - src/Templates/RuntimeModule.dhall src/Templates/FacadeModule.dhall -git commit -m "python.gen: make sync/async mutually exclusive at unified output paths" -``` - -(This task intentionally does not update `tests/fixture-project/project1.pgn.yaml` yet — the main `python:` artifact still has `sync: true` from before Task 1's rename, so at this point in the plan, generating against it produces sync-surface content at the paths async used to occupy, and `tests/golden/` has not been refreshed yet, so `test_generated_matches_golden` and the sync-specific round-trip tests are expected to fail until Task 3 and Task 4 land. This is fine mid-plan; the full suite is the gate at the end, not after every task.) - ---- - -### Task 3: Update the fixture project config - -**Files:** -- Modify: `tests/fixture-project/project1.pgn.yaml` - -**Interfaces:** -- Produces: a `python-sync` artifact (packageName `specimen-sync-client`) alongside the existing `python` artifact (now `sync` omitted, i.e. async by default), both ahead of the CI truncation marker. Task 4/5 consume `artifacts/python_sync` and the `specimen_sync_client` package name. - -- [ ] **Step 1: Rewrite `tests/fixture-project/project1.pgn.yaml`** - -```yaml -space: python-gen -name: fixture -version: 0.0.0 -postgres: 18 -artifacts: - python: - gen: ../../src/package.dhall - config: - packageName: specimen-client - python-sync: - gen: ../../src/package.dhall - config: - packageName: specimen-sync-client - sync: true - # The variants below pin pgn's actual YAML->Dhall decode semantics for the - # Optional Config knobs (undocumented by pgn itself); see - # tests/test_config_variants.py for the assertions. - python-name-only: - gen: ../../src/package.dhall - config: - packageName: name-only-client - python-sync-only: - gen: ../../src/package.dhall - config: - sync: true - python-empty: - gen: ../../src/package.dhall - config: {} - python-bare: - gen: ../../src/package.dhall - python-unknown-key: - gen: ../../src/package.dhall - config: - packageName: unknown-key-client - bogusField: 1 - python-null: - gen: ../../src/package.dhall - config: - packageName: null-client - sync: null -``` - -Note `python:` drops the `emitSync: true` key entirely (was: `packageName: specimen-client`, `emitSync: true`) — the primary golden fixture now represents the default (`sync` omitted → async), matching what most of `tests/test_generated.py` exercises. `python-sync` is new and sits *before* the `# The variants below` marker, so CI's truncation step (`.github/workflows/ci.yml`) keeps it alongside `python`. - -- [ ] **Step 2: Commit** - -```bash -git add tests/fixture-project/project1.pgn.yaml -git commit -m "python.gen: add python-sync fixture artifact, drop emitSync from main artifact" -``` - ---- - -### Task 4: Add the `tests/golden_sync/` shell and wire up golden regeneration for both artifacts - -**Files:** -- Create: `tests/golden_sync/pyproject.toml` -- Create: `tests/golden_sync/README.md` -- Create: `tests/golden_sync/src/specimen_sync_client/py.typed` -- Modify: `tests/_harness.py` -- Modify: `mise.toml` -- Modify: `tests/golden/pyproject.toml` (drop the now-absent `emitSync`-driven `sync/` package reference — see Step 5) - -**Interfaces:** -- Produces: `GOLDEN_DIR_SYNC` (a `Path` constant in `tests/_harness.py`), importable by `tests/conftest.py` and `tests/test_generated.py`. - -- [ ] **Step 1: Create `tests/golden_sync/pyproject.toml`** - -```toml -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "specimen-sync-client" -version = "0.0.0" -requires-python = ">=3.12" -dependencies = ["psycopg>=3.2"] - -[tool.hatch.build.targets.wheel] -packages = ["src/specimen_sync_client"] - -[tool.ruff] -exclude = ["src/specimen_sync_client/_generated"] -``` - -- [ ] **Step 2: Create `tests/golden_sync/src/specimen_sync_client/py.typed`** - -Empty file (PEP 561 marker, matching `tests/golden/src/specimen_client/py.typed`). - -```bash -mkdir -p tests/golden_sync/src/specimen_sync_client -touch tests/golden_sync/src/specimen_sync_client/py.typed -``` - -- [ ] **Step 3: Create `tests/golden_sync/README.md`** - -```markdown -# Golden files (sync surface) - -The sync-surface counterpart of `tests/golden/`: a committed full fixture -package generated with `config: { sync: true }`, package name -`specimen_sync_client`. Same structure and purpose as `tests/golden/README.md` -describes for the async (default) surface — the hand-written shell here -(`pyproject.toml`, `py.typed`) plus the generated `_generated/` subtree and -the generated package-root facade `src/specimen_sync_client/__init__.py`. - -`test_generated_sync_matches_golden` regenerates the `python-sync` fixture -artifact into a temp tree and asserts every file under the fresh -`_generated/` plus the facade equals its golden twin here, both ways (no -missing, no extra). `test_generated_sync_passes_basedpyright_strict` runs -basedpyright strict on this full golden package. - -## Updating the golden - -Run only when a generator change legitimately alters the sync surface's -output, and review the resulting diff before committing. - -```bash -mise run golden -``` - -`mise run golden` refreshes both `tests/golden/` (the `python` artifact) and -this directory (the `python-sync` artifact) in one pass — see `mise.toml`. -``` - -- [ ] **Step 4: Add `GOLDEN_DIR_SYNC` to `tests/_harness.py`** - -Line 28, immediately after `GOLDEN_DIR = HERE / "golden"`: - -```python -GOLDEN_DIR = HERE / "golden" -GOLDEN_DIR_SYNC = HERE / "golden_sync" -``` - -- [ ] **Step 5: Confirm `tests/golden/pyproject.toml` needs no change** - -`tests/golden/pyproject.toml`'s `[tool.ruff] exclude` and `packages` entries already reference only `src/specimen_client` (not a `sync/` sub-path), so this file needs no edit — the sync surface's shell lives entirely under the new `tests/golden_sync/`, a sibling directory, not nested inside the existing golden tree. (Listed in File Structure above out of caution; this step is a no-op confirmation, not an edit.) - -- [ ] **Step 6: Rewrite the `golden` task in `mise.toml`** - -Replace the `[tasks.golden]` block: - -```toml -# Regenerates the "python" and "python-sync" artifacts: a full 7-artifact -# generate peaks at ~31 GB RSS (memory goes to normalizing the generator -# closure per artifact, see ci.yml), so this keeps only the two artifacts -# golden actually needs instead of all 7. -[tasks.golden] -description = "Refresh tests/golden and tests/golden_sync from the working-tree src/" -run = ''' -#!/usr/bin/env bash -set -euo pipefail -root="$(pwd)" -tmp="$(mktemp -d)" -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/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("../../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 -rsync -a --delete artifacts/python/src/specimen_client/_generated/ "$root/tests/golden/src/specimen_client/_generated/" -cp artifacts/python/src/specimen_client/__init__.py "$root/tests/golden/src/specimen_client/__init__.py" -rsync -a --delete artifacts/python_sync/src/specimen_sync_client/_generated/ "$root/tests/golden_sync/src/specimen_sync_client/_generated/" -cp artifacts/python_sync/src/specimen_sync_client/__init__.py "$root/tests/golden_sync/src/specimen_sync_client/__init__.py" -echo "golden refreshed; review with: git diff tests/golden tests/golden_sync" -''' -``` - -Note the `cp .../sync/__init__.py` line from the original task is gone (no sync facade sub-path exists anymore); the sync surface's facade is now at the same top-level `__init__.py` position as async's, just in the sibling `python_sync` artifact directory. - -- [ ] **Step 7: Run the golden refresh** - -Run: `cd python.gen && mise run golden` -Expected: exits 0, prints `golden refreshed; review with: git diff tests/golden tests/golden_sync`. Needs a reachable Postgres (`PGN_TEST_DATABASE_URL`, default `postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable`). - -- [ ] **Step 8: Review the diff** - -Run: `cd python.gen && git status tests/golden tests/golden_sync && git diff tests/golden` -Expected: `tests/golden/src/specimen_client/_generated/sync/` and `tests/golden/src/specimen_client/sync/__init__.py` are deleted (14 files: `sync/__init__.py`, `sync/_register.py`, `sync/_runtime.py`, `sync/statements/__init__.py`, 10 `sync/statements/*.py`, package-root `sync/__init__.py`). `tests/golden_sync/src/specimen_sync_client/_generated/` is new and structurally mirrors what `tests/golden/src/specimen_client/_generated/` looked like *before* this plan minus the `sync/` nesting, with `def`/`Connection`/no-`await` content instead of `async def`/`AsyncConnection`/`await `. No other content in either tree should have changed (params, SQL, custom types, row shapes are untouched by this plan). - -- [ ] **Step 9: Commit** - -```bash -git add tests/golden tests/golden_sync tests/_harness.py mise.toml -git commit -m "python.gen: add tests/golden_sync, regenerate both golden trees for exclusive sync" -``` - ---- - -### Task 5: Update the pytest harness for two independent golden trees - -**Files:** -- Modify: `tests/conftest.py` -- Modify: `tests/test_generated.py` - -**Interfaces:** -- Consumes: `GOLDEN_DIR_SYNC` (Task 4), `artifacts/python_sync` (Task 3), package name `specimen_sync_client`. -- Produces: `full_package_sync` fixture (mirrors `full_package`), usable by any test needing an importable sync-surface package. - -- [ ] **Step 1: Add a `generated_tree_sync`-adjacent path helper and `full_package_sync` fixture to `tests/conftest.py`** - -Add after the existing `full_package` fixture (end of file): - -```python -@pytest.fixture(scope="session") -def full_package_sync(generated_tree: Path, tmp_path_factory: pytest.TempPathFactory) -> Path: - """The sync-surface counterpart of `full_package`. - - `generated_tree` already ran `pgn generate` against the full project - (all artifacts, including `python-sync`), so this reuses that one - subprocess call rather than invoking pgn again: it just points at the - sibling `python_sync` artifact directory instead of `python`. - """ - generated_tree_sync = generated_tree.parent.parent / "artifacts" / "python_sync" - root = tmp_path_factory.mktemp("pkg-sync") - shell_src = GOLDEN_DIR_SYNC / "src" - generated_src = generated_tree_sync / "src" - _ = shutil.copytree(shell_src, root / "src", ignore=shutil.ignore_patterns("_generated", "__init__.py")) - for pkg_dir in generated_src.iterdir(): - dest_pkg = root / "src" / pkg_dir.name - _ = shutil.copytree(pkg_dir / "_generated", dest_pkg / "_generated") - _ = shutil.copy2(pkg_dir / "__init__.py", dest_pkg / "__init__.py") - return root -``` - -Add `GOLDEN_DIR_SYNC` to the existing `from tests._harness import (...)` block at the top of the file: - -```python -from tests._harness import ( - FIXTURE_PROJECT, - GOLDEN_DIR, - GOLDEN_DIR_SYNC, - HERE, - SRC_DIR, - admin_database_url, - effective_database_name, - run_pgn, -) -``` - -- [ ] **Step 2: Update `tests/test_generated.py`'s module-level constants** - -Replace lines 33-39: - -```python -# The generator emits the /_generated subtree plus the package-root -# __init__.py facade; the rest of the shell (pyproject.toml, py.typed) is -# hand-written and lives in golden as a committed fixture, not produced by -# generate. -GENERATED_SUBTREE = Path("src/specimen_client/_generated") -FACADE_INIT = Path("src/specimen_client/__init__.py") -GENERATED_SUBTREE_SYNC = Path("src/specimen_sync_client/_generated") -FACADE_INIT_SYNC = Path("src/specimen_sync_client/__init__.py") -``` - -Update the import line to include `GOLDEN_DIR_SYNC`: - -```python -from tests._harness import FIXTURE_PROJECT, GOLDEN_DIR, GOLDEN_DIR_SYNC, HERE, ensure_droppable, run_pgn -``` - -- [ ] **Step 3: Simplify `test_generated_matches_golden`, add its sync counterpart** - -Replace the `for facade in (FACADE_INIT, SYNC_FACADE_INIT):` loop (lines 116-118) with a single check: - -```python - if (generated_tree / FACADE_INIT).read_text() != (GOLDEN_DIR / FACADE_INIT).read_text(): - mismatched.append(str(FACADE_INIT)) -``` - -Add a new test after `test_generated_matches_golden`: - -```python -def test_generated_sync_matches_golden(generated_tree: Path) -> None: - """Sync-surface counterpart of test_generated_matches_golden. - - generated_tree points at the "python" artifact; the sync surface's - output lives in the sibling "python_sync" artifact directory produced by - the same pgn generate call (see the generated_tree fixture). - """ - generated_tree_sync = generated_tree.parent.parent / "artifacts" / "python_sync" - produced_root = generated_tree_sync / GENERATED_SUBTREE_SYNC - golden_root = GOLDEN_DIR_SYNC / GENERATED_SUBTREE_SYNC - - produced = _relative_files(produced_root) - golden = _relative_files(golden_root) - - missing = sorted(str(p) for p in golden - produced) - extra = sorted(str(p) for p in produced - golden) - assert not missing, f"golden files not produced by the generator: {missing}" - assert not extra, f"generator emitted files absent from golden: {extra}" - - mismatched: list[str] = [] - for rel in sorted(produced, key=str): - if (produced_root / rel).read_text() != (golden_root / rel).read_text(): - mismatched.append(str(rel)) - - if (generated_tree_sync / FACADE_INIT_SYNC).read_text() != (GOLDEN_DIR_SYNC / FACADE_INIT_SYNC).read_text(): - mismatched.append(str(FACADE_INIT_SYNC)) - - assert not mismatched, ( - "generated sync output drifted from golden in: " - + ", ".join(mismatched) - + "\nupdate via: mise run golden (see tests/golden_sync/README.md)" - ) -``` - -- [ ] **Step 4: Add `test_generated_sync_passes_basedpyright_strict`** - -Add after `test_generated_passes_basedpyright_strict`: - -```python -def test_generated_sync_passes_basedpyright_strict(tmp_path: Path) -> None: - """basedpyright strict on the full sync-surface golden package.""" - config = tmp_path / "pyrightconfig.json" - _ = config.write_text( - json.dumps( - { - "pythonVersion": "3.12", - "typeCheckingMode": "strict", - "include": [str(GOLDEN_DIR_SYNC / "src")], - "venvPath": str(HARNESS_ROOT), - "venv": ".venv", - "reportMissingModuleSource": False, - } - ) - ) - result = subprocess.run( - ["basedpyright", "--project", str(config), "--outputjson"], - capture_output=True, - text=True, - ) - - if not result.stdout.strip(): - pytest.fail(f"basedpyright produced no JSON (exit {result.returncode}):\n{result.stderr}") - - summary = json.loads(result.stdout)["summary"] - assert summary["filesAnalyzed"] > 0, f"basedpyright analyzed no files; bad include path?\n{result.stdout}" - assert summary["errorCount"] == 0 and summary["warningCount"] == 0, ( - f"basedpyright strict reported issues: {summary}\n{result.stdout}" - ) -``` - -- [ ] **Step 5: Delete `test_roundtrip_sync_and_cross_surface_identity`, replace with `test_roundtrip_sync_surface`** - -Delete the whole function (lines 392-479 in the original — cross-surface identity is not a meaningful concept once each surface is its own independent generate with its own `_rows.py`/package). Replace with: - -```python -def test_roundtrip_sync_surface(full_package_sync: Path, roundtrip_db: str) -> None: - """The sync surface, generated independently with config.sync = true. - - Drives the generated sync functions (psycopg.Connection, no await) end - to end, at the same unified paths the async surface uses (no `.sync.` - sub-path) — this package was generated standalone, not alongside async. - """ - _apply_migrations(roundtrip_db) - import_module = _import_client_sync(full_package_sync) - - register = import_module("specimen_sync_client._generated._register") - mood_mod = import_module("specimen_sync_client._generated.types.mood") - point_mod = import_module("specimen_sync_client._generated.types.point_2_d") - insert = import_module("specimen_sync_client._generated.statements.insert_specimen") - get = import_module("specimen_sync_client._generated.statements.get_specimen") - by_moods = import_module("specimen_sync_client._generated.statements.list_specimens_by_moods") - bump = import_module("specimen_sync_client._generated.statements.bump_specimen_revision") - - Mood = mood_mod.Mood - Point2D = point_mod.Point2D - - conn = psycopg.connect(roundtrip_db, autocommit=True) - try: - register.register_types(conn) - - inserted = insert.insert_specimen( - conn, - doc_jsonb={"k": "v", "n": 1}, - feeling=Mood.HAPPY, - origin=Point2D(x=1.5, y=2.5), - flag=True, - small=1, - medium=2, - large=3, - ratio=0.5, - precise=0.25, - title="alpha", - code="C-1", - letter="x", - born_on=date(2020, 1, 2), - amount=Decimal("12.34"), - blob=b"\x00\x01", - doc_json=[1, 2, 3], - maybe_text=None, - maybe_int=None, - maybe_uuid=None, - maybe_ts=None, - maybe_num=None, - tags=["a", None, "b"], - related_ids=None, - grid=None, - moods=[Mood.HAPPY, None, Mood.SAD], - ) - assert isinstance(inserted.feeling, Mood) - assert inserted.feeling is Mood.HAPPY - assert isinstance(inserted.origin, Point2D) - assert inserted.doc_jsonb == {"k": "v", "n": 1} - assert inserted.moods == [Mood.HAPPY, None, Mood.SAD] - assert inserted.moods is not None - assert inserted.moods[0] is Mood.HAPPY - - specimen_id = inserted.id - hit = get.get_specimen(conn, id=specimen_id) - assert hit is not None - assert hit.id == specimen_id - assert get.get_specimen(conn, id=specimen_id + 10_000) is None - - mood_rows = by_moods.list_specimens_by_moods(conn, moods=[Mood.HAPPY]) - assert [r.id for r in mood_rows] == [specimen_id] - assert by_moods.list_specimens_by_moods(conn, moods=[Mood.SAD]) == [] - - affected = bump.bump_specimen_revision(conn, id=specimen_id) - assert affected == 1 - bumped = get.get_specimen(conn, id=specimen_id) - assert bumped is not None - assert bumped.rev == 2 - finally: - conn.close() -``` - -Add the `_import_client_sync` helper next to `_import_client` (same shape, different module-name prefix so `sys.modules` cache invalidation targets the right package): - -```python -def _import_client_sync(full_package_sync: Path): # noqa: ANN202 - dynamic module set - src = str(full_package_sync / "src") - if src not in sys.path: - sys.path.insert(0, src) - for name in list(sys.modules): - if name == "specimen_sync_client" or name.startswith("specimen_sync_client."): - del sys.modules[name] - return importlib.import_module -``` - -- [ ] **Step 6: Move `test_roundtrip_single_field_composite_sync` onto the sync-only package** - -Replace the function body: - -```python -def test_roundtrip_single_field_composite_sync(full_package_sync: Path, roundtrip_db: str) -> None: - """Sync-surface counterpart of test_roundtrip_single_field_composite.""" - _apply_migrations(roundtrip_db) - import_module = _import_client_sync(full_package_sync) - - register = import_module("specimen_sync_client._generated._register") - tag_mod = import_module("specimen_sync_client._generated.types.tag_value") - insert = import_module("specimen_sync_client._generated.statements.insert_tagged_item") - get = import_module("specimen_sync_client._generated.statements.get_tagged_item") - - TagValue = tag_mod.TagValue - - conn = psycopg.connect(roundtrip_db, autocommit=True) - try: - register.register_types(conn) - - inserted = insert.insert_tagged_item(conn, name="widget", tag=TagValue(value="blue")) - assert isinstance(inserted.tag, TagValue) - assert inserted.tag == TagValue(value="blue") - - hit = get.get_tagged_item(conn, id=inserted.id) - assert hit is not None - assert hit.tag == TagValue(value="blue") - finally: - conn.close() -``` - -- [ ] **Step 7: Run the generated-output test module** - -Run: `cd python.gen && mise run test -- tests/test_generated.py -v` -Expected: all tests pass, including the new `test_generated_sync_matches_golden`, `test_generated_sync_passes_basedpyright_strict`, `test_roundtrip_sync_surface`, and `test_roundtrip_single_field_composite_sync`. - -- [ ] **Step 8: Commit** - -```bash -git add tests/conftest.py tests/test_generated.py -git commit -m "python.gen: split sync-surface tests onto their own golden fixture" -``` - ---- - -### Task 6: Fix the config-variant tests' sync-detection signal - -**Files:** -- Modify: `tests/test_config_variants.py` - -**Interfaces:** -- Consumes: `_generated/_runtime.py` content (Task 2 made `def fetch_many`/`async def fetch_many` the sync/async tell, since there is no more `sync/` subdirectory to check for existence). - -- [ ] **Step 1: Rewrite `tests/test_config_variants.py`** - -```python -"""Pins pgn's actual YAML->Dhall decode semantics for the Optional Config knobs. - -pgn's decode behavior for record types is undocumented, so each artifact in -project1.pgn.yaml drives a different subset of `config` keys through the same -compile.dhall and the assertions below record what pgn 0.6.5 was observed to do, -not a documented contract. These variants are pinned by the directory/package -name and which surface (sync or async) they produce, not a full golden tree. -""" - -from __future__ import annotations - -import os -from pathlib import Path - -import pytest - -pytestmark = pytest.mark.skipif( - os.environ.get("HARNESS_CI_REDUCED") == "1", - reason=( - "variant decode semantics are pinned locally; CI runs the reduced " - "single-artifact project because pgn generate of all 7 artifacts " - "exhausts GitHub-hosted runner memory" - ), -) - - -def _artifact_src(generated_tree: Path, artifact_key: str) -> Path: - # generated_tree is /artifacts/python; artifact directories are - # named after the artifact key with "-" replaced by "_" (pgn's own doing, - # independent of the Dhall config below). - project_root = generated_tree.parent.parent - artifact_dir = artifact_key.replace("-", "_") - return project_root / "artifacts" / artifact_dir / "src" - - -def _package_dir(generated_tree: Path, artifact_key: str) -> Path: - src = _artifact_src(generated_tree, artifact_key) - packages = [p for p in src.iterdir() if p.is_dir()] - assert len(packages) == 1, f"expected exactly one package under {src}, found {packages}" - return packages[0] - - -def _is_sync(package: Path) -> bool: - """Whether the generated package's runtime module is the sync surface. - - There is no `sync/` subdirectory to check anymore (config.sync selects - which content is rendered at the *same* `_runtime.py` path); the async - body defines `async def fetch_many`, the sync body defines `def - fetch_many` with no `async`. - """ - runtime = (package / "_generated" / "_runtime.py").read_text() - return "async def fetch_many" not in runtime - - -def test_name_only_config_derives_package_and_defaults_sync_off(generated_tree: Path) -> None: - package = _package_dir(generated_tree, "python-name-only") - assert package.name == "name_only_client" - assert not _is_sync(package) - - -def test_sync_only_config_defaults_package_name_from_project(generated_tree: Path) -> None: - package = _package_dir(generated_tree, "python-sync-only") - assert package.name == "fixture" - assert _is_sync(package) - - -def test_empty_config_object_defaults_both_fields(generated_tree: Path) -> None: - package = _package_dir(generated_tree, "python-empty") - assert package.name == "fixture" - assert not _is_sync(package) - - -def test_absent_config_key_defaults_both_fields(generated_tree: Path) -> None: - """No `config:` key at all decodes the same as `config: {}` (None Config).""" - package = _package_dir(generated_tree, "python-bare") - assert package.name == "fixture" - assert not _is_sync(package) - - -def test_unknown_config_key_is_ignored_not_rejected(generated_tree: Path) -> None: - """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 _is_sync(package) - - -def test_null_value_decodes_as_absent_field(generated_tree: Path) -> None: - """`sync: null` decodes to None, same as omitting the key (default False).""" - package = _package_dir(generated_tree, "python-null") - assert package.name == "null_client" - assert not _is_sync(package) -``` - -- [ ] **Step 2: Run the config-variant tests** - -Run: `cd python.gen && mise run test -- tests/test_config_variants.py -v` -Expected: all six tests pass (unless `HARNESS_CI_REDUCED=1` is set locally, in which case they skip — matches existing behavior). - -- [ ] **Step 3: Commit** - -```bash -git add tests/test_config_variants.py -git commit -m "python.gen: fix config-variant sync detection for unified output paths" -``` - ---- - -### Task 7: Update documentation - -**Files:** -- Modify: `README.md` -- Modify: `DESIGN.md` -- Modify: `CHANGELOG.md` - -- [ ] **Step 1: Update `README.md`'s config reference table** - -Replace the table at lines 76-80: - -```markdown -| key | type | default | -| --------------- | ------------------ | ------------------------------ | -| `packageName` | `Text` | the project name, kebab-cased | -| `sync` | `Bool` | `False` | -| `onUnsupported` | `"Fail" \| "Skip"` | `"Fail"` | -``` - -- [ ] **Step 2: Update the quickstart example (lines 36-42)** - -```yaml -artifacts: - python: - gen: https://raw.githubusercontent.com/slavashvets/python.gen/master/src/package.dhall - config: - packageName: my-db-client - sync: true -``` - -- [ ] **Step 3: Update "Key features" (line 21-23)** - -```markdown -- **Async or sync client over psycopg 3.** `config.sync` (default `False`) - picks exactly one surface — the async client (`psycopg.AsyncConnection`) or - the sync one (`psycopg.Connection`) — at the same output paths either way; - install `psycopg[binary]` for the fast C implementation. -``` - -- [ ] **Step 4: Update "Using the generated code" (lines 220-240)** - -Replace the closing paragraph and code block: - -```markdown -## Using the generated code - -Import from the flat facade, not from `_generated` directly: - -```python -from my_db_client import get_specimen, GetSpecimenRow, Mood -``` - -The facade re-exports every Row dataclass, every enum/composite, and every -statement function with `X as X` markers and a matching `__all__`. If the -project has composites or enums, call `register_types(conn)` once per -connection before relying on native composite decode or enum-array results; -scalar enums do not need it. - -With `sync: true`, the entire client is generated against -`psycopg.Connection` instead — same import path, same facade shape, only the -function signatures and I/O become synchronous: - -```python -from my_db_client import get_specimen, GetSpecimenRow, Mood - -def handler(conn): - row = get_specimen(conn, id=42) -``` - -A project that needs both an async backend and a sync consumer (e.g. a -Dagster pipeline) generates two artifacts pointed at this same `gen:`, one -with `sync: true` and one without, each with its own `packageName` — not one -artifact with both surfaces bundled together. -``` - -- [ ] **Step 5: Update `DESIGN.md` sections describing the surface mechanism** - -Rewrite the "What the generator emits" / "What the generator adds when `emitSync`" split (roughly lines 43-71) into a single section describing unconditional, surface-selected emission: - -```markdown -### What the generator emits - -``` -src//__init__.py # generated facade (re-exports everything) -src//_generated/__init__.py -src//_generated/_core.py # surface-agnostic: JsonValue, NoRowError, require_array -src//_generated/_runtime.py # I/O helpers for whichever surface config.sync picked -src//_generated/_register.py # only if composites or enums -src//_generated/_rows.py # shared Row dataclasses + decode functions -src//_generated/statements/__init__.py -src//_generated/statements/.py # one thin wrapper per query, def or async def -src//_generated/types/__init__.py # only if custom types exist -src//_generated/types/.py -``` - -Exactly one surface is emitted per generate, picked by `config.sync` -(`False`, the default, emits async; `True` emits sync) — the tree shape and -every import path are identical either way; only the statement modules' -`def`/`async def`, `Connection`/`AsyncConnection`, and `_runtime.py`'s body -change. A project that needs both surfaces generates two artifacts against -this same `gen:` with different `packageName`s, one with `sync: true` and -one without. -``` - -Rewrite the "Surface mechanism" section (roughly lines 192-217) to drop the "emits both, gated by config.emitSync" framing: - -```markdown -## 4. Surface mechanism (async / sync) - -```dhall -{ defKeyword : Text -- "async def" | "def" -, connType : Text -- "AsyncConnection" | "Connection" -, awaitKw : Text -- "await " | "" -, rowsImport : Text -- ".._rows" (same depth for both surfaces) -, corePrefix : Text -- ".._core" (same depth for both surfaces) -, typesPrefix : Text -- "..types" (same depth for both surfaces) -} -``` - -`Surface.async` and `Surface.sync` are the two values. `Interpreters/ -Project.dhall` picks one — `if config.sync then Surface.sync else -Surface.async` — and threads it through a single render pass: -`Interpreters/Query.dhall` calls the statement-module template once per -query (not twice), and `Interpreters/Project.dhall` picks the matching -`_runtime.py` body and, when custom types exist, the matching `_register.py` -body. `_rows.py` and `types/` are surface-agnostic and always render exactly -once, so switching `config.sync` costs only the thin I/O wrappers. -``` - -Update the facade section (roughly lines 400-409) to remove the two-facade description: - -```markdown -- every statement function from `_generated/statements/`. - -The facade lives at `/__init__.py` (prefix `._generated`, statements -under `statements`) regardless of which surface `config.sync` selected. -``` - -Update the Config-flow section's example (roughly lines 427-429, 503-509) to use `sync` in place of `emitSync`, matching Task 1/2's rename, e.g.: - -```dhall -let Config = { packageName : Optional Text, sync : Optional Bool, onUnsupported : Optional OnUnsupported.Mode } - -let Config/default = { packageName = None Text, sync = None Bool, onUnsupported = None OnUnsupported.Mode } -``` - -And the prose right below the second code block (roughly lines 500-514, "### Config flow"): replace every `emitSync` with `sync`, and `` `emitSync` to `False` `` with `` `sync` to `False` ``. - -Also update the "Generator decomposition" file-tree block (roughly lines 450-480): `Structures/Surface.dhall`'s comment changes from "async/sync token table (section 4)" to "async/sync token table, both surfaces at the same import depth (section 4)"; `Interpreters/Query.dhall`'s comment changes from "assemble one query: shared rowDef + async/sync statement modules" to "assemble one query: shared rowDef + one statement module for whichever surface config.sync picked". - -- [ ] **Step 6: Add a `CHANGELOG.md` "Upcoming" entry** - -Add to the top of the `# Upcoming` section: - -```markdown -- **Breaking:** `emitSync` is gone. In its place, `sync : Optional Bool` - (default `False`) picks exactly one surface per generate — async or sync — - emitted at the same unified paths either way (no more `sync/` subdirectory, - no more second package-root facade). Previously `emitSync: true` added a - second, nested sync tree alongside the always-emitted async one; a project - that needs both surfaces now generates two artifacts against this same - `gen:` with different `packageName`s, one with `sync: true` and one - without. See `docs/plans/2026-07-12-configurable-sync-output.md` for the - full rationale and migration shape. `tests/golden_sync/` is a new committed - golden fixture (`specimen_sync_client`) exercising the sync surface - end-to-end (basedpyright strict + round-trip), alongside the existing - `tests/golden/` (`specimen_client`, now async-only). -``` - -- [ ] **Step 7: Commit** - -```bash -git add README.md DESIGN.md CHANGELOG.md -git commit -m "python.gen: document the exclusive sync/async config change" -``` - ---- - -### Task 8: Full verification pass - -**Files:** none (verification only). - -- [ ] **Step 1: Run the full harness** - -Run: `cd python.gen && mise run test` -Expected: all tests pass (allow ~10 minutes; most of it is pgn's own type inference against Postgres). - -- [ ] **Step 2: Confirm no stray `emitSync` references anywhere in the repo** - -Run: `cd python.gen && grep -rn "emitSync" . --include="*.dhall" --include="*.md" --include="*.py" --include="*.yaml" --include="*.toml" | grep -v "^CHANGELOG.md:"` -Expected: no output (the `CHANGELOG.md` exclusion is because older, already-shipped entries legitimately still mention the old field name as history — only today's new entry from Task 7 should be free of it going forward, and that new entry doesn't mention `emitSync` at all so the grep with the exclusion is really just a safety margin, not an expected hit). - -- [ ] **Step 3: Confirm both golden trees are internally consistent with the new fixture config** - -Run: `cd python.gen && git status --short tests/golden tests/golden_sync` -Expected: clean (everything from Task 4's regeneration already committed). - -- [ ] **Step 4: Manually skim one generated statement file from each surface** - -Run: `head -30 tests/golden/src/specimen_client/_generated/statements/get_specimen.py tests/golden_sync/src/specimen_sync_client/_generated/statements/get_specimen.py` -Expected: the async file shows `from psycopg import AsyncConnection` and `async def get_specimen(...)`; the sync file shows `from psycopg import Connection` and `def get_specimen(...)` (no `async`), both importing from `.._rows`/`.._core` (not `..._rows`/`..._core`) — confirming Task 2's depth collapse took effect. From 270eb445b978e7bdd363d628a35be2d3453679c0 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sun, 12 Jul 2026 10:34:23 +0300 Subject: [PATCH 22/41] Redistribute rows per statement and fix custom type codecs --- DESIGN.md | 34 +- demos/Exhaustive.dhall | 2 +- ...026-07-12-distribute-rows-per-statement.md | 996 ------------------ src/Interpreters/Member.dhall | 6 +- src/Interpreters/ParamsMember.dhall | 8 +- src/Interpreters/Project.dhall | 34 - src/Interpreters/Query.dhall | 32 +- src/Structures/Surface.dhall | 3 - src/Templates/CompositeModule.dhall | 26 +- src/Templates/CoreModule.dhall | 2 +- src/Templates/EnumModule.dhall | 9 +- src/Templates/FacadeModule.dhall | 24 +- src/Templates/RowsModule.dhall | 121 --- src/Templates/StatementModule.dhall | 88 +- tests/golden/src/specimen_client/__init__.py | 33 +- .../src/specimen_client/_generated/_rows.py | 303 ------ .../_generated/statements/get_specimen.py | 81 +- .../_generated/statements/get_tagged_item.py | 22 +- .../_generated/statements/insert_specimen.py | 86 +- .../statements/insert_tagged_item.py | 24 +- .../statements/list_specimens_by_class.py | 19 +- .../statements/list_specimens_by_feeling.py | 39 +- .../statements/list_specimens_by_ids.py | 23 +- .../statements/list_specimens_by_moods.py | 31 +- .../list_specimens_keyword_column.py | 19 +- .../_generated/statements/search_specimens.py | 23 +- .../specimen_client/_generated/types/mood.py | 4 +- .../_generated/types/point_2_d.py | 6 +- .../_generated/types/tag_value.py | 6 +- .../src/specimen_sync_client/__init__.py | 33 +- .../specimen_sync_client/_generated/_rows.py | 303 ------ .../_generated/statements/get_specimen.py | 81 +- .../_generated/statements/get_tagged_item.py | 22 +- .../_generated/statements/insert_specimen.py | 86 +- .../statements/insert_tagged_item.py | 24 +- .../statements/list_specimens_by_class.py | 19 +- .../statements/list_specimens_by_feeling.py | 39 +- .../statements/list_specimens_by_ids.py | 23 +- .../statements/list_specimens_by_moods.py | 31 +- .../list_specimens_keyword_column.py | 19 +- .../_generated/statements/search_specimens.py | 23 +- .../_generated/types/mood.py | 4 +- .../_generated/types/point_2_d.py | 6 +- .../_generated/types/tag_value.py | 6 +- tests/test_unsupported_types.py | 3 - 45 files changed, 890 insertions(+), 1936 deletions(-) delete mode 100644 docs/plans/2026-07-12-distribute-rows-per-statement.md delete mode 100644 src/Templates/RowsModule.dhall delete mode 100644 tests/golden/src/specimen_client/_generated/_rows.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/_rows.py diff --git a/DESIGN.md b/DESIGN.md index 734a0b0..b141db5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -116,7 +116,7 @@ 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._decode(v) for v in cast(list[str], row["..."])]`, with per-element + `[Mood.pg_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). @@ -129,7 +129,7 @@ those the decode is a `cast(, row[""])` to satisfy strict 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 +(`[${typeName}.pg_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. @@ -223,8 +223,8 @@ loud-fail contract for bind shapes psycopg cannot adapt faithfully. It 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 +value.dims`, calling `.pg_encode()` for a scalar custom-type param and +building `[x.pg_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 @@ -283,14 +283,14 @@ 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 +(`${typeName}.pg_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 +entirely. `CompositeModule.dhall`'s `pg_decode`/`pg_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 +field's own `pg_decode`/`pg_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** @@ -300,7 +300,7 @@ 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 +`pg_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. @@ -465,7 +465,7 @@ Absent >` from the (post-Skip-filter) custom types and threaded it to 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 +generated class's `pg_decode`/`pg_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, @@ -545,10 +545,10 @@ could classify a `Custom` reference and pull its fields. `buildLookup` and `ab8f4df`. In their place, `CompositeModule.dhall`/`EnumModule.dhall` now emit a -`_decode`/`_encode` method directly onto each generated custom type's +`pg_decode`/`pg_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 +reference site (e.g. `${typeName}.pg_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 @@ -566,30 +566,30 @@ 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 +`${field}.pg_encode()` broke enum-array param encode (calling `.pg_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 +per-element transform to `${typeName}.pg_decode(v)` / `x.pg_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 +`.pg_decode`/`.pg_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 +`${typeName}.pg_decode(v)`/`x.pg_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 +`CompositeModule.dhall`'s `pg_decode`/`pg_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 @@ -608,7 +608,7 @@ 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 +`CompositeModule.dhall`'s `pg_decode`/`pg_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 diff --git a/demos/Exhaustive.dhall b/demos/Exhaustive.dhall index 584f943..cf4ed6a 100644 --- a/demos/Exhaustive.dhall +++ b/demos/Exhaustive.dhall @@ -26,7 +26,7 @@ let project = Sdk.Fixtures.Exhaustive let config = Some { packageName = None Text - , sync = Some True + , sync = Some False , onUnsupported = Some OnUnsupported.Mode.Skip } diff --git a/docs/plans/2026-07-12-distribute-rows-per-statement.md b/docs/plans/2026-07-12-distribute-rows-per-statement.md deleted file mode 100644 index 6e909eb..0000000 --- a/docs/plans/2026-07-12-distribute-rows-per-statement.md +++ /dev/null @@ -1,996 +0,0 @@ -# Distribute Rows Per Statement Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Delete the shared `_rows.py` god-module. Each query's Row dataclass and `decode_` function move verbatim into that query's own statement module (`statements/.py`), since there is already a strict 1:1 relationship between a query and its Row (no cross-statement sharing, no deduplication — confirmed empirically: 227 queries, 227 distinct Row classes, one dataclass and one decode function per query, always named after the query itself). - -**Architecture:** `Templates/RowsModule.dhall` is deleted; its `RowDef` type and `renderRow` function move into `Templates/StatementModule.dhall`, the file's sole remaining consumer. `StatementModule.dhall`'s `Params` gains `rowDef : Optional RowDef` (replacing the old `rowClassName`-only field, which existed just to build an `import ... from _rows` line that no longer exists) and renders the Row class + decode function inline, directly above the `SQL = ...` constant. `Interpreters/Query.dhall` merges its param-side and result-side `ImportSet`s into one combined set per statement file (previously kept separate: param imports fed the statement module, result/row imports fed a project-wide accumulator for `_rows.py`). `Interpreters/Project.dhall` drops the `rowDefs`/`rowImports`/`rowsFiles` machinery entirely. `Templates/FacadeModule.dhall`'s per-statement import line grows an optional Row-class alias, so the facade imports a query's Row class from that query's own statement module instead of a shared `_rows` import block. - -**Tech Stack:** Dhall (dhall-lang 1.42 vendored via `dhall` CLI; pgn's forked interpreter for the parts still using it), Python 3.12 generated output (psycopg3), basedpyright strict, pytest golden-file harness (`mise run test`, `mise run golden`), pgn 0.9.1 (mise-pinned). - -## Global Constraints - -- **Depends on `docs/plans/2026-07-12-configurable-sync-output.md` landing first.** This plan assumes exactly one statement tree per generate at unified paths (`statements/.py`, no `sync/` nesting) — it touches the same statement-module template that plan collapsed to a single surface. Do not start this plan until that one is merged and both `tests/golden/` and `tests/golden_sync/` are green. -- No change to decode LOGIC (the `cast(...)` expressions, enum/composite decode bodies, field ordering) — this plan only relocates already-correct rendering code from one file to another. `Interpreters/Member.dhall`, `Result.dhall`, `ResultColumns.dhall` are untouched. -- Row class and decode function names are unchanged (`SelectMood000Row`, `decode_select_mood_0_0_0`, etc.) — this is a pure code-motion plan, not a renaming plan (renaming to something shorter given the new file-local context, e.g. `Row`/`decode`, is an explicitly deferred, separate concern). -- Golden fixture output (`tests/golden/`, `tests/golden_sync/`) must be regenerated via `mise run golden`, not hand-edited. -- `mise run test` must pass after regeneration, for both golden trees. -- Every Dhall file touched must independently type-check via `dhall type --file=src/package.dhall`. -- This is a breaking change for any consumer importing `from ._generated._rows import X` directly. The README already tells consumers not to do that ("Import from the flat facade, not from `_generated` directly"), so this is "you were told" breakage, not a silent one — still worth a CHANGELOG line since `_rows.py` is a real, previously-stable module path someone could have depended on anyway. - ---- - -## File Structure - -| File | Change | -|---|---| -| `src/Templates/StatementModule.dhall` | Absorb `RowDef`/`renderRow` from `RowsModule.dhall`. `Params`: replace `rowClassName : Optional Text` with `rowDef : Optional RowDef`. Drop `rowsImportLine`. Add row-driven stdlib imports (`Mapping`, `dataclass`, `cast`) and a `require_array` import line, both gated on whether the query has a row. Splice the rendered row block between the import section and the `SQL = ...` constant. | -| `src/Templates/RowsModule.dhall` | Delete. | -| `src/Interpreters/Query.dhall` | Drop the `RowsModule` import; merge param and result `ImportSet`s into one; retarget the row's type to `StatementModule.RowDef`; drop `rowDef`/`rowImports` from `Output` (both become `render`-internal only). | -| `src/Interpreters/Project.dhall` | Drop the `RowsModule` import and the `rowDefs`/`rowImports`/`rowsFiles` bindings; drop `# rowsFiles` from `allFiles`. | -| `src/Templates/FacadeModule.dhall` | Drop the separate `_rows` import block (`rowBlock`); fold each statement's Row-class alias onto its own statement import line. | -| `tests/test_unsupported_types.py` | Drop the `_rows.py` orphan-reference check and the `_rows` import-smoke-test line (row content is now private to each statement file, already covered by the existing "skipped statement file doesn't exist" checks). | -| `tests/golden/`, `tests/golden_sync/` | Regenerate via `mise run golden`; `_rows.py` disappears from both, its content redistributed into each `statements/*.py` file. | -| `README.md` | "Using the generated code" no longer needs updating (already says "import from the flat facade," unaffected by where Rows physically live) — no change needed; confirmed in Task 3. | -| `DESIGN.md` | Section "## 2. Shared type layer: `_rows.py` and `types/`" rewritten (rows are no longer shared/centralized); "Generated package layout" tree (as left by the sync-output plan) drops the `_rows.py` line; "Generator decomposition" file tree drops the `RowsModule.dhall` line and updates `StatementModule.dhall`'s description; the byte-offset example referencing `tests/golden/.../_rows.py`'s `moods` column is repointed at the new location. | -| `CHANGELOG.md` | New "Upcoming" entry documenting the removal of `_rows.py`. | - ---- - -### Task 1: Move `RowDef`/`renderRow` into `StatementModule.dhall` and render the row inline - -**Files:** -- Modify: `src/Templates/StatementModule.dhall` -- Delete: `src/Templates/RowsModule.dhall` - -**Interfaces:** -- Produces: `StatementModule.RowDef = { className : Text, fieldsBlock : Text, decodeBlock : Text, decodeName : Text }` (moved verbatim from `RowsModule.RowDef`) and `StatementModule.Params.rowDef : Optional RowDef` (replacing `rowClassName : Optional Text`). Task 2 (`Query.dhall`) is the consumer. - -- [ ] **Step 1: Read `src/Templates/RowsModule.dhall`'s `RowDef` and `renderRow` one more time to confirm the exact text being moved** - -Run: `cat src/Templates/RowsModule.dhall` -Expected (for reference — this exact text is being relocated, not rewritten): - -```dhall -let RowDef = - { className : Text - , fieldsBlock : Text - , decodeBlock : Text - , decodeName : Text - } -``` - -```dhall -let renderRow - : RowDef -> Text - = \(row : RowDef) -> - "@dataclass(frozen=True, slots=True)\n" - ++ "class " - ++ row.className - ++ ":\n" - ++ indentAll 4 row.fieldsBlock - ++ "\n\n\n" - ++ "def " - ++ row.decodeName - ++ "(row: Mapping[str, object]) -> " - ++ row.className - ++ ":\n" - ++ " return " - ++ row.className - ++ "(\n" - ++ indentAll 8 row.decodeBlock - ++ "\n )" -``` - -- [ ] **Step 2: Rewrite `src/Templates/StatementModule.dhall` in full** - -```dhall -let Prelude = ../Deps/Prelude.dhall - -let Lude = ../Deps/Lude.dhall - -let Sdk = ../Deps/Sdk.dhall - -let ImportSet = ../Structures/ImportSet.dhall - -let Surface = ../Structures/Surface.dhall - --- A query's frozen Row dataclass plus its module-level decode function, --- rendered directly into that query's own statement module (there is a --- strict 1:1 relationship between a query and its Row — no cross-statement --- sharing — so co-locating them costs nothing and removes the need for a --- shared _rows.py import). -let RowDef = - { className : Text - , fieldsBlock : Text - , decodeBlock : Text - , decodeName : Text - } - --- Prefix every line (including the first) with `n` spaces, leaving blank lines --- untouched so trailing whitespace never appears. -let indentAll - : Natural -> Text -> Text - = \(n : Natural) -> - \(text : Text) -> - let pad = Prelude.Text.replicate n " " - - in pad ++ Lude.Text.indentNonEmpty n text - -let renderRow - : RowDef -> Text - = \(row : RowDef) -> - "@dataclass(frozen=True, slots=True)\n" - ++ "class " - ++ row.className - ++ ":\n" - ++ indentAll 4 row.fieldsBlock - ++ "\n\n\n" - ++ "def " - ++ row.decodeName - ++ "(row: Mapping[str, object]) -> " - ++ row.className - ++ ":\n" - ++ " return " - ++ row.className - ++ "(\n" - ++ indentAll 8 row.decodeBlock - ++ "\n )" - --- "" when the query returns no rows (Void/RowsAffected); otherwise the --- rendered class + decode function, framed with the same "\n\n\n" (two --- blank lines) PEP8 spacing _rows.py used between consecutive row defs, on --- both sides — matching the two-blank-lines-before/after convention for a --- top-level class or function. -let renderRowBlock - : Optional RowDef -> Text - = \(rowDef : Optional RowDef) -> - merge - { None = "" - , Some = \(row : RowDef) -> "\n" ++ renderRow row ++ "\n\n\n" - } - rowDef - -let hasRow - : Optional RowDef -> Bool - = \(rowDef : Optional RowDef) -> - merge { None = False, Some = \(_ : RowDef) -> True } rowDef - --- A statement module is the thin per-surface I/O wrapper: it renders its own --- Row dataclass and decode function (when the query returns rows) and one --- `async def`/`def` over a connection. `imports` carries BOTH the parameter --- type imports and the result-column imports merged into one set --- (Interpreters/Query.dhall combines them), since both now live in this one --- file. -let Params = - { functionName : Text - , returnType : Text - , helperName : Text - , callsDecode : Bool - , sqlLiteral : Text - , rowDef : Optional RowDef - , decodeName : Text - , paramSigLines : List Text - , paramDictEntries : List Text - , imports : ImportSet.Type - , surface : Surface.Type - } - -let importLineIf - : Bool -> Text -> List Text - = \(cond : Bool) -> \(line : Text) -> if cond then [ line ] else [] : List Text - --- "from datetime import ..." collapses the four datetime members into one line. -let datetimeImport - : ImportSet.Type -> List Text - = \(imports : ImportSet.Type) -> - let names = - importLineIf imports.date "date" - # importLineIf imports.datetime "datetime" - # importLineIf imports.time "time" - # importLineIf imports.timedelta "timedelta" - - in if Prelude.Bool.not (Prelude.List.null Text names) - then [ "from datetime import " ++ Prelude.Text.concatSep ", " names ] - else [] : List Text - --- The I/O helper (fetch_*/execute_*) always comes from _runtime; JsonValue and --- require_array, when used, come from _core via the surface's corePrefix. -let runtimeImport - : Text -> Text - = \(helperName : Text) -> "from .._runtime import " ++ helperName - -let coreImport - : Text -> Bool -> List Text - = \(corePrefix : Text) -> - \(jsonValue : Bool) -> - if jsonValue - then [ "from ${corePrefix} import JsonValue" ] - else [] : List Text - -let customImportLines - : Text -> ImportSet.Type -> List Text - = \(typesPrefix : Text) -> - \(imports : ImportSet.Type) -> - Prelude.List.map - ImportSet.CustomImport - Text - ( \(c : ImportSet.CustomImport) -> - "from ${typesPrefix}.${c.moduleName} import ${c.className}" - ) - imports.customTypes - -let renderImports - : Params -> Text - = \(params : Params) -> - let imports = params.imports - - let rowIsPresent = hasRow params.rowDef - - let stdlibBlock = - importLineIf rowIsPresent "from collections.abc import Mapping" - # importLineIf rowIsPresent "from dataclasses import dataclass" - # datetimeImport imports - # importLineIf imports.decimal "from decimal import Decimal" - # importLineIf rowIsPresent "from typing import cast" - # importLineIf imports.uuid "from uuid import UUID" - - let psycopgBlock = - [ "from psycopg import ${params.surface.connType}" ] - # importLineIf - imports.json - "from psycopg.types.json import Json" - # importLineIf - imports.jsonb - "from psycopg.types.json import Jsonb" - - let localBlock = - coreImport params.surface.corePrefix imports.jsonValue - # importLineIf - imports.enumArray - "from ${params.surface.corePrefix} import require_array" - # [ runtimeImport params.helperName ] - # customImportLines params.surface.typesPrefix imports - - let groups = - [ [ "from __future__ import annotations" ] - , stdlibBlock - , psycopgBlock - , localBlock - ] - - let nonEmptyGroups = - Prelude.List.filter - (List Text) - ( \(g : List Text) -> - Prelude.Bool.not (Prelude.List.null Text g) - ) - groups - - in Prelude.Text.concatMapSep - "\n\n" - (List Text) - (\(g : List Text) -> Prelude.Text.concatSep "\n" g) - nonEmptyGroups - -let renderSignature - : Params -> Text - = \(params : Params) -> - let hasParams = - Prelude.Bool.not (Prelude.List.null Text params.paramSigLines) - - let kwMarker = if hasParams then " *,\n" else "" - - let paramBlock = - Prelude.Text.concatMap - Text - (\(line : Text) -> " " ++ line ++ ",\n") - params.paramSigLines - - in params.surface.defKeyword - ++ " " - ++ params.functionName - ++ "(\n" - ++ " conn: ${params.surface.connType}[object],\n" - ++ kwMarker - ++ paramBlock - ++ ") -> " - ++ params.returnType - ++ ":" - --- Emit the dict multi-line with a magic trailing comma so ruff keeps it --- expanded at any width, which keeps the generated file format-stable. -let renderParamsDict - : Params -> Text - = \(params : Params) -> - if Prelude.List.null Text params.paramDictEntries - then "params: dict[str, object] = {}" - else "params: dict[str, object] = {\n" - ++ Prelude.Text.concatMap - Text - (\(entry : Text) -> " " ++ entry ++ ",\n") - params.paramDictEntries - ++ "}" - -let renderCall - : Params -> Text - = \(params : Params) -> - let await = params.surface.awaitKw - - in if params.callsDecode - then "return ${await}${params.helperName}(conn, _SQL, params, ${params.decodeName})" - else "return ${await}${params.helperName}(conn, _SQL, params)" - -in Sdk.Sigs.template - Params - ( \(params : Params) -> - renderImports params - ++ "\n\n" - ++ renderRowBlock params.rowDef - -- The leading backslash after the opening quotes keeps the first SQL - -- line flush (no blank line); the newline before the closing quotes is - -- the only deviation from the raw text, a harmless trailing newline for - -- psycopg. - ++ "SQL = \"\"\"\\\n" - ++ params.sqlLiteral - -- Encode once at import; the helpers take bytes so each call skips a - -- per-query str->bytes allocation (psycopg auto-prepare keys on the - -- bytes value, so equal bytes still hit the prepared-statement cache). - ++ "\n\"\"\"\n\n_SQL = SQL.encode()\n\n\n" - ++ renderSignature params - ++ "\n" - ++ indentAll 4 (renderParamsDict params) - ++ "\n" - ++ indentAll 4 (renderCall params) - ++ "\n" - ) - /\ { RowDef } -``` - -Note the final `/\ { RowDef }` — `Query.dhall` (Task 2) needs to reference `StatementModule.RowDef` as a type, the same way it used to reference `RowsModule.RowDef`, so `RowDef` must be exported from the module's record the same way `RowsModule.dhall` did (`Sdk.Sigs.template Params run /\ { RowDef }`). - -- [ ] **Step 3: Delete `src/Templates/RowsModule.dhall`** - -Run: `git rm src/Templates/RowsModule.dhall` - -- [ ] **Step 4: Confirm the file is self-contained** - -Run: `cd python.gen && grep -n "RowsModule" src/Templates/StatementModule.dhall` -Expected: no output (the new file must not reference the deleted module). - -- [ ] **Step 5: Commit** - -```bash -git add src/Templates/StatementModule.dhall -git commit -m "python.gen: move RowDef/renderRow into StatementModule, render rows inline" -``` - -(This task alone does not yet type-check the whole generator — `Query.dhall` and `Project.dhall` still reference the now-deleted `RowsModule` and the old `Params.rowClassName` field. Task 2 fixes that; run `dhall type --file=src/package.dhall` at the end of Task 2, not this one.) - ---- - -### Task 2: Merge imports and retarget `Query.dhall` and `Project.dhall` - -**Files:** -- Modify: `src/Interpreters/Query.dhall` -- Modify: `src/Interpreters/Project.dhall` - -**Interfaces:** -- Consumes: `StatementModule.RowDef` (Task 1). -- Produces: `Query.dhall`'s `Output` shrinks to `{ functionName : Text, rowClassName : Optional Text, modulePath : Text, content : Text }` (drops `rowDef` and `rowImports`, both now `render`-internal only). `Project.dhall`'s `combineOutputs` no longer builds `_rows.py`. - -- [ ] **Step 1: Rewrite `src/Interpreters/Query.dhall` in full** - -```dhall -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 Surface = ../Structures/Surface.dhall - -let OnUnsupported = ../Structures/OnUnsupported.dhall - -let ResultModule = ./Result.dhall - -let QueryFragmentsModule = ./QueryFragments.dhall - -let ParamsMember = ./ParamsMember.dhall - -let StatementModule = ../Templates/StatementModule.dhall - -let Config = - { packageName : Text - , importName : Text - , sync : Bool - , onUnsupported : OnUnsupported.Mode - } - -let Compiled = Lude.Compiled - -let Input = Model.Query - --- A query renders to one thin statement module: its own Row dataclass and --- decode function (when it returns rows) plus the I/O wrapper for whichever --- surface config.sync picked. rowClassName is still surfaced here (not just --- internal to the rendered content) because Project.dhall's facade needs the --- name to build the re-export line; the Row's full definition does not --- leave this module. -let Output = - { functionName : Text - , rowClassName : Optional Text - , modulePath : Text - , content : Text - } - -let render = - \(config : Config) -> - \(input : Input) -> - \(result : ResultModule.Output) -> - \(fragments : QueryFragmentsModule.Output) -> - \(params : List ParamsMember.Output) -> - -- The function name is also the module filename and the facade import name; - -- a query named like a Python keyword would emit `def class(...)`, a module - -- `class.py`, and `from ... import class` (all SyntaxErrors), so sanitize it - -- like params and result columns. SQL/dict/row lookups key off raw names. - let functionName = PyIdent.pySafeName input.name.inSnakeCase - - let decodeName = "decode_${functionName}" - - let paramSigLines = - Prelude.List.map - ParamsMember.Output - Text - (\(p : ParamsMember.Output) -> p.fieldName ++ ": " ++ p.pyType) - params - - let paramDictEntries = - Prelude.List.map - ParamsMember.Output - Text - ( \(p : ParamsMember.Output) -> - "\"" ++ p.pgName ++ "\": " ++ p.bindExpr - ) - params - - let paramImports = - ImportSet.combineAll - ( Prelude.List.map - ParamsMember.Output - ImportSet.Type - (\(p : ParamsMember.Output) -> p.imports) - params - ) - - let rowClassName = - Prelude.Optional.map - ResultModule.RowClass - Text - (\(rc : ResultModule.RowClass) -> rc.name) - result.rowClass - - let rowDef = - Prelude.Optional.map - ResultModule.RowClass - StatementModule.RowDef - ( \(rc : ResultModule.RowClass) -> - { className = rc.name - , fieldsBlock = rc.fieldsBlock - , decodeBlock = rc.decodeBlock - , decodeName - } - ) - result.rowClass - - -- The Row's own imports (JsonValue, Decimal, custom types, ...) and - -- the parameters' imports both land in this one file now, so they - -- merge into a single ImportSet instead of flowing to two separate - -- consumers (the statement module and, formerly, _rows.py). - let mergedImports = ImportSet.combine paramImports result.imports - - let surface = if config.sync then Surface.sync else Surface.async - - let content = - StatementModule.run - { functionName - , returnType = result.returnType - , helperName = result.helperName - , callsDecode = result.callsDecode - , sqlLiteral = fragments.sqlLiteral - , rowDef - , decodeName - , paramSigLines - , paramDictEntries - , imports = mergedImports - , surface - } - - in { functionName - , rowClassName - , modulePath = "statements/${functionName}.py" - , content - } - -let run = - \(config : Config) -> - \(input : Input) -> - let rowClassName = input.name.inPascalCase ++ "Row" - - in Compiled.nest - Output - input.srcPath - ( Compiled.map3 - ResultModule.Output - QueryFragmentsModule.Output - (List ParamsMember.Output) - Output - (render config input) - ( Compiled.nest - ResultModule.Output - "result" - (ResultModule.run (config /\ { rowClassName }) input.result) - ) - ( Compiled.nest - QueryFragmentsModule.Output - "sql" - (QueryFragmentsModule.run config input.fragments) - ) - ( Compiled.nest - (List ParamsMember.Output) - "params" - ( Compiled.traverseList - Model.Member - ParamsMember.Output - ( \(member : Model.Member) -> - Compiled.nest - ParamsMember.Output - member.pgName - (ParamsMember.run config member) - ) - input.params - ) - ) - ) - -in Sdk.Sigs.interpreter Config Input Output run -``` - -- [ ] **Step 2: Drop the `RowsModule` import and the `_rows.py` machinery from `src/Interpreters/Project.dhall`** - -Delete line 25 (`let RowsModule = ../Templates/RowsModule.dhall`). - -Delete the `rowDefs`, `rowImports`, and `rowsFiles` bindings from `combineOutputs` (these sat between the `runtimeModule`/`statementsInit` bindings and `statementFiles`, per the sync-output plan's Task 2 Step 3 rewrite): - -```dhall - let rowDefs = - Prelude.List.concatMap - QueryGen.Output - RowsModule.RowDef - ( \(query : QueryGen.Output) -> - Prelude.Optional.toList RowsModule.RowDef query.rowDef - ) - queries - - let rowImports = - ImportSet.combineAll - ( Prelude.List.map - QueryGen.Output - ImportSet.Type - (\(query : QueryGen.Output) -> query.rowImports) - queries - ) - - let rowsFiles = - if Prelude.List.null RowsModule.RowDef rowDefs - then [] : List Lude.File.Type - else [ { path = srcPrefix ++ "_rows.py" - , content = - RowsModule.run { rows = rowDefs, imports = rowImports } - } - ] - -``` - -(all three bindings, deleted in full — `statementFiles` becomes the very next binding after `statementsInit`.) - -Update `allFiles` to drop the now-nonexistent `rowsFiles`: - -```dhall - let allFiles = - staticFiles - # registerFiles - # typesInitFiles - # typeFiles - # statementFiles -``` - -- [ ] **Step 3: Type-check the whole generator** - -Run: `cd python.gen && dhall type --file=src/package.dhall` -Expected: prints the generator's function type, no errors. - -- [ ] **Step 4: Confirm no `RowsModule` references remain** - -Run: `cd python.gen && grep -rn "RowsModule" src/` -Expected: no output. - -- [ ] **Step 5: Commit** - -```bash -git add src/Interpreters/Query.dhall src/Interpreters/Project.dhall -git commit -m "python.gen: merge row and param imports per statement, delete _rows.py emission" -``` - ---- - -### Task 3: Update `FacadeModule.dhall`'s Row re-export - -**Files:** -- Modify: `src/Templates/FacadeModule.dhall` -- Modify: `src/Templates/CoreModule.dhall` (comment only) - -- [ ] **Step 0: Fix the stale `_rows.py` mention in `src/Templates/CoreModule.dhall`'s header comment** - -Line 9, "and _rows.py, the statement modules, and the facades import these" → "and the statement modules and facades import these" (the full comment, lines 3-10): - -```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 --- async and sync surfaces: the JsonValue alias, the NoRowError/DecodeError --- exceptions, and the require_array decode guard. It performs no I/O, so there is --- exactly one copy regardless of surface. The two _runtime.py modules re-export --- JsonValue/NoRowError/require_array from here so off-contract imports keep --- working, and the statement modules and facades import these names from --- _core directly. -``` - -- [ ] **Step 1: Fold the Row-class alias onto each statement's own import line** - -Replace the body of `run` (everything from `let runtimeBlock = ...` through `let allNames = ...`, i.e. lines 49-112 of the pre-this-plan file): - -```dhall -let run = - \(params : Params) -> - let runtimeBlock = - "from ${generatedPrefix}._core import " - ++ Prelude.Text.concatMapSep - ", " - Text - alias - runtimeNames - - let typeBlock = - Prelude.Text.concatMapSep - "\n" - TypeExport - ( \(t : TypeExport) -> - "from ${generatedPrefix}.types.${t.moduleName} import ${alias t.className}" - ) - params.types - - let rows = rowNames params.statements - - -- A query's Row class now lives in that query's own statement - -- module (there is no shared _rows module anymore), so the row - -- alias, when present, rides on the same import line as the - -- statement function itself: "from ...statements.fn import fn as - -- fn, RowCls as RowCls" — one line per statement, not two separate - -- import blocks. - let statementBlock = - Prelude.Text.concatMapSep - "\n" - StatementExport - ( \(s : StatementExport) -> - let rowSuffix = - merge - { None = "", Some = \(row : Text) -> ", ${alias row}" } - s.rowClassName - - in "from ${generatedPrefix}.${statementsPath}.${s.functionName} import " - ++ alias s.functionName - ++ rowSuffix - ) - params.statements - - let importGroups = - [ runtimeBlock ] - # ( if Prelude.List.null TypeExport params.types - then [] : List Text - else [ typeBlock ] - ) - # ( if Prelude.List.null StatementExport params.statements - then [] : List Text - else [ statementBlock ] - ) - - let importSection = Prelude.Text.concatSep "\n\n" importGroups - - let allNames = - runtimeNames - # Prelude.List.map - TypeExport - Text - (\(t : TypeExport) -> t.className) - params.types - # rows - # Prelude.List.map - StatementExport - Text - (\(s : StatementExport) -> s.functionName) - params.statements - - let allEntries = - Prelude.Text.concatMap - Text - (\(name : Text) -> " \"${name}\",\n") - allNames - - in '' - ${importSection} - - __all__ = [ - ${allEntries}] - '' -``` - -Note `rowNames`/`rowNames params.statements` (the `rows` binding) is still needed — it still feeds `allNames` for `__all__` — only the separate `rowBlock` import section it used to also build is gone. Leave the `rowNames` function definition itself untouched. - -- [ ] **Step 2: Type-check the whole generator** - -Run: `cd python.gen && dhall type --file=src/package.dhall` -Expected: prints the generator's function type, no errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/Templates/FacadeModule.dhall src/Templates/CoreModule.dhall -git commit -m "python.gen: facade imports each Row class from its own statement module" -``` - ---- - -### Task 4: Regenerate both golden trees and fix the unsupported-types test - -**Files:** -- Modify: `tests/test_unsupported_types.py` -- Regenerate: `tests/golden/`, `tests/golden_sync/` - -**Interfaces:** -- Consumes: `mise run golden` (unchanged mechanism from the sync-output plan's Task 4 — that plan already made it regenerate both trees in one pass). - -- [ ] **Step 1: Delete the stale freeze file** - -Run: `rm -f tests/fixture-project/freeze1.pgn.yaml` - -- [ ] **Step 2: Update `tests/test_unsupported_types.py`** - -Delete the `rows = (src / "_rows.py").read_text()` line and its assertion. Before: - -```python - facade = (package_src / "__init__.py").read_text() - rows = (src / "_rows.py").read_text() - register = (src / "_register.py").read_text() - types_init = (src / "types" / "__init__.py").read_text() - for orphan in ( - "probe_unsupported", - "probe_json_array", - "probe_nested_composite", - "wrapped_point", - "WrappedPoint", - ): - assert orphan not in facade, f"facade references skipped {orphan}" - assert orphan not in rows, f"_rows references skipped {orphan}" - assert orphan not in register, f"_register references skipped {orphan}" - assert orphan not in types_init, f"types/__init__ references skipped {orphan}" -``` - -After: - -```python - facade = (package_src / "__init__.py").read_text() - register = (src / "_register.py").read_text() - types_init = (src / "types" / "__init__.py").read_text() - for orphan in ( - "probe_unsupported", - "probe_json_array", - "probe_nested_composite", - "wrapped_point", - "WrappedPoint", - ): - assert orphan not in facade, f"facade references skipped {orphan}" - assert orphan not in register, f"_register references skipped {orphan}" - assert orphan not in types_init, f"types/__init__ references skipped {orphan}" -``` - -(A skipped query's Row now lives only inside that query's own statement file, which the adjacent assertion at line 171 — `assert not (src / "statements" / f"{name}.py").exists()` — already proves never got written; there is no longer a shared file where an orphaned Row reference could leak.) - -Delete the `importlib.import_module("fixture._generated._rows")` line. Before: - -```python - importlib.import_module("fixture") - importlib.import_module("fixture._generated._register") - importlib.import_module("fixture._generated._rows") - for name in kept_statements: - importlib.import_module(f"fixture._generated.statements.{name}") -``` - -After: - -```python - importlib.import_module("fixture") - importlib.import_module("fixture._generated._register") - for name in kept_statements: - importlib.import_module(f"fixture._generated.statements.{name}") -``` - -- [ ] **Step 3: Run the golden refresh** - -Run: `cd python.gen && mise run golden` -Expected: exits 0. Needs a reachable Postgres (`PGN_TEST_DATABASE_URL`). - -- [ ] **Step 4: Review the diff** - -Run: `cd python.gen && git status tests/golden tests/golden_sync && git diff --stat tests/golden tests/golden_sync` -Expected: `tests/golden/src/specimen_client/_generated/_rows.py` and `tests/golden_sync/src/specimen_sync_client/_generated/_rows.py` are deleted. Every `_generated/statements/*.py` file in both trees grows (gains its own Row dataclass + decode function, plus the new `Mapping`/`dataclass`/`typing.cast` imports where it returns rows). `src/specimen_client/__init__.py` and `src/specimen_sync_client/__init__.py` (the facades) change: each statement's import line grows a `, RowCls as RowCls` suffix, and the old separate `from ._generated._rows import (...)` block disappears. No SQL, param, or decode-expression content should differ from before this plan — only file placement and import statements. - -- [ ] **Step 5: Spot-check one regenerated statement file** - -Run: `cat tests/golden/src/specimen_client/_generated/statements/get_specimen.py` -Expected: imports (including `from collections.abc import Mapping`, `from dataclasses import dataclass`, `from typing import cast`), then `@dataclass(frozen=True, slots=True)\nclass GetSpecimenRow:`, then `def decode_get_specimen(row: Mapping[str, object]) -> GetSpecimenRow:`, then two blank lines, then `SQL = """...`, then `async def get_specimen(...)`. No `from .._rows import` line anywhere in the file. - -- [ ] **Step 6: Commit** - -```bash -git add tests/golden tests/golden_sync tests/test_unsupported_types.py -git commit -m "python.gen: regenerate golden trees with rows distributed per statement" -``` - ---- - -### Task 5: Run the full pytest harness - -**Files:** none (verification only — Tasks 1-4 already touched every file the harness exercises; this task's job is to confirm they cohere). - -- [ ] **Step 1: Run the full harness** - -Run: `cd python.gen && mise run test` -Expected: all tests pass, including `test_generated_matches_golden`, `test_generated_sync_matches_golden`, both basedpyright-strict tests, all round-trip tests, and `test_unsupported_types.py`'s skip-mode tests. - -- [ ] **Step 2: Confirm no stray `_rows`/`RowsModule` references remain in source or tests** - -Run: `cd python.gen && grep -rln "_rows\|RowsModule" src/ tests/*.py demos/ 2>/dev/null` -Expected: no output. - -- [ ] **Step 3: Commit if Steps 1-2 required any fixes** - -If the full-suite run surfaced anything Tasks 1-4 missed, fix it here and commit; otherwise this task is a clean pass-through and needs no commit of its own. - ---- - -### Task 6: Update documentation - -**Files:** -- Modify: `DESIGN.md` -- Modify: `CHANGELOG.md` - -- [ ] **Step 1: Rewrite `DESIGN.md`'s "Generated package layout" tree** - -Starting from the tree the sync-output plan (`docs/plans/2026-07-12-configurable-sync-output.md`, Task 7 Step 5) already left behind, drop the `_rows.py` line: - -```text -src//__init__.py # generated facade (re-exports everything) -src//_generated/__init__.py -src//_generated/_core.py # surface-agnostic: JsonValue, NoRowError, require_array -src//_generated/_runtime.py # I/O helpers for whichever surface config.sync picked -src//_generated/_register.py # only if composites or enums -src//_generated/statements/__init__.py -src//_generated/statements/.py # one wrapper per query: its own Row + decode + def, def or async def -src//_generated/types/__init__.py # only if custom types exist -src//_generated/types/.py -``` - -- [ ] **Step 2: Rewrite `## 2. Shared type layer: `_rows.py` and `types/`` (renumber/retitle to "## 2. Per-statement rows and the shared `types/` layer")** - -```markdown -## 2. Per-statement rows and the shared `types/` layer - -Decode is LOCAL to each statement now, not centralized. For each query that -returns rows, its own statement module holds one -`@dataclass(frozen=True, slots=True)` Row plus a module-level -`decode_(row: Mapping[str, object]) -> ` function, immediately -above the `SQL = ...` constant. There is a strict 1:1 relationship between a -query and its Row (no two queries share a Row class, even when their result -shapes are identical), so co-locating them costs nothing: - -```python -# statements/get_specimen.py -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from typing import cast - -from psycopg import AsyncConnection - -from .._runtime import fetch_single - - -@dataclass(frozen=True, slots=True) -class GetSpecimenRow: - ... - - -def decode_get_specimen(row: Mapping[str, object]) -> GetSpecimenRow: - return GetSpecimenRow(...) - - -SQL = """...""" - -_SQL = SQL.encode() - - -async def get_specimen(conn: AsyncConnection[object], *, id: int) -> GetSpecimenRow | None: - ... - return await fetch_single(conn, _SQL, params, decode_get_specimen) -``` - -`Templates/StatementModule.dhall` renders both the Row and the wrapper in one -pass; `Interpreters/Query.dhall` merges the parameter-side and result-side -`ImportSet`s into one set per file, since both now live in the same module. -The package-root facade re-exports every Row from its own statement module -(`from ._generated.statements.get_specimen import get_specimen as -get_specimen, GetSpecimenRow as GetSpecimenRow`), not from a shared module. - -`types/.py` still holds the enum / composite class, unchanged by this -— custom types genuinely are shared across every query that references them, -unlike Rows. A statement module's custom-type imports (whether the type -appears in a param or a result column) all use the same `${typesPrefix}` -prefix (`..types`) now, since both originate from the same file. -``` - -- [ ] **Step 3: Update the "Generator decomposition" file tree** - -Drop the `RowsModule.dhall` line and update `StatementModule.dhall`'s description: - -```text - Templates/ - CoreModule.dhall # shared _core.py: JsonValue, NoRowError/DecodeError, require_array - RuntimeModule.dhall # async + sync _runtime.py bodies - StatementModule.dhall # one wrapper per query: its own Row dataclass + decode fn, plus the I/O def - RegisterModule.dhall # _register.py (per surface) - FacadeModule.dhall # the flat package-root facade - EnumModule.dhall / CompositeModule.dhall / TypesInit.dhall / InitModule.dhall -``` - -- [ ] **Step 4: Repoint the byte-offset example that referenced `_rows.py`** - -Run: `grep -n "_rows.py" DESIGN.md` and update the surrounding sentence (around what was originally line 600, describing the `moods` column) to reference `tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py` (or whichever statement file the example was illustrating) instead of `_rows.py`. - -- [ ] **Step 5: Add a `CHANGELOG.md` "Upcoming" entry** - -```markdown -- **Breaking:** `_rows.py` is gone. Each query's Row dataclass and - `decode_` function now live directly in that query's own statement - module (`statements/.py`), rendered immediately above the - `SQL = ...` constant, instead of in one shared, project-wide file. This - only affects code importing `from ._generated._rows import ...` - directly — the README has always said to import from the flat facade - instead, and the facade's re-exported names (`GetSpecimenRow`, - `decode_get_specimen`, etc.) are unchanged. See - `docs/plans/2026-07-12-distribute-rows-per-statement.md` for the full - rationale. -``` - -- [ ] **Step 6: Commit** - -```bash -git add DESIGN.md CHANGELOG.md -git commit -m "python.gen: document per-statement row distribution" -``` diff --git a/src/Interpreters/Member.dhall b/src/Interpreters/Member.dhall index a9acd0a..79c1a0a 100644 --- a/src/Interpreters/Member.dhall +++ b/src/Interpreters/Member.dhall @@ -107,7 +107,7 @@ let run = ( mkOutput (ImportSet.custom customImport) ( wrapNullable - (\(src : Text) -> "${typeName}._decode(${src})") + (\(src : Text) -> "${typeName}.pg_decode(${src})") ) ) else if dimsIsOne @@ -118,8 +118,8 @@ let run = let elemDecode = if value.elementIsNullable - then "None if v is None else ${typeName}._decode(v)" - else "${typeName}._decode(v)" + then "None if v is None else ${typeName}.pg_decode(v)" + else "${typeName}.pg_decode(v)" in Lude.Compiled.ok Output diff --git a/src/Interpreters/ParamsMember.dhall b/src/Interpreters/ParamsMember.dhall index 6d7b794..4e0c630 100644 --- a/src/Interpreters/ParamsMember.dhall +++ b/src/Interpreters/ParamsMember.dhall @@ -285,13 +285,13 @@ let run = let scalarEncode = if input.isNullable - then "None if ${fieldName} is None else ${fieldName}._encode()" - else "${fieldName}._encode()" + then "None if ${fieldName} is None else ${fieldName}.pg_encode()" + else "${fieldName}.pg_encode()" let arrayElemEncode = if value.elementIsNullable - then "None if x is None else x._encode()" - else "x._encode()" + then "None if x is None else x.pg_encode()" + else "x.pg_encode()" let arrayEncode = let base = "[${arrayElemEncode} for x in ${fieldName}]" diff --git a/src/Interpreters/Project.dhall b/src/Interpreters/Project.dhall index cbdbcaa..fa0cf68 100644 --- a/src/Interpreters/Project.dhall +++ b/src/Interpreters/Project.dhall @@ -22,10 +22,6 @@ let RegisterModule = ../Templates/RegisterModule.dhall let FacadeModule = ../Templates/FacadeModule.dhall -let RowsModule = ../Templates/RowsModule.dhall - -let ImportSet = ../Structures/ImportSet.dhall - let Surface = ../Structures/Surface.dhall let OnUnsupported = ../Structures/OnUnsupported.dhall @@ -180,35 +176,6 @@ let combineOutputs = InitModule.run { docstring = "Generated SQL statements." } } - -- The shared Row dataclasses + decode functions, imported by the - -- statement modules of whichever surface was selected. - let rowDefs = - Prelude.List.concatMap - QueryGen.Output - RowsModule.RowDef - ( \(query : QueryGen.Output) -> - Prelude.Optional.toList RowsModule.RowDef query.rowDef - ) - queries - - let rowImports = - ImportSet.combineAll - ( Prelude.List.map - QueryGen.Output - ImportSet.Type - (\(query : QueryGen.Output) -> query.rowImports) - queries - ) - - let rowsFiles = - if Prelude.List.null RowsModule.RowDef rowDefs - then [] : List Lude.File.Type - else [ { path = srcPrefix ++ "_rows.py" - , content = - RowsModule.run { rows = rowDefs, imports = rowImports } - } - ] - let statementFiles = Prelude.List.map QueryGen.Output @@ -273,7 +240,6 @@ let combineOutputs = let allFiles = staticFiles # registerFiles - # rowsFiles # typesInitFiles # typeFiles # statementFiles diff --git a/src/Interpreters/Query.dhall b/src/Interpreters/Query.dhall index e5e3aa7..06addf7 100644 --- a/src/Interpreters/Query.dhall +++ b/src/Interpreters/Query.dhall @@ -1,6 +1,8 @@ +let Lude = ../Deps/Lude.dhall + let Prelude = ../Deps/Prelude.dhall -let Lude = ../Deps/Lude.dhall +let Model = ../Deps/Contract.dhall let Sdk = ../Deps/Sdk.dhall @@ -12,8 +14,6 @@ let Surface = ../Structures/Surface.dhall let OnUnsupported = ../Structures/OnUnsupported.dhall -let RowsModule = ../Templates/RowsModule.dhall - let ResultModule = ./Result.dhall let QueryFragmentsModule = ./QueryFragments.dhall @@ -31,17 +31,17 @@ let Config = let Compiled = Lude.Compiled -let Model = ../Deps/Contract.dhall - let Input = Model.Query --- A query contributes a shared Row (assembled into `_rows.py` by Project) plus --- one thin statement module, rendered for whichever surface config.sync picked. +-- A query renders to one thin statement module: its own Row dataclass and +-- decode function (when it returns rows) plus the I/O wrapper for whichever +-- surface config.sync picked. rowClassName is still surfaced here (not just +-- internal to the rendered content) because Project.dhall's facade needs the +-- name to build the re-export line; the Row's full definition does not +-- leave this module. let Output = { functionName : Text , rowClassName : Optional Text - , rowDef : Optional RowsModule.RowDef - , rowImports : ImportSet.Type , modulePath : Text , content : Text } @@ -95,7 +95,7 @@ let render = let rowDef = Prelude.Optional.map ResultModule.RowClass - RowsModule.RowDef + StatementModule.RowDef ( \(rc : ResultModule.RowClass) -> { className = rc.name , fieldsBlock = rc.fieldsBlock @@ -105,6 +105,12 @@ let render = ) result.rowClass + -- The Row's own imports (JsonValue, Decimal, custom types, ...) and + -- the parameters' imports both land in this one file now, so they + -- merge into a single ImportSet instead of flowing to two separate + -- consumers (the statement module and, formerly, _rows.py). + let mergedImports = ImportSet.combine paramImports result.imports + let surface = if config.sync then Surface.sync else Surface.async let content = @@ -114,18 +120,16 @@ let render = , helperName = result.helperName , callsDecode = result.callsDecode , sqlLiteral = fragments.sqlLiteral - , rowClassName + , rowDef , decodeName , paramSigLines , paramDictEntries - , imports = paramImports + , imports = mergedImports , surface } in { functionName , rowClassName - , rowDef - , rowImports = result.imports , modulePath = "statements/${functionName}.py" , content } diff --git a/src/Structures/Surface.dhall b/src/Structures/Surface.dhall index fc8552e..09845f4 100644 --- a/src/Structures/Surface.dhall +++ b/src/Structures/Surface.dhall @@ -10,7 +10,6 @@ let Surface = { defKeyword : Text , connType : Text , awaitKw : Text - , rowsImport : Text , corePrefix : Text , typesPrefix : Text } @@ -20,7 +19,6 @@ let async = { defKeyword = "async def" , connType = "AsyncConnection" , awaitKw = "await " - , rowsImport = ".._rows" , corePrefix = ".._core" , typesPrefix = "..types" } @@ -30,7 +28,6 @@ let sync = { defKeyword = "def" , connType = "Connection" , awaitKw = "" - , rowsImport = ".._rows" , corePrefix = ".._core" , typesPrefix = "..types" } diff --git a/src/Templates/CompositeModule.dhall b/src/Templates/CompositeModule.dhall index 32c4ee5..d2f9dc7 100644 --- a/src/Templates/CompositeModule.dhall +++ b/src/Templates/CompositeModule.dhall @@ -44,18 +44,28 @@ let run = 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. + -- pg_decode/pg_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. + -- + -- Named `pg_decode`/`pg_encode` rather than `_decode`/`_encode`: every + -- caller lives in a different generated module + -- (Member.dhall/ParamsMember.dhall), so a leading underscore only + -- earns a basedpyright strict reportPrivateUsage error, not real + -- privacy. Plain `decode`/`encode` was tried first and rejected: + -- EnumModule.dhall's generated class subclasses StrEnum, and `encode` + -- there collides with `str.encode`'s incompatible signature + -- (reportIncompatibleMethodOverride). The `pg_` prefix keeps both + -- generated shapes on one shared name with no collision either way. let codecMethods = "\n" ++ " @staticmethod\n" - ++ " def _decode(src: object) -> \"${params.typeName}\":\n" + ++ " def pg_decode(src: object) -> \"${params.typeName}\":\n" ++ " return ${params.typeName}(*cast(tuple[${fieldTypesJoined}], src))\n" ++ "\n" - ++ " def _encode(self) -> tuple[${fieldTypesJoined}]:\n" + ++ " def pg_encode(self) -> tuple[${fieldTypesJoined}]:\n" ++ " return ${encodeTupleExpr}" let imports = @@ -76,7 +86,7 @@ let run = """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. + raw string, which the generated pg_decode cannot splat into the dataclass. """ ${fieldLines} diff --git a/src/Templates/CoreModule.dhall b/src/Templates/CoreModule.dhall index 34a4943..5868f55 100644 --- a/src/Templates/CoreModule.dhall +++ b/src/Templates/CoreModule.dhall @@ -6,7 +6,7 @@ let Sdk = ../Deps/Sdk.dhall -- exceptions, and the require_array decode guard. It performs no I/O, so there is -- exactly one copy regardless of surface. The two _runtime.py modules re-export -- JsonValue/NoRowError/require_array from here so off-contract imports keep --- working, and _rows.py, the statement modules, and the facades import these +-- working, and the statement modules and facades import these -- names from _core directly. let content = '' diff --git a/src/Templates/EnumModule.dhall b/src/Templates/EnumModule.dhall index 04c4bfe..4618932 100644 --- a/src/Templates/EnumModule.dhall +++ b/src/Templates/EnumModule.dhall @@ -35,13 +35,18 @@ let run = ) params.variants + -- pg_decode/pg_encode, not decode/encode or _decode/_encode: see + -- CompositeModule.dhall's codecMethods comment. The `pg_` prefix + -- matters here specifically — this class subclasses StrEnum, so a + -- plain `encode` would override `str.encode`'s incompatible + -- signature (reportIncompatibleMethodOverride). let codecMethods = "\n" ++ " @staticmethod\n" - ++ " def _decode(src: object) -> \"${params.typeName}\":\n" + ++ " def pg_decode(src: object) -> \"${params.typeName}\":\n" ++ " return ${params.typeName}(cast(str, src))\n" ++ "\n" - ++ " def _encode(self) -> \"${params.typeName}\":\n" + ++ " def pg_encode(self) -> \"${params.typeName}\":\n" ++ " return self" in '' diff --git a/src/Templates/FacadeModule.dhall b/src/Templates/FacadeModule.dhall index b1b0d44..87e326b 100644 --- a/src/Templates/FacadeModule.dhall +++ b/src/Templates/FacadeModule.dhall @@ -65,20 +65,25 @@ let run = let rows = rowNames params.statements - let rowBlock = - "from ${generatedPrefix}._rows import (\n" - ++ Prelude.Text.concatMap - Text - (\(name : Text) -> " ${alias name},\n") - rows - ++ ")" - + -- A query's Row class now lives in that query's own statement + -- module (there is no shared _rows module anymore), so the row + -- alias, when present, rides on the same import line as the + -- statement function itself: "from ...statements.fn import fn as + -- fn, RowCls as RowCls" -- one line per statement, not two separate + -- import blocks. let statementBlock = Prelude.Text.concatMapSep "\n" StatementExport ( \(s : StatementExport) -> - "from ${generatedPrefix}.${statementsPath}.${s.functionName} import ${alias s.functionName}" + let rowSuffix = + merge + { None = "", Some = \(row : Text) -> ", ${alias row}" } + s.rowClassName + + in "from ${generatedPrefix}.${statementsPath}.${s.functionName} import " + ++ alias s.functionName + ++ rowSuffix ) params.statements @@ -88,7 +93,6 @@ let run = then [] : List Text else [ typeBlock ] ) - # (if Prelude.List.null Text rows then [] : List Text else [ rowBlock ]) # ( if Prelude.List.null StatementExport params.statements then [] : List Text else [ statementBlock ] diff --git a/src/Templates/RowsModule.dhall b/src/Templates/RowsModule.dhall deleted file mode 100644 index b1d5b13..0000000 --- a/src/Templates/RowsModule.dhall +++ /dev/null @@ -1,121 +0,0 @@ -let Prelude = ../Deps/Prelude.dhall - -let Lude = ../Deps/Lude.dhall - -let Sdk = ../Deps/Sdk.dhall - -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 --- same types (cross-surface identity) and decode logic. decodeName is unique per --- query (decode_) since they now co-habit one module. -let RowDef = - { className : Text - , fieldsBlock : Text - , decodeBlock : Text - , decodeName : Text - } - -let Params = { rows : List RowDef, imports : ImportSet.Type } - -let indentAll - : Natural -> Text -> Text - = \(n : Natural) -> - \(text : Text) -> - let pad = Prelude.Text.replicate n " " - - in pad ++ Lude.Text.indentNonEmpty n text - -let importLineIf - : Bool -> Text -> List Text - = \(cond : Bool) -> \(line : Text) -> if cond then [ line ] else [] : List Text - -let datetimeImport - : ImportSet.Type -> List Text - = \(imports : ImportSet.Type) -> - let names = - importLineIf imports.date "date" - # importLineIf imports.datetime "datetime" - # importLineIf imports.time "time" - # importLineIf imports.timedelta "timedelta" - - in if Prelude.Bool.not (Prelude.List.null Text names) - then [ "from datetime import " ++ Prelude.Text.concatSep ", " names ] - else [] : List Text - --- Custom-type imports resolve from `_rows.py` (one level above `types/`), so the --- prefix is `.types`, unlike a statement module's `..types`. -let customImportLines - : ImportSet.Type -> List Text - = \(imports : ImportSet.Type) -> - Prelude.List.map - ImportSet.CustomImport - Text - ( \(c : ImportSet.CustomImport) -> - "from .types." ++ c.moduleName ++ " import " ++ c.className - ) - imports.customTypes - -let renderImports - : ImportSet.Type -> Text - = \(imports : ImportSet.Type) -> - let stdlibBlock = - [ "from collections.abc import Mapping" - , "from dataclasses import dataclass" - ] - # datetimeImport imports - # importLineIf imports.decimal "from decimal import Decimal" - # [ "from typing import cast" ] - # importLineIf imports.uuid "from uuid import UUID" - - let localBlock = - importLineIf imports.jsonValue "from ._core import JsonValue" - # importLineIf imports.enumArray "from ._core import require_array" - # customImportLines imports - - let groups = - [ [ "from __future__ import annotations" ], stdlibBlock, localBlock ] - - let nonEmptyGroups = - Prelude.List.filter - (List Text) - ( \(g : List Text) -> - Prelude.Bool.not (Prelude.List.null Text g) - ) - groups - - in Prelude.Text.concatMapSep - "\n\n" - (List Text) - (\(g : List Text) -> Prelude.Text.concatSep "\n" g) - nonEmptyGroups - -let renderRow - : RowDef -> Text - = \(row : RowDef) -> - "@dataclass(frozen=True, slots=True)\n" - ++ "class " - ++ row.className - ++ ":\n" - ++ indentAll 4 row.fieldsBlock - ++ "\n\n\n" - ++ "def " - ++ row.decodeName - ++ "(row: Mapping[str, object]) -> " - ++ row.className - ++ ":\n" - ++ " return " - ++ row.className - ++ "(\n" - ++ indentAll 8 row.decodeBlock - ++ "\n )" - -let run = - \(params : Params) -> - renderImports params.imports - ++ "\n\n\n" - ++ Prelude.Text.concatMapSep "\n\n\n" RowDef renderRow params.rows - ++ "\n" - -in Sdk.Sigs.template Params run /\ { RowDef } diff --git a/src/Templates/StatementModule.dhall b/src/Templates/StatementModule.dhall index 325e21f..435dddd 100644 --- a/src/Templates/StatementModule.dhall +++ b/src/Templates/StatementModule.dhall @@ -8,6 +8,18 @@ let ImportSet = ../Structures/ImportSet.dhall let Surface = ../Structures/Surface.dhall +-- A query's frozen Row dataclass plus its module-level decode function, +-- rendered directly into that query's own statement module (there is a +-- strict 1:1 relationship between a query and its Row -- no cross-statement +-- sharing -- so co-locating them costs nothing and removes the need for a +-- shared _rows.py import). +let RowDef = + { className : Text + , fieldsBlock : Text + , decodeBlock : Text + , decodeName : Text + } + -- Prefix every line (including the first) with `n` spaces, leaving blank lines -- untouched so trailing whitespace never appears. let indentAll @@ -18,17 +30,58 @@ let indentAll 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 --- `async def`/`def` over a connection. `imports` carries the PARAMETER type --- imports only; the result-column imports live with the Row in `_rows`. +let renderRow + : RowDef -> Text + = \(row : RowDef) -> + "@dataclass(frozen=True, slots=True)\n" + ++ "class " + ++ row.className + ++ ":\n" + ++ indentAll 4 row.fieldsBlock + ++ "\n\n\n" + ++ "def " + ++ row.decodeName + ++ "(row: Mapping[str, object]) -> " + ++ row.className + ++ ":\n" + ++ " return " + ++ row.className + ++ "(\n" + ++ indentAll 8 row.decodeBlock + ++ "\n )" + +-- "" when the query returns no rows (Void/RowsAffected); otherwise the +-- rendered class + decode function, framed with the same "\n\n\n" (two +-- blank lines) PEP8 spacing _rows.py used between consecutive row defs, on +-- both sides -- matching the two-blank-lines-before/after convention for a +-- top-level class or function. +let renderRowBlock + : Optional RowDef -> Text + = \(rowDef : Optional RowDef) -> + merge + { None = "" + , Some = \(row : RowDef) -> "\n" ++ renderRow row ++ "\n\n\n" + } + rowDef + +let hasRow + : Optional RowDef -> Bool + = \(rowDef : Optional RowDef) -> + merge { None = False, Some = \(_ : RowDef) -> True } rowDef + +-- A statement module is the thin per-surface I/O wrapper: it renders its own +-- Row dataclass and decode function (when the query returns rows) and one +-- `async def`/`def` over a connection. `imports` carries BOTH the parameter +-- type imports and the result-column imports merged into one set +-- (Interpreters/Query.dhall combines them), since both now live in this one +-- file. let Params = { functionName : Text , returnType : Text , helperName : Text , callsDecode : Bool , sqlLiteral : Text - , rowClassName : Optional Text + , rowDef : Optional RowDef , decodeName : Text , paramSigLines : List Text , paramDictEntries : List Text @@ -81,26 +134,19 @@ let customImportLines ) imports.customTypes -let rowsImportLine - : Params -> List Text - = \(params : Params) -> - merge - { None = [] : List Text - , Some = - \(rowClass : Text) -> - [ "from ${params.surface.rowsImport} import ${rowClass}, ${params.decodeName}" - ] - } - params.rowClassName - let renderImports : Params -> Text = \(params : Params) -> let imports = params.imports + let rowIsPresent = hasRow params.rowDef + let stdlibBlock = - datetimeImport imports + importLineIf rowIsPresent "from collections.abc import Mapping" + # importLineIf rowIsPresent "from dataclasses import dataclass" + # datetimeImport imports # importLineIf imports.decimal "from decimal import Decimal" + # importLineIf rowIsPresent "from typing import cast" # importLineIf imports.uuid "from uuid import UUID" let psycopgBlock = @@ -114,7 +160,9 @@ let renderImports let localBlock = coreImport params.surface.corePrefix imports.jsonValue - # rowsImportLine params + # importLineIf + imports.enumArray + "from ${params.surface.corePrefix} import require_array" # [ runtimeImport params.helperName ] # customImportLines params.surface.typesPrefix imports @@ -192,6 +240,7 @@ in Sdk.Sigs.template ( \(params : Params) -> renderImports params ++ "\n\n" + ++ renderRowBlock params.rowDef -- The leading backslash after the opening quotes keeps the first SQL -- line flush (no blank line); the newline before the closing quotes is -- the only deviation from the raw text, a harmless trailing newline for @@ -209,3 +258,4 @@ in Sdk.Sigs.template ++ indentAll 4 (renderCall params) ++ "\n" ) + /\ { RowDef } diff --git a/tests/golden/src/specimen_client/__init__.py b/tests/golden/src/specimen_client/__init__.py index c34dbc1..7ca58d6 100644 --- a/tests/golden/src/specimen_client/__init__.py +++ b/tests/golden/src/specimen_client/__init__.py @@ -8,30 +8,17 @@ from ._generated.types.point_2_d import Point2D as Point2D from ._generated.types.tag_value import TagValue as TagValue -from ._generated._rows import ( - GetSpecimenRow as GetSpecimenRow, - GetTaggedItemRow as GetTaggedItemRow, - InsertSpecimenRow as InsertSpecimenRow, - InsertTaggedItemRow as InsertTaggedItemRow, - ListSpecimensByClassRow as ListSpecimensByClassRow, - ListSpecimensByFeelingRow as ListSpecimensByFeelingRow, - ListSpecimensByIdsRow as ListSpecimensByIdsRow, - ListSpecimensByMoodsRow as ListSpecimensByMoodsRow, - ListSpecimensKeywordColumnRow as ListSpecimensKeywordColumnRow, - SearchSpecimensRow as SearchSpecimensRow, -) - from ._generated.statements.bump_specimen_revision import bump_specimen_revision as bump_specimen_revision -from ._generated.statements.get_specimen import get_specimen as get_specimen -from ._generated.statements.get_tagged_item import get_tagged_item as get_tagged_item -from ._generated.statements.insert_specimen import insert_specimen as insert_specimen -from ._generated.statements.insert_tagged_item import insert_tagged_item as insert_tagged_item -from ._generated.statements.list_specimens_by_class import list_specimens_by_class as list_specimens_by_class -from ._generated.statements.list_specimens_by_feeling import list_specimens_by_feeling as list_specimens_by_feeling -from ._generated.statements.list_specimens_by_ids import list_specimens_by_ids as list_specimens_by_ids -from ._generated.statements.list_specimens_by_moods import list_specimens_by_moods as list_specimens_by_moods -from ._generated.statements.list_specimens_keyword_column import list_specimens_keyword_column as list_specimens_keyword_column -from ._generated.statements.search_specimens import search_specimens as search_specimens +from ._generated.statements.get_specimen import get_specimen as get_specimen, GetSpecimenRow as GetSpecimenRow +from ._generated.statements.get_tagged_item import get_tagged_item as get_tagged_item, GetTaggedItemRow as GetTaggedItemRow +from ._generated.statements.insert_specimen import insert_specimen as insert_specimen, InsertSpecimenRow as InsertSpecimenRow +from ._generated.statements.insert_tagged_item import insert_tagged_item as insert_tagged_item, InsertTaggedItemRow as InsertTaggedItemRow +from ._generated.statements.list_specimens_by_class import list_specimens_by_class as list_specimens_by_class, ListSpecimensByClassRow as ListSpecimensByClassRow +from ._generated.statements.list_specimens_by_feeling import list_specimens_by_feeling as list_specimens_by_feeling, ListSpecimensByFeelingRow as ListSpecimensByFeelingRow +from ._generated.statements.list_specimens_by_ids import list_specimens_by_ids as list_specimens_by_ids, ListSpecimensByIdsRow as ListSpecimensByIdsRow +from ._generated.statements.list_specimens_by_moods import list_specimens_by_moods as list_specimens_by_moods, ListSpecimensByMoodsRow as ListSpecimensByMoodsRow +from ._generated.statements.list_specimens_keyword_column import list_specimens_keyword_column as list_specimens_keyword_column, ListSpecimensKeywordColumnRow as ListSpecimensKeywordColumnRow +from ._generated.statements.search_specimens import search_specimens as search_specimens, SearchSpecimensRow as SearchSpecimensRow __all__ = [ "JsonValue", diff --git a/tests/golden/src/specimen_client/_generated/_rows.py b/tests/golden/src/specimen_client/_generated/_rows.py deleted file mode 100644 index b413258..0000000 --- a/tests/golden/src/specimen_client/_generated/_rows.py +++ /dev/null @@ -1,303 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from datetime import date, datetime -from decimal import Decimal -from typing import cast -from uuid import UUID - -from ._core import JsonValue -from ._core import require_array -from .types.mood import Mood -from .types.point_2_d import Point2D -from .types.tag_value import TagValue -from .types.mood import Mood -from .types.mood import Mood -from .types.point_2_d import Point2D -from .types.tag_value import TagValue -from .types.mood import Mood -from .types.point_2_d import Point2D -from .types.mood import Mood -from .types.mood import Mood -from .types.mood import Mood - - -@dataclass(frozen=True, slots=True) -class GetSpecimenRow: - id: int - pub_id: UUID - flag: bool - small: int - medium: int - large: int - ratio: float - precise: float - title: str - code: str - letter: str - born_on: date - created_at: datetime - amount: Decimal - blob: bytes - doc_json: JsonValue - doc_jsonb: JsonValue - maybe_text: str | None - maybe_int: int | None - maybe_uuid: UUID | None - maybe_ts: datetime | None - maybe_num: Decimal | None - tags: list[str | None] - related_ids: list[UUID | None] | None - grid: list[int | None] | None - feeling: Mood - origin: Point2D | None - label: str - rev: int - meta: JsonValue - - -def decode_get_specimen(row: Mapping[str, object]) -> GetSpecimenRow: - return GetSpecimenRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - flag=cast(bool, row["flag"]), - small=cast(int, row["small"]), - medium=cast(int, row["medium"]), - large=cast(int, row["large"]), - ratio=cast(float, row["ratio"]), - precise=cast(float, row["precise"]), - title=cast(str, row["title"]), - code=cast(str, row["code"]), - letter=cast(str, row["letter"]), - born_on=cast(date, row["born_on"]), - created_at=cast(datetime, row["created_at"]), - amount=cast(Decimal, row["amount"]), - blob=cast(bytes, row["blob"]), - doc_json=cast(JsonValue, row["doc_json"]), - doc_jsonb=cast(JsonValue, row["doc_jsonb"]), - maybe_text=cast(str | None, row["maybe_text"]), - maybe_int=cast(int | None, row["maybe_int"]), - maybe_uuid=cast(UUID | None, row["maybe_uuid"]), - maybe_ts=cast(datetime | None, row["maybe_ts"]), - maybe_num=cast(Decimal | None, row["maybe_num"]), - tags=cast(list[str | None], row["tags"]), - related_ids=cast(list[UUID | None] | None, row["related_ids"]), - grid=cast(list[int | None] | None, row["grid"]), - feeling=Mood._decode(row["feeling"]), - origin=None if row["origin"] is None else Point2D._decode(row["origin"]), - label=cast(str, row["label"]), - rev=cast(int, row["rev"]), - meta=cast(JsonValue, row["meta"]), - ) - - -@dataclass(frozen=True, slots=True) -class GetTaggedItemRow: - id: int - name: str - tag: TagValue - - -def decode_get_tagged_item(row: Mapping[str, object]) -> GetTaggedItemRow: - return GetTaggedItemRow( - id=cast(int, row["id"]), - name=cast(str, row["name"]), - tag=TagValue._decode(row["tag"]), - ) - - -@dataclass(frozen=True, slots=True) -class InsertSpecimenRow: - id: int - pub_id: UUID - flag: bool - small: int - medium: int - large: int - ratio: float - precise: float - title: str - code: str - letter: str - born_on: date - created_at: datetime - amount: Decimal - blob: bytes - doc_json: JsonValue - doc_jsonb: JsonValue - maybe_text: str | None - maybe_int: int | None - maybe_uuid: UUID | None - maybe_ts: datetime | None - maybe_num: Decimal | None - tags: list[str | None] - related_ids: list[UUID | None] | None - grid: list[int | None] | None - feeling: Mood - moods: list[Mood | None] | None - origin: Point2D | None - label: str - rev: int - meta: JsonValue - - -def decode_insert_specimen(row: Mapping[str, object]) -> InsertSpecimenRow: - return InsertSpecimenRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - flag=cast(bool, row["flag"]), - small=cast(int, row["small"]), - medium=cast(int, row["medium"]), - large=cast(int, row["large"]), - ratio=cast(float, row["ratio"]), - precise=cast(float, row["precise"]), - title=cast(str, row["title"]), - code=cast(str, row["code"]), - letter=cast(str, row["letter"]), - born_on=cast(date, row["born_on"]), - created_at=cast(datetime, row["created_at"]), - amount=cast(Decimal, row["amount"]), - blob=cast(bytes, row["blob"]), - doc_json=cast(JsonValue, row["doc_json"]), - doc_jsonb=cast(JsonValue, row["doc_jsonb"]), - maybe_text=cast(str | None, row["maybe_text"]), - maybe_int=cast(int | None, row["maybe_int"]), - maybe_uuid=cast(UUID | None, row["maybe_uuid"]), - maybe_ts=cast(datetime | None, row["maybe_ts"]), - maybe_num=cast(Decimal | None, row["maybe_num"]), - tags=cast(list[str | None], row["tags"]), - related_ids=cast(list[UUID | None] | None, row["related_ids"]), - grid=cast(list[int | None] | None, row["grid"]), - feeling=Mood._decode(row["feeling"]), - moods=None if row["moods"] is None else [None if v is None else Mood._decode(v) for v in cast(list[str | None], require_array(row["moods"]))], - origin=None if row["origin"] is None else Point2D._decode(row["origin"]), - label=cast(str, row["label"]), - rev=cast(int, row["rev"]), - meta=cast(JsonValue, row["meta"]), - ) - - -@dataclass(frozen=True, slots=True) -class InsertTaggedItemRow: - id: int - name: str - tag: TagValue - - -def decode_insert_tagged_item(row: Mapping[str, object]) -> InsertTaggedItemRow: - return InsertTaggedItemRow( - id=cast(int, row["id"]), - name=cast(str, row["name"]), - tag=TagValue._decode(row["tag"]), - ) - - -@dataclass(frozen=True, slots=True) -class ListSpecimensByClassRow: - id: int - title: str - - -def decode_list_specimens_by_class(row: Mapping[str, object]) -> ListSpecimensByClassRow: - return ListSpecimensByClassRow( - id=cast(int, row["id"]), - title=cast(str, row["title"]), - ) - - -@dataclass(frozen=True, slots=True) -class ListSpecimensByFeelingRow: - id: int - pub_id: UUID - feeling: Mood - title: str - label: str - rev: int - origin: Point2D | None - tags: list[str | None] - meta: JsonValue - - -def decode_list_specimens_by_feeling(row: Mapping[str, object]) -> ListSpecimensByFeelingRow: - return ListSpecimensByFeelingRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - feeling=Mood._decode(row["feeling"]), - title=cast(str, row["title"]), - label=cast(str, row["label"]), - rev=cast(int, row["rev"]), - origin=None if row["origin"] is None else Point2D._decode(row["origin"]), - tags=cast(list[str | None], row["tags"]), - meta=cast(JsonValue, row["meta"]), - ) - - -@dataclass(frozen=True, slots=True) -class ListSpecimensByIdsRow: - id: int - pub_id: UUID - feeling: Mood - title: str - - -def decode_list_specimens_by_ids(row: Mapping[str, object]) -> ListSpecimensByIdsRow: - return ListSpecimensByIdsRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - feeling=Mood._decode(row["feeling"]), - title=cast(str, row["title"]), - ) - - -@dataclass(frozen=True, slots=True) -class ListSpecimensByMoodsRow: - id: int - pub_id: UUID - feeling: Mood - moods: list[Mood | None] | None - title: str - - -def decode_list_specimens_by_moods(row: Mapping[str, object]) -> ListSpecimensByMoodsRow: - return ListSpecimensByMoodsRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - feeling=Mood._decode(row["feeling"]), - moods=None if row["moods"] is None else [None if v is None else Mood._decode(v) for v in cast(list[str | None], require_array(row["moods"]))], - title=cast(str, row["title"]), - ) - - -@dataclass(frozen=True, slots=True) -class ListSpecimensKeywordColumnRow: - id: int - class_: str - - -def decode_list_specimens_keyword_column(row: Mapping[str, object]) -> ListSpecimensKeywordColumnRow: - return ListSpecimensKeywordColumnRow( - id=cast(int, row["id"]), - class_=cast(str, row["class"]), - ) - - -@dataclass(frozen=True, slots=True) -class SearchSpecimensRow: - id: int - title: str - label: str - meta: JsonValue - - -def decode_search_specimens(row: Mapping[str, object]) -> SearchSpecimensRow: - return SearchSpecimensRow( - id=cast(int, row["id"]), - title=cast(str, row["title"]), - label=cast(str, row["label"]), - meta=cast(JsonValue, row["meta"]), - ) diff --git a/tests/golden/src/specimen_client/_generated/statements/get_specimen.py b/tests/golden/src/specimen_client/_generated/statements/get_specimen.py index d152d88..20d1c33 100644 --- a/tests/golden/src/specimen_client/_generated/statements/get_specimen.py +++ b/tests/golden/src/specimen_client/_generated/statements/get_specimen.py @@ -4,10 +4,89 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal +from typing import cast +from uuid import UUID + from psycopg import AsyncConnection -from .._rows import GetSpecimenRow, decode_get_specimen +from .._core import JsonValue from .._runtime import fetch_optional +from ..types.mood import Mood +from ..types.point_2_d import Point2D + + +@dataclass(frozen=True, slots=True) +class GetSpecimenRow: + id: int + pub_id: UUID + flag: bool + small: int + medium: int + large: int + ratio: float + precise: float + title: str + code: str + letter: str + born_on: date + created_at: datetime + amount: Decimal + blob: bytes + doc_json: JsonValue + doc_jsonb: JsonValue + maybe_text: str | None + maybe_int: int | None + maybe_uuid: UUID | None + maybe_ts: datetime | None + maybe_num: Decimal | None + tags: list[str | None] + related_ids: list[UUID | None] | None + grid: list[int | None] | None + feeling: Mood + origin: Point2D | None + label: str + rev: int + meta: JsonValue + + +def decode_get_specimen(row: Mapping[str, object]) -> GetSpecimenRow: + return GetSpecimenRow( + id=cast(int, row["id"]), + pub_id=cast(UUID, row["pub_id"]), + flag=cast(bool, row["flag"]), + small=cast(int, row["small"]), + medium=cast(int, row["medium"]), + large=cast(int, row["large"]), + ratio=cast(float, row["ratio"]), + precise=cast(float, row["precise"]), + title=cast(str, row["title"]), + code=cast(str, row["code"]), + letter=cast(str, row["letter"]), + born_on=cast(date, row["born_on"]), + created_at=cast(datetime, row["created_at"]), + amount=cast(Decimal, row["amount"]), + blob=cast(bytes, row["blob"]), + doc_json=cast(JsonValue, row["doc_json"]), + doc_jsonb=cast(JsonValue, row["doc_jsonb"]), + maybe_text=cast(str | None, row["maybe_text"]), + maybe_int=cast(int | None, row["maybe_int"]), + maybe_uuid=cast(UUID | None, row["maybe_uuid"]), + maybe_ts=cast(datetime | None, row["maybe_ts"]), + maybe_num=cast(Decimal | None, row["maybe_num"]), + tags=cast(list[str | None], row["tags"]), + related_ids=cast(list[UUID | None] | None, row["related_ids"]), + grid=cast(list[int | None] | None, row["grid"]), + feeling=Mood.pg_decode(row["feeling"]), + origin=None if row["origin"] is None else Point2D.pg_decode(row["origin"]), + label=cast(str, row["label"]), + rev=cast(int, row["rev"]), + meta=cast(JsonValue, row["meta"]), + ) + SQL = """\ -- zero_or_one: select by pk with limit 1. diff --git a/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py b/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py index 2cede6c..37d0306 100644 --- a/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py +++ b/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py @@ -4,10 +4,30 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast + from psycopg import AsyncConnection -from .._rows import GetTaggedItemRow, decode_get_tagged_item from .._runtime import fetch_optional +from ..types.tag_value import TagValue + + +@dataclass(frozen=True, slots=True) +class GetTaggedItemRow: + id: int + name: str + tag: TagValue + + +def decode_get_tagged_item(row: Mapping[str, object]) -> GetTaggedItemRow: + return GetTaggedItemRow( + id=cast(int, row["id"]), + name=cast(str, row["name"]), + tag=TagValue.pg_decode(row["tag"]), + ) + SQL = """\ -- zero_or_one: select by pk with limit 1; the single-field composite here is diff --git a/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py b/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py index 9f1b2e5..9ed0d42 100644 --- a/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py +++ b/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py @@ -4,8 +4,11 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass from datetime import date, datetime from decimal import Decimal +from typing import cast from uuid import UUID from psycopg import AsyncConnection @@ -13,11 +16,86 @@ from psycopg.types.json import Jsonb from .._core import JsonValue -from .._rows import InsertSpecimenRow, decode_insert_specimen +from .._core import require_array from .._runtime import fetch_single from ..types.mood import Mood from ..types.mood import Mood from ..types.point_2_d import Point2D +from ..types.mood import Mood +from ..types.mood import Mood +from ..types.point_2_d import Point2D + + +@dataclass(frozen=True, slots=True) +class InsertSpecimenRow: + id: int + pub_id: UUID + flag: bool + small: int + medium: int + large: int + ratio: float + precise: float + title: str + code: str + letter: str + born_on: date + created_at: datetime + amount: Decimal + blob: bytes + doc_json: JsonValue + doc_jsonb: JsonValue + maybe_text: str | None + maybe_int: int | None + maybe_uuid: UUID | None + maybe_ts: datetime | None + maybe_num: Decimal | None + tags: list[str | None] + related_ids: list[UUID | None] | None + grid: list[int | None] | None + feeling: Mood + moods: list[Mood | None] | None + origin: Point2D | None + label: str + rev: int + meta: JsonValue + + +def decode_insert_specimen(row: Mapping[str, object]) -> InsertSpecimenRow: + return InsertSpecimenRow( + id=cast(int, row["id"]), + pub_id=cast(UUID, row["pub_id"]), + flag=cast(bool, row["flag"]), + small=cast(int, row["small"]), + medium=cast(int, row["medium"]), + large=cast(int, row["large"]), + ratio=cast(float, row["ratio"]), + precise=cast(float, row["precise"]), + title=cast(str, row["title"]), + code=cast(str, row["code"]), + letter=cast(str, row["letter"]), + born_on=cast(date, row["born_on"]), + created_at=cast(datetime, row["created_at"]), + amount=cast(Decimal, row["amount"]), + blob=cast(bytes, row["blob"]), + doc_json=cast(JsonValue, row["doc_json"]), + doc_jsonb=cast(JsonValue, row["doc_jsonb"]), + maybe_text=cast(str | None, row["maybe_text"]), + maybe_int=cast(int | None, row["maybe_int"]), + maybe_uuid=cast(UUID | None, row["maybe_uuid"]), + maybe_ts=cast(datetime | None, row["maybe_ts"]), + maybe_num=cast(Decimal | None, row["maybe_num"]), + tags=cast(list[str | None], row["tags"]), + related_ids=cast(list[UUID | None] | None, row["related_ids"]), + grid=cast(list[int | None] | None, row["grid"]), + feeling=Mood.pg_decode(row["feeling"]), + moods=None if row["moods"] is None else [None if v is None else Mood.pg_decode(v) for v in cast(list[str | None], require_array(row["moods"]))], + origin=None if row["origin"] is None else Point2D.pg_decode(row["origin"]), + label=cast(str, row["label"]), + rev=cast(int, row["rev"]), + meta=cast(JsonValue, row["meta"]), + ) + SQL = """\ -- single row: insert ... returning the full type surface. @@ -111,8 +189,8 @@ async def insert_specimen( "tags": tags, "related_ids": related_ids, "grid": grid, - "feeling": feeling._encode(), - "moods": None if moods is None else [None if x is None else x._encode() for x in moods], - "origin": None if origin is None else origin._encode(), + "feeling": feeling.pg_encode(), + "moods": None if moods is None else [None if x is None else x.pg_encode() for x in moods], + "origin": None if origin is None else origin.pg_encode(), } return await fetch_single(conn, _SQL, params, decode_insert_specimen) diff --git a/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py b/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py index 912594e..33e477a 100644 --- a/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py +++ b/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py @@ -4,11 +4,31 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast + from psycopg import AsyncConnection -from .._rows import InsertTaggedItemRow, decode_insert_tagged_item from .._runtime import fetch_single from ..types.tag_value import TagValue +from ..types.tag_value import TagValue + + +@dataclass(frozen=True, slots=True) +class InsertTaggedItemRow: + id: int + name: str + tag: TagValue + + +def decode_insert_tagged_item(row: Mapping[str, object]) -> InsertTaggedItemRow: + return InsertTaggedItemRow( + id=cast(int, row["id"]), + name=cast(str, row["name"]), + tag=TagValue.pg_decode(row["tag"]), + ) + SQL = """\ -- single row: insert exercising a single-field composite as a parameter and @@ -29,6 +49,6 @@ async def insert_tagged_item( ) -> InsertTaggedItemRow: params: dict[str, object] = { "name": name, - "tag": tag._encode(), + "tag": tag.pg_encode(), } return await fetch_single(conn, _SQL, params, decode_insert_tagged_item) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py index 0c93129..a78b665 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py @@ -4,11 +4,28 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast + from psycopg import AsyncConnection -from .._rows import ListSpecimensByClassRow, decode_list_specimens_by_class from .._runtime import fetch_many + +@dataclass(frozen=True, slots=True) +class ListSpecimensByClassRow: + id: int + title: str + + +def decode_list_specimens_by_class(row: Mapping[str, object]) -> ListSpecimensByClassRow: + return ListSpecimensByClassRow( + id=cast(int, row["id"]), + title=cast(str, row["title"]), + ) + + SQL = """\ -- Keyword-param coverage: the placeholder `class` is a Python reserved word, so -- the generated signature must use `class_` while the params-dict key and the diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py index 73699d7..59a9aab 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py @@ -4,11 +4,46 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast +from uuid import UUID + from psycopg import AsyncConnection -from .._rows import ListSpecimensByFeelingRow, decode_list_specimens_by_feeling +from .._core import JsonValue from .._runtime import fetch_many from ..types.mood import Mood +from ..types.mood import Mood +from ..types.point_2_d import Point2D + + +@dataclass(frozen=True, slots=True) +class ListSpecimensByFeelingRow: + id: int + pub_id: UUID + feeling: Mood + title: str + label: str + rev: int + origin: Point2D | None + tags: list[str | None] + meta: JsonValue + + +def decode_list_specimens_by_feeling(row: Mapping[str, object]) -> ListSpecimensByFeelingRow: + return ListSpecimensByFeelingRow( + id=cast(int, row["id"]), + pub_id=cast(UUID, row["pub_id"]), + feeling=Mood.pg_decode(row["feeling"]), + title=cast(str, row["title"]), + label=cast(str, row["label"]), + rev=cast(int, row["rev"]), + origin=None if row["origin"] is None else Point2D.pg_decode(row["origin"]), + tags=cast(list[str | None], row["tags"]), + meta=cast(JsonValue, row["meta"]), + ) + SQL = """\ -- many: select with order by. Enum parameter ($feeling). @@ -28,6 +63,6 @@ async def list_specimens_by_feeling( feeling: Mood | None, ) -> list[ListSpecimensByFeelingRow]: params: dict[str, object] = { - "feeling": None if feeling is None else feeling._encode(), + "feeling": None if feeling is None else feeling.pg_encode(), } return await fetch_many(conn, _SQL, params, decode_list_specimens_by_feeling) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py index 29bc6ea..0025fbd 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py @@ -4,12 +4,33 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast from uuid import UUID from psycopg import AsyncConnection -from .._rows import ListSpecimensByIdsRow, decode_list_specimens_by_ids from .._runtime import fetch_many +from ..types.mood import Mood + + +@dataclass(frozen=True, slots=True) +class ListSpecimensByIdsRow: + id: int + pub_id: UUID + feeling: Mood + title: str + + +def decode_list_specimens_by_ids(row: Mapping[str, object]) -> ListSpecimensByIdsRow: + return ListSpecimensByIdsRow( + id=cast(int, row["id"]), + pub_id=cast(UUID, row["pub_id"]), + feeling=Mood.pg_decode(row["feeling"]), + title=cast(str, row["title"]), + ) + SQL = """\ -- many: array parameter via = any($pub_ids). diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py index 1d5ba33..97c6434 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py @@ -4,11 +4,38 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast +from uuid import UUID + from psycopg import AsyncConnection -from .._rows import ListSpecimensByMoodsRow, decode_list_specimens_by_moods +from .._core import require_array from .._runtime import fetch_many from ..types.mood import Mood +from ..types.mood import Mood +from ..types.mood import Mood + + +@dataclass(frozen=True, slots=True) +class ListSpecimensByMoodsRow: + id: int + pub_id: UUID + feeling: Mood + moods: list[Mood | None] | None + title: str + + +def decode_list_specimens_by_moods(row: Mapping[str, object]) -> ListSpecimensByMoodsRow: + return ListSpecimensByMoodsRow( + id=cast(int, row["id"]), + pub_id=cast(UUID, row["pub_id"]), + feeling=Mood.pg_decode(row["feeling"]), + moods=None if row["moods"] is None else [None if v is None else Mood.pg_decode(v) for v in cast(list[str | None], require_array(row["moods"]))], + title=cast(str, row["title"]), + ) + SQL = """\ -- many: enum array parameter via = any($moods::mood[]); returns the enum array column. @@ -28,6 +55,6 @@ async def list_specimens_by_moods( moods: list[Mood | None] | None, ) -> list[ListSpecimensByMoodsRow]: params: dict[str, object] = { - "moods": None if moods is None else [None if x is None else x._encode() for x in moods], + "moods": None if moods is None else [None if x is None else x.pg_encode() for x in moods], } return await fetch_many(conn, _SQL, params, decode_list_specimens_by_moods) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py index cae6f52..2b589cb 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py @@ -4,11 +4,28 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast + from psycopg import AsyncConnection -from .._rows import ListSpecimensKeywordColumnRow, decode_list_specimens_keyword_column from .._runtime import fetch_many + +@dataclass(frozen=True, slots=True) +class ListSpecimensKeywordColumnRow: + id: int + class_: str + + +def decode_list_specimens_keyword_column(row: Mapping[str, object]) -> ListSpecimensKeywordColumnRow: + return ListSpecimensKeywordColumnRow( + id=cast(int, row["id"]), + class_=cast(str, row["class"]), + ) + + SQL = """\ -- Keyword result-column coverage: the column aliased to the reserved word class -- must emit a dataclass field and decode kwarg of class_ while the row lookup diff --git a/tests/golden/src/specimen_client/_generated/statements/search_specimens.py b/tests/golden/src/specimen_client/_generated/statements/search_specimens.py index 7caea94..3c4ab94 100644 --- a/tests/golden/src/specimen_client/_generated/statements/search_specimens.py +++ b/tests/golden/src/specimen_client/_generated/statements/search_specimens.py @@ -4,13 +4,34 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast + from psycopg import AsyncConnection from psycopg.types.json import Jsonb from .._core import JsonValue -from .._rows import SearchSpecimensRow, decode_search_specimens from .._runtime import fetch_many + +@dataclass(frozen=True, slots=True) +class SearchSpecimensRow: + id: int + title: str + label: str + meta: JsonValue + + +def decode_search_specimens(row: Mapping[str, object]) -> SearchSpecimensRow: + return SearchSpecimensRow( + id=cast(int, row["id"]), + title=cast(str, row["title"]), + label=cast(str, row["label"]), + meta=cast(JsonValue, row["meta"]), + ) + + SQL = """\ -- many: nullable parameter via coalesce, jsonb containment parameter, and a -- domain comparison with the param cast to the base type (pgn cannot bind a diff --git a/tests/golden/src/specimen_client/_generated/types/mood.py b/tests/golden/src/specimen_client/_generated/types/mood.py index a91c901..3556da7 100644 --- a/tests/golden/src/specimen_client/_generated/types/mood.py +++ b/tests/golden/src/specimen_client/_generated/types/mood.py @@ -12,8 +12,8 @@ class Mood(StrEnum): MEH = "meh" @staticmethod - def _decode(src: object) -> "Mood": + def pg_decode(src: object) -> "Mood": return Mood(cast(str, src)) - def _encode(self) -> "Mood": + def pg_encode(self) -> "Mood": return self diff --git a/tests/golden/src/specimen_client/_generated/types/point_2_d.py b/tests/golden/src/specimen_client/_generated/types/point_2_d.py index 9bf2915..e212a40 100644 --- a/tests/golden/src/specimen_client/_generated/types/point_2_d.py +++ b/tests/golden/src/specimen_client/_generated/types/point_2_d.py @@ -11,15 +11,15 @@ class Point2D: """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. + raw string, which the generated pg_decode cannot splat into the dataclass. """ x: float | None y: float | None @staticmethod - def _decode(src: object) -> "Point2D": + def pg_decode(src: object) -> "Point2D": return Point2D(*cast(tuple[float | None, float | None], src)) - def _encode(self) -> tuple[float | None, float | None]: + def pg_encode(self) -> tuple[float | None, float | None]: return (self.x, self.y) diff --git a/tests/golden/src/specimen_client/_generated/types/tag_value.py b/tests/golden/src/specimen_client/_generated/types/tag_value.py index bfdbbfc..13ff4b9 100644 --- a/tests/golden/src/specimen_client/_generated/types/tag_value.py +++ b/tests/golden/src/specimen_client/_generated/types/tag_value.py @@ -11,14 +11,14 @@ class TagValue: """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. + raw string, which the generated pg_decode cannot splat into the dataclass. """ value: str | None @staticmethod - def _decode(src: object) -> "TagValue": + def pg_decode(src: object) -> "TagValue": return TagValue(*cast(tuple[str | None], src)) - def _encode(self) -> tuple[str | None]: + def pg_encode(self) -> tuple[str | None]: return (self.value,) diff --git a/tests/golden_sync/src/specimen_sync_client/__init__.py b/tests/golden_sync/src/specimen_sync_client/__init__.py index c34dbc1..7ca58d6 100644 --- a/tests/golden_sync/src/specimen_sync_client/__init__.py +++ b/tests/golden_sync/src/specimen_sync_client/__init__.py @@ -8,30 +8,17 @@ from ._generated.types.point_2_d import Point2D as Point2D from ._generated.types.tag_value import TagValue as TagValue -from ._generated._rows import ( - GetSpecimenRow as GetSpecimenRow, - GetTaggedItemRow as GetTaggedItemRow, - InsertSpecimenRow as InsertSpecimenRow, - InsertTaggedItemRow as InsertTaggedItemRow, - ListSpecimensByClassRow as ListSpecimensByClassRow, - ListSpecimensByFeelingRow as ListSpecimensByFeelingRow, - ListSpecimensByIdsRow as ListSpecimensByIdsRow, - ListSpecimensByMoodsRow as ListSpecimensByMoodsRow, - ListSpecimensKeywordColumnRow as ListSpecimensKeywordColumnRow, - SearchSpecimensRow as SearchSpecimensRow, -) - from ._generated.statements.bump_specimen_revision import bump_specimen_revision as bump_specimen_revision -from ._generated.statements.get_specimen import get_specimen as get_specimen -from ._generated.statements.get_tagged_item import get_tagged_item as get_tagged_item -from ._generated.statements.insert_specimen import insert_specimen as insert_specimen -from ._generated.statements.insert_tagged_item import insert_tagged_item as insert_tagged_item -from ._generated.statements.list_specimens_by_class import list_specimens_by_class as list_specimens_by_class -from ._generated.statements.list_specimens_by_feeling import list_specimens_by_feeling as list_specimens_by_feeling -from ._generated.statements.list_specimens_by_ids import list_specimens_by_ids as list_specimens_by_ids -from ._generated.statements.list_specimens_by_moods import list_specimens_by_moods as list_specimens_by_moods -from ._generated.statements.list_specimens_keyword_column import list_specimens_keyword_column as list_specimens_keyword_column -from ._generated.statements.search_specimens import search_specimens as search_specimens +from ._generated.statements.get_specimen import get_specimen as get_specimen, GetSpecimenRow as GetSpecimenRow +from ._generated.statements.get_tagged_item import get_tagged_item as get_tagged_item, GetTaggedItemRow as GetTaggedItemRow +from ._generated.statements.insert_specimen import insert_specimen as insert_specimen, InsertSpecimenRow as InsertSpecimenRow +from ._generated.statements.insert_tagged_item import insert_tagged_item as insert_tagged_item, InsertTaggedItemRow as InsertTaggedItemRow +from ._generated.statements.list_specimens_by_class import list_specimens_by_class as list_specimens_by_class, ListSpecimensByClassRow as ListSpecimensByClassRow +from ._generated.statements.list_specimens_by_feeling import list_specimens_by_feeling as list_specimens_by_feeling, ListSpecimensByFeelingRow as ListSpecimensByFeelingRow +from ._generated.statements.list_specimens_by_ids import list_specimens_by_ids as list_specimens_by_ids, ListSpecimensByIdsRow as ListSpecimensByIdsRow +from ._generated.statements.list_specimens_by_moods import list_specimens_by_moods as list_specimens_by_moods, ListSpecimensByMoodsRow as ListSpecimensByMoodsRow +from ._generated.statements.list_specimens_keyword_column import list_specimens_keyword_column as list_specimens_keyword_column, ListSpecimensKeywordColumnRow as ListSpecimensKeywordColumnRow +from ._generated.statements.search_specimens import search_specimens as search_specimens, SearchSpecimensRow as SearchSpecimensRow __all__ = [ "JsonValue", diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/_rows.py b/tests/golden_sync/src/specimen_sync_client/_generated/_rows.py deleted file mode 100644 index b413258..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/_rows.py +++ /dev/null @@ -1,303 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from datetime import date, datetime -from decimal import Decimal -from typing import cast -from uuid import UUID - -from ._core import JsonValue -from ._core import require_array -from .types.mood import Mood -from .types.point_2_d import Point2D -from .types.tag_value import TagValue -from .types.mood import Mood -from .types.mood import Mood -from .types.point_2_d import Point2D -from .types.tag_value import TagValue -from .types.mood import Mood -from .types.point_2_d import Point2D -from .types.mood import Mood -from .types.mood import Mood -from .types.mood import Mood - - -@dataclass(frozen=True, slots=True) -class GetSpecimenRow: - id: int - pub_id: UUID - flag: bool - small: int - medium: int - large: int - ratio: float - precise: float - title: str - code: str - letter: str - born_on: date - created_at: datetime - amount: Decimal - blob: bytes - doc_json: JsonValue - doc_jsonb: JsonValue - maybe_text: str | None - maybe_int: int | None - maybe_uuid: UUID | None - maybe_ts: datetime | None - maybe_num: Decimal | None - tags: list[str | None] - related_ids: list[UUID | None] | None - grid: list[int | None] | None - feeling: Mood - origin: Point2D | None - label: str - rev: int - meta: JsonValue - - -def decode_get_specimen(row: Mapping[str, object]) -> GetSpecimenRow: - return GetSpecimenRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - flag=cast(bool, row["flag"]), - small=cast(int, row["small"]), - medium=cast(int, row["medium"]), - large=cast(int, row["large"]), - ratio=cast(float, row["ratio"]), - precise=cast(float, row["precise"]), - title=cast(str, row["title"]), - code=cast(str, row["code"]), - letter=cast(str, row["letter"]), - born_on=cast(date, row["born_on"]), - created_at=cast(datetime, row["created_at"]), - amount=cast(Decimal, row["amount"]), - blob=cast(bytes, row["blob"]), - doc_json=cast(JsonValue, row["doc_json"]), - doc_jsonb=cast(JsonValue, row["doc_jsonb"]), - maybe_text=cast(str | None, row["maybe_text"]), - maybe_int=cast(int | None, row["maybe_int"]), - maybe_uuid=cast(UUID | None, row["maybe_uuid"]), - maybe_ts=cast(datetime | None, row["maybe_ts"]), - maybe_num=cast(Decimal | None, row["maybe_num"]), - tags=cast(list[str | None], row["tags"]), - related_ids=cast(list[UUID | None] | None, row["related_ids"]), - grid=cast(list[int | None] | None, row["grid"]), - feeling=Mood._decode(row["feeling"]), - origin=None if row["origin"] is None else Point2D._decode(row["origin"]), - label=cast(str, row["label"]), - rev=cast(int, row["rev"]), - meta=cast(JsonValue, row["meta"]), - ) - - -@dataclass(frozen=True, slots=True) -class GetTaggedItemRow: - id: int - name: str - tag: TagValue - - -def decode_get_tagged_item(row: Mapping[str, object]) -> GetTaggedItemRow: - return GetTaggedItemRow( - id=cast(int, row["id"]), - name=cast(str, row["name"]), - tag=TagValue._decode(row["tag"]), - ) - - -@dataclass(frozen=True, slots=True) -class InsertSpecimenRow: - id: int - pub_id: UUID - flag: bool - small: int - medium: int - large: int - ratio: float - precise: float - title: str - code: str - letter: str - born_on: date - created_at: datetime - amount: Decimal - blob: bytes - doc_json: JsonValue - doc_jsonb: JsonValue - maybe_text: str | None - maybe_int: int | None - maybe_uuid: UUID | None - maybe_ts: datetime | None - maybe_num: Decimal | None - tags: list[str | None] - related_ids: list[UUID | None] | None - grid: list[int | None] | None - feeling: Mood - moods: list[Mood | None] | None - origin: Point2D | None - label: str - rev: int - meta: JsonValue - - -def decode_insert_specimen(row: Mapping[str, object]) -> InsertSpecimenRow: - return InsertSpecimenRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - flag=cast(bool, row["flag"]), - small=cast(int, row["small"]), - medium=cast(int, row["medium"]), - large=cast(int, row["large"]), - ratio=cast(float, row["ratio"]), - precise=cast(float, row["precise"]), - title=cast(str, row["title"]), - code=cast(str, row["code"]), - letter=cast(str, row["letter"]), - born_on=cast(date, row["born_on"]), - created_at=cast(datetime, row["created_at"]), - amount=cast(Decimal, row["amount"]), - blob=cast(bytes, row["blob"]), - doc_json=cast(JsonValue, row["doc_json"]), - doc_jsonb=cast(JsonValue, row["doc_jsonb"]), - maybe_text=cast(str | None, row["maybe_text"]), - maybe_int=cast(int | None, row["maybe_int"]), - maybe_uuid=cast(UUID | None, row["maybe_uuid"]), - maybe_ts=cast(datetime | None, row["maybe_ts"]), - maybe_num=cast(Decimal | None, row["maybe_num"]), - tags=cast(list[str | None], row["tags"]), - related_ids=cast(list[UUID | None] | None, row["related_ids"]), - grid=cast(list[int | None] | None, row["grid"]), - feeling=Mood._decode(row["feeling"]), - moods=None if row["moods"] is None else [None if v is None else Mood._decode(v) for v in cast(list[str | None], require_array(row["moods"]))], - origin=None if row["origin"] is None else Point2D._decode(row["origin"]), - label=cast(str, row["label"]), - rev=cast(int, row["rev"]), - meta=cast(JsonValue, row["meta"]), - ) - - -@dataclass(frozen=True, slots=True) -class InsertTaggedItemRow: - id: int - name: str - tag: TagValue - - -def decode_insert_tagged_item(row: Mapping[str, object]) -> InsertTaggedItemRow: - return InsertTaggedItemRow( - id=cast(int, row["id"]), - name=cast(str, row["name"]), - tag=TagValue._decode(row["tag"]), - ) - - -@dataclass(frozen=True, slots=True) -class ListSpecimensByClassRow: - id: int - title: str - - -def decode_list_specimens_by_class(row: Mapping[str, object]) -> ListSpecimensByClassRow: - return ListSpecimensByClassRow( - id=cast(int, row["id"]), - title=cast(str, row["title"]), - ) - - -@dataclass(frozen=True, slots=True) -class ListSpecimensByFeelingRow: - id: int - pub_id: UUID - feeling: Mood - title: str - label: str - rev: int - origin: Point2D | None - tags: list[str | None] - meta: JsonValue - - -def decode_list_specimens_by_feeling(row: Mapping[str, object]) -> ListSpecimensByFeelingRow: - return ListSpecimensByFeelingRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - feeling=Mood._decode(row["feeling"]), - title=cast(str, row["title"]), - label=cast(str, row["label"]), - rev=cast(int, row["rev"]), - origin=None if row["origin"] is None else Point2D._decode(row["origin"]), - tags=cast(list[str | None], row["tags"]), - meta=cast(JsonValue, row["meta"]), - ) - - -@dataclass(frozen=True, slots=True) -class ListSpecimensByIdsRow: - id: int - pub_id: UUID - feeling: Mood - title: str - - -def decode_list_specimens_by_ids(row: Mapping[str, object]) -> ListSpecimensByIdsRow: - return ListSpecimensByIdsRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - feeling=Mood._decode(row["feeling"]), - title=cast(str, row["title"]), - ) - - -@dataclass(frozen=True, slots=True) -class ListSpecimensByMoodsRow: - id: int - pub_id: UUID - feeling: Mood - moods: list[Mood | None] | None - title: str - - -def decode_list_specimens_by_moods(row: Mapping[str, object]) -> ListSpecimensByMoodsRow: - return ListSpecimensByMoodsRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - feeling=Mood._decode(row["feeling"]), - moods=None if row["moods"] is None else [None if v is None else Mood._decode(v) for v in cast(list[str | None], require_array(row["moods"]))], - title=cast(str, row["title"]), - ) - - -@dataclass(frozen=True, slots=True) -class ListSpecimensKeywordColumnRow: - id: int - class_: str - - -def decode_list_specimens_keyword_column(row: Mapping[str, object]) -> ListSpecimensKeywordColumnRow: - return ListSpecimensKeywordColumnRow( - id=cast(int, row["id"]), - class_=cast(str, row["class"]), - ) - - -@dataclass(frozen=True, slots=True) -class SearchSpecimensRow: - id: int - title: str - label: str - meta: JsonValue - - -def decode_search_specimens(row: Mapping[str, object]) -> SearchSpecimensRow: - return SearchSpecimensRow( - id=cast(int, row["id"]), - title=cast(str, row["title"]), - label=cast(str, row["label"]), - meta=cast(JsonValue, row["meta"]), - ) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_specimen.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_specimen.py index d6a598b..d5e1f01 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_specimen.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_specimen.py @@ -4,10 +4,89 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal +from typing import cast +from uuid import UUID + from psycopg import Connection -from .._rows import GetSpecimenRow, decode_get_specimen +from .._core import JsonValue from .._runtime import fetch_optional +from ..types.mood import Mood +from ..types.point_2_d import Point2D + + +@dataclass(frozen=True, slots=True) +class GetSpecimenRow: + id: int + pub_id: UUID + flag: bool + small: int + medium: int + large: int + ratio: float + precise: float + title: str + code: str + letter: str + born_on: date + created_at: datetime + amount: Decimal + blob: bytes + doc_json: JsonValue + doc_jsonb: JsonValue + maybe_text: str | None + maybe_int: int | None + maybe_uuid: UUID | None + maybe_ts: datetime | None + maybe_num: Decimal | None + tags: list[str | None] + related_ids: list[UUID | None] | None + grid: list[int | None] | None + feeling: Mood + origin: Point2D | None + label: str + rev: int + meta: JsonValue + + +def decode_get_specimen(row: Mapping[str, object]) -> GetSpecimenRow: + return GetSpecimenRow( + id=cast(int, row["id"]), + pub_id=cast(UUID, row["pub_id"]), + flag=cast(bool, row["flag"]), + small=cast(int, row["small"]), + medium=cast(int, row["medium"]), + large=cast(int, row["large"]), + ratio=cast(float, row["ratio"]), + precise=cast(float, row["precise"]), + title=cast(str, row["title"]), + code=cast(str, row["code"]), + letter=cast(str, row["letter"]), + born_on=cast(date, row["born_on"]), + created_at=cast(datetime, row["created_at"]), + amount=cast(Decimal, row["amount"]), + blob=cast(bytes, row["blob"]), + doc_json=cast(JsonValue, row["doc_json"]), + doc_jsonb=cast(JsonValue, row["doc_jsonb"]), + maybe_text=cast(str | None, row["maybe_text"]), + maybe_int=cast(int | None, row["maybe_int"]), + maybe_uuid=cast(UUID | None, row["maybe_uuid"]), + maybe_ts=cast(datetime | None, row["maybe_ts"]), + maybe_num=cast(Decimal | None, row["maybe_num"]), + tags=cast(list[str | None], row["tags"]), + related_ids=cast(list[UUID | None] | None, row["related_ids"]), + grid=cast(list[int | None] | None, row["grid"]), + feeling=Mood.pg_decode(row["feeling"]), + origin=None if row["origin"] is None else Point2D.pg_decode(row["origin"]), + label=cast(str, row["label"]), + rev=cast(int, row["rev"]), + meta=cast(JsonValue, row["meta"]), + ) + SQL = """\ -- zero_or_one: select by pk with limit 1. diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_tagged_item.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_tagged_item.py index 29c06f3..a7b4f30 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_tagged_item.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_tagged_item.py @@ -4,10 +4,30 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast + from psycopg import Connection -from .._rows import GetTaggedItemRow, decode_get_tagged_item from .._runtime import fetch_optional +from ..types.tag_value import TagValue + + +@dataclass(frozen=True, slots=True) +class GetTaggedItemRow: + id: int + name: str + tag: TagValue + + +def decode_get_tagged_item(row: Mapping[str, object]) -> GetTaggedItemRow: + return GetTaggedItemRow( + id=cast(int, row["id"]), + name=cast(str, row["name"]), + tag=TagValue.pg_decode(row["tag"]), + ) + SQL = """\ -- zero_or_one: select by pk with limit 1; the single-field composite here is diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py index 455abe9..f535e40 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py @@ -4,8 +4,11 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass from datetime import date, datetime from decimal import Decimal +from typing import cast from uuid import UUID from psycopg import Connection @@ -13,11 +16,86 @@ from psycopg.types.json import Jsonb from .._core import JsonValue -from .._rows import InsertSpecimenRow, decode_insert_specimen +from .._core import require_array from .._runtime import fetch_single from ..types.mood import Mood from ..types.mood import Mood from ..types.point_2_d import Point2D +from ..types.mood import Mood +from ..types.mood import Mood +from ..types.point_2_d import Point2D + + +@dataclass(frozen=True, slots=True) +class InsertSpecimenRow: + id: int + pub_id: UUID + flag: bool + small: int + medium: int + large: int + ratio: float + precise: float + title: str + code: str + letter: str + born_on: date + created_at: datetime + amount: Decimal + blob: bytes + doc_json: JsonValue + doc_jsonb: JsonValue + maybe_text: str | None + maybe_int: int | None + maybe_uuid: UUID | None + maybe_ts: datetime | None + maybe_num: Decimal | None + tags: list[str | None] + related_ids: list[UUID | None] | None + grid: list[int | None] | None + feeling: Mood + moods: list[Mood | None] | None + origin: Point2D | None + label: str + rev: int + meta: JsonValue + + +def decode_insert_specimen(row: Mapping[str, object]) -> InsertSpecimenRow: + return InsertSpecimenRow( + id=cast(int, row["id"]), + pub_id=cast(UUID, row["pub_id"]), + flag=cast(bool, row["flag"]), + small=cast(int, row["small"]), + medium=cast(int, row["medium"]), + large=cast(int, row["large"]), + ratio=cast(float, row["ratio"]), + precise=cast(float, row["precise"]), + title=cast(str, row["title"]), + code=cast(str, row["code"]), + letter=cast(str, row["letter"]), + born_on=cast(date, row["born_on"]), + created_at=cast(datetime, row["created_at"]), + amount=cast(Decimal, row["amount"]), + blob=cast(bytes, row["blob"]), + doc_json=cast(JsonValue, row["doc_json"]), + doc_jsonb=cast(JsonValue, row["doc_jsonb"]), + maybe_text=cast(str | None, row["maybe_text"]), + maybe_int=cast(int | None, row["maybe_int"]), + maybe_uuid=cast(UUID | None, row["maybe_uuid"]), + maybe_ts=cast(datetime | None, row["maybe_ts"]), + maybe_num=cast(Decimal | None, row["maybe_num"]), + tags=cast(list[str | None], row["tags"]), + related_ids=cast(list[UUID | None] | None, row["related_ids"]), + grid=cast(list[int | None] | None, row["grid"]), + feeling=Mood.pg_decode(row["feeling"]), + moods=None if row["moods"] is None else [None if v is None else Mood.pg_decode(v) for v in cast(list[str | None], require_array(row["moods"]))], + origin=None if row["origin"] is None else Point2D.pg_decode(row["origin"]), + label=cast(str, row["label"]), + rev=cast(int, row["rev"]), + meta=cast(JsonValue, row["meta"]), + ) + SQL = """\ -- single row: insert ... returning the full type surface. @@ -111,8 +189,8 @@ def insert_specimen( "tags": tags, "related_ids": related_ids, "grid": grid, - "feeling": feeling._encode(), - "moods": None if moods is None else [None if x is None else x._encode() for x in moods], - "origin": None if origin is None else origin._encode(), + "feeling": feeling.pg_encode(), + "moods": None if moods is None else [None if x is None else x.pg_encode() for x in moods], + "origin": None if origin is None else origin.pg_encode(), } return fetch_single(conn, _SQL, params, decode_insert_specimen) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py index c02fcd2..30107d5 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py @@ -4,11 +4,31 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast + from psycopg import Connection -from .._rows import InsertTaggedItemRow, decode_insert_tagged_item from .._runtime import fetch_single from ..types.tag_value import TagValue +from ..types.tag_value import TagValue + + +@dataclass(frozen=True, slots=True) +class InsertTaggedItemRow: + id: int + name: str + tag: TagValue + + +def decode_insert_tagged_item(row: Mapping[str, object]) -> InsertTaggedItemRow: + return InsertTaggedItemRow( + id=cast(int, row["id"]), + name=cast(str, row["name"]), + tag=TagValue.pg_decode(row["tag"]), + ) + SQL = """\ -- single row: insert exercising a single-field composite as a parameter and @@ -29,6 +49,6 @@ def insert_tagged_item( ) -> InsertTaggedItemRow: params: dict[str, object] = { "name": name, - "tag": tag._encode(), + "tag": tag.pg_encode(), } return fetch_single(conn, _SQL, params, decode_insert_tagged_item) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_class.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_class.py index eb32126..c50a265 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_class.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_class.py @@ -4,11 +4,28 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast + from psycopg import Connection -from .._rows import ListSpecimensByClassRow, decode_list_specimens_by_class from .._runtime import fetch_many + +@dataclass(frozen=True, slots=True) +class ListSpecimensByClassRow: + id: int + title: str + + +def decode_list_specimens_by_class(row: Mapping[str, object]) -> ListSpecimensByClassRow: + return ListSpecimensByClassRow( + id=cast(int, row["id"]), + title=cast(str, row["title"]), + ) + + SQL = """\ -- Keyword-param coverage: the placeholder `class` is a Python reserved word, so -- the generated signature must use `class_` while the params-dict key and the diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py index 449865a..c4f321f 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py @@ -4,11 +4,46 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast +from uuid import UUID + from psycopg import Connection -from .._rows import ListSpecimensByFeelingRow, decode_list_specimens_by_feeling +from .._core import JsonValue from .._runtime import fetch_many from ..types.mood import Mood +from ..types.mood import Mood +from ..types.point_2_d import Point2D + + +@dataclass(frozen=True, slots=True) +class ListSpecimensByFeelingRow: + id: int + pub_id: UUID + feeling: Mood + title: str + label: str + rev: int + origin: Point2D | None + tags: list[str | None] + meta: JsonValue + + +def decode_list_specimens_by_feeling(row: Mapping[str, object]) -> ListSpecimensByFeelingRow: + return ListSpecimensByFeelingRow( + id=cast(int, row["id"]), + pub_id=cast(UUID, row["pub_id"]), + feeling=Mood.pg_decode(row["feeling"]), + title=cast(str, row["title"]), + label=cast(str, row["label"]), + rev=cast(int, row["rev"]), + origin=None if row["origin"] is None else Point2D.pg_decode(row["origin"]), + tags=cast(list[str | None], row["tags"]), + meta=cast(JsonValue, row["meta"]), + ) + SQL = """\ -- many: select with order by. Enum parameter ($feeling). @@ -28,6 +63,6 @@ def list_specimens_by_feeling( feeling: Mood | None, ) -> list[ListSpecimensByFeelingRow]: params: dict[str, object] = { - "feeling": None if feeling is None else feeling._encode(), + "feeling": None if feeling is None else feeling.pg_encode(), } return fetch_many(conn, _SQL, params, decode_list_specimens_by_feeling) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_ids.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_ids.py index 0512032..9a4ec23 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_ids.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_ids.py @@ -4,12 +4,33 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast from uuid import UUID from psycopg import Connection -from .._rows import ListSpecimensByIdsRow, decode_list_specimens_by_ids from .._runtime import fetch_many +from ..types.mood import Mood + + +@dataclass(frozen=True, slots=True) +class ListSpecimensByIdsRow: + id: int + pub_id: UUID + feeling: Mood + title: str + + +def decode_list_specimens_by_ids(row: Mapping[str, object]) -> ListSpecimensByIdsRow: + return ListSpecimensByIdsRow( + id=cast(int, row["id"]), + pub_id=cast(UUID, row["pub_id"]), + feeling=Mood.pg_decode(row["feeling"]), + title=cast(str, row["title"]), + ) + SQL = """\ -- many: array parameter via = any($pub_ids). diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py index 32f03ea..94f18e2 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py @@ -4,11 +4,38 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast +from uuid import UUID + from psycopg import Connection -from .._rows import ListSpecimensByMoodsRow, decode_list_specimens_by_moods +from .._core import require_array from .._runtime import fetch_many from ..types.mood import Mood +from ..types.mood import Mood +from ..types.mood import Mood + + +@dataclass(frozen=True, slots=True) +class ListSpecimensByMoodsRow: + id: int + pub_id: UUID + feeling: Mood + moods: list[Mood | None] | None + title: str + + +def decode_list_specimens_by_moods(row: Mapping[str, object]) -> ListSpecimensByMoodsRow: + return ListSpecimensByMoodsRow( + id=cast(int, row["id"]), + pub_id=cast(UUID, row["pub_id"]), + feeling=Mood.pg_decode(row["feeling"]), + moods=None if row["moods"] is None else [None if v is None else Mood.pg_decode(v) for v in cast(list[str | None], require_array(row["moods"]))], + title=cast(str, row["title"]), + ) + SQL = """\ -- many: enum array parameter via = any($moods::mood[]); returns the enum array column. @@ -28,6 +55,6 @@ def list_specimens_by_moods( moods: list[Mood | None] | None, ) -> list[ListSpecimensByMoodsRow]: params: dict[str, object] = { - "moods": None if moods is None else [None if x is None else x._encode() for x in moods], + "moods": None if moods is None else [None if x is None else x.pg_encode() for x in moods], } return fetch_many(conn, _SQL, params, decode_list_specimens_by_moods) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_keyword_column.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_keyword_column.py index 0a7b949..240ce8d 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_keyword_column.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_keyword_column.py @@ -4,11 +4,28 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast + from psycopg import Connection -from .._rows import ListSpecimensKeywordColumnRow, decode_list_specimens_keyword_column from .._runtime import fetch_many + +@dataclass(frozen=True, slots=True) +class ListSpecimensKeywordColumnRow: + id: int + class_: str + + +def decode_list_specimens_keyword_column(row: Mapping[str, object]) -> ListSpecimensKeywordColumnRow: + return ListSpecimensKeywordColumnRow( + id=cast(int, row["id"]), + class_=cast(str, row["class"]), + ) + + SQL = """\ -- Keyword result-column coverage: the column aliased to the reserved word class -- must emit a dataclass field and decode kwarg of class_ while the row lookup diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/search_specimens.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/search_specimens.py index 42bb069..859dfef 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/search_specimens.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/search_specimens.py @@ -4,13 +4,34 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass +from typing import cast + from psycopg import Connection from psycopg.types.json import Jsonb from .._core import JsonValue -from .._rows import SearchSpecimensRow, decode_search_specimens from .._runtime import fetch_many + +@dataclass(frozen=True, slots=True) +class SearchSpecimensRow: + id: int + title: str + label: str + meta: JsonValue + + +def decode_search_specimens(row: Mapping[str, object]) -> SearchSpecimensRow: + return SearchSpecimensRow( + id=cast(int, row["id"]), + title=cast(str, row["title"]), + label=cast(str, row["label"]), + meta=cast(JsonValue, row["meta"]), + ) + + SQL = """\ -- many: nullable parameter via coalesce, jsonb containment parameter, and a -- domain comparison with the param cast to the base type (pgn cannot bind a diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/types/mood.py b/tests/golden_sync/src/specimen_sync_client/_generated/types/mood.py index a91c901..3556da7 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/types/mood.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/types/mood.py @@ -12,8 +12,8 @@ class Mood(StrEnum): MEH = "meh" @staticmethod - def _decode(src: object) -> "Mood": + def pg_decode(src: object) -> "Mood": return Mood(cast(str, src)) - def _encode(self) -> "Mood": + def pg_encode(self) -> "Mood": return self diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/types/point_2_d.py b/tests/golden_sync/src/specimen_sync_client/_generated/types/point_2_d.py index 9bf2915..e212a40 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/types/point_2_d.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/types/point_2_d.py @@ -11,15 +11,15 @@ class Point2D: """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. + raw string, which the generated pg_decode cannot splat into the dataclass. """ x: float | None y: float | None @staticmethod - def _decode(src: object) -> "Point2D": + def pg_decode(src: object) -> "Point2D": return Point2D(*cast(tuple[float | None, float | None], src)) - def _encode(self) -> tuple[float | None, float | None]: + def pg_encode(self) -> tuple[float | None, float | None]: return (self.x, self.y) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/types/tag_value.py b/tests/golden_sync/src/specimen_sync_client/_generated/types/tag_value.py index bfdbbfc..13ff4b9 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/types/tag_value.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/types/tag_value.py @@ -11,14 +11,14 @@ class TagValue: """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. + raw string, which the generated pg_decode cannot splat into the dataclass. """ value: str | None @staticmethod - def _decode(src: object) -> "TagValue": + def pg_decode(src: object) -> "TagValue": return TagValue(*cast(tuple[str | None], src)) - def _encode(self) -> tuple[str | None]: + def pg_encode(self) -> tuple[str | None]: return (self.value,) diff --git a/tests/test_unsupported_types.py b/tests/test_unsupported_types.py index 642db87..b21a00d 100644 --- a/tests/test_unsupported_types.py +++ b/tests/test_unsupported_types.py @@ -177,7 +177,6 @@ def test_skip_unsupported_drops_offending_units_and_cascades( assert (src / "types" / f"{name}.py").is_file(), f"{name} should not have been skipped" facade = (package_src / "__init__.py").read_text() - rows = (src / "_rows.py").read_text() register = (src / "_register.py").read_text() types_init = (src / "types" / "__init__.py").read_text() for orphan in ( @@ -188,7 +187,6 @@ def test_skip_unsupported_drops_offending_units_and_cascades( "WrappedPoint", ): assert orphan not in facade, f"facade references skipped {orphan}" - assert orphan not in rows, f"_rows references skipped {orphan}" assert orphan not in register, f"_register references skipped {orphan}" assert orphan not in types_init, f"types/__init__ references skipped {orphan}" @@ -200,7 +198,6 @@ def test_skip_unsupported_drops_offending_units_and_cascades( del sys.modules[name] importlib.import_module("fixture") importlib.import_module("fixture._generated._register") - importlib.import_module("fixture._generated._rows") for name in kept_statements: importlib.import_module(f"fixture._generated.statements.{name}") finally: From d94fa342deeae35597837d04770f6f34f7efcff6 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sun, 12 Jul 2026 15:38:01 +0300 Subject: [PATCH 23/41] python.gen: rename demos/ to fixtures/ Matches the fixtures/ naming already used by the other generators (e.g. java.gen). Updates path references in build.bash, CI config, build-contract-shell.sh, DESIGN.md, and the file's own usage comment; regenerate_demo_output becomes regenerate_fixture_output. No behavior change. Also drops the stale .superpowers/sdd/ scratch directory (gitignored, untracked). --- .github/scripts/build-contract-shell.sh | 2 +- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 4 ++++ DESIGN.md | 6 ++--- docs/tasks/demos-to-fixtures.md | 32 ------------------------- {demos => fixtures}/Exhaustive.dhall | 4 ++-- 6 files changed, 11 insertions(+), 39 deletions(-) delete mode 100644 docs/tasks/demos-to-fixtures.md rename {demos => fixtures}/Exhaustive.dhall (85%) diff --git a/.github/scripts/build-contract-shell.sh b/.github/scripts/build-contract-shell.sh index 9fbc79c..67429c5 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 demos/Exhaustive.dhall) in a minimal +# Wraps the Dhall-generated package (from fixtures/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 b5d526f..b200b7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,7 +141,7 @@ jobs: - name: Generate output from Dhall uses: nikita-volkov/dhall-directory-tree.github-action@60a18dc647d6daea805263ea0fed7bb8011f3bcd # v2 with: - dhall_file: demos/Exhaustive.dhall + dhall_file: fixtures/Exhaustive.dhall output_dir: contract-output - name: Assert the compile did not fail diff --git a/CHANGELOG.md b/CHANGELOG.md index a56b709..ae969ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,3 +110,7 @@ loud-abort behavior. `Skip` drops the smallest self-consistent unit (a statement or a custom type, cascading to anything that references it) and keeps generating the rest. +- Renamed `demos/` to `fixtures/` (`demos/Exhaustive.dhall` is now + `fixtures/Exhaustive.dhall`), matching the `fixtures/` naming already used + by the other generators. `build.bash`'s `regenerate_demo_output` is now + `regenerate_fixture_output`. No behavior change. diff --git a/DESIGN.md b/DESIGN.md index b141db5..8e01d61 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -622,7 +622,7 @@ 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` +unblock end-to-end regeneration: `fixtures/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 @@ -742,11 +742,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 `demos/Exhaustive.dhall` and runs basedpyright +`Fixtures.Exhaustive` via `fixtures/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 `demos/Exhaustive.dhall` directly. +understand `Text/equal`, so it cannot run `fixtures/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/docs/tasks/demos-to-fixtures.md b/docs/tasks/demos-to-fixtures.md deleted file mode 100644 index fe5b6dd..0000000 --- a/docs/tasks/demos-to-fixtures.md +++ /dev/null @@ -1,32 +0,0 @@ -# python.gen: `demos/` → `fixtures/` rename - -Postponed from the cross-generator rename. Apply when ready. - -## Directory rename - -```bash -cd /Users/mojojojo/repos/pgenie/python.gen -git mv demos/ fixtures/ -``` - -## File edits - -| File | Changes | -|---|---| -| `build.bash` | `demos/Exhaustive.dhall` → `fixtures/Exhaustive.dhall`; `regenerate_demo_output` → `regenerate_fixture_output` | -| `.github/workflows/ci.yml` | `demos/Exhaustive.dhall` → `fixtures/Exhaustive.dhall` | -| `.github/scripts/build-contract-shell.sh` | comment: `demos/Exhaustive.dhall` → `fixtures/Exhaustive.dhall` | -| `DESIGN.md` | 3 references: lines 646, 766, 770 — `demos/Exhaustive.dhall` → `fixtures/Exhaustive.dhall` | -| `CHANGELOG.md` | Add Upcoming entry documenting the rename | - -## Cleanup - -```bash -rm -rf .superpowers/sdd/ -``` - -## Left as-is - -- `Sdk.Fixtures.Exhaustive` import (stays) -- `tests/fixture-project/` (stays — separate concept) -- `notes.md` (historical) \ No newline at end of file diff --git a/demos/Exhaustive.dhall b/fixtures/Exhaustive.dhall similarity index 85% rename from demos/Exhaustive.dhall rename to fixtures/Exhaustive.dhall index cf4ed6a..0b1c0ff 100644 --- a/demos/Exhaustive.dhall +++ b/fixtures/Exhaustive.dhall @@ -1,5 +1,5 @@ -- Applies this generator to gen-sdk's shared cross-backend fixture project --- (the same "music_catalogue" project java.gen's own demos/Exhaustive.dhall +-- (the same "music_catalogue" project java.gen's own fixtures/Exhaustive.dhall -- exercises), so a Python client compiles from it and passes basedpyright -- strict. Pinned directly at gen-sdk's package.dhall, separately from -- src/Deps/Sdk.dhall: that file only imports gen-sdk's `package.dhall` `as @@ -13,7 +13,7 @@ -- Intended to be executed with: -- -- ```bash --- dhall to-directory-tree --file demos/Exhaustive.dhall --output --allow-path-separators +-- dhall to-directory-tree --file fixtures/Exhaustive.dhall --output --allow-path-separators -- ``` let Sdk = ../src/Deps/Sdk.dhall From 5147204942e1ca68f6d85d4553e8245c556b0ee5 Mon Sep 17 00:00:00 2001 From: Viacheslav Shvets Date: Mon, 13 Jul 2026 01:19:56 +0300 Subject: [PATCH 24/41] fix(gen): restore fail-loud custom type resolution --- docs/upstream-asks.md | 42 +- src/Interpreters/CustomType.dhall | 22 +- src/Interpreters/Member.dhall | 131 ++++-- src/Interpreters/ParamsMember.dhall | 90 ++++- src/Interpreters/Project.dhall | 95 ++++- src/Interpreters/Query.dhall | 15 +- src/Interpreters/Result.dhall | 11 +- src/Interpreters/ResultColumns.dhall | 9 +- src/Interpreters/Scalar.dhall | 2 +- src/Structures/CustomKind.dhall | 13 + src/Structures/ImportSet.dhall | 120 +++++- src/Templates/StatementModule.dhall | 4 +- .../_generated/statements/insert_specimen.py | 4 - .../statements/insert_tagged_item.py | 1 - .../statements/list_specimens_by_feeling.py | 1 - .../statements/list_specimens_by_moods.py | 2 - .../_generated/statements/insert_specimen.py | 4 - .../statements/insert_tagged_item.py | 1 - .../statements/list_specimens_by_feeling.py | 1 - .../statements/list_specimens_by_moods.py | 2 - tests/test_unsupported_types.py | 378 +++++++++++++++--- 21 files changed, 751 insertions(+), 197 deletions(-) create mode 100644 src/Structures/CustomKind.dhall diff --git a/docs/upstream-asks.md b/docs/upstream-asks.md index 8d10b37..773c348 100644 --- a/docs/upstream-asks.md +++ b/docs/upstream-asks.md @@ -102,27 +102,19 @@ 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 -last remaining use of the fork-only `Text/equal` builtin is -`Interpreters/Project.dhall`'s `buildLookup`, which matches a custom type -by its snake-case name while building the `CustomKind.Lookup`. It returns a -structural `TypeKind` value, not `Text`, so the `Text/replace`-based trick -that removed `Text/equal` from keyword sanitizing (section 13) does not -carry over. +Project-wide custom-type lookup is required for sound Skip behavior. After +an unsupported custom type is removed, every dependent statement must +resolve that reference as absent and be removed too. The same +classification is needed at result and parameter boundaries while the +current class codecs remain, and it is the basis for removing those codecs +in a later stage. + +`Interpreters/Project.dhall` currently matches custom types by snake-case +name through `Text/equal`, a builtin supplied only by pgn's embedded Dhall +runtime. The lookup returns a structural `TypeKind`, so the Text-only +replacement technique used by identifier sanitizing cannot replace it. ### Proposed change @@ -136,17 +128,9 @@ this generator can do unilaterally. `buildLookup`'s name-equality matching, which removes the last need for the forked `Text/equal` builtin in this generator's own code. -Sequencing constraint: the pragma work in ask 1 adds `Text/equal` uses in -`Pragma.dhall`, so landing this ask alone does not de-fork the generator. -Full removal of the fork dependency is possible only after ask 1 lands and -`Pragma.dhall` is deleted; the order matters and is part of this -negotiation, not an implementation detail. - ### Compatibility notes A new field on `Scalar.Custom` touches every gen-sdk consumer (java.gen included), so it should be introduced in coordination with them. This -generator pins gen-sdk imports by sha256 and adopts the change on the next -pin bump. Note that gen-sdk's own `Fixtures` module also relies on the fork -builtin (section 12), so this ask de-forks generators, not gen-sdk's full -package entry point. +generator pins gen-sdk imports by sha256 and adopts the change on a +deliberate future pin bump. diff --git a/src/Interpreters/CustomType.dhall b/src/Interpreters/CustomType.dhall index dbde176..c8c5b89 100644 --- a/src/Interpreters/CustomType.dhall +++ b/src/Interpreters/CustomType.dhall @@ -4,10 +4,10 @@ let Prelude = ../Deps/Prelude.dhall let Model = ../Deps/Contract.dhall -let Sdk = ../Deps/Sdk.dhall - let ImportSet = ../Structures/ImportSet.dhall +let CustomKind = ../Structures/CustomKind.dhall + let OnUnsupported = ../Structures/OnUnsupported.dhall let MemberGen = ./Member.dhall @@ -73,6 +73,7 @@ let renderExtraImports = let run = \(config : Config) -> + \(lookup : CustomKind.Lookup) -> \(input : Input) -> let typeName = input.name.inPascalCase @@ -113,7 +114,20 @@ let run = = Lude.Compiled.traverseList Model.Member MemberGen.Output - ( \(m : Model.Member) -> MemberGen.run config m ) + ( \(m : Model.Member) -> + merge + { Primitive = + \(_ : Model.Primitive) -> + MemberGen.run config lookup m + , Custom = + \(name : Model.Name) -> + Lude.Compiled.report + MemberGen.Output + [ m.pgName, name.inSnakeCase ] + "Nested custom type members are not supported before PostgreSQL adapter verification" + } + m.value.scalar + ) members let assemble = @@ -169,4 +183,4 @@ let run = } input.definition -in Sdk.Sigs.interpreter Config Input Output run +in { Input, Output, run } diff --git a/src/Interpreters/Member.dhall b/src/Interpreters/Member.dhall index 79c1a0a..4814e25 100644 --- a/src/Interpreters/Member.dhall +++ b/src/Interpreters/Member.dhall @@ -4,10 +4,10 @@ let Prelude = ../Deps/Prelude.dhall let Model = ../Deps/Contract.dhall -let Sdk = ../Deps/Sdk.dhall - let ImportSet = ../Structures/ImportSet.dhall +let CustomKind = ../Structures/CustomKind.dhall + let PyIdent = ../Structures/PyIdent.dhall let OnUnsupported = ../Structures/OnUnsupported.dhall @@ -37,6 +37,7 @@ let Output = let run = \(config : 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 @@ -65,7 +66,7 @@ let run = , pgName = input.pgName , pyType , isNullable = input.isNullable - , imports = baseImports + , imports = ImportSet.combine baseImports ImportSet.cast , decodeExpr = passthroughDecode } , Custom = @@ -77,7 +78,11 @@ let run = let typeName = name.inPascalCase let customImport = - { className = typeName, moduleName = name.inSnakeCase } + \(order : Natural) -> + { className = typeName + , moduleName = name.inSnakeCase + , order + } let mkOutput = \(customImports : ImportSet.Type) -> @@ -101,43 +106,91 @@ let run = 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}.pg_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}.pg_decode(v)" - else "${typeName}.pg_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}))]" + in merge + { Enum = + \(order : Natural) -> + let enumImport = + ImportSet.customEnum + (customImport order) + + in if Natural/isZero value.dims + then Lude.Compiled.ok + Output + ( mkOutput + enumImport + ( wrapNullable + ( \(src : Text) -> + "${typeName}.pg_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}.pg_decode(v)" + else "${typeName}.pg_decode(v)" + + let arrayImports = + ImportSet.combineAll + [ enumImport + , ImportSet.enumArray + , ImportSet.cast + ] + + in Lude.Compiled.ok + Output + ( mkOutput + arrayImports + ( wrapNullable + ( \(src : Text) -> + "[${elemDecode} for v in cast(${elemCast}, require_array(${src}))]" + ) + ) + ) + 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) + ) + ( wrapNullable + ( \(src : Text) -> + "${typeName}.pg_decode(${src})" + ) ) ) - ) - else Lude.Compiled.report + 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 - [ input.pgName, name.inSnakeCase ] - "Array of dimensionality > 1 is not supported" + [ name.inSnakeCase ] + "Custom type not found in project customTypes" + } + (lookup name) ) ( Lude.Compiled.report Output @@ -156,4 +209,4 @@ let run = in Lude.Compiled.flatMap Value.Output Output buildOutput compiledValue -in Sdk.Sigs.interpreter Config Input Output run +in { Input, Output, run } diff --git a/src/Interpreters/ParamsMember.dhall b/src/Interpreters/ParamsMember.dhall index 4e0c630..dc7db3b 100644 --- a/src/Interpreters/ParamsMember.dhall +++ b/src/Interpreters/ParamsMember.dhall @@ -4,10 +4,10 @@ let Prelude = ../Deps/Prelude.dhall let Model = ../Deps/Contract.dhall -let Sdk = ../Deps/Sdk.dhall - let ImportSet = ../Structures/ImportSet.dhall +let CustomKind = ../Structures/CustomKind.dhall + let OnUnsupported = ../Structures/OnUnsupported.dhall let Value = ./Value.dhall @@ -232,6 +232,7 @@ let isJsonArray = let run = \(config : Config) -> + \(lookup : CustomKind.Lookup) -> \(input : Input) -> let fieldName = pySafeName input.name.inSnakeCase @@ -279,9 +280,11 @@ let run = (Lude.Compiled.Type Output) ( \(name : Model.Name) -> let customImport = - { className = name.inPascalCase - , moduleName = name.inSnakeCase - } + \(order : Natural) -> + { className = name.inPascalCase + , moduleName = name.inSnakeCase + , order + } let scalarEncode = if input.isNullable @@ -300,15 +303,72 @@ let run = 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 - ) + 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 + ( ImportSet.combine + value.imports + enumImport + ) + scalarEncode + ) + else if dimsIsOne + then Lude.Compiled.ok + Output + ( mkOutput + ( ImportSet.combine + value.imports + enumImport + ) + arrayEncode + ) + else Lude.Compiled.report + Output + [ input.pgName + , name.inSnakeCase + ] + "Array of an enum parameter 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.combine + value.imports + ( ImportSet.customComposite + (customImport composite.order) + ) + ) + scalarEncode + ) + 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" + } + (lookup name) ) ( if isJsonArrayParam then Lude.Compiled.report @@ -329,4 +389,4 @@ let run = in Lude.Compiled.flatMap Value.Output Output buildOutput compiledValue -in Sdk.Sigs.interpreter Config Input Output run +in { Input, Output, run } diff --git a/src/Interpreters/Project.dhall b/src/Interpreters/Project.dhall index fa0cf68..e81d371 100644 --- a/src/Interpreters/Project.dhall +++ b/src/Interpreters/Project.dhall @@ -6,6 +6,12 @@ let Model = ../Deps/Contract.dhall let Sdk = ../Deps/Sdk.dhall +let CustomKind = ../Structures/CustomKind.dhall + +let PyIdent = ../Structures/PyIdent.dhall + +let Value = ./Value.dhall + let QueryGen = ./Query.dhall let CustomTypeGen = ./CustomType.dhall @@ -72,6 +78,75 @@ let withHeader = \(file : Lude.File.Type) -> { path = file.path, content = generatedHeader ++ file.content } +let lookupConfig + : ResolvedConfig + = { packageName = "" + , importName = "" + , sync = False + , onUnsupported = OnUnsupported.Mode.Fail + } + +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 + +let IndexedCustomType = { index : Natural, value : Model.CustomType } + +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 customType = entry.value + + -- Text/equal is a pgn embedded-Dhall builtin. The upstream exit is + -- a stable custom kind or id on Scalar.Custom. + in if Text/equal + name.inSnakeCase + customType.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 + } + customType.definition + else rest name + ) + (\(_ : Model.Name) -> CustomKind.TypeKind.Absent) + -- 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 @@ -252,7 +327,7 @@ 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 calling QueryGen.run config query from +-- side re-uses `queryChecks` because 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 @@ -301,6 +376,8 @@ let run = let skip = merge { Fail = False, Skip = True } resolvedConfig.onUnsupported + let rawLookup = buildLookup input.customTypes + let typeSucceeds : Model.CustomType -> Bool = \(ct : Model.CustomType) -> @@ -310,7 +387,7 @@ let run = True , Err = \(_ : Report) -> False } - (CustomTypeGen.run resolvedConfig ct) + (CustomTypeGen.run resolvedConfig rawLookup 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 @@ -325,7 +402,7 @@ let run = , Err = \(err : Report) -> Some { path = [ ct.name.inSnakeCase ] # err.path, message = err.message } } - (CustomTypeGen.run resolvedConfig ct) + (CustomTypeGen.run resolvedConfig rawLookup ct) -- A skipped custom type resolves to Absent for any query that -- references it, and that query's own Member/ParamsMember @@ -338,6 +415,8 @@ 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 @@ -345,7 +424,9 @@ let run = = Lude.Compiled.traverseList Model.CustomType CustomTypeGen.Output - (\(ct : Model.CustomType) -> CustomTypeGen.run resolvedConfig ct) + ( \(ct : Model.CustomType) -> + CustomTypeGen.run resolvedConfig lookup ct + ) effectiveCustomTypes let queryChecks @@ -360,7 +441,7 @@ let run = { query, keep = True, warning = None Report } , Err = \(err : Report) -> { query, keep = False, warning = Some err } } - (QueryGen.run resolvedConfig query) + (QueryGen.run resolvedConfig lookup query) ) input.queries @@ -381,7 +462,9 @@ let run = = Lude.Compiled.traverseList Model.Query QueryGen.Output - (\(query : Model.Query) -> QueryGen.run resolvedConfig query) + ( \(query : Model.Query) -> + QueryGen.run resolvedConfig lookup query + ) effectiveQueries let skipWarnings diff --git a/src/Interpreters/Query.dhall b/src/Interpreters/Query.dhall index 06addf7..763479b 100644 --- a/src/Interpreters/Query.dhall +++ b/src/Interpreters/Query.dhall @@ -4,10 +4,10 @@ let Prelude = ../Deps/Prelude.dhall let Model = ../Deps/Contract.dhall -let Sdk = ../Deps/Sdk.dhall - let ImportSet = ../Structures/ImportSet.dhall +let CustomKind = ../Structures/CustomKind.dhall + let PyIdent = ../Structures/PyIdent.dhall let Surface = ../Structures/Surface.dhall @@ -136,6 +136,7 @@ let render = let run = \(config : Config) -> + \(lookup : CustomKind.Lookup) -> \(input : Input) -> let rowClassName = input.name.inPascalCase ++ "Row" @@ -151,7 +152,11 @@ let run = ( Compiled.nest ResultModule.Output "result" - (ResultModule.run (config /\ { rowClassName }) input.result) + ( ResultModule.run + (config /\ { rowClassName }) + lookup + input.result + ) ) ( Compiled.nest QueryFragmentsModule.Output @@ -168,11 +173,11 @@ let run = Compiled.nest ParamsMember.Output member.pgName - (ParamsMember.run config member) + (ParamsMember.run config lookup member) ) input.params ) ) ) -in Sdk.Sigs.interpreter Config Input Output run +in { Input, Output, run } diff --git a/src/Interpreters/Result.dhall b/src/Interpreters/Result.dhall index a54bd6e..f8597ad 100644 --- a/src/Interpreters/Result.dhall +++ b/src/Interpreters/Result.dhall @@ -4,10 +4,10 @@ let Lude = ../Deps/Lude.dhall let Model = ../Deps/Contract.dhall -let Sdk = ../Deps/Sdk.dhall - let ImportSet = ../Structures/ImportSet.dhall +let CustomKind = ../Structures/CustomKind.dhall + let OnUnsupported = ../Structures/OnUnsupported.dhall let ResultColumns = ./ResultColumns.dhall @@ -69,6 +69,7 @@ let cardinalityShape let rowsOutput = \(config : Config) -> + \(lookup : CustomKind.Lookup) -> \(rows : Model.ResultRows) -> let shape = cardinalityShape rows.cardinality config.rowClassName @@ -92,18 +93,20 @@ let rowsOutput = ) ( ResultColumns.run config.{ packageName, importName, sync, onUnsupported } + lookup columns ) let run = \(config : Config) -> + \(lookup : CustomKind.Lookup) -> \(input : Input) -> merge { Void = Compiled.ok Output (noResult "None" "execute_void") , RowsAffected = Compiled.ok Output (noResult "int" "execute_rows_affected") - , Rows = rowsOutput config + , Rows = rowsOutput config lookup } input -in Sdk.Sigs.interpreter Config Input Output run /\ { RowClass } +in { Input, Output, RowClass, run } diff --git a/src/Interpreters/ResultColumns.dhall b/src/Interpreters/ResultColumns.dhall index e6be05e..f5d8b3f 100644 --- a/src/Interpreters/ResultColumns.dhall +++ b/src/Interpreters/ResultColumns.dhall @@ -4,10 +4,10 @@ let Lude = ../Deps/Lude.dhall let Model = ../Deps/Contract.dhall -let Sdk = ../Deps/Sdk.dhall - let ImportSet = ../Structures/ImportSet.dhall +let CustomKind = ../Structures/CustomKind.dhall + let OnUnsupported = ../Structures/OnUnsupported.dhall let Member = ./Member.dhall @@ -59,6 +59,7 @@ let assemble let run = \(config : Config) -> + \(lookup : CustomKind.Lookup) -> \(input : Input) -> Compiled.map (List Member.Output) @@ -71,9 +72,9 @@ let run = Compiled.nest Member.Output member.pgName - (Member.run config member) + (Member.run config lookup member) ) input ) -in Sdk.Sigs.interpreter Config Input Output run +in { Input, Output, run } diff --git a/src/Interpreters/Scalar.dhall b/src/Interpreters/Scalar.dhall index 78939f4..94d903d 100644 --- a/src/Interpreters/Scalar.dhall +++ b/src/Interpreters/Scalar.dhall @@ -20,7 +20,7 @@ let Config = let Input = Model.Scalar -- Passthrough for primitives; Custom is opaque here. The enum-vs-composite --- decision needs the project customTypes lookup, which lives in F/G, not here. +-- decision is mandatory in Member, ParamsMember, and CustomType consumers. let ScalarDecode = < Passthrough | Custom > let Output = diff --git a/src/Structures/CustomKind.dhall b/src/Structures/CustomKind.dhall new file mode 100644 index 0000000..eb20520 --- /dev/null +++ b/src/Structures/CustomKind.dhall @@ -0,0 +1,13 @@ +let Model = ../Deps/Contract.dhall + +let CompositeField = { fieldName : Text, pyType : Text } + +let TypeKind = + < Enum : Natural + | Composite : { fields : List CompositeField, order : Natural } + | Absent + > + +let Lookup = Model.Name -> TypeKind + +in { TypeKind, Lookup, CompositeField } diff --git a/src/Structures/ImportSet.dhall b/src/Structures/ImportSet.dhall index b5c7b81..d59b748 100644 --- a/src/Structures/ImportSet.dhall +++ b/src/Structures/ImportSet.dhall @@ -1,16 +1,8 @@ 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 } +-- dedupKey is the type's project index, which also gives imports a stable order +-- without relying on unavailable standard-Dhall Text comparison. +let CustomImport = { className : Text, moduleName : Text, dedupKey : Natural } let Self = { uuid : Bool @@ -23,6 +15,7 @@ let Self = , json : Bool , jsonValue : Bool , enumArray : Bool + , needsCast : Bool , customTypes : List CustomImport } @@ -37,6 +30,7 @@ let base = , json = False , jsonValue = False , enumArray = False + , needsCast = False , customTypes = [] : List CustomImport } @@ -84,10 +78,103 @@ let enumArray : Self = base // { enumArray = True } +let cast + : Self + = base // { needsCast = True } + let custom : CustomImport -> Self = \(c : CustomImport) -> base // { customTypes = [ c ] } +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 + (\(key : Natural) -> eqNat key 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) + +let sortCustoms + : List CustomImport -> List CustomImport + = \(items : List CustomImport) -> + let insert = + \(item : CustomImport) -> + \(acc : List CustomImport) -> + let State = { placed : Bool, out : List CustomImport } + + let step = + \(state : State) -> + \(current : CustomImport) -> + if state.placed + then { placed = True, out = state.out # [ current ] } + else if leNat item.dedupKey current.dedupKey + then { placed = True + , out = state.out # [ item, current ] + } + else { placed = False + , out = state.out # [ current ] + } + + 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) -> @@ -101,13 +188,18 @@ let combine = , json = left.json || right.json , jsonValue = left.jsonValue || right.jsonValue , enumArray = left.enumArray || right.enumArray - , customTypes = left.customTypes # right.customTypes + , needsCast = left.needsCast || right.needsCast + , 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 @@ -121,7 +213,11 @@ in { Type = Self , json , jsonValue , enumArray + , cast , custom + , customEnum + , customComposite , combine , combineAll + , sortedCustoms } diff --git a/src/Templates/StatementModule.dhall b/src/Templates/StatementModule.dhall index 435dddd..9082b8b 100644 --- a/src/Templates/StatementModule.dhall +++ b/src/Templates/StatementModule.dhall @@ -132,7 +132,7 @@ let customImportLines ( \(c : ImportSet.CustomImport) -> "from ${typesPrefix}.${c.moduleName} import ${c.className}" ) - imports.customTypes + (ImportSet.sortedCustoms imports) let renderImports : Params -> Text @@ -146,7 +146,7 @@ let renderImports # importLineIf rowIsPresent "from dataclasses import dataclass" # datetimeImport imports # importLineIf imports.decimal "from decimal import Decimal" - # importLineIf rowIsPresent "from typing import cast" + # importLineIf imports.needsCast "from typing import cast" # importLineIf imports.uuid "from uuid import UUID" let psycopgBlock = diff --git a/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py b/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py index 9ed0d42..0a86c22 100644 --- a/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py +++ b/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py @@ -19,10 +19,6 @@ from .._core import require_array from .._runtime import fetch_single from ..types.mood import Mood -from ..types.mood import Mood -from ..types.point_2_d import Point2D -from ..types.mood import Mood -from ..types.mood import Mood from ..types.point_2_d import Point2D diff --git a/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py b/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py index 33e477a..814fc0e 100644 --- a/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py +++ b/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py @@ -12,7 +12,6 @@ from .._runtime import fetch_single from ..types.tag_value import TagValue -from ..types.tag_value import TagValue @dataclass(frozen=True, slots=True) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py index 59a9aab..81d026b 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py @@ -14,7 +14,6 @@ from .._core import JsonValue from .._runtime import fetch_many from ..types.mood import Mood -from ..types.mood import Mood from ..types.point_2_d import Point2D diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py index 97c6434..34ac7a3 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py @@ -14,8 +14,6 @@ from .._core import require_array from .._runtime import fetch_many from ..types.mood import Mood -from ..types.mood import Mood -from ..types.mood import Mood @dataclass(frozen=True, slots=True) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py index f535e40..36cc59b 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py @@ -19,10 +19,6 @@ from .._core import require_array from .._runtime import fetch_single from ..types.mood import Mood -from ..types.mood import Mood -from ..types.point_2_d import Point2D -from ..types.mood import Mood -from ..types.mood import Mood from ..types.point_2_d import Point2D diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py index 30107d5..1b3dcf8 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py @@ -12,7 +12,6 @@ from .._runtime import fetch_single from ..types.tag_value import TagValue -from ..types.tag_value import TagValue @dataclass(frozen=True, slots=True) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py index c4f321f..2b476df 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py @@ -14,7 +14,6 @@ from .._core import JsonValue from .._runtime import fetch_many from ..types.mood import Mood -from ..types.mood import Mood from ..types.point_2_d import Point2D diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py index 94f18e2..3353b7a 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py @@ -14,8 +14,6 @@ from .._core import require_array from .._runtime import fetch_many from ..types.mood import Mood -from ..types.mood import Mood -from ..types.mood import Mood @dataclass(frozen=True, slots=True) diff --git a/tests/test_unsupported_types.py b/tests/test_unsupported_types.py index b21a00d..7a0013b 100644 --- a/tests/test_unsupported_types.py +++ b/tests/test_unsupported_types.py @@ -17,6 +17,7 @@ import importlib import json +import re import shutil import subprocess import sys @@ -24,17 +25,61 @@ import pytest -from tests._harness import FIXTURE_PROJECT, HERE, SRC_DIR, run_pgn +from tests._harness import ( + FIXTURE_PROJECT, + GOLDEN_DIR, + GOLDEN_DIR_SYNC, + 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: +def _fresh_project(tmp_path: Path) -> tuple[Path, Path]: root = tmp_path / "pygen" _ = 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) + return root, project + + +def _write_single_artifact( + project: Path, + generator: str, + package_name: str, + mode: str | None = "Fail", +) -> None: + unsupported_config = "" if mode is None else f" onUnsupported: {mode}\n" + _ = (project / "project1.pgn.yaml").write_text( + "space: python-gen\n" + "name: fixture\n" + "version: 0.0.0\n" + "postgres: 18\n" + "artifacts:\n" + " python:\n" + f" gen: {generator}\n" + " config:\n" + f" packageName: {package_name}\n" + f"{unsupported_config}" + ) + + +def _combined_output(result: subprocess.CompletedProcess[str]) -> str: + return result.stdout + result.stderr + + +def _clear_package_modules(package_name: str) -> None: + for name in list(sys.modules): + if name == package_name or name.startswith(f"{package_name}."): + del sys.modules[name] + + +def test_unsupported_pg_type_fails_loudly(pgn_bin: str, pgn_admin_url: str, tmp_path: Path) -> None: + _, project = _fresh_project(tmp_path) + _write_single_artifact(project, "../../src/package.dhall", "unsupported-money", None) # `money` has no Python mapping (psycopg decodes it as a locale string, not Decimal), # so the Primitive interpreter must reject it instead of guessing. @@ -50,26 +95,12 @@ 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(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) + _, project = _fresh_project(tmp_path) # Explicit `onUnsupported: Fail` (vs the absent-key default the money test # covers): pins that pgn decodes the bare YAML string "Fail" into the # < Fail | Skip > union tag, mirroring the "Skip" decode the Skip test pins. - _ = (project / "project1.pgn.yaml").write_text( - "space: python-gen\n" - "name: fixture\n" - "version: 0.0.0\n" - "postgres: 18\n" - "artifacts:\n" - " python:\n" - " gen: ../../src/package.dhall\n" - " config:\n" - " onUnsupported: Fail\n" - ) + _write_single_artifact(project, "../../src/package.dhall", "unsupported-json-array") # A jsonb[] param has no faithful psycopg bind (Jsonb wraps a scalar, not # element-wise); the generator must reject it, not emit an unwrapped list. @@ -86,6 +117,221 @@ def test_json_array_param_fails_loudly(pgn_bin: str, pgn_admin_url: str, tmp_pat ) +def _write_contract_probe( + root: Path, + *, + interpreter: str, + lookup_kind: str, + dimensionality: int, + nested: bool, +) -> None: + array_settings = ( + "None Model.ArraySettings" + if dimensionality == 0 + else f"Some {{ dimensionality = {dimensionality}, elementIsNullable = False }}" + ) + lookup = { + "absent": "CustomKind.TypeKind.Absent", + "enum": "CustomKind.TypeKind.Enum 0", + "composite": ( + "CustomKind.TypeKind.Composite " + "{ fields = [] : List CustomKind.CompositeField, order = 0 }" + ), + }[lookup_kind] + target_input = ( + "customType" + if nested + else "member" + ) + custom_type = ( + """ + let customType + : Model.CustomType + = { definition = + < Enum : List Model.EnumVariant + | Composite : List Model.Member + | Domain : Model.Value + >.Composite [ member ] + , name + , pgName = "outer_custom" + , pgSchema = "public" + } + """ + if nested + else "" + ) + wrapper = f""" +let Lude = ./Deps/Lude.dhall + +let Model = ./Deps/Contract.dhall + +let Sdk = ./Deps/Sdk.dhall + +let OnUnsupported = ./Structures/OnUnsupported.dhall + +let CustomKind = ./Structures/CustomKind.dhall + +let Target = ./Interpreters/{interpreter}.dhall + +let Config = + {{ packageName : Optional Text + , sync : Optional Bool + , onUnsupported : Optional OnUnsupported.Mode + }} + +let Config/default = + {{ packageName = None Text + , sync = None Bool + , onUnsupported = None OnUnsupported.Mode + }} + +let interpreterConfig = + {{ packageName = "contract-probe" + , importName = "contract_probe" + , sync = False + , onUnsupported = OnUnsupported.Mode.Fail + }} + +let run = + \\(_ : Config) -> + \\(input : Model.Project) -> + let name = input.name + + let member + : Model.Member + = {{ isNullable = False + , name + , pgName = "probe_value" + , value = + {{ arraySettings = {array_settings} + , scalar = Model.Scalar.Custom name + }} + }} + +{custom_type} + let lookup + : CustomKind.Lookup + = \\(_ : Model.Name) -> {lookup} + + in Lude.Compiled.map + Target.Output + Lude.Files.Type + (\\(_ : Target.Output) -> [] : Lude.Files.Type) + (Target.run interpreterConfig lookup {target_input}) + +in Sdk.Sigs.generator Config Config/default run +""" + _ = (root / "src" / "contract-probe.dhall").write_text(wrapper) + + +# PostgreSQL flattens array rank and valid projects resolve customs, so synthetic +# inputs are required to reach the rank-2 and missing-reference contracts. +@pytest.mark.parametrize( + ("case_id", "interpreter", "lookup_kind", "dimensionality", "nested", "message", "path_tokens"), + [ + pytest.param( + "missing_custom", + "Member", + "absent", + 0, + False, + "Custom type not found in project customTypes", + ("fixture",), + id="missing-custom", + ), + pytest.param( + "nested_custom", + "CustomType", + "enum", + 0, + True, + "Nested custom type members are not supported before PostgreSQL adapter verification", + ("fixture", "probe_value"), + id="nested-custom-member", + ), + pytest.param( + "composite_array_result", + "Member", + "composite", + 1, + False, + "Array of a composite type is not supported (element-wise decode is unimplemented)", + ("fixture", "probe_value"), + id="composite-array-result", + ), + pytest.param( + "composite_array_parameter", + "ParamsMember", + "composite", + 1, + False, + "Array of a composite type as a parameter is not supported", + ("fixture", "probe_value"), + id="composite-array-parameter", + ), + pytest.param( + "enum_rank_two_result", + "Member", + "enum", + 2, + False, + "Array of an enum with dimensionality > 1 is not supported", + ("fixture", "probe_value"), + id="enum-rank-two-result", + ), + pytest.param( + "enum_rank_two_parameter", + "ParamsMember", + "enum", + 2, + False, + "Array of an enum parameter with dimensionality > 1 is not supported", + ("fixture", "probe_value"), + id="enum-rank-two-parameter", + ), + ], +) +def test_custom_shape_contracts_fail_loudly( + pgn_bin: str, + pgn_admin_url: str, + tmp_path: Path, + case_id: str, + interpreter: str, + lookup_kind: str, + dimensionality: int, + nested: bool, + message: str, + path_tokens: tuple[str, ...], +) -> None: + root, project = _fresh_project(tmp_path) + for query_file in (project / "queries").iterdir(): + query_file.unlink() + _write_contract_probe( + root, + interpreter=interpreter, + lookup_kind=lookup_kind, + dimensionality=dimensionality, + nested=nested, + ) + _write_single_artifact(project, "../../src/contract-probe.dhall", f"probe-{case_id}") + + result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") + combined = _combined_output(result) + assert result.returncode != 0, f"expected {case_id} to fail:\n{combined}" + assert message in combined, f"missing exact diagnostic {message!r}:\n{combined}" + + plain = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", combined) + message_at = plain.index(message) + diagnostic = plain[max(0, message_at - 500) : message_at + len(message) + 1000] + stage = re.search( + r"Stage:\s*Generating\s*>\s*python\s*>\s*Compiling\s*>\s*([^\r\n]+)", + diagnostic, + ) + assert stage, f"diagnostic had no pgn Stage path:\n{plain}" + rendered_path = tuple(re.split(r"\s*>\s*", stage.group(1).strip())) + assert rendered_path == path_tokens, f"expected exact path {path_tokens}, got {rendered_path}:\n{plain}" + + def test_skip_unsupported_drops_offending_units_and_cascades( pgn_bin: str, pgn_admin_url: str, tmp_path: Path ) -> None: @@ -94,20 +340,16 @@ def test_skip_unsupported_drops_offending_units_and_cascades( Three independent failures in one project: a money result column and a jsonb[] param each doom their own statement (Primitive.dhall / ParamsMember .dhall); a composite nesting another composite dooms the custom type - itself (CustomType.dhall's nestedLookup is hardcoded Absent for any nested - custom-type reference) and, because the lookup built from the surviving - types resolves it to Absent, the query selecting that composite column + itself (CustomType.dhall rejects nested custom members directly) and, + because the lookup rebuilt from the surviving types resolves it to Absent, + the query selecting that composite column cascades into a skip too. Generation must still succeed, the surviving statements and all 3 fixture types must be unaffected, no generated file may reference a skipped name, the package must still import, and basedpyright strict must still pass on the result -- the same gate the golden package is held to. """ - root = tmp_path / "pygen" - _ = 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) + _, project = _fresh_project(tmp_path) # Skip mode evaluates every query twice (the keep/drop check plus the # final render; see Interpreters/Project.dhall), and a full-fixture Skip @@ -119,25 +361,17 @@ def test_skip_unsupported_drops_offending_units_and_cascades( if query_file.name.split(".", 1)[0] not in kept_statements: query_file.unlink() - # A single Skip artifact, package name left at its "fixture" default so it - # cannot collide with the "specimen_client" package the shared golden/ - # roundtrip suites import. - _ = (project / "project1.pgn.yaml").write_text( - "space: python-gen\n" - "name: fixture\n" - "version: 0.0.0\n" - "postgres: 18\n" - "artifacts:\n" - " python:\n" - " gen: ../../src/package.dhall\n" - " config:\n" - " onUnsupported: Skip\n" - ) + # A unique package prefix prevents collisions with the golden and roundtrip + # packages imported by the shared test process. + _write_single_artifact(project, "../../src/package.dhall", "skip-cascade", "Skip") _ = (project / "queries" / "probe_unsupported.sql").write_text("SELECT 1::money AS amount\n") _ = (project / "queries" / "probe_json_array.sql").write_text( "SELECT cardinality($payloads::jsonb []) AS n\n" ) + _ = (project / "queries" / "probe_custom_only.sql").write_text( + "SELECT 'happy'::mood AS feeling\n" + ) _ = (project / "migrations" / "2.sql").write_text( "create type wrapped_point as (\n" " label text,\n" @@ -159,26 +393,30 @@ def test_skip_unsupported_drops_offending_units_and_cascades( # Since 0.7.2 pgn surfaces the generator's Compiled.warnings during # generate (pgenie-io/pgenie#67), so every skipped unit must be visible in # the combined output. - combined = (result.stdout + result.stderr).lower() - for marker in ("unsupported type", "json/jsonb array", "custom type not found"): + combined = _combined_output(result).lower() + for marker in ( + "unsupported type", + "json/jsonb array as a parameter is not supported", + "nested custom type members are not supported before postgresql adapter verification", + "custom type not found in project customtypes", + ): assert marker in combined, f"expected pgn to surface the warning, {marker!r} missing from output" generated = project / "artifacts" / "python" - package_src = generated / "src" / "fixture" + package_name = "skip_cascade" + package_src = generated / "src" / package_name src = package_src / "_generated" for name in ("probe_unsupported", "probe_json_array", "probe_nested_composite"): assert not (src / "statements" / f"{name}.py").exists(), f"{name} should have been skipped" assert not (src / "types" / "wrapped_point.py").exists(), "wrapped_point should have been skipped" - for name in kept_statements: + for name in kept_statements + ["probe_custom_only"]: assert (src / "statements" / f"{name}.py").is_file(), f"{name} should not have been skipped" for name in ("mood", "point_2_d", "tag_value"): assert (src / "types" / f"{name}.py").is_file(), f"{name} should not have been skipped" - facade = (package_src / "__init__.py").read_text() - register = (src / "_register.py").read_text() - types_init = (src / "types" / "__init__.py").read_text() + generated_python = {path: path.read_text() for path in package_src.rglob("*.py")} for orphan in ( "probe_unsupported", "probe_json_array", @@ -186,25 +424,27 @@ def test_skip_unsupported_drops_offending_units_and_cascades( "wrapped_point", "WrappedPoint", ): - assert orphan not in facade, f"facade references skipped {orphan}" - assert orphan not in register, f"_register references skipped {orphan}" - assert orphan not in types_init, f"types/__init__ references skipped {orphan}" + assert not any(orphan in text for text in generated_python.values()), ( + f"surviving generated output references skipped {orphan}" + ) + + custom_only = (src / "statements" / "probe_custom_only.py").read_text() + assert "from ..types.mood import Mood" in custom_only + assert "from typing import cast" not in custom_only src_root = str(generated / "src") sys.path.insert(0, src_root) try: - for name in list(sys.modules): - if name == "fixture" or name.startswith("fixture."): - del sys.modules[name] - importlib.import_module("fixture") - importlib.import_module("fixture._generated._register") - for name in kept_statements: - importlib.import_module(f"fixture._generated.statements.{name}") + _clear_package_modules(package_name) + for module_path in sorted(package_src.rglob("*.py")): + relative = module_path.relative_to(generated / "src").with_suffix("") + parts = list(relative.parts) + if parts[-1] == "__init__": + parts.pop() + importlib.import_module(".".join(parts)) finally: sys.path.remove(src_root) - for name in list(sys.modules): - if name == "fixture" or name.startswith("fixture."): - del sys.modules[name] + _clear_package_modules(package_name) config = tmp_path / "pyrightconfig.json" _ = config.write_text( @@ -231,3 +471,21 @@ def test_skip_unsupported_drops_offending_units_and_cascades( assert summary["errorCount"] == 0 and summary["warningCount"] == 0, ( f"basedpyright strict reported issues on the Skip output: {summary}\n{pyright_result.stdout}" ) + + +def test_custom_imports_are_unique_and_deterministic() -> None: + surfaces = [ + GOLDEN_DIR / "src" / "specimen_client" / "_generated" / "statements", + GOLDEN_DIR_SYNC / "src" / "specimen_sync_client" / "_generated" / "statements", + ] + custom_import = re.compile(r"^from \.\.types\.[a-zA-Z0-9_]+ import [a-zA-Z0-9_]+$", re.MULTILINE) + + for statements in surfaces: + insert_imports = custom_import.findall((statements / "insert_specimen.py").read_text()) + assert insert_imports == [ + "from ..types.mood import Mood", + "from ..types.point_2_d import Point2D", + ] + for module in statements.glob("*.py"): + imports = custom_import.findall(module.read_text()) + assert len(imports) == len(set(imports)), f"duplicate custom import in {module}" From 50f74fa6f57702f8a2e94fe9384327ac987f79d4 Mon Sep 17 00:00:00 2001 From: Viacheslav Shvets Date: Mon, 13 Jul 2026 01:37:07 +0300 Subject: [PATCH 25/41] fix(gen): isolate generated statement namespaces --- src/Interpreters/Member.dhall | 4 +- src/Interpreters/ParamsMember.dhall | 16 +- src/Interpreters/Query.dhall | 13 +- src/Structures/PyIdent.dhall | 60 +++- src/Templates/StatementModule.dhall | 15 +- .../statements/bump_specimen_revision.py | 4 +- .../_generated/statements/get_specimen.py | 64 ++-- .../_generated/statements/get_tagged_item.py | 12 +- .../_generated/statements/insert_specimen.py | 68 ++-- .../statements/insert_tagged_item.py | 12 +- .../statements/list_specimens_by_class.py | 12 +- .../statements/list_specimens_by_feeling.py | 22 +- .../statements/list_specimens_by_ids.py | 14 +- .../statements/list_specimens_by_moods.py | 18 +- .../list_specimens_keyword_column.py | 12 +- .../_generated/statements/search_specimens.py | 16 +- .../statements/bump_specimen_revision.py | 4 +- .../_generated/statements/get_specimen.py | 64 ++-- .../_generated/statements/get_tagged_item.py | 12 +- .../_generated/statements/insert_specimen.py | 68 ++-- .../statements/insert_tagged_item.py | 12 +- .../statements/list_specimens_by_class.py | 12 +- .../statements/list_specimens_by_feeling.py | 22 +- .../statements/list_specimens_by_ids.py | 14 +- .../statements/list_specimens_by_moods.py | 18 +- .../list_specimens_keyword_column.py | 12 +- .../_generated/statements/search_specimens.py | 16 +- tests/test_identifier_collisions.py | 310 ++++++++++++++++++ 28 files changed, 632 insertions(+), 294 deletions(-) create mode 100644 tests/test_identifier_collisions.py diff --git a/src/Interpreters/Member.dhall b/src/Interpreters/Member.dhall index 4814e25..ec275ef 100644 --- a/src/Interpreters/Member.dhall +++ b/src/Interpreters/Member.dhall @@ -56,7 +56,7 @@ let run = let baseImports = value.imports let passthroughDecode = - \(src : Text) -> "cast(${castTarget}, ${src})" + \(src : Text) -> "_cast(${castTarget}, ${src})" in merge { Passthrough = @@ -148,7 +148,7 @@ let run = arrayImports ( wrapNullable ( \(src : Text) -> - "[${elemDecode} for v in cast(${elemCast}, require_array(${src}))]" + "[${elemDecode} for v in _cast(${elemCast}, _require_array(${src}))]" ) ) ) diff --git a/src/Interpreters/ParamsMember.dhall b/src/Interpreters/ParamsMember.dhall index dc7db3b..8ba8b99 100644 --- a/src/Interpreters/ParamsMember.dhall +++ b/src/Interpreters/ParamsMember.dhall @@ -23,20 +23,6 @@ let Input = Model.Member let PyIdent = ../Structures/PyIdent.dhall --- Identifiers the statement template hardcodes around the splatted params: the --- receiver `conn` and the locals `sql`/`params`/`decode`/`cur`/`row`. A param --- named like one of these would collide (e.g. a duplicate `conn` argument), so --- they are sanitized alongside the Python keywords. -let reservedNames = - [ "conn", "sql", "params", "decode", "cur", "row" ] - --- A SQL placeholder may be spelled as a Python reserved word (e.g. $class, $from) --- or clash with a template-reserved name (e.g. $conn). Suffix an underscore so the --- emitted signature and bind variable are valid Python; the params-dict key and --- the %(name)s placeholder keep the raw name (see Query.dhall), so psycopg still --- binds by the original name. -let pySafeName = PyIdent.sanitizeAgainst (PyIdent.pythonKeywords # reservedNames) - let Output = { fieldName : Text , pgName : Text @@ -234,7 +220,7 @@ let run = \(config : Config) -> \(lookup : CustomKind.Lookup) -> \(input : Input) -> - let fieldName = pySafeName input.name.inSnakeCase + let fieldName = PyIdent.parameterSafeName input.name.inSnakeCase let needsJsonbImport = isJsonbScalar input.value diff --git a/src/Interpreters/Query.dhall b/src/Interpreters/Query.dhall index 763479b..3545347 100644 --- a/src/Interpreters/Query.dhall +++ b/src/Interpreters/Query.dhall @@ -52,13 +52,10 @@ let render = \(result : ResultModule.Output) -> \(fragments : QueryFragmentsModule.Output) -> \(params : List ParamsMember.Output) -> - -- The function name is also the module filename and the facade import name; - -- a query named like a Python keyword would emit `def class(...)`, a module - -- `class.py`, and `from ... import class` (all SyntaxErrors), so sanitize it - -- like params and result columns. SQL/dict/row lookups key off raw names. - let functionName = PyIdent.pySafeName input.name.inSnakeCase - - let decodeName = "decode_${functionName}" + -- The function name is also the module filename and facade import name, so + -- protect both Python syntax and the private globals in a statement module. + -- SQL/dict/row lookups still key off raw names. + let functionName = PyIdent.querySafeName input.name.inSnakeCase let paramSigLines = Prelude.List.map @@ -100,7 +97,6 @@ let render = { className = rc.name , fieldsBlock = rc.fieldsBlock , decodeBlock = rc.decodeBlock - , decodeName } ) result.rowClass @@ -121,7 +117,6 @@ let render = , callsDecode = result.callsDecode , sqlLiteral = fragments.sqlLiteral , rowDef - , decodeName , paramSigLines , paramDictEntries , imports = mergedImports diff --git a/src/Structures/PyIdent.dhall b/src/Structures/PyIdent.dhall index e7db139..5e29996 100644 --- a/src/Structures/PyIdent.dhall +++ b/src/Structures/PyIdent.dhall @@ -41,9 +41,9 @@ let pythonKeywords = , "yield" ] --- Suffix an underscore when `name` collides with one of `reserved`. Callers keep --- the raw name for the SQL placeholder / dict key / row[...] lookup; only the --- Python identifier is sanitized. +-- Add the caller's suffix when `name` collides with one of `reserved`. Callers +-- keep the raw name for the SQL placeholder / dict key / row[...] lookup; only +-- the Python identifier is sanitized. -- -- Equality without Text/equal: java.gen's escapeJavaKeyword delimiter trick does -- not apply here because pgn's embedded Text/replace misses needles spanning a @@ -60,7 +60,8 @@ let pythonKeywords = -- every reserved word and blows up exponentially. let markTrue = "0000000000000000000000000001" -let sanitizeAgainst = +let suffixAgainst = + \(suffix : Text) -> \(reserved : List Text) -> \(name : Text) -> List/fold @@ -73,12 +74,59 @@ let sanitizeAgainst = let finalNeedle = Text/replace signal name markTrue - in Text/replace finalNeedle (name ++ "_") acc + in Text/replace finalNeedle (name ++ suffix) acc ) name +let sanitizeAgainst = suffixAgainst "_" + let pySafeName : Text -> Text = sanitizeAgainst pythonKeywords -in { pythonKeywords, sanitizeAgainst, pySafeName } +let moduleReservedNames = + [ "_cast" + , "_require_array" + , "_fetch_optional" + , "_fetch_single" + , "_fetch_many" + , "_execute_rows_affected" + , "_execute_void" + , "_decode_row" + , "_types" + , "date" + , "datetime" + , "time" + , "timedelta" + ] + +let parameterReservedNames = + [ "conn" + , "sql" + , "params" + , "decode" + , "cur" + , "row" + , "_decode_row" + , "_fetch_optional" + , "_fetch_single" + , "_fetch_many" + , "_execute_rows_affected" + , "_execute_void" + ] + +let parameterSafeName + : Text -> Text + = sanitizeAgainst (pythonKeywords # parameterReservedNames) + +let querySafeName = + \(name : Text) -> suffixAgainst "_query" moduleReservedNames (pySafeName name) + +in { pythonKeywords + , sanitizeAgainst + , pySafeName + , moduleReservedNames + , parameterReservedNames + , parameterSafeName + , querySafeName + } diff --git a/src/Templates/StatementModule.dhall b/src/Templates/StatementModule.dhall index 9082b8b..3e0d072 100644 --- a/src/Templates/StatementModule.dhall +++ b/src/Templates/StatementModule.dhall @@ -17,7 +17,6 @@ let RowDef = { className : Text , fieldsBlock : Text , decodeBlock : Text - , decodeName : Text } -- Prefix every line (including the first) with `n` spaces, leaving blank lines @@ -40,7 +39,7 @@ let renderRow ++ indentAll 4 row.fieldsBlock ++ "\n\n\n" ++ "def " - ++ row.decodeName + ++ "_decode_row" ++ "(row: Mapping[str, object]) -> " ++ row.className ++ ":\n" @@ -82,7 +81,6 @@ let Params = , callsDecode : Bool , sqlLiteral : Text , rowDef : Optional RowDef - , decodeName : Text , paramSigLines : List Text , paramDictEntries : List Text , imports : ImportSet.Type @@ -112,7 +110,8 @@ let datetimeImport -- the shared name directly, not through the _runtime re-export). let runtimeImport : Text -> Text - = \(helperName : Text) -> "from .._runtime import " ++ helperName + = \(helperName : Text) -> + "from .._runtime import ${helperName} as _${helperName}" let coreImport : Text -> Bool -> List Text @@ -146,7 +145,7 @@ let renderImports # importLineIf rowIsPresent "from dataclasses import dataclass" # datetimeImport imports # importLineIf imports.decimal "from decimal import Decimal" - # importLineIf imports.needsCast "from typing import cast" + # importLineIf imports.needsCast "from typing import cast as _cast" # importLineIf imports.uuid "from uuid import UUID" let psycopgBlock = @@ -162,7 +161,7 @@ let renderImports coreImport params.surface.corePrefix imports.jsonValue # importLineIf imports.enumArray - "from ${params.surface.corePrefix} import require_array" + "from ${params.surface.corePrefix} import require_array as _require_array" # [ runtimeImport params.helperName ] # customImportLines params.surface.typesPrefix imports @@ -232,8 +231,8 @@ let renderCall let await = params.surface.awaitKw in if params.callsDecode - then "return ${await}${params.helperName}(conn, _SQL, params, ${params.decodeName})" - else "return ${await}${params.helperName}(conn, _SQL, params)" + then "return ${await}_${params.helperName}(conn, _SQL, params, _decode_row)" + else "return ${await}_${params.helperName}(conn, _SQL, params)" in Sdk.Sigs.template Params diff --git a/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py b/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py index 9a8421b..c034037 100644 --- a/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py +++ b/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py @@ -6,7 +6,7 @@ from psycopg import AsyncConnection -from .._runtime import execute_rows_affected +from .._runtime import execute_rows_affected as _execute_rows_affected SQL = """\ -- rows_affected: UPDATE without RETURNING, keyed by id; exercises the rowcount @@ -27,4 +27,4 @@ async def bump_specimen_revision( params: dict[str, object] = { "id": id, } - return await execute_rows_affected(conn, _SQL, params) + return await _execute_rows_affected(conn, _SQL, params) diff --git a/tests/golden/src/specimen_client/_generated/statements/get_specimen.py b/tests/golden/src/specimen_client/_generated/statements/get_specimen.py index 20d1c33..ccfc4b5 100644 --- a/tests/golden/src/specimen_client/_generated/statements/get_specimen.py +++ b/tests/golden/src/specimen_client/_generated/statements/get_specimen.py @@ -8,13 +8,13 @@ from dataclasses import dataclass from datetime import date, datetime from decimal import Decimal -from typing import cast +from typing import cast as _cast from uuid import UUID from psycopg import AsyncConnection from .._core import JsonValue -from .._runtime import fetch_optional +from .._runtime import fetch_optional as _fetch_optional from ..types.mood import Mood from ..types.point_2_d import Point2D @@ -53,38 +53,38 @@ class GetSpecimenRow: meta: JsonValue -def decode_get_specimen(row: Mapping[str, object]) -> GetSpecimenRow: +def _decode_row(row: Mapping[str, object]) -> GetSpecimenRow: return GetSpecimenRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - flag=cast(bool, row["flag"]), - small=cast(int, row["small"]), - medium=cast(int, row["medium"]), - large=cast(int, row["large"]), - ratio=cast(float, row["ratio"]), - precise=cast(float, row["precise"]), - title=cast(str, row["title"]), - code=cast(str, row["code"]), - letter=cast(str, row["letter"]), - born_on=cast(date, row["born_on"]), - created_at=cast(datetime, row["created_at"]), - amount=cast(Decimal, row["amount"]), - blob=cast(bytes, row["blob"]), - doc_json=cast(JsonValue, row["doc_json"]), - doc_jsonb=cast(JsonValue, row["doc_jsonb"]), - maybe_text=cast(str | None, row["maybe_text"]), - maybe_int=cast(int | None, row["maybe_int"]), - maybe_uuid=cast(UUID | None, row["maybe_uuid"]), - maybe_ts=cast(datetime | None, row["maybe_ts"]), - maybe_num=cast(Decimal | None, row["maybe_num"]), - tags=cast(list[str | None], row["tags"]), - related_ids=cast(list[UUID | None] | None, row["related_ids"]), - grid=cast(list[int | None] | None, row["grid"]), + id=_cast(int, row["id"]), + pub_id=_cast(UUID, row["pub_id"]), + flag=_cast(bool, row["flag"]), + small=_cast(int, row["small"]), + medium=_cast(int, row["medium"]), + large=_cast(int, row["large"]), + ratio=_cast(float, row["ratio"]), + precise=_cast(float, row["precise"]), + title=_cast(str, row["title"]), + code=_cast(str, row["code"]), + letter=_cast(str, row["letter"]), + born_on=_cast(date, row["born_on"]), + created_at=_cast(datetime, row["created_at"]), + amount=_cast(Decimal, row["amount"]), + blob=_cast(bytes, row["blob"]), + doc_json=_cast(JsonValue, row["doc_json"]), + doc_jsonb=_cast(JsonValue, row["doc_jsonb"]), + maybe_text=_cast(str | None, row["maybe_text"]), + maybe_int=_cast(int | None, row["maybe_int"]), + maybe_uuid=_cast(UUID | None, row["maybe_uuid"]), + maybe_ts=_cast(datetime | None, row["maybe_ts"]), + maybe_num=_cast(Decimal | None, row["maybe_num"]), + tags=_cast(list[str | None], row["tags"]), + related_ids=_cast(list[UUID | None] | None, row["related_ids"]), + grid=_cast(list[int | None] | None, row["grid"]), feeling=Mood.pg_decode(row["feeling"]), origin=None if row["origin"] is None else Point2D.pg_decode(row["origin"]), - label=cast(str, row["label"]), - rev=cast(int, row["rev"]), - meta=cast(JsonValue, row["meta"]), + label=_cast(str, row["label"]), + rev=_cast(int, row["rev"]), + meta=_cast(JsonValue, row["meta"]), ) @@ -115,4 +115,4 @@ async def get_specimen( params: dict[str, object] = { "id": id, } - return await fetch_optional(conn, _SQL, params, decode_get_specimen) + return await _fetch_optional(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py b/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py index 37d0306..9910f32 100644 --- a/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py +++ b/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py @@ -6,11 +6,11 @@ from collections.abc import Mapping from dataclasses import dataclass -from typing import cast +from typing import cast as _cast from psycopg import AsyncConnection -from .._runtime import fetch_optional +from .._runtime import fetch_optional as _fetch_optional from ..types.tag_value import TagValue @@ -21,10 +21,10 @@ class GetTaggedItemRow: tag: TagValue -def decode_get_tagged_item(row: Mapping[str, object]) -> GetTaggedItemRow: +def _decode_row(row: Mapping[str, object]) -> GetTaggedItemRow: return GetTaggedItemRow( - id=cast(int, row["id"]), - name=cast(str, row["name"]), + id=_cast(int, row["id"]), + name=_cast(str, row["name"]), tag=TagValue.pg_decode(row["tag"]), ) @@ -50,4 +50,4 @@ async def get_tagged_item( params: dict[str, object] = { "id": id, } - return await fetch_optional(conn, _SQL, params, decode_get_tagged_item) + return await _fetch_optional(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py b/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py index 0a86c22..66cbe0b 100644 --- a/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py +++ b/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from datetime import date, datetime from decimal import Decimal -from typing import cast +from typing import cast as _cast from uuid import UUID from psycopg import AsyncConnection @@ -16,8 +16,8 @@ from psycopg.types.json import Jsonb from .._core import JsonValue -from .._core import require_array -from .._runtime import fetch_single +from .._core import require_array as _require_array +from .._runtime import fetch_single as _fetch_single from ..types.mood import Mood from ..types.point_2_d import Point2D @@ -57,39 +57,39 @@ class InsertSpecimenRow: meta: JsonValue -def decode_insert_specimen(row: Mapping[str, object]) -> InsertSpecimenRow: +def _decode_row(row: Mapping[str, object]) -> InsertSpecimenRow: return InsertSpecimenRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - flag=cast(bool, row["flag"]), - small=cast(int, row["small"]), - medium=cast(int, row["medium"]), - large=cast(int, row["large"]), - ratio=cast(float, row["ratio"]), - precise=cast(float, row["precise"]), - title=cast(str, row["title"]), - code=cast(str, row["code"]), - letter=cast(str, row["letter"]), - born_on=cast(date, row["born_on"]), - created_at=cast(datetime, row["created_at"]), - amount=cast(Decimal, row["amount"]), - blob=cast(bytes, row["blob"]), - doc_json=cast(JsonValue, row["doc_json"]), - doc_jsonb=cast(JsonValue, row["doc_jsonb"]), - maybe_text=cast(str | None, row["maybe_text"]), - maybe_int=cast(int | None, row["maybe_int"]), - maybe_uuid=cast(UUID | None, row["maybe_uuid"]), - maybe_ts=cast(datetime | None, row["maybe_ts"]), - maybe_num=cast(Decimal | None, row["maybe_num"]), - tags=cast(list[str | None], row["tags"]), - related_ids=cast(list[UUID | None] | None, row["related_ids"]), - grid=cast(list[int | None] | None, row["grid"]), + id=_cast(int, row["id"]), + pub_id=_cast(UUID, row["pub_id"]), + flag=_cast(bool, row["flag"]), + small=_cast(int, row["small"]), + medium=_cast(int, row["medium"]), + large=_cast(int, row["large"]), + ratio=_cast(float, row["ratio"]), + precise=_cast(float, row["precise"]), + title=_cast(str, row["title"]), + code=_cast(str, row["code"]), + letter=_cast(str, row["letter"]), + born_on=_cast(date, row["born_on"]), + created_at=_cast(datetime, row["created_at"]), + amount=_cast(Decimal, row["amount"]), + blob=_cast(bytes, row["blob"]), + doc_json=_cast(JsonValue, row["doc_json"]), + doc_jsonb=_cast(JsonValue, row["doc_jsonb"]), + maybe_text=_cast(str | None, row["maybe_text"]), + maybe_int=_cast(int | None, row["maybe_int"]), + maybe_uuid=_cast(UUID | None, row["maybe_uuid"]), + maybe_ts=_cast(datetime | None, row["maybe_ts"]), + maybe_num=_cast(Decimal | None, row["maybe_num"]), + tags=_cast(list[str | None], row["tags"]), + related_ids=_cast(list[UUID | None] | None, row["related_ids"]), + grid=_cast(list[int | None] | None, row["grid"]), feeling=Mood.pg_decode(row["feeling"]), - moods=None if row["moods"] is None else [None if v is None else Mood.pg_decode(v) for v in cast(list[str | None], require_array(row["moods"]))], + moods=None if row["moods"] is None else [None if v is None else Mood.pg_decode(v) for v in _cast(list[str | None], _require_array(row["moods"]))], origin=None if row["origin"] is None else Point2D.pg_decode(row["origin"]), - label=cast(str, row["label"]), - rev=cast(int, row["rev"]), - meta=cast(JsonValue, row["meta"]), + label=_cast(str, row["label"]), + rev=_cast(int, row["rev"]), + meta=_cast(JsonValue, row["meta"]), ) @@ -189,4 +189,4 @@ async def insert_specimen( "moods": None if moods is None else [None if x is None else x.pg_encode() for x in moods], "origin": None if origin is None else origin.pg_encode(), } - return await fetch_single(conn, _SQL, params, decode_insert_specimen) + return await _fetch_single(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py b/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py index 814fc0e..3e0542d 100644 --- a/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py +++ b/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py @@ -6,11 +6,11 @@ from collections.abc import Mapping from dataclasses import dataclass -from typing import cast +from typing import cast as _cast from psycopg import AsyncConnection -from .._runtime import fetch_single +from .._runtime import fetch_single as _fetch_single from ..types.tag_value import TagValue @@ -21,10 +21,10 @@ class InsertTaggedItemRow: tag: TagValue -def decode_insert_tagged_item(row: Mapping[str, object]) -> InsertTaggedItemRow: +def _decode_row(row: Mapping[str, object]) -> InsertTaggedItemRow: return InsertTaggedItemRow( - id=cast(int, row["id"]), - name=cast(str, row["name"]), + id=_cast(int, row["id"]), + name=_cast(str, row["name"]), tag=TagValue.pg_decode(row["tag"]), ) @@ -50,4 +50,4 @@ async def insert_tagged_item( "name": name, "tag": tag.pg_encode(), } - return await fetch_single(conn, _SQL, params, decode_insert_tagged_item) + return await _fetch_single(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py index a78b665..12ae7f1 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py @@ -6,11 +6,11 @@ from collections.abc import Mapping from dataclasses import dataclass -from typing import cast +from typing import cast as _cast from psycopg import AsyncConnection -from .._runtime import fetch_many +from .._runtime import fetch_many as _fetch_many @dataclass(frozen=True, slots=True) @@ -19,10 +19,10 @@ class ListSpecimensByClassRow: title: str -def decode_list_specimens_by_class(row: Mapping[str, object]) -> ListSpecimensByClassRow: +def _decode_row(row: Mapping[str, object]) -> ListSpecimensByClassRow: return ListSpecimensByClassRow( - id=cast(int, row["id"]), - title=cast(str, row["title"]), + id=_cast(int, row["id"]), + title=_cast(str, row["title"]), ) @@ -50,4 +50,4 @@ async def list_specimens_by_class( params: dict[str, object] = { "class": class_, } - return await fetch_many(conn, _SQL, params, decode_list_specimens_by_class) + return await _fetch_many(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py index 81d026b..fe552d0 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py @@ -6,13 +6,13 @@ from collections.abc import Mapping from dataclasses import dataclass -from typing import cast +from typing import cast as _cast from uuid import UUID from psycopg import AsyncConnection from .._core import JsonValue -from .._runtime import fetch_many +from .._runtime import fetch_many as _fetch_many from ..types.mood import Mood from ..types.point_2_d import Point2D @@ -30,17 +30,17 @@ class ListSpecimensByFeelingRow: meta: JsonValue -def decode_list_specimens_by_feeling(row: Mapping[str, object]) -> ListSpecimensByFeelingRow: +def _decode_row(row: Mapping[str, object]) -> ListSpecimensByFeelingRow: return ListSpecimensByFeelingRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), + id=_cast(int, row["id"]), + pub_id=_cast(UUID, row["pub_id"]), feeling=Mood.pg_decode(row["feeling"]), - title=cast(str, row["title"]), - label=cast(str, row["label"]), - rev=cast(int, row["rev"]), + title=_cast(str, row["title"]), + label=_cast(str, row["label"]), + rev=_cast(int, row["rev"]), origin=None if row["origin"] is None else Point2D.pg_decode(row["origin"]), - tags=cast(list[str | None], row["tags"]), - meta=cast(JsonValue, row["meta"]), + tags=_cast(list[str | None], row["tags"]), + meta=_cast(JsonValue, row["meta"]), ) @@ -64,4 +64,4 @@ async def list_specimens_by_feeling( params: dict[str, object] = { "feeling": None if feeling is None else feeling.pg_encode(), } - return await fetch_many(conn, _SQL, params, decode_list_specimens_by_feeling) + return await _fetch_many(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py index 0025fbd..2bca0a3 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py @@ -6,12 +6,12 @@ from collections.abc import Mapping from dataclasses import dataclass -from typing import cast +from typing import cast as _cast from uuid import UUID from psycopg import AsyncConnection -from .._runtime import fetch_many +from .._runtime import fetch_many as _fetch_many from ..types.mood import Mood @@ -23,12 +23,12 @@ class ListSpecimensByIdsRow: title: str -def decode_list_specimens_by_ids(row: Mapping[str, object]) -> ListSpecimensByIdsRow: +def _decode_row(row: Mapping[str, object]) -> ListSpecimensByIdsRow: return ListSpecimensByIdsRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), + id=_cast(int, row["id"]), + pub_id=_cast(UUID, row["pub_id"]), feeling=Mood.pg_decode(row["feeling"]), - title=cast(str, row["title"]), + title=_cast(str, row["title"]), ) @@ -52,4 +52,4 @@ async def list_specimens_by_ids( params: dict[str, object] = { "pub_ids": pub_ids, } - return await fetch_many(conn, _SQL, params, decode_list_specimens_by_ids) + return await _fetch_many(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py index 34ac7a3..4f54c5c 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py @@ -6,13 +6,13 @@ from collections.abc import Mapping from dataclasses import dataclass -from typing import cast +from typing import cast as _cast from uuid import UUID from psycopg import AsyncConnection -from .._core import require_array -from .._runtime import fetch_many +from .._core import require_array as _require_array +from .._runtime import fetch_many as _fetch_many from ..types.mood import Mood @@ -25,13 +25,13 @@ class ListSpecimensByMoodsRow: title: str -def decode_list_specimens_by_moods(row: Mapping[str, object]) -> ListSpecimensByMoodsRow: +def _decode_row(row: Mapping[str, object]) -> ListSpecimensByMoodsRow: return ListSpecimensByMoodsRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), + id=_cast(int, row["id"]), + pub_id=_cast(UUID, row["pub_id"]), feeling=Mood.pg_decode(row["feeling"]), - moods=None if row["moods"] is None else [None if v is None else Mood.pg_decode(v) for v in cast(list[str | None], require_array(row["moods"]))], - title=cast(str, row["title"]), + moods=None if row["moods"] is None else [None if v is None else Mood.pg_decode(v) for v in _cast(list[str | None], _require_array(row["moods"]))], + title=_cast(str, row["title"]), ) @@ -55,4 +55,4 @@ async def list_specimens_by_moods( params: dict[str, object] = { "moods": None if moods is None else [None if x is None else x.pg_encode() for x in moods], } - return await fetch_many(conn, _SQL, params, decode_list_specimens_by_moods) + return await _fetch_many(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py index 2b589cb..ee0d780 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py @@ -6,11 +6,11 @@ from collections.abc import Mapping from dataclasses import dataclass -from typing import cast +from typing import cast as _cast from psycopg import AsyncConnection -from .._runtime import fetch_many +from .._runtime import fetch_many as _fetch_many @dataclass(frozen=True, slots=True) @@ -19,10 +19,10 @@ class ListSpecimensKeywordColumnRow: class_: str -def decode_list_specimens_keyword_column(row: Mapping[str, object]) -> ListSpecimensKeywordColumnRow: +def _decode_row(row: Mapping[str, object]) -> ListSpecimensKeywordColumnRow: return ListSpecimensKeywordColumnRow( - id=cast(int, row["id"]), - class_=cast(str, row["class"]), + id=_cast(int, row["id"]), + class_=_cast(str, row["class"]), ) @@ -46,4 +46,4 @@ async def list_specimens_keyword_column( conn: AsyncConnection[object], ) -> list[ListSpecimensKeywordColumnRow]: params: dict[str, object] = {} - return await fetch_many(conn, _SQL, params, decode_list_specimens_keyword_column) + return await _fetch_many(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/search_specimens.py b/tests/golden/src/specimen_client/_generated/statements/search_specimens.py index 3c4ab94..2065a64 100644 --- a/tests/golden/src/specimen_client/_generated/statements/search_specimens.py +++ b/tests/golden/src/specimen_client/_generated/statements/search_specimens.py @@ -6,13 +6,13 @@ from collections.abc import Mapping from dataclasses import dataclass -from typing import cast +from typing import cast as _cast from psycopg import AsyncConnection from psycopg.types.json import Jsonb from .._core import JsonValue -from .._runtime import fetch_many +from .._runtime import fetch_many as _fetch_many @dataclass(frozen=True, slots=True) @@ -23,12 +23,12 @@ class SearchSpecimensRow: meta: JsonValue -def decode_search_specimens(row: Mapping[str, object]) -> SearchSpecimensRow: +def _decode_row(row: Mapping[str, object]) -> SearchSpecimensRow: return SearchSpecimensRow( - id=cast(int, row["id"]), - title=cast(str, row["title"]), - label=cast(str, row["label"]), - meta=cast(JsonValue, row["meta"]), + id=_cast(int, row["id"]), + title=_cast(str, row["title"]), + label=_cast(str, row["label"]), + meta=_cast(JsonValue, row["meta"]), ) @@ -61,4 +61,4 @@ async def search_specimens( "meta_filter": None if meta_filter is None else Jsonb(meta_filter), "label": label, } - return await fetch_many(conn, _SQL, params, decode_search_specimens) + return await _fetch_many(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/bump_specimen_revision.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/bump_specimen_revision.py index 2f39073..5b1a178 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/bump_specimen_revision.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/bump_specimen_revision.py @@ -6,7 +6,7 @@ from psycopg import Connection -from .._runtime import execute_rows_affected +from .._runtime import execute_rows_affected as _execute_rows_affected SQL = """\ -- rows_affected: UPDATE without RETURNING, keyed by id; exercises the rowcount @@ -27,4 +27,4 @@ def bump_specimen_revision( params: dict[str, object] = { "id": id, } - return execute_rows_affected(conn, _SQL, params) + return _execute_rows_affected(conn, _SQL, params) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_specimen.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_specimen.py index d5e1f01..8cdd370 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_specimen.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_specimen.py @@ -8,13 +8,13 @@ from dataclasses import dataclass from datetime import date, datetime from decimal import Decimal -from typing import cast +from typing import cast as _cast from uuid import UUID from psycopg import Connection from .._core import JsonValue -from .._runtime import fetch_optional +from .._runtime import fetch_optional as _fetch_optional from ..types.mood import Mood from ..types.point_2_d import Point2D @@ -53,38 +53,38 @@ class GetSpecimenRow: meta: JsonValue -def decode_get_specimen(row: Mapping[str, object]) -> GetSpecimenRow: +def _decode_row(row: Mapping[str, object]) -> GetSpecimenRow: return GetSpecimenRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - flag=cast(bool, row["flag"]), - small=cast(int, row["small"]), - medium=cast(int, row["medium"]), - large=cast(int, row["large"]), - ratio=cast(float, row["ratio"]), - precise=cast(float, row["precise"]), - title=cast(str, row["title"]), - code=cast(str, row["code"]), - letter=cast(str, row["letter"]), - born_on=cast(date, row["born_on"]), - created_at=cast(datetime, row["created_at"]), - amount=cast(Decimal, row["amount"]), - blob=cast(bytes, row["blob"]), - doc_json=cast(JsonValue, row["doc_json"]), - doc_jsonb=cast(JsonValue, row["doc_jsonb"]), - maybe_text=cast(str | None, row["maybe_text"]), - maybe_int=cast(int | None, row["maybe_int"]), - maybe_uuid=cast(UUID | None, row["maybe_uuid"]), - maybe_ts=cast(datetime | None, row["maybe_ts"]), - maybe_num=cast(Decimal | None, row["maybe_num"]), - tags=cast(list[str | None], row["tags"]), - related_ids=cast(list[UUID | None] | None, row["related_ids"]), - grid=cast(list[int | None] | None, row["grid"]), + id=_cast(int, row["id"]), + pub_id=_cast(UUID, row["pub_id"]), + flag=_cast(bool, row["flag"]), + small=_cast(int, row["small"]), + medium=_cast(int, row["medium"]), + large=_cast(int, row["large"]), + ratio=_cast(float, row["ratio"]), + precise=_cast(float, row["precise"]), + title=_cast(str, row["title"]), + code=_cast(str, row["code"]), + letter=_cast(str, row["letter"]), + born_on=_cast(date, row["born_on"]), + created_at=_cast(datetime, row["created_at"]), + amount=_cast(Decimal, row["amount"]), + blob=_cast(bytes, row["blob"]), + doc_json=_cast(JsonValue, row["doc_json"]), + doc_jsonb=_cast(JsonValue, row["doc_jsonb"]), + maybe_text=_cast(str | None, row["maybe_text"]), + maybe_int=_cast(int | None, row["maybe_int"]), + maybe_uuid=_cast(UUID | None, row["maybe_uuid"]), + maybe_ts=_cast(datetime | None, row["maybe_ts"]), + maybe_num=_cast(Decimal | None, row["maybe_num"]), + tags=_cast(list[str | None], row["tags"]), + related_ids=_cast(list[UUID | None] | None, row["related_ids"]), + grid=_cast(list[int | None] | None, row["grid"]), feeling=Mood.pg_decode(row["feeling"]), origin=None if row["origin"] is None else Point2D.pg_decode(row["origin"]), - label=cast(str, row["label"]), - rev=cast(int, row["rev"]), - meta=cast(JsonValue, row["meta"]), + label=_cast(str, row["label"]), + rev=_cast(int, row["rev"]), + meta=_cast(JsonValue, row["meta"]), ) @@ -115,4 +115,4 @@ def get_specimen( params: dict[str, object] = { "id": id, } - return fetch_optional(conn, _SQL, params, decode_get_specimen) + return _fetch_optional(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_tagged_item.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_tagged_item.py index a7b4f30..51f9b84 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_tagged_item.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_tagged_item.py @@ -6,11 +6,11 @@ from collections.abc import Mapping from dataclasses import dataclass -from typing import cast +from typing import cast as _cast from psycopg import Connection -from .._runtime import fetch_optional +from .._runtime import fetch_optional as _fetch_optional from ..types.tag_value import TagValue @@ -21,10 +21,10 @@ class GetTaggedItemRow: tag: TagValue -def decode_get_tagged_item(row: Mapping[str, object]) -> GetTaggedItemRow: +def _decode_row(row: Mapping[str, object]) -> GetTaggedItemRow: return GetTaggedItemRow( - id=cast(int, row["id"]), - name=cast(str, row["name"]), + id=_cast(int, row["id"]), + name=_cast(str, row["name"]), tag=TagValue.pg_decode(row["tag"]), ) @@ -50,4 +50,4 @@ def get_tagged_item( params: dict[str, object] = { "id": id, } - return fetch_optional(conn, _SQL, params, decode_get_tagged_item) + return _fetch_optional(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py index 36cc59b..3c6e197 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from datetime import date, datetime from decimal import Decimal -from typing import cast +from typing import cast as _cast from uuid import UUID from psycopg import Connection @@ -16,8 +16,8 @@ from psycopg.types.json import Jsonb from .._core import JsonValue -from .._core import require_array -from .._runtime import fetch_single +from .._core import require_array as _require_array +from .._runtime import fetch_single as _fetch_single from ..types.mood import Mood from ..types.point_2_d import Point2D @@ -57,39 +57,39 @@ class InsertSpecimenRow: meta: JsonValue -def decode_insert_specimen(row: Mapping[str, object]) -> InsertSpecimenRow: +def _decode_row(row: Mapping[str, object]) -> InsertSpecimenRow: return InsertSpecimenRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - flag=cast(bool, row["flag"]), - small=cast(int, row["small"]), - medium=cast(int, row["medium"]), - large=cast(int, row["large"]), - ratio=cast(float, row["ratio"]), - precise=cast(float, row["precise"]), - title=cast(str, row["title"]), - code=cast(str, row["code"]), - letter=cast(str, row["letter"]), - born_on=cast(date, row["born_on"]), - created_at=cast(datetime, row["created_at"]), - amount=cast(Decimal, row["amount"]), - blob=cast(bytes, row["blob"]), - doc_json=cast(JsonValue, row["doc_json"]), - doc_jsonb=cast(JsonValue, row["doc_jsonb"]), - maybe_text=cast(str | None, row["maybe_text"]), - maybe_int=cast(int | None, row["maybe_int"]), - maybe_uuid=cast(UUID | None, row["maybe_uuid"]), - maybe_ts=cast(datetime | None, row["maybe_ts"]), - maybe_num=cast(Decimal | None, row["maybe_num"]), - tags=cast(list[str | None], row["tags"]), - related_ids=cast(list[UUID | None] | None, row["related_ids"]), - grid=cast(list[int | None] | None, row["grid"]), + id=_cast(int, row["id"]), + pub_id=_cast(UUID, row["pub_id"]), + flag=_cast(bool, row["flag"]), + small=_cast(int, row["small"]), + medium=_cast(int, row["medium"]), + large=_cast(int, row["large"]), + ratio=_cast(float, row["ratio"]), + precise=_cast(float, row["precise"]), + title=_cast(str, row["title"]), + code=_cast(str, row["code"]), + letter=_cast(str, row["letter"]), + born_on=_cast(date, row["born_on"]), + created_at=_cast(datetime, row["created_at"]), + amount=_cast(Decimal, row["amount"]), + blob=_cast(bytes, row["blob"]), + doc_json=_cast(JsonValue, row["doc_json"]), + doc_jsonb=_cast(JsonValue, row["doc_jsonb"]), + maybe_text=_cast(str | None, row["maybe_text"]), + maybe_int=_cast(int | None, row["maybe_int"]), + maybe_uuid=_cast(UUID | None, row["maybe_uuid"]), + maybe_ts=_cast(datetime | None, row["maybe_ts"]), + maybe_num=_cast(Decimal | None, row["maybe_num"]), + tags=_cast(list[str | None], row["tags"]), + related_ids=_cast(list[UUID | None] | None, row["related_ids"]), + grid=_cast(list[int | None] | None, row["grid"]), feeling=Mood.pg_decode(row["feeling"]), - moods=None if row["moods"] is None else [None if v is None else Mood.pg_decode(v) for v in cast(list[str | None], require_array(row["moods"]))], + moods=None if row["moods"] is None else [None if v is None else Mood.pg_decode(v) for v in _cast(list[str | None], _require_array(row["moods"]))], origin=None if row["origin"] is None else Point2D.pg_decode(row["origin"]), - label=cast(str, row["label"]), - rev=cast(int, row["rev"]), - meta=cast(JsonValue, row["meta"]), + label=_cast(str, row["label"]), + rev=_cast(int, row["rev"]), + meta=_cast(JsonValue, row["meta"]), ) @@ -189,4 +189,4 @@ def insert_specimen( "moods": None if moods is None else [None if x is None else x.pg_encode() for x in moods], "origin": None if origin is None else origin.pg_encode(), } - return fetch_single(conn, _SQL, params, decode_insert_specimen) + return _fetch_single(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py index 1b3dcf8..0cae921 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py @@ -6,11 +6,11 @@ from collections.abc import Mapping from dataclasses import dataclass -from typing import cast +from typing import cast as _cast from psycopg import Connection -from .._runtime import fetch_single +from .._runtime import fetch_single as _fetch_single from ..types.tag_value import TagValue @@ -21,10 +21,10 @@ class InsertTaggedItemRow: tag: TagValue -def decode_insert_tagged_item(row: Mapping[str, object]) -> InsertTaggedItemRow: +def _decode_row(row: Mapping[str, object]) -> InsertTaggedItemRow: return InsertTaggedItemRow( - id=cast(int, row["id"]), - name=cast(str, row["name"]), + id=_cast(int, row["id"]), + name=_cast(str, row["name"]), tag=TagValue.pg_decode(row["tag"]), ) @@ -50,4 +50,4 @@ def insert_tagged_item( "name": name, "tag": tag.pg_encode(), } - return fetch_single(conn, _SQL, params, decode_insert_tagged_item) + return _fetch_single(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_class.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_class.py index c50a265..25a1908 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_class.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_class.py @@ -6,11 +6,11 @@ from collections.abc import Mapping from dataclasses import dataclass -from typing import cast +from typing import cast as _cast from psycopg import Connection -from .._runtime import fetch_many +from .._runtime import fetch_many as _fetch_many @dataclass(frozen=True, slots=True) @@ -19,10 +19,10 @@ class ListSpecimensByClassRow: title: str -def decode_list_specimens_by_class(row: Mapping[str, object]) -> ListSpecimensByClassRow: +def _decode_row(row: Mapping[str, object]) -> ListSpecimensByClassRow: return ListSpecimensByClassRow( - id=cast(int, row["id"]), - title=cast(str, row["title"]), + id=_cast(int, row["id"]), + title=_cast(str, row["title"]), ) @@ -50,4 +50,4 @@ def list_specimens_by_class( params: dict[str, object] = { "class": class_, } - return fetch_many(conn, _SQL, params, decode_list_specimens_by_class) + return _fetch_many(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py index 2b476df..3c882d6 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py @@ -6,13 +6,13 @@ from collections.abc import Mapping from dataclasses import dataclass -from typing import cast +from typing import cast as _cast from uuid import UUID from psycopg import Connection from .._core import JsonValue -from .._runtime import fetch_many +from .._runtime import fetch_many as _fetch_many from ..types.mood import Mood from ..types.point_2_d import Point2D @@ -30,17 +30,17 @@ class ListSpecimensByFeelingRow: meta: JsonValue -def decode_list_specimens_by_feeling(row: Mapping[str, object]) -> ListSpecimensByFeelingRow: +def _decode_row(row: Mapping[str, object]) -> ListSpecimensByFeelingRow: return ListSpecimensByFeelingRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), + id=_cast(int, row["id"]), + pub_id=_cast(UUID, row["pub_id"]), feeling=Mood.pg_decode(row["feeling"]), - title=cast(str, row["title"]), - label=cast(str, row["label"]), - rev=cast(int, row["rev"]), + title=_cast(str, row["title"]), + label=_cast(str, row["label"]), + rev=_cast(int, row["rev"]), origin=None if row["origin"] is None else Point2D.pg_decode(row["origin"]), - tags=cast(list[str | None], row["tags"]), - meta=cast(JsonValue, row["meta"]), + tags=_cast(list[str | None], row["tags"]), + meta=_cast(JsonValue, row["meta"]), ) @@ -64,4 +64,4 @@ def list_specimens_by_feeling( params: dict[str, object] = { "feeling": None if feeling is None else feeling.pg_encode(), } - return fetch_many(conn, _SQL, params, decode_list_specimens_by_feeling) + return _fetch_many(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_ids.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_ids.py index 9a4ec23..4e11126 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_ids.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_ids.py @@ -6,12 +6,12 @@ from collections.abc import Mapping from dataclasses import dataclass -from typing import cast +from typing import cast as _cast from uuid import UUID from psycopg import Connection -from .._runtime import fetch_many +from .._runtime import fetch_many as _fetch_many from ..types.mood import Mood @@ -23,12 +23,12 @@ class ListSpecimensByIdsRow: title: str -def decode_list_specimens_by_ids(row: Mapping[str, object]) -> ListSpecimensByIdsRow: +def _decode_row(row: Mapping[str, object]) -> ListSpecimensByIdsRow: return ListSpecimensByIdsRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), + id=_cast(int, row["id"]), + pub_id=_cast(UUID, row["pub_id"]), feeling=Mood.pg_decode(row["feeling"]), - title=cast(str, row["title"]), + title=_cast(str, row["title"]), ) @@ -52,4 +52,4 @@ def list_specimens_by_ids( params: dict[str, object] = { "pub_ids": pub_ids, } - return fetch_many(conn, _SQL, params, decode_list_specimens_by_ids) + return _fetch_many(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py index 3353b7a..46381ac 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py @@ -6,13 +6,13 @@ from collections.abc import Mapping from dataclasses import dataclass -from typing import cast +from typing import cast as _cast from uuid import UUID from psycopg import Connection -from .._core import require_array -from .._runtime import fetch_many +from .._core import require_array as _require_array +from .._runtime import fetch_many as _fetch_many from ..types.mood import Mood @@ -25,13 +25,13 @@ class ListSpecimensByMoodsRow: title: str -def decode_list_specimens_by_moods(row: Mapping[str, object]) -> ListSpecimensByMoodsRow: +def _decode_row(row: Mapping[str, object]) -> ListSpecimensByMoodsRow: return ListSpecimensByMoodsRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), + id=_cast(int, row["id"]), + pub_id=_cast(UUID, row["pub_id"]), feeling=Mood.pg_decode(row["feeling"]), - moods=None if row["moods"] is None else [None if v is None else Mood.pg_decode(v) for v in cast(list[str | None], require_array(row["moods"]))], - title=cast(str, row["title"]), + moods=None if row["moods"] is None else [None if v is None else Mood.pg_decode(v) for v in _cast(list[str | None], _require_array(row["moods"]))], + title=_cast(str, row["title"]), ) @@ -55,4 +55,4 @@ def list_specimens_by_moods( params: dict[str, object] = { "moods": None if moods is None else [None if x is None else x.pg_encode() for x in moods], } - return fetch_many(conn, _SQL, params, decode_list_specimens_by_moods) + return _fetch_many(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_keyword_column.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_keyword_column.py index 240ce8d..9bc9195 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_keyword_column.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_keyword_column.py @@ -6,11 +6,11 @@ from collections.abc import Mapping from dataclasses import dataclass -from typing import cast +from typing import cast as _cast from psycopg import Connection -from .._runtime import fetch_many +from .._runtime import fetch_many as _fetch_many @dataclass(frozen=True, slots=True) @@ -19,10 +19,10 @@ class ListSpecimensKeywordColumnRow: class_: str -def decode_list_specimens_keyword_column(row: Mapping[str, object]) -> ListSpecimensKeywordColumnRow: +def _decode_row(row: Mapping[str, object]) -> ListSpecimensKeywordColumnRow: return ListSpecimensKeywordColumnRow( - id=cast(int, row["id"]), - class_=cast(str, row["class"]), + id=_cast(int, row["id"]), + class_=_cast(str, row["class"]), ) @@ -46,4 +46,4 @@ def list_specimens_keyword_column( conn: Connection[object], ) -> list[ListSpecimensKeywordColumnRow]: params: dict[str, object] = {} - return fetch_many(conn, _SQL, params, decode_list_specimens_keyword_column) + return _fetch_many(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/search_specimens.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/search_specimens.py index 859dfef..8130275 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/search_specimens.py +++ b/tests/golden_sync/src/specimen_sync_client/_generated/statements/search_specimens.py @@ -6,13 +6,13 @@ from collections.abc import Mapping from dataclasses import dataclass -from typing import cast +from typing import cast as _cast from psycopg import Connection from psycopg.types.json import Jsonb from .._core import JsonValue -from .._runtime import fetch_many +from .._runtime import fetch_many as _fetch_many @dataclass(frozen=True, slots=True) @@ -23,12 +23,12 @@ class SearchSpecimensRow: meta: JsonValue -def decode_search_specimens(row: Mapping[str, object]) -> SearchSpecimensRow: +def _decode_row(row: Mapping[str, object]) -> SearchSpecimensRow: return SearchSpecimensRow( - id=cast(int, row["id"]), - title=cast(str, row["title"]), - label=cast(str, row["label"]), - meta=cast(JsonValue, row["meta"]), + id=_cast(int, row["id"]), + title=_cast(str, row["title"]), + label=_cast(str, row["label"]), + meta=_cast(JsonValue, row["meta"]), ) @@ -61,4 +61,4 @@ def search_specimens( "meta_filter": None if meta_filter is None else Jsonb(meta_filter), "label": label, } - return fetch_many(conn, _SQL, params, decode_search_specimens) + return _fetch_many(conn, _SQL, params, _decode_row) diff --git a/tests/test_identifier_collisions.py b/tests/test_identifier_collisions.py new file mode 100644 index 0000000..41f5a69 --- /dev/null +++ b/tests/test_identifier_collisions.py @@ -0,0 +1,310 @@ +# The generated client is imported dynamically, so its symbols are necessarily Any. +# pyright: reportAny=false +"""Collision coverage for public query names and statement implementation globals.""" + +from __future__ import annotations + +import asyncio +import ast +import importlib +import json +import shutil +import subprocess +import sys +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import date +from pathlib import Path + +import psycopg +from psycopg.conninfo import make_conninfo + +from tests._harness import FIXTURE_PROJECT, HERE, SRC_DIR, ensure_droppable, run_pgn + +HARNESS_ROOT = HERE.parent +EXPECTED_FUNCTIONS = frozenset({"cast", "require_array", "fetch_many", "date_query"}) +HELPERS = { + "cast": "fetch_single", + "require_array": "fetch_single", + "fetch_many": "fetch_many", + "date_query": "fetch_single", +} + + +def _fresh_project(tmp_path: Path) -> tuple[Path, Path, str, str]: + root = tmp_path / "pygen" + _ = 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) + shutil.rmtree(project / "queries") + (project / "queries").mkdir() + + package_name = f"identifier-collisions-{uuid.uuid4().hex[:8]}" + import_name = package_name.replace("-", "_") + _ = (project / "project1.pgn.yaml").write_text( + "space: python-gen\n" + "name: identifier-collisions\n" + "version: 0.0.0\n" + "postgres: 18\n" + "artifacts:\n" + " python:\n" + " gen: ../../src/package.dhall\n" + " config:\n" + f" packageName: {package_name}\n" + ) + + queries = { + "cast.sql": "SELECT 1::int8 AS value\n", + "require_array.sql": "SELECT ARRAY['happy'::mood] AS moods\n", + "fetch_many.sql": ( + "SELECT value\n" + "FROM (VALUES (1::int8), (2::int8)) AS rows(value)\n" + "ORDER BY value\n" + ), + "date.sql": "SELECT DATE '2026-07-13' AS value\n", + } + for name, sql in queries.items(): + _ = (project / "queries" / name).write_text(sql) + + return root, project, package_name, import_name + + +def _assert_private_query_canaries( + root: Path, project: Path, pgn_bin: str, pgn_admin_url: str +) -> None: + # pgn rejects leading-underscore query filenames before invoking a generator, + # so exercise those policy inputs through a pgn-executed synthetic generator. + wrapper = r''' +let Lude = ./Deps/Lude.dhall + +let Model = ./Deps/Contract.dhall + +let Sdk = ./Deps/Sdk.dhall + +let PyIdent = ./Structures/PyIdent.dhall + +let Config = { packageName : Optional Text } + +let Config/default = { packageName = None Text } + +let run = + \(_ : Config) -> + \(_ : Model.Project) -> + Lude.Compiled.ok + Lude.Files.Type + ( [ { path = "query-safe-name.txt" + , content = + PyIdent.querySafeName "_types" + ++ "\n" + ++ PyIdent.querySafeName "_decode_row" + ++ "\n" + } + ] : Lude.Files.Type + ) + +in Sdk.Sigs.generator Config Config/default run +''' + _ = (root / "src" / "query-safe-name-probe.dhall").write_text(wrapper) + + canary = shutil.copytree(project, root / "tests" / "query-safe-name-project") + shutil.rmtree(canary / "queries") + (canary / "queries").mkdir() + shutil.rmtree(canary / "artifacts", ignore_errors=True) + _ = (canary / "project1.pgn.yaml").write_text( + "space: python-gen\n" + "name: query-safe-name-probe\n" + "version: 0.0.0\n" + "postgres: 18\n" + "artifacts:\n" + " python:\n" + " gen: ../../src/query-safe-name-probe.dhall\n" + " config:\n" + " packageName: query-safe-name-probe\n" + ) + + result = run_pgn(pgn_bin, pgn_admin_url, canary, "generate") + assert result.returncode == 0, f"query-name canary generation failed:\n{result.stdout}\n{result.stderr}" + output = canary / "artifacts" / "python" / "query-safe-name.txt" + assert output.read_text().splitlines() == ["_types_query", "_decode_row_query"] + + +@contextmanager +def _scratch_database(admin_url: str) -> Iterator[str]: + name = f"pgn_collision_{uuid.uuid4().hex[:12]}" + admin = psycopg.connect(admin_url, autocommit=True) + try: + _ = admin.execute(f'CREATE DATABASE "{name}"'.encode()) + finally: + admin.close() + + target = make_conninfo(admin_url, dbname=name) + try: + yield target + finally: + admin = psycopg.connect(admin_url, autocommit=True) + try: + terminate = ( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " + "WHERE datname = %s AND pid <> pg_backend_pid()" + ) + _ = admin.execute(terminate.encode(), (name,)) + ensure_droppable(name) + _ = admin.execute(f'DROP DATABASE IF EXISTS "{name}"'.encode()) + finally: + admin.close() + + +def _apply_migrations(project: Path, db_url: str) -> None: + migrations = sorted((project / "migrations").glob("*.sql"), key=lambda path: int(path.stem)) + with psycopg.connect(db_url, autocommit=True) as conn: + for migration in migrations: + _ = conn.execute(migration.read_text().encode()) + + +def _clear_package_modules(import_name: str) -> None: + for name in list(sys.modules): + if name == import_name or name.startswith(f"{import_name}."): + del sys.modules[name] + + +def _facade_statement_exports(facade: Path) -> dict[str, str]: + exports: dict[str, str] = {} + tree = ast.parse(facade.read_text()) + for node in tree.body: + if not isinstance(node, ast.ImportFrom) or node.module is None: + continue + prefix = "_generated.statements." + if node.level != 1 or not node.module.startswith(prefix): + continue + module_name = node.module.removeprefix(prefix) + query_import = node.names[0] + assert query_import.asname == query_import.name + exports[module_name] = query_import.name + return exports + + +def _assert_statement_structure(statements: Path) -> None: + modules = {path.stem for path in statements.glob("*.py") if path.name != "__init__.py"} + assert modules == EXPECTED_FUNCTIONS + + for function_name, helper in HELPERS.items(): + source = (statements / f"{function_name}.py").read_text() + tree = ast.parse(source) + runtime_imports = [ + node + for node in tree.body + if isinstance(node, ast.ImportFrom) and node.level == 2 and node.module == "_runtime" + ] + assert len(runtime_imports) == 1 + assert [(alias.name, alias.asname) for alias in runtime_imports[0].names] == [ + (helper, f"_{helper}") + ] + assert f"from .._runtime import {helper} as _{helper}" in source + assert f"return await _{helper}(conn, _SQL, params, _decode_row)" in source + assert source.count("def _decode_row(") == 1 + assert "def decode_" not in source + + cast_imports = [ + node + for node in tree.body + if isinstance(node, ast.ImportFrom) and node.level == 0 and node.module == "typing" + ] + assert len(cast_imports) == 1 + assert [(alias.name, alias.asname) for alias in cast_imports[0].names] == [("cast", "_cast")] + + core_require_imports = [ + alias + for node in tree.body + if isinstance(node, ast.ImportFrom) and node.level == 2 and node.module == "_core" + for alias in node.names + if alias.name == "require_array" + ] + expected_core = [("require_array", "_require_array")] if function_name == "require_array" else [] + assert [(alias.name, alias.asname) for alias in core_require_imports] == expected_core + + require_array_source = (statements / "require_array.py").read_text() + assert "from .._core import require_array as _require_array" in require_array_source + assert "_cast(list[str | None], _require_array(row[\"moods\"]))" in require_array_source + assert "from datetime import date" in (statements / "date_query.py").read_text() + + +def _assert_strict(package_src: Path, tmp_path: Path) -> None: + config = tmp_path / "identifier-collisions-pyright.json" + _ = config.write_text( + json.dumps( + { + "pythonVersion": "3.12", + "typeCheckingMode": "strict", + "include": [str(package_src)], + "venvPath": str(HARNESS_ROOT), + "venv": ".venv", + "reportMissingModuleSource": False, + } + ) + ) + result = subprocess.run( + ["basedpyright", "--project", str(config), "--outputjson"], + capture_output=True, + text=True, + ) + assert result.stdout.strip(), f"basedpyright produced no JSON (exit {result.returncode}):\n{result.stderr}" + summary = json.loads(result.stdout)["summary"] + assert summary["filesAnalyzed"] > 0, f"basedpyright analyzed no files:\n{result.stdout}" + assert summary["errorCount"] == 0 and summary["warningCount"] == 0, ( + f"basedpyright strict reported issues: {summary}\n{result.stdout}" + ) + + +def test_identifier_collisions_roundtrip( + pgn_bin: str, pgn_admin_url: str, tmp_path: Path +) -> None: + root, project, package_name, import_name = _fresh_project(tmp_path) + _assert_private_query_canaries(root, project, pgn_bin, pgn_admin_url) + result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") + assert result.returncode == 0, f"pgn generate failed:\n{result.stdout}\n{result.stderr}" + + generated_src = project / "artifacts" / "python" / "src" + package_src = generated_src / import_name + statements = package_src / "_generated" / "statements" + assert package_src.is_dir(), f"pgn did not generate package {package_name!r}" + _assert_statement_structure(statements) + facade_exports = _facade_statement_exports(package_src / "__init__.py") + assert facade_exports == {name: name for name in EXPECTED_FUNCTIONS} + assert not (statements / "date.py").exists() + assert not (statements / "_types.py").exists() + _assert_strict(package_src, tmp_path) + + sys.path.insert(0, str(generated_src)) + _clear_package_modules(import_name) + try: + facade = importlib.import_module(import_name) + register = importlib.import_module(f"{import_name}._generated._register") + mood_module = importlib.import_module(f"{import_name}._generated.types.mood") + Mood = mood_module.Mood + + with _scratch_database(pgn_admin_url) as db_url: + _apply_migrations(project, db_url) + + async def scenario() -> None: + conn = await psycopg.AsyncConnection.connect(db_url, autocommit=True) + try: + await register.register_types(conn) + cast_row = await facade.cast(conn) + require_array_row = await facade.require_array(conn) + many_rows = await facade.fetch_many(conn) + date_row = await facade.date_query(conn) + + assert cast_row.value == 1 + assert require_array_row.moods == [Mood.HAPPY] + assert require_array_row.moods[0] is Mood.HAPPY + assert [row.value for row in many_rows] == [1, 2] + assert date_row.value == date(2026, 7, 13) + finally: + await conn.close() + + asyncio.run(scenario()) + finally: + _clear_package_modules(import_name) + sys.path.remove(str(generated_src)) From e46dc9605a24c19f46a556cee6dc99039aa049cc Mon Sep 17 00:00:00 2001 From: Viacheslav Shvets Date: Mon, 13 Jul 2026 01:58:11 +0300 Subject: [PATCH 26/41] refactor(gen): share async and sync query modules H2: CONFIRM, 23 Python files and 1,394 total lines. Statements: 11 files, 11 SQL assignments, and 974 lines. The statement-line excess above 900 is provisional; C3 must reduce it to 900 or fewer. --- mise.toml | 34 +- src/Interpreters/CustomType.dhall | 2 +- src/Interpreters/Member.dhall | 2 +- src/Interpreters/ParamsMember.dhall | 2 +- src/Interpreters/Primitive.dhall | 2 +- src/Interpreters/Project.dhall | 81 ++-- src/Interpreters/Query.dhall | 14 +- src/Interpreters/QueryFragments.dhall | 2 +- src/Interpreters/Result.dhall | 4 +- src/Interpreters/ResultColumns.dhall | 2 +- src/Interpreters/Scalar.dhall | 2 +- src/Interpreters/Value.dhall | 2 +- src/Structures/PyIdent.dhall | 12 + src/Structures/Surface.dhall | 19 +- src/Templates/FacadeModule.dhall | 44 ++- src/Templates/RegisterModule.dhall | 38 +- src/Templates/RuntimeModule.dhall | 7 +- src/Templates/StatementModule.dhall | 80 ++-- src/package.dhall | 13 +- tests/_harness.py | 1 - tests/conftest.py | 47 +-- tests/fixture-project/project1.pgn.yaml | 19 +- tests/golden/README.md | 16 +- tests/golden/src/specimen_client/__init__.py | 6 + .../specimen_client/_generated/_register.py | 15 +- .../statements/bump_specimen_revision.py | 14 +- .../_generated/statements/get_specimen.py | 14 +- .../_generated/statements/get_tagged_item.py | 14 +- .../_generated/statements/insert_specimen.py | 62 ++- .../statements/insert_tagged_item.py | 16 +- .../statements/list_specimens_by_class.py | 14 +- .../statements/list_specimens_by_feeling.py | 14 +- .../statements/list_specimens_by_ids.py | 14 +- .../statements/list_specimens_by_moods.py | 14 +- .../list_specimens_keyword_column.py | 10 +- .../_generated/statements/search_specimens.py | 18 +- .../_generated/sync}/_runtime.py | 2 +- .../src/specimen_client/sync/__init__.py | 53 +++ tests/golden_sync/README.md | 26 -- tests/golden_sync/pyproject.toml | 15 - .../src/specimen_sync_client/__init__.py | 50 --- .../_generated/__init__.py | 7 - .../specimen_sync_client/_generated/_core.py | 35 -- .../_generated/_register.py | 26 -- .../_generated/statements/__init__.py | 7 - .../statements/bump_specimen_revision.py | 30 -- .../_generated/statements/get_specimen.py | 118 ------ .../_generated/statements/get_tagged_item.py | 53 --- .../_generated/statements/insert_specimen.py | 192 ---------- .../statements/insert_tagged_item.py | 53 --- .../statements/list_specimens_by_class.py | 53 --- .../statements/list_specimens_by_feeling.py | 67 ---- .../statements/list_specimens_by_ids.py | 55 --- .../statements/list_specimens_by_moods.py | 58 --- .../list_specimens_keyword_column.py | 49 --- .../_generated/statements/search_specimens.py | 64 ---- .../_generated/types/__init__.py | 7 - .../_generated/types/mood.py | 19 - .../_generated/types/point_2_d.py | 25 -- .../_generated/types/tag_value.py | 24 -- .../src/specimen_sync_client/py.typed | 0 tests/test_config_variants.py | 62 +-- tests/test_generated.py | 355 ++++++++---------- tests/test_identifier_collisions.py | 76 +++- tests/test_unsupported_types.py | 29 +- 65 files changed, 800 insertions(+), 1480 deletions(-) rename tests/{golden_sync/src/specimen_sync_client/_generated => golden/src/specimen_client/_generated/sync}/_runtime.py (94%) create mode 100644 tests/golden/src/specimen_client/sync/__init__.py delete mode 100644 tests/golden_sync/README.md delete mode 100644 tests/golden_sync/pyproject.toml delete mode 100644 tests/golden_sync/src/specimen_sync_client/__init__.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/__init__.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/_core.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/_register.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/statements/__init__.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/statements/bump_specimen_revision.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/statements/get_specimen.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/statements/get_tagged_item.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_class.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_ids.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_keyword_column.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/statements/search_specimens.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/types/__init__.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/types/mood.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/types/point_2_d.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/_generated/types/tag_value.py delete mode 100644 tests/golden_sync/src/specimen_sync_client/py.typed diff --git a/mise.toml b/mise.toml index 3855c94..850d91e 100644 --- a/mise.toml +++ b/mise.toml @@ -32,17 +32,11 @@ run = "bench/generate.sh with" description = "Build the fixture client with the pre-as-Source Deps" 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 src/ (no stale freeze) and keeps pgn's -# scratch files out of the repo. -# Regenerates the "python" and "python-sync" artifacts: a full 7-artifact -# generate peaks at ~31 GB RSS (memory goes to normalizing the generator -# closure per artifact, see ci.yml), so this keeps only the two artifacts -# golden actually needs instead of all 7. +# Regenerates only the combined "python" artifact. A full 7-artifact generate +# peaks at ~31 GB RSS, so the temp fixture keeps only the golden artifact. The +# temp copy also forces a fresh working-tree resolve and isolates pgn scratch. [tasks.golden] -description = "Refresh tests/golden and tests/golden_sync from the working-tree src/" +description = "Refresh the combined tests/golden package from the working-tree src/" run = ''' #!/usr/bin/env bash set -euo pipefail @@ -52,20 +46,16 @@ 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/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("../../src/package.dhall", sys.argv[2]) + "\n") -EOF +sed -e '/^ # The variants below/,$d' \ + -e "s|../../src/package.dhall|$root/src/package.dhall|" \ + "$tmp/fixture/project1.pgn.yaml" > "$tmp/fixture/project1.pgn.yaml.new" +mv "$tmp/fixture/project1.pgn.yaml.new" "$tmp/fixture/project1.pgn.yaml" cd "$tmp/fixture" pgn --database-url "${PGN_TEST_DATABASE_URL:-postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable}" generate rsync -a --delete artifacts/python/src/specimen_client/_generated/ "$root/tests/golden/src/specimen_client/_generated/" cp artifacts/python/src/specimen_client/__init__.py "$root/tests/golden/src/specimen_client/__init__.py" -rsync -a --delete artifacts/python_sync/src/specimen_sync_client/_generated/ "$root/tests/golden_sync/src/specimen_sync_client/_generated/" -cp artifacts/python_sync/src/specimen_sync_client/__init__.py "$root/tests/golden_sync/src/specimen_sync_client/__init__.py" -echo "golden refreshed; review with: git diff tests/golden tests/golden_sync" +mkdir -p "$root/tests/golden/src/specimen_client/sync" +cp artifacts/python/src/specimen_client/sync/__init__.py "$root/tests/golden/src/specimen_client/sync/__init__.py" +rm -rf "$root/tests/golden_sync" +echo "combined golden refreshed; review with: git diff tests/golden" ''' diff --git a/src/Interpreters/CustomType.dhall b/src/Interpreters/CustomType.dhall index c8c5b89..c4785f1 100644 --- a/src/Interpreters/CustomType.dhall +++ b/src/Interpreters/CustomType.dhall @@ -19,7 +19,7 @@ let CompositeModule = ../Templates/CompositeModule.dhall let Config = { packageName : Text , importName : Text - , sync : Bool + , emitSync : Bool , onUnsupported : OnUnsupported.Mode } diff --git a/src/Interpreters/Member.dhall b/src/Interpreters/Member.dhall index ec275ef..556143d 100644 --- a/src/Interpreters/Member.dhall +++ b/src/Interpreters/Member.dhall @@ -17,7 +17,7 @@ let Value = ./Value.dhall let Config = { packageName : Text , importName : Text - , sync : Bool + , emitSync : Bool , onUnsupported : OnUnsupported.Mode } diff --git a/src/Interpreters/ParamsMember.dhall b/src/Interpreters/ParamsMember.dhall index 8ba8b99..2c90b05 100644 --- a/src/Interpreters/ParamsMember.dhall +++ b/src/Interpreters/ParamsMember.dhall @@ -15,7 +15,7 @@ let Value = ./Value.dhall let Config = { packageName : Text , importName : Text - , sync : Bool + , emitSync : Bool , onUnsupported : OnUnsupported.Mode } diff --git a/src/Interpreters/Primitive.dhall b/src/Interpreters/Primitive.dhall index d58e07f..052ef1b 100644 --- a/src/Interpreters/Primitive.dhall +++ b/src/Interpreters/Primitive.dhall @@ -11,7 +11,7 @@ let OnUnsupported = ../Structures/OnUnsupported.dhall let Config = { packageName : Text , importName : Text - , sync : Bool + , emitSync : Bool , onUnsupported : OnUnsupported.Mode } diff --git a/src/Interpreters/Project.dhall b/src/Interpreters/Project.dhall index e81d371..2f610a9 100644 --- a/src/Interpreters/Project.dhall +++ b/src/Interpreters/Project.dhall @@ -28,8 +28,6 @@ let RegisterModule = ../Templates/RegisterModule.dhall let FacadeModule = ../Templates/FacadeModule.dhall -let Surface = ../Structures/Surface.dhall - let OnUnsupported = ../Structures/OnUnsupported.dhall let Report = { path : List Text, message : Text } @@ -37,11 +35,11 @@ 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, sync off, onUnsupported Fail); there is no separate config type +-- name, sync emission off, onUnsupported Fail); there is no separate config type -- or resolve step between package.dhall and here. let Config = { packageName : Optional Text - , sync : Optional Bool + , emitSync : Optional Bool , onUnsupported : Optional OnUnsupported.Mode } @@ -50,7 +48,7 @@ let Config = let ResolvedConfig = { packageName : Text , importName : Text - , sync : Bool + , emitSync : Bool , onUnsupported : OnUnsupported.Mode } @@ -82,7 +80,7 @@ let lookupConfig : ResolvedConfig = { packageName = "" , importName = "" - , sync = False + , emitSync = False , onUnsupported = OnUnsupported.Mode.Fail } @@ -225,15 +223,6 @@ let combineOutputs = ) customTypes - let surface = if config.sync then Surface.sync else Surface.async - - let facade = - { path = packagePrefix ++ "__init__.py" - , content = - FacadeModule.run - { statements = facadeStatements, types = facadeTypes } - } - -- Surface-agnostic; performs no I/O, so exactly one copy is emitted -- regardless of surface. Both runtime bodies re-export from it. let coreModule = @@ -241,10 +230,17 @@ let combineOutputs = let runtimeModule = { path = srcPrefix ++ "_runtime.py" - , content = - if config.sync then RuntimeModule.runSync {=} else RuntimeModule.run {=} + , content = RuntimeModule.run {=} } + let syncRuntimeFiles = + if config.emitSync + then [ { path = srcPrefix ++ "sync/_runtime.py" + , content = RuntimeModule.runSync {=} + } + ] + else [] : List Lude.File.Type + let statementsInit = { path = srcPrefix ++ "statements/__init__.py" , content = @@ -304,13 +300,56 @@ let combineOutputs = if hasCustomRegistration then [ { path = srcPrefix ++ "_register.py" , content = - RegisterModule.run { compositeNames, enumNames, surface } + RegisterModule.run + { compositeNames + , enumNames + , emitSync = config.emitSync + } + } + ] + else [] : List Lude.File.Type + + let registrationSource = + if hasCustomRegistration + then Some "register_types" + else None Text + + let facade = + { path = packagePrefix ++ "__init__.py" + , content = + FacadeModule.run + { statements = facadeStatements + , types = facadeTypes + , generatedPrefix = "._generated" + , functionSuffix = "" + , registrationSource + , includeSyncModule = config.emitSync + } + } + + let syncFacadeFiles = + if config.emitSync + then [ { path = packagePrefix ++ "sync/__init__.py" + , content = + FacadeModule.run + { statements = facadeStatements + , types = facadeTypes + , generatedPrefix = ".._generated" + , functionSuffix = "_sync" + , registrationSource = + if hasCustomRegistration + then Some "register_types_sync" + else None Text + , includeSyncModule = False + } } ] else [] : List Lude.File.Type let staticFiles = [ facade, topInit, coreModule, runtimeModule, statementsInit ] + # syncFacadeFiles + # syncRuntimeFiles let allFiles = staticFiles @@ -352,10 +391,10 @@ let run = (\(t : Text) -> t) input.name.inKebabCase - let sync = + let emitSync = Prelude.Optional.fold Bool - config.sync + config.emitSync Bool (\(b : Bool) -> b) False @@ -372,7 +411,7 @@ let run = let resolvedConfig : ResolvedConfig - = { packageName, importName, sync, onUnsupported } + = { packageName, importName, emitSync, onUnsupported } let skip = merge { Fail = False, Skip = True } resolvedConfig.onUnsupported diff --git a/src/Interpreters/Query.dhall b/src/Interpreters/Query.dhall index 3545347..04e143c 100644 --- a/src/Interpreters/Query.dhall +++ b/src/Interpreters/Query.dhall @@ -25,7 +25,7 @@ let StatementModule = ../Templates/StatementModule.dhall let Config = { packageName : Text , importName : Text - , sync : Bool + , emitSync : Bool , onUnsupported : OnUnsupported.Mode } @@ -33,9 +33,9 @@ let Compiled = Lude.Compiled let Input = Model.Query --- A query renders to one thin statement module: its own Row dataclass and --- decode function (when it returns rows) plus the I/O wrapper for whichever --- surface config.sync picked. rowClassName is still surfaced here (not just +-- A query renders to one statement module: its own Row dataclass and decode +-- function (when it returns rows), the async function, and optional adjacent +-- sync function. rowClassName is still surfaced here (not just -- internal to the rendered content) because Project.dhall's facade needs the -- name to build the re-export line; the Row's full definition does not -- leave this module. @@ -107,8 +107,6 @@ let render = -- consumers (the statement module and, formerly, _rows.py). let mergedImports = ImportSet.combine paramImports result.imports - let surface = if config.sync then Surface.sync else Surface.async - let content = StatementModule.run { functionName @@ -120,7 +118,9 @@ let render = , paramSigLines , paramDictEntries , imports = mergedImports - , surface + , emitSync = config.emitSync + , asyncSurface = Surface.async + , syncSurface = Surface.sync } in { functionName diff --git a/src/Interpreters/QueryFragments.dhall b/src/Interpreters/QueryFragments.dhall index a8a3cf0..7813900 100644 --- a/src/Interpreters/QueryFragments.dhall +++ b/src/Interpreters/QueryFragments.dhall @@ -13,7 +13,7 @@ let OnUnsupported = ../Structures/OnUnsupported.dhall let Config = { packageName : Text , importName : Text - , sync : Bool + , emitSync : Bool , onUnsupported : OnUnsupported.Mode } diff --git a/src/Interpreters/Result.dhall b/src/Interpreters/Result.dhall index f8597ad..bc48049 100644 --- a/src/Interpreters/Result.dhall +++ b/src/Interpreters/Result.dhall @@ -22,7 +22,7 @@ let Compiled = Lude.Compiled let Config = { packageName : Text , importName : Text - , sync : Bool + , emitSync : Bool , onUnsupported : OnUnsupported.Mode , rowClassName : Text } @@ -92,7 +92,7 @@ let rowsOutput = } ) ( ResultColumns.run - config.{ packageName, importName, sync, onUnsupported } + config.{ packageName, importName, emitSync, onUnsupported } lookup columns ) diff --git a/src/Interpreters/ResultColumns.dhall b/src/Interpreters/ResultColumns.dhall index f5d8b3f..3111242 100644 --- a/src/Interpreters/ResultColumns.dhall +++ b/src/Interpreters/ResultColumns.dhall @@ -17,7 +17,7 @@ let Compiled = Lude.Compiled let Config = { packageName : Text , importName : Text - , sync : Bool + , emitSync : Bool , onUnsupported : OnUnsupported.Mode } diff --git a/src/Interpreters/Scalar.dhall b/src/Interpreters/Scalar.dhall index 94d903d..d735396 100644 --- a/src/Interpreters/Scalar.dhall +++ b/src/Interpreters/Scalar.dhall @@ -13,7 +13,7 @@ let Primitive = ./Primitive.dhall let Config = { packageName : Text , importName : Text - , sync : Bool + , emitSync : Bool , onUnsupported : OnUnsupported.Mode } diff --git a/src/Interpreters/Value.dhall b/src/Interpreters/Value.dhall index b9ff26f..e1c8d6a 100644 --- a/src/Interpreters/Value.dhall +++ b/src/Interpreters/Value.dhall @@ -15,7 +15,7 @@ let Scalar = ./Scalar.dhall let Config = { packageName : Text , importName : Text - , sync : Bool + , emitSync : Bool , onUnsupported : OnUnsupported.Mode } diff --git a/src/Structures/PyIdent.dhall b/src/Structures/PyIdent.dhall index 5e29996..cd1cc02 100644 --- a/src/Structures/PyIdent.dhall +++ b/src/Structures/PyIdent.dhall @@ -92,8 +92,15 @@ let moduleReservedNames = , "_fetch_many" , "_execute_rows_affected" , "_execute_void" + , "_fetch_optional_sync" + , "_fetch_single_sync" + , "_fetch_many_sync" + , "_execute_rows_affected_sync" + , "_execute_void_sync" , "_decode_row" , "_types" + , "sync" + , "register_types" , "date" , "datetime" , "time" @@ -113,6 +120,11 @@ let parameterReservedNames = , "_fetch_many" , "_execute_rows_affected" , "_execute_void" + , "_fetch_optional_sync" + , "_fetch_single_sync" + , "_fetch_many_sync" + , "_execute_rows_affected_sync" + , "_execute_void_sync" ] let parameterSafeName diff --git a/src/Structures/Surface.dhall b/src/Structures/Surface.dhall index 09845f4..4afc519 100644 --- a/src/Structures/Surface.dhall +++ b/src/Structures/Surface.dhall @@ -1,15 +1,12 @@ --- A code-generation surface: the async or sync flavour of a statement module. --- Exactly one surface is emitted per generate (Interpreters/Project.dhall --- picks async or sync from config.sync and renders everything at the same --- unified paths), so the two Surface values differ only in the tokens that --- vary between `async def`/`def`, `AsyncConnection`/`Connection`, and --- `await `/``. Both reach the shared `_rows`/`_core`/`types` modules at the --- same relative import depth, since neither surface is nested under a --- surface-named subdirectory. +-- Render tokens for the two explicit functions co-located in each canonical +-- statement module. Both surfaces import the same core and custom types. let Surface = { defKeyword : Text , connType : Text , awaitKw : Text + , functionSuffix : Text + , runtimePrefix : Text + , helperSuffix : Text , corePrefix : Text , typesPrefix : Text } @@ -19,6 +16,9 @@ let async = { defKeyword = "async def" , connType = "AsyncConnection" , awaitKw = "await " + , functionSuffix = "" + , runtimePrefix = ".._runtime" + , helperSuffix = "" , corePrefix = ".._core" , typesPrefix = "..types" } @@ -28,6 +28,9 @@ let sync = { defKeyword = "def" , connType = "Connection" , awaitKw = "" + , functionSuffix = "_sync" + , runtimePrefix = "..sync._runtime" + , helperSuffix = "_sync" , corePrefix = ".._core" , typesPrefix = "..types" } diff --git a/src/Templates/FacadeModule.dhall b/src/Templates/FacadeModule.dhall index 87e326b..8c65751 100644 --- a/src/Templates/FacadeModule.dhall +++ b/src/Templates/FacadeModule.dhall @@ -10,16 +10,13 @@ let StatementExport = -- A custom type re-exported from types/: the leaf module name plus the class. let TypeExport = { moduleName : Text, className : Text } --- The facade always lives at the package root, importing from `_generated` --- (prefix `._generated`) and `_generated/statements` (statementsPath --- `statements`) — both constant now that exactly one surface is emitted per --- generate (Interpreters/Project.dhall picks it via config.sync). -let generatedPrefix = "._generated" -let statementsPath = "statements" - let Params = { statements : List StatementExport , types : List TypeExport + , generatedPrefix : Text + , functionSuffix : Text + , registrationSource : Optional Text + , includeSyncModule : Bool } -- "x as x" re-export markers (PEP 484) so basedpyright strict and ruff treat the @@ -47,7 +44,7 @@ let runtimeNames = [ "JsonValue", "NoRowError" ] let run = \(params : Params) -> let runtimeBlock = - "from ${generatedPrefix}._core import " + "from ${params.generatedPrefix}._core import " ++ Prelude.Text.concatMapSep ", " Text @@ -59,7 +56,7 @@ let run = "\n" TypeExport ( \(t : TypeExport) -> - "from ${generatedPrefix}.types.${t.moduleName} import ${alias t.className}" + "from ${params.generatedPrefix}.types.${t.moduleName} import ${alias t.className}" ) params.types @@ -81,12 +78,28 @@ let run = { None = "", Some = \(row : Text) -> ", ${alias row}" } s.rowClassName - in "from ${generatedPrefix}.${statementsPath}.${s.functionName} import " - ++ alias s.functionName + in "from ${params.generatedPrefix}.statements.${s.functionName} import " + ++ s.functionName + ++ params.functionSuffix + ++ " as " + ++ s.functionName ++ rowSuffix ) params.statements + let registrationBlock = + merge + { None = [] : List Text + , Some = + \(source : Text) -> + [ "from ${params.generatedPrefix}._register import ${source} as register_types" + ] + } + params.registrationSource + + let syncBlock = + if params.includeSyncModule then [ "from . import sync as sync" ] else [] : List Text + let importGroups = [ runtimeBlock ] # ( if Prelude.List.null TypeExport params.types @@ -97,6 +110,8 @@ let run = then [] : List Text else [ statementBlock ] ) + # registrationBlock + # syncBlock let importSection = Prelude.Text.concatSep "\n\n" importGroups @@ -113,6 +128,13 @@ let run = Text (\(s : StatementExport) -> s.functionName) params.statements + # ( merge + { None = [] : List Text + , Some = \(_ : Text) -> [ "register_types" ] + } + params.registrationSource + ) + # (if params.includeSyncModule then [ "sync" ] else [] : List Text) let allEntries = Prelude.Text.concatMap diff --git a/src/Templates/RegisterModule.dhall b/src/Templates/RegisterModule.dhall index cc94bfb..1bbd150 100644 --- a/src/Templates/RegisterModule.dhall +++ b/src/Templates/RegisterModule.dhall @@ -2,9 +2,7 @@ let Prelude = ../Deps/Prelude.dhall let Sdk = ../Deps/Sdk.dhall -let Surface = ../Structures/Surface.dhall - --- Per-connection type registration, emitted once per surface. psycopg decodes an +-- Per-connection type registration, emitted once. 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 -- frozen dataclass. An enum scalar already decodes as text, but an enum ARRAY @@ -15,7 +13,7 @@ let Surface = ../Structures/Surface.dhall let Params = { compositeNames : List Text , enumNames : List Text - , surface : Surface.Type + , emitSync : Bool } let tupleLiteral = @@ -44,8 +42,6 @@ let enumLoop = let run = \(params : Params) -> - let surface = params.surface - let hasComposites = Prelude.Bool.not (Prelude.List.null Text params.compositeNames) @@ -54,7 +50,9 @@ let run = let importLines = [ "from __future__ import annotations" , "" - , "from psycopg import ${surface.connType}" + , if params.emitSync + then "from psycopg import AsyncConnection, Connection" + else "from psycopg import AsyncConnection" ] # ( if hasEnums then [ "from psycopg.types import TypeInfo" ] @@ -76,21 +74,37 @@ let run = else [] : List Text ) - let bodyLines = + let asyncBodyLines = ( if hasComposites - then compositeLoop surface.awaitKw + then compositeLoop "await " else [] : List Text ) - # (if hasEnums then enumLoop surface.awaitKw else [] : List Text) + # (if hasEnums then enumLoop "await " else [] : List Text) + + let syncBodyLines = + ( if hasComposites + then compositeLoop "" + else [] : List Text + ) + # (if hasEnums then enumLoop "" else [] : List Text) + + let syncFunction = + if params.emitSync + then [ "", "" ] + # [ "def register_types_sync(conn: Connection[object]) -> None:" + ] + # syncBodyLines + else [] : List Text let allLines = importLines # [ "", "" ] # constantLines # [ "", "" ] - # [ "${surface.defKeyword} register_types(conn: ${surface.connType}[object]) -> None:" + # [ "async def register_types(conn: AsyncConnection[object]) -> None:" ] - # bodyLines + # asyncBodyLines + # syncFunction in Prelude.Text.concatSep "\n" allLines ++ "\n" diff --git a/src/Templates/RuntimeModule.dhall b/src/Templates/RuntimeModule.dhall index e13a2d0..5e0cc1d 100644 --- a/src/Templates/RuntimeModule.dhall +++ b/src/Templates/RuntimeModule.dhall @@ -81,9 +81,8 @@ let content = _ = await cur.execute(sql, params) '' --- The sync surface's body, selected in place of `content` at the same --- `_runtime.py` path when config.sync is True (Interpreters/Project.dhall). --- The five helpers are the same shape with `def`/`Connection`/`with`/no- +-- The sync runtime lives at `_generated/sync/_runtime.py`. The five helpers +-- are the same shape with `def`/`Connection`/`with`/no- -- `await`. JsonValue/NoRowError/require_array are re-exported from _core so -- both surfaces share one canonical identity rather than two equal-but- -- distinct definitions. @@ -97,7 +96,7 @@ let syncContent = from psycopg import Connection from psycopg.rows import dict_row - from ._core import JsonValue as JsonValue, NoRowError as NoRowError, require_array as require_array + from .._core import JsonValue as JsonValue, NoRowError as NoRowError, require_array as require_array _T = TypeVar("_T") _Row = Mapping[str, object] diff --git a/src/Templates/StatementModule.dhall b/src/Templates/StatementModule.dhall index 3e0d072..38e2ad2 100644 --- a/src/Templates/StatementModule.dhall +++ b/src/Templates/StatementModule.dhall @@ -68,9 +68,8 @@ let hasRow = \(rowDef : Optional RowDef) -> merge { None = False, Some = \(_ : RowDef) -> True } rowDef --- A statement module is the thin per-surface I/O wrapper: it renders its own --- Row dataclass and decode function (when the query returns rows) and one --- `async def`/`def` over a connection. `imports` carries BOTH the parameter +-- A canonical statement module owns its Row, decoder, SQL, async function, and +-- optional adjacent sync function. `imports` carries both the parameter -- type imports and the result-column imports merged into one set -- (Interpreters/Query.dhall combines them), since both now live in this one -- file. @@ -84,7 +83,9 @@ let Params = , paramSigLines : List Text , paramDictEntries : List Text , imports : ImportSet.Type - , surface : Surface.Type + , emitSync : Bool + , asyncSurface : Surface.Type + , syncSurface : Surface.Type } let importLineIf @@ -105,13 +106,11 @@ let datetimeImport then [ "from datetime import " ++ Prelude.Text.concatSep ", " names ] else [] : List Text --- The I/O helper (fetch_*/execute_*) always comes from _runtime; JsonValue, when --- used, comes from _core via the surface's corePrefix (statement modules import --- the shared name directly, not through the _runtime re-export). let runtimeImport - : Text -> Text - = \(helperName : Text) -> - "from .._runtime import ${helperName} as _${helperName}" + : Surface.Type -> Text -> Text + = \(surface : Surface.Type) -> + \(helperName : Text) -> + "from ${surface.runtimePrefix} import ${helperName} as _${helperName}${surface.helperSuffix}" let coreImport : Text -> Bool -> List Text @@ -140,6 +139,10 @@ let renderImports let rowIsPresent = hasRow params.rowDef + let asyncSurface = params.asyncSurface + + let syncSurface = params.syncSurface + let stdlibBlock = importLineIf rowIsPresent "from collections.abc import Mapping" # importLineIf rowIsPresent "from dataclasses import dataclass" @@ -148,8 +151,12 @@ let renderImports # importLineIf imports.needsCast "from typing import cast as _cast" # importLineIf imports.uuid "from uuid import UUID" + let connectionNames = + [ asyncSurface.connType ] + # (if params.emitSync then [ syncSurface.connType ] else [] : List Text) + let psycopgBlock = - [ "from psycopg import ${params.surface.connType}" ] + [ "from psycopg import " ++ Prelude.Text.concatSep ", " connectionNames ] # importLineIf imports.json "from psycopg.types.json import Json" @@ -158,12 +165,16 @@ let renderImports "from psycopg.types.json import Jsonb" let localBlock = - coreImport params.surface.corePrefix imports.jsonValue + coreImport asyncSurface.corePrefix imports.jsonValue # importLineIf imports.enumArray - "from ${params.surface.corePrefix} import require_array as _require_array" - # [ runtimeImport params.helperName ] - # customImportLines params.surface.typesPrefix imports + "from ${asyncSurface.corePrefix} import require_array as _require_array" + # [ runtimeImport asyncSurface params.helperName ] + # ( if params.emitSync + then [ runtimeImport syncSurface params.helperName ] + else [] : List Text + ) + # customImportLines asyncSurface.typesPrefix imports let groups = [ [ "from __future__ import annotations" ] @@ -187,8 +198,9 @@ let renderImports nonEmptyGroups let renderSignature - : Params -> Text + : Params -> Surface.Type -> Text = \(params : Params) -> + \(surface : Surface.Type) -> let hasParams = Prelude.Bool.not (Prelude.List.null Text params.paramSigLines) @@ -200,11 +212,12 @@ let renderSignature (\(line : Text) -> " " ++ line ++ ",\n") params.paramSigLines - in params.surface.defKeyword + in surface.defKeyword ++ " " ++ params.functionName + ++ surface.functionSuffix ++ "(\n" - ++ " conn: ${params.surface.connType}[object],\n" + ++ " conn: ${surface.connType}[object],\n" ++ kwMarker ++ paramBlock ++ ") -> " @@ -226,13 +239,26 @@ let renderParamsDict ++ "}" let renderCall - : Params -> Text + : Params -> Surface.Type -> Text = \(params : Params) -> - let await = params.surface.awaitKw + \(surface : Surface.Type) -> + let await = surface.awaitKw + + let helper = "_" ++ params.helperName ++ surface.helperSuffix in if params.callsDecode - then "return ${await}_${params.helperName}(conn, _SQL, params, _decode_row)" - else "return ${await}_${params.helperName}(conn, _SQL, params)" + then "return ${await}${helper}(conn, _SQL, params, _decode_row)" + else "return ${await}${helper}(conn, _SQL, params)" + +let renderFunction + : Params -> Surface.Type -> Text + = \(params : Params) -> + \(surface : Surface.Type) -> + renderSignature params surface + ++ "\n" + ++ indentAll 4 (renderParamsDict params) + ++ "\n" + ++ indentAll 4 (renderCall params surface) in Sdk.Sigs.template Params @@ -250,11 +276,11 @@ in Sdk.Sigs.template -- per-query str->bytes allocation (psycopg auto-prepare keys on the -- bytes value, so equal bytes still hit the prepared-statement cache). ++ "\n\"\"\"\n\n_SQL = SQL.encode()\n\n\n" - ++ renderSignature params - ++ "\n" - ++ indentAll 4 (renderParamsDict params) - ++ "\n" - ++ indentAll 4 (renderCall params) + ++ renderFunction params params.asyncSurface + ++ ( if params.emitSync + then "\n\n\n" ++ renderFunction params params.syncSurface + else "" + ) ++ "\n" ) /\ { RowDef } diff --git a/src/package.dhall b/src/package.dhall index b3c282e..67ff3c1 100644 --- a/src/package.dhall +++ b/src/package.dhall @@ -4,12 +4,9 @@ let OnUnsupported = ./Structures/OnUnsupported.dhall let ProjectInterpreter = ./Interpreters/Project.dhall --- User-facing config for this generator. `sync` picks which single surface --- the generator emits: `False` (default) emits the async surface --- (psycopg.AsyncConnection); `True` emits the sync surface --- (psycopg.Connection) instead, at the exact same paths — flipping this --- flag never changes the output tree's shape or any import path, only file --- contents. `onUnsupported` picks Fail (default, abort loudly) or Skip (drop +-- User-facing config for this generator. Async output is always emitted; +-- `emitSync = True` adds a sync facade, runtime, and adjacent query functions. +-- `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 @@ -17,14 +14,14 @@ let ProjectInterpreter = ./Interpreters/Project.dhall -- Interpreters/Project.dhall's `run` supplies the defaults. let Config = { packageName : Optional Text - , sync : Optional Bool + , emitSync : Optional Bool , onUnsupported : Optional OnUnsupported.Mode } : Type let Config/default : Config = { packageName = None Text - , sync = None Bool + , emitSync = None Bool , onUnsupported = None OnUnsupported.Mode } diff --git a/tests/_harness.py b/tests/_harness.py index 9b90292..9e5ffbd 100644 --- a/tests/_harness.py +++ b/tests/_harness.py @@ -26,7 +26,6 @@ SRC_DIR = HERE.parent / "src" FIXTURE_PROJECT = HERE / "fixture-project" GOLDEN_DIR = HERE / "golden" -GOLDEN_DIR_SYNC = HERE / "golden_sync" # pgn creates its own temp database from this admin URL; we never write into the # target database itself. Default points at a local Postgres on the standard port; diff --git a/tests/conftest.py b/tests/conftest.py index 719c227..4662c58 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,7 +16,6 @@ from tests._harness import ( FIXTURE_PROJECT, GOLDEN_DIR, - GOLDEN_DIR_SYNC, HERE, SRC_DIR, admin_database_url, @@ -103,36 +102,18 @@ def full_package(generated_tree: Path, tmp_path_factory: pytest.TempPathFactory) root = tmp_path_factory.mktemp("pkg") shell_src = GOLDEN_DIR / "src" generated_src = generated_tree / "src" - _ = shutil.copytree(shell_src, root / "src", ignore=shutil.ignore_patterns("_generated", "__init__.py")) - for pkg_dir in generated_src.iterdir(): - dest_pkg = root / "src" / pkg_dir.name - _ = shutil.copytree(pkg_dir / "_generated", dest_pkg / "_generated") - _ = shutil.copy2(pkg_dir / "__init__.py", dest_pkg / "__init__.py") - # The sync facade (sync/__init__.py) lives outside _generated, like the - # async facade; overlay it too when the project emits a sync surface. - sync_facade = pkg_dir / "sync" / "__init__.py" - if sync_facade.exists(): - (dest_pkg / "sync").mkdir(parents=True, exist_ok=True) - _ = shutil.copy2(sync_facade, dest_pkg / "sync" / "__init__.py") - return root - - -@pytest.fixture(scope="session") -def full_package_sync(generated_tree: Path, tmp_path_factory: pytest.TempPathFactory) -> Path: - """The sync-surface counterpart of `full_package`. - - `generated_tree` already ran `pgn generate` against the full project - (all artifacts, including `python-sync`), so this reuses that one - subprocess call rather than invoking pgn again: it just points at the - sibling `python_sync` artifact directory instead of `python`. - """ - generated_tree_sync = generated_tree.parent.parent / "artifacts" / "python_sync" - root = tmp_path_factory.mktemp("pkg-sync") - shell_src = GOLDEN_DIR_SYNC / "src" - generated_src = generated_tree_sync / "src" - _ = shutil.copytree(shell_src, root / "src", ignore=shutil.ignore_patterns("_generated", "__init__.py")) - for pkg_dir in generated_src.iterdir(): - dest_pkg = root / "src" / pkg_dir.name - _ = shutil.copytree(pkg_dir / "_generated", dest_pkg / "_generated") - _ = shutil.copy2(pkg_dir / "__init__.py", dest_pkg / "__init__.py") + packages = [path for path in generated_src.iterdir() if path.is_dir()] + assert len(packages) == 1, f"expected exactly one generated package, found {packages}" + pkg_dir = packages[0] + assert pkg_dir.name == "specimen_client" + + _ = shutil.copytree( + shell_src, + root / "src", + ignore=shutil.ignore_patterns("_generated", "__init__.py", "sync"), + ) + dest_pkg = root / "src" / pkg_dir.name + _ = shutil.copytree(pkg_dir / "_generated", dest_pkg / "_generated") + _ = shutil.copy2(pkg_dir / "__init__.py", dest_pkg / "__init__.py") + _ = shutil.copytree(pkg_dir / "sync", dest_pkg / "sync") return root diff --git a/tests/fixture-project/project1.pgn.yaml b/tests/fixture-project/project1.pgn.yaml index c6fc885..4a40523 100644 --- a/tests/fixture-project/project1.pgn.yaml +++ b/tests/fixture-project/project1.pgn.yaml @@ -7,11 +7,7 @@ artifacts: gen: ../../src/package.dhall config: packageName: specimen-client - python-sync: - gen: ../../src/package.dhall - config: - packageName: specimen-sync-client - sync: true + emitSync: true # The variants below pin pgn's actual YAML->Dhall decode semantics for the # Optional Config knobs (undocumented by pgn itself); see # tests/test_config_variants.py for the assertions. @@ -22,19 +18,18 @@ artifacts: python-sync-only: gen: ../../src/package.dhall config: - sync: true + emitSync: true + python-explicit-false: + gen: ../../src/package.dhall + config: + emitSync: false python-empty: gen: ../../src/package.dhall config: {} python-bare: gen: ../../src/package.dhall - python-unknown-key: - gen: ../../src/package.dhall - config: - packageName: unknown-key-client - bogusField: 1 python-null: gen: ../../src/package.dhall config: packageName: null-client - sync: null + emitSync: null diff --git a/tests/golden/README.md b/tests/golden/README.md index 40b9074..66d8589 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -4,15 +4,16 @@ A committed full fixture package: the hand-written shell (`pyproject.toml`, `src/specimen_client/py.typed`) plus the generated subtree under `src/specimen_client/_generated/` and the generated package-root facade `src/specimen_client/__init__.py`. The generator emits the `_generated/` subtree and -the facade; the rest of the shell is hand-written and committed here as a fixture. +the root and sync facades; the rest of the shell is hand-written and committed +here as a fixture. `test_generated_matches_golden` regenerates the fixture client into a temp tree -and asserts every file under the fresh `_generated/` plus the facade equals its -golden twin, both ways (no missing, no extra). +and asserts every generated file under the fresh package, including both facades, +equals its golden twin, both ways (no missing, no extra). `test_generated_passes_basedpyright_strict` runs basedpyright strict on the full -golden package (shell + facade + `_generated`) so the strictness guarantee covers -the real consumer layout. This `README.md` and the hand-written shell stay out of -the byte-comparison. +fresh package (hand-written shell overlaid with both facades and `_generated`) so +the strictness guarantee covers the real consumer layout. This `README.md` and +the hand-written shell stay out of the byte-comparison. ## Updating the golden @@ -30,4 +31,5 @@ The task (see `mise.toml`) copies the fixture project to a temp dir, cuts it down to the `python` artifact (a full 7-artifact generate peaks at ~31 GB RSS, a single-artifact one at ~10 GB), regenerates from the working-tree `gen/` with a fresh resolve (no stale `freeze1.pgn.yaml`), and rsyncs the -`_generated/` subtree plus both facades back into the golden tree. +`_generated/` subtree plus both facades back into the golden tree. The old +separate sync golden is removed only after all combined copies succeed. diff --git a/tests/golden/src/specimen_client/__init__.py b/tests/golden/src/specimen_client/__init__.py index 7ca58d6..ac5e76d 100644 --- a/tests/golden/src/specimen_client/__init__.py +++ b/tests/golden/src/specimen_client/__init__.py @@ -20,6 +20,10 @@ from ._generated.statements.list_specimens_keyword_column import list_specimens_keyword_column as list_specimens_keyword_column, ListSpecimensKeywordColumnRow as ListSpecimensKeywordColumnRow from ._generated.statements.search_specimens import search_specimens as search_specimens, SearchSpecimensRow as SearchSpecimensRow +from ._generated._register import register_types as register_types + +from . import sync as sync + __all__ = [ "JsonValue", "NoRowError", @@ -47,4 +51,6 @@ "list_specimens_by_moods", "list_specimens_keyword_column", "search_specimens", + "register_types", + "sync", ] diff --git a/tests/golden/src/specimen_client/_generated/_register.py b/tests/golden/src/specimen_client/_generated/_register.py index b42a0bd..be3da54 100644 --- a/tests/golden/src/specimen_client/_generated/_register.py +++ b/tests/golden/src/specimen_client/_generated/_register.py @@ -4,7 +4,7 @@ from __future__ import annotations -from psycopg import AsyncConnection +from psycopg import AsyncConnection, Connection from psycopg.types import TypeInfo from psycopg.types.composite import CompositeInfo, register_composite @@ -24,3 +24,16 @@ async def register_types(conn: AsyncConnection[object]) -> None: if enum_info is None: raise LookupError(f"enum type {name!r} not found; cannot register it") enum_info.register(conn) + + +def register_types_sync(conn: Connection[object]) -> None: + for name in _COMPOSITE_TYPES: + composite_info = CompositeInfo.fetch(conn, name) + if composite_info is None: + raise LookupError(f"composite type {name!r} not found; cannot register it") + register_composite(composite_info, conn) + for name in _ENUM_TYPES: + enum_info = TypeInfo.fetch(conn, name) + if enum_info is None: + raise LookupError(f"enum type {name!r} not found; cannot register it") + enum_info.register(conn) diff --git a/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py b/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py index c034037..6d27de3 100644 --- a/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py +++ b/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py @@ -4,9 +4,10 @@ from __future__ import annotations -from psycopg import AsyncConnection +from psycopg import AsyncConnection, Connection from .._runtime import execute_rows_affected as _execute_rows_affected +from ..sync._runtime import execute_rows_affected as _execute_rows_affected_sync SQL = """\ -- rows_affected: UPDATE without RETURNING, keyed by id; exercises the rowcount @@ -28,3 +29,14 @@ async def bump_specimen_revision( "id": id, } return await _execute_rows_affected(conn, _SQL, params) + + +def bump_specimen_revision_sync( + conn: Connection[object], + *, + id: int | None, +) -> int: + params: dict[str, object] = { + "id": id, + } + return _execute_rows_affected_sync(conn, _SQL, params) diff --git a/tests/golden/src/specimen_client/_generated/statements/get_specimen.py b/tests/golden/src/specimen_client/_generated/statements/get_specimen.py index ccfc4b5..37a3673 100644 --- a/tests/golden/src/specimen_client/_generated/statements/get_specimen.py +++ b/tests/golden/src/specimen_client/_generated/statements/get_specimen.py @@ -11,10 +11,11 @@ from typing import cast as _cast from uuid import UUID -from psycopg import AsyncConnection +from psycopg import AsyncConnection, Connection from .._core import JsonValue from .._runtime import fetch_optional as _fetch_optional +from ..sync._runtime import fetch_optional as _fetch_optional_sync from ..types.mood import Mood from ..types.point_2_d import Point2D @@ -116,3 +117,14 @@ async def get_specimen( "id": id, } return await _fetch_optional(conn, _SQL, params, _decode_row) + + +def get_specimen_sync( + conn: Connection[object], + *, + id: int | None, +) -> GetSpecimenRow | None: + params: dict[str, object] = { + "id": id, + } + return _fetch_optional_sync(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py b/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py index 9910f32..ec8c873 100644 --- a/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py +++ b/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py @@ -8,9 +8,10 @@ from dataclasses import dataclass from typing import cast as _cast -from psycopg import AsyncConnection +from psycopg import AsyncConnection, Connection from .._runtime import fetch_optional as _fetch_optional +from ..sync._runtime import fetch_optional as _fetch_optional_sync from ..types.tag_value import TagValue @@ -51,3 +52,14 @@ async def get_tagged_item( "id": id, } return await _fetch_optional(conn, _SQL, params, _decode_row) + + +def get_tagged_item_sync( + conn: Connection[object], + *, + id: int | None, +) -> GetTaggedItemRow | None: + params: dict[str, object] = { + "id": id, + } + return _fetch_optional_sync(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py b/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py index 66cbe0b..15ab613 100644 --- a/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py +++ b/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py @@ -11,13 +11,14 @@ from typing import cast as _cast from uuid import UUID -from psycopg import AsyncConnection +from psycopg import AsyncConnection, Connection from psycopg.types.json import Json from psycopg.types.json import Jsonb from .._core import JsonValue from .._core import require_array as _require_array from .._runtime import fetch_single as _fetch_single +from ..sync._runtime import fetch_single as _fetch_single_sync from ..types.mood import Mood from ..types.point_2_d import Point2D @@ -190,3 +191,62 @@ async def insert_specimen( "origin": None if origin is None else origin.pg_encode(), } return await _fetch_single(conn, _SQL, params, _decode_row) + + +def insert_specimen_sync( + conn: Connection[object], + *, + flag: bool, + small: int, + medium: int, + large: int, + ratio: float, + precise: float, + title: str, + code: str, + letter: str, + born_on: date, + amount: Decimal, + blob: bytes, + doc_json: JsonValue, + doc_jsonb: JsonValue, + maybe_text: str | None, + maybe_int: int | None, + maybe_uuid: UUID | None, + maybe_ts: datetime | None, + maybe_num: Decimal | None, + tags: list[str | None], + related_ids: list[UUID | None] | None, + grid: list[int | None] | None, + feeling: Mood, + moods: list[Mood | None] | None, + origin: Point2D | None, +) -> InsertSpecimenRow: + params: dict[str, object] = { + "flag": flag, + "small": small, + "medium": medium, + "large": large, + "ratio": ratio, + "precise": precise, + "title": title, + "code": code, + "letter": letter, + "born_on": born_on, + "amount": amount, + "blob": blob, + "doc_json": Json(doc_json), + "doc_jsonb": Jsonb(doc_jsonb), + "maybe_text": maybe_text, + "maybe_int": maybe_int, + "maybe_uuid": maybe_uuid, + "maybe_ts": maybe_ts, + "maybe_num": maybe_num, + "tags": tags, + "related_ids": related_ids, + "grid": grid, + "feeling": feeling.pg_encode(), + "moods": None if moods is None else [None if x is None else x.pg_encode() for x in moods], + "origin": None if origin is None else origin.pg_encode(), + } + return _fetch_single_sync(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py b/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py index 3e0542d..a9e6a0c 100644 --- a/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py +++ b/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py @@ -8,9 +8,10 @@ from dataclasses import dataclass from typing import cast as _cast -from psycopg import AsyncConnection +from psycopg import AsyncConnection, Connection from .._runtime import fetch_single as _fetch_single +from ..sync._runtime import fetch_single as _fetch_single_sync from ..types.tag_value import TagValue @@ -51,3 +52,16 @@ async def insert_tagged_item( "tag": tag.pg_encode(), } return await _fetch_single(conn, _SQL, params, _decode_row) + + +def insert_tagged_item_sync( + conn: Connection[object], + *, + name: str, + tag: TagValue, +) -> InsertTaggedItemRow: + params: dict[str, object] = { + "name": name, + "tag": tag.pg_encode(), + } + return _fetch_single_sync(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py index 12ae7f1..8b0546f 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py @@ -8,9 +8,10 @@ from dataclasses import dataclass from typing import cast as _cast -from psycopg import AsyncConnection +from psycopg import AsyncConnection, Connection from .._runtime import fetch_many as _fetch_many +from ..sync._runtime import fetch_many as _fetch_many_sync @dataclass(frozen=True, slots=True) @@ -51,3 +52,14 @@ async def list_specimens_by_class( "class": class_, } return await _fetch_many(conn, _SQL, params, _decode_row) + + +def list_specimens_by_class_sync( + conn: Connection[object], + *, + class_: str | None, +) -> list[ListSpecimensByClassRow]: + params: dict[str, object] = { + "class": class_, + } + return _fetch_many_sync(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py index fe552d0..b99ddf0 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py @@ -9,10 +9,11 @@ from typing import cast as _cast from uuid import UUID -from psycopg import AsyncConnection +from psycopg import AsyncConnection, Connection from .._core import JsonValue from .._runtime import fetch_many as _fetch_many +from ..sync._runtime import fetch_many as _fetch_many_sync from ..types.mood import Mood from ..types.point_2_d import Point2D @@ -65,3 +66,14 @@ async def list_specimens_by_feeling( "feeling": None if feeling is None else feeling.pg_encode(), } return await _fetch_many(conn, _SQL, params, _decode_row) + + +def list_specimens_by_feeling_sync( + conn: Connection[object], + *, + feeling: Mood | None, +) -> list[ListSpecimensByFeelingRow]: + params: dict[str, object] = { + "feeling": None if feeling is None else feeling.pg_encode(), + } + return _fetch_many_sync(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py index 2bca0a3..f15f826 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py @@ -9,9 +9,10 @@ from typing import cast as _cast from uuid import UUID -from psycopg import AsyncConnection +from psycopg import AsyncConnection, Connection from .._runtime import fetch_many as _fetch_many +from ..sync._runtime import fetch_many as _fetch_many_sync from ..types.mood import Mood @@ -53,3 +54,14 @@ async def list_specimens_by_ids( "pub_ids": pub_ids, } return await _fetch_many(conn, _SQL, params, _decode_row) + + +def list_specimens_by_ids_sync( + conn: Connection[object], + *, + pub_ids: list[UUID | None] | None, +) -> list[ListSpecimensByIdsRow]: + params: dict[str, object] = { + "pub_ids": pub_ids, + } + return _fetch_many_sync(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py index 4f54c5c..0c222ec 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py @@ -9,10 +9,11 @@ from typing import cast as _cast from uuid import UUID -from psycopg import AsyncConnection +from psycopg import AsyncConnection, Connection from .._core import require_array as _require_array from .._runtime import fetch_many as _fetch_many +from ..sync._runtime import fetch_many as _fetch_many_sync from ..types.mood import Mood @@ -56,3 +57,14 @@ async def list_specimens_by_moods( "moods": None if moods is None else [None if x is None else x.pg_encode() for x in moods], } return await _fetch_many(conn, _SQL, params, _decode_row) + + +def list_specimens_by_moods_sync( + conn: Connection[object], + *, + moods: list[Mood | None] | None, +) -> list[ListSpecimensByMoodsRow]: + params: dict[str, object] = { + "moods": None if moods is None else [None if x is None else x.pg_encode() for x in moods], + } + return _fetch_many_sync(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py index ee0d780..6c7ab32 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py @@ -8,9 +8,10 @@ from dataclasses import dataclass from typing import cast as _cast -from psycopg import AsyncConnection +from psycopg import AsyncConnection, Connection from .._runtime import fetch_many as _fetch_many +from ..sync._runtime import fetch_many as _fetch_many_sync @dataclass(frozen=True, slots=True) @@ -47,3 +48,10 @@ async def list_specimens_keyword_column( ) -> list[ListSpecimensKeywordColumnRow]: params: dict[str, object] = {} return await _fetch_many(conn, _SQL, params, _decode_row) + + +def list_specimens_keyword_column_sync( + conn: Connection[object], +) -> list[ListSpecimensKeywordColumnRow]: + params: dict[str, object] = {} + return _fetch_many_sync(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/search_specimens.py b/tests/golden/src/specimen_client/_generated/statements/search_specimens.py index 2065a64..8cd04ca 100644 --- a/tests/golden/src/specimen_client/_generated/statements/search_specimens.py +++ b/tests/golden/src/specimen_client/_generated/statements/search_specimens.py @@ -8,11 +8,12 @@ from dataclasses import dataclass from typing import cast as _cast -from psycopg import AsyncConnection +from psycopg import AsyncConnection, Connection from psycopg.types.json import Jsonb from .._core import JsonValue from .._runtime import fetch_many as _fetch_many +from ..sync._runtime import fetch_many as _fetch_many_sync @dataclass(frozen=True, slots=True) @@ -62,3 +63,18 @@ async def search_specimens( "label": label, } return await _fetch_many(conn, _SQL, params, _decode_row) + + +def search_specimens_sync( + conn: Connection[object], + *, + title_like: str | None, + meta_filter: JsonValue | None, + label: str | None, +) -> list[SearchSpecimensRow]: + params: dict[str, object] = { + "title_like": title_like, + "meta_filter": None if meta_filter is None else Jsonb(meta_filter), + "label": label, + } + return _fetch_many_sync(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/_runtime.py b/tests/golden/src/specimen_client/_generated/sync/_runtime.py similarity index 94% rename from tests/golden_sync/src/specimen_sync_client/_generated/_runtime.py rename to tests/golden/src/specimen_client/_generated/sync/_runtime.py index ced0afa..312a249 100644 --- a/tests/golden_sync/src/specimen_sync_client/_generated/_runtime.py +++ b/tests/golden/src/specimen_client/_generated/sync/_runtime.py @@ -10,7 +10,7 @@ from psycopg import Connection from psycopg.rows import dict_row -from ._core import JsonValue as JsonValue, NoRowError as NoRowError, require_array as require_array +from .._core import JsonValue as JsonValue, NoRowError as NoRowError, require_array as require_array _T = TypeVar("_T") _Row = Mapping[str, object] diff --git a/tests/golden/src/specimen_client/sync/__init__.py b/tests/golden/src/specimen_client/sync/__init__.py new file mode 100644 index 0000000..aadd327 --- /dev/null +++ b/tests/golden/src/specimen_client/sync/__init__.py @@ -0,0 +1,53 @@ +# @generated by python.gen (pGenie). DO NOT EDIT. +# SPDX-FileCopyrightText: 2026 Viacheslav Shvets +# SPDX-License-Identifier: MIT-0 + +from .._generated._core import JsonValue as JsonValue, NoRowError as NoRowError + +from .._generated.types.mood import Mood as Mood +from .._generated.types.point_2_d import Point2D as Point2D +from .._generated.types.tag_value import TagValue as TagValue + +from .._generated.statements.bump_specimen_revision import bump_specimen_revision_sync as bump_specimen_revision +from .._generated.statements.get_specimen import get_specimen_sync as get_specimen, GetSpecimenRow as GetSpecimenRow +from .._generated.statements.get_tagged_item import get_tagged_item_sync as get_tagged_item, GetTaggedItemRow as GetTaggedItemRow +from .._generated.statements.insert_specimen import insert_specimen_sync as insert_specimen, InsertSpecimenRow as InsertSpecimenRow +from .._generated.statements.insert_tagged_item import insert_tagged_item_sync as insert_tagged_item, InsertTaggedItemRow as InsertTaggedItemRow +from .._generated.statements.list_specimens_by_class import list_specimens_by_class_sync as list_specimens_by_class, ListSpecimensByClassRow as ListSpecimensByClassRow +from .._generated.statements.list_specimens_by_feeling import list_specimens_by_feeling_sync as list_specimens_by_feeling, ListSpecimensByFeelingRow as ListSpecimensByFeelingRow +from .._generated.statements.list_specimens_by_ids import list_specimens_by_ids_sync as list_specimens_by_ids, ListSpecimensByIdsRow as ListSpecimensByIdsRow +from .._generated.statements.list_specimens_by_moods import list_specimens_by_moods_sync as list_specimens_by_moods, ListSpecimensByMoodsRow as ListSpecimensByMoodsRow +from .._generated.statements.list_specimens_keyword_column import list_specimens_keyword_column_sync as list_specimens_keyword_column, ListSpecimensKeywordColumnRow as ListSpecimensKeywordColumnRow +from .._generated.statements.search_specimens import search_specimens_sync as search_specimens, SearchSpecimensRow as SearchSpecimensRow + +from .._generated._register import register_types_sync as register_types + +__all__ = [ + "JsonValue", + "NoRowError", + "Mood", + "Point2D", + "TagValue", + "GetSpecimenRow", + "GetTaggedItemRow", + "InsertSpecimenRow", + "InsertTaggedItemRow", + "ListSpecimensByClassRow", + "ListSpecimensByFeelingRow", + "ListSpecimensByIdsRow", + "ListSpecimensByMoodsRow", + "ListSpecimensKeywordColumnRow", + "SearchSpecimensRow", + "bump_specimen_revision", + "get_specimen", + "get_tagged_item", + "insert_specimen", + "insert_tagged_item", + "list_specimens_by_class", + "list_specimens_by_feeling", + "list_specimens_by_ids", + "list_specimens_by_moods", + "list_specimens_keyword_column", + "search_specimens", + "register_types", +] diff --git a/tests/golden_sync/README.md b/tests/golden_sync/README.md deleted file mode 100644 index 6f6d14b..0000000 --- a/tests/golden_sync/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Golden files (sync surface) - -The sync-surface counterpart of `tests/golden/`: a committed full fixture -package generated with `config: { sync: true }`, package name -`specimen_sync_client`. Same structure and purpose as `tests/golden/README.md` -describes for the async (default) surface — the hand-written shell here -(`pyproject.toml`, `py.typed`) plus the generated `_generated/` subtree and -the generated package-root facade `src/specimen_sync_client/__init__.py`. - -`test_generated_sync_matches_golden` regenerates the `python-sync` fixture -artifact into a temp tree and asserts every file under the fresh -`_generated/` plus the facade equals its golden twin here, both ways (no -missing, no extra). `test_generated_sync_passes_basedpyright_strict` runs -basedpyright strict on this full golden package. - -## Updating the golden - -Run only when a generator change legitimately alters the sync surface's -output, and review the resulting diff before committing. - -```bash -mise run golden -``` - -`mise run golden` refreshes both `tests/golden/` (the `python` artifact) and -this directory (the `python-sync` artifact) in one pass — see `mise.toml`. \ No newline at end of file diff --git a/tests/golden_sync/pyproject.toml b/tests/golden_sync/pyproject.toml deleted file mode 100644 index 26a00bf..0000000 --- a/tests/golden_sync/pyproject.toml +++ /dev/null @@ -1,15 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "specimen-sync-client" -version = "0.0.0" -requires-python = ">=3.12" -dependencies = ["psycopg>=3.2"] - -[tool.hatch.build.targets.wheel] -packages = ["src/specimen_sync_client"] - -[tool.ruff] -exclude = ["src/specimen_sync_client/_generated"] \ No newline at end of file diff --git a/tests/golden_sync/src/specimen_sync_client/__init__.py b/tests/golden_sync/src/specimen_sync_client/__init__.py deleted file mode 100644 index 7ca58d6..0000000 --- a/tests/golden_sync/src/specimen_sync_client/__init__.py +++ /dev/null @@ -1,50 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from ._generated._core import JsonValue as JsonValue, NoRowError as NoRowError - -from ._generated.types.mood import Mood as Mood -from ._generated.types.point_2_d import Point2D as Point2D -from ._generated.types.tag_value import TagValue as TagValue - -from ._generated.statements.bump_specimen_revision import bump_specimen_revision as bump_specimen_revision -from ._generated.statements.get_specimen import get_specimen as get_specimen, GetSpecimenRow as GetSpecimenRow -from ._generated.statements.get_tagged_item import get_tagged_item as get_tagged_item, GetTaggedItemRow as GetTaggedItemRow -from ._generated.statements.insert_specimen import insert_specimen as insert_specimen, InsertSpecimenRow as InsertSpecimenRow -from ._generated.statements.insert_tagged_item import insert_tagged_item as insert_tagged_item, InsertTaggedItemRow as InsertTaggedItemRow -from ._generated.statements.list_specimens_by_class import list_specimens_by_class as list_specimens_by_class, ListSpecimensByClassRow as ListSpecimensByClassRow -from ._generated.statements.list_specimens_by_feeling import list_specimens_by_feeling as list_specimens_by_feeling, ListSpecimensByFeelingRow as ListSpecimensByFeelingRow -from ._generated.statements.list_specimens_by_ids import list_specimens_by_ids as list_specimens_by_ids, ListSpecimensByIdsRow as ListSpecimensByIdsRow -from ._generated.statements.list_specimens_by_moods import list_specimens_by_moods as list_specimens_by_moods, ListSpecimensByMoodsRow as ListSpecimensByMoodsRow -from ._generated.statements.list_specimens_keyword_column import list_specimens_keyword_column as list_specimens_keyword_column, ListSpecimensKeywordColumnRow as ListSpecimensKeywordColumnRow -from ._generated.statements.search_specimens import search_specimens as search_specimens, SearchSpecimensRow as SearchSpecimensRow - -__all__ = [ - "JsonValue", - "NoRowError", - "Mood", - "Point2D", - "TagValue", - "GetSpecimenRow", - "GetTaggedItemRow", - "InsertSpecimenRow", - "InsertTaggedItemRow", - "ListSpecimensByClassRow", - "ListSpecimensByFeelingRow", - "ListSpecimensByIdsRow", - "ListSpecimensByMoodsRow", - "ListSpecimensKeywordColumnRow", - "SearchSpecimensRow", - "bump_specimen_revision", - "get_specimen", - "get_tagged_item", - "insert_specimen", - "insert_tagged_item", - "list_specimens_by_class", - "list_specimens_by_feeling", - "list_specimens_by_ids", - "list_specimens_by_moods", - "list_specimens_keyword_column", - "search_specimens", -] diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/__init__.py b/tests/golden_sync/src/specimen_sync_client/_generated/__init__.py deleted file mode 100644 index 3117013..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -"""Generated database client for specimen-sync-client.""" - -__all__: list[str] = [] diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/_core.py b/tests/golden_sync/src/specimen_sync_client/_generated/_core.py deleted file mode 100644 index 8927292..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/_core.py +++ /dev/null @@ -1,35 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -"""Shared types and decode helpers, surface-agnostic; no I/O.""" - -from __future__ import annotations - -from typing import cast - -type JsonValue = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] - - -class NoRowError(RuntimeError): - """A single-row query returned no rows.""" - - -class DecodeError(RuntimeError): - """A row value failed to decode into its target type.""" - - -def require_array(value: object) -> list[object]: - """Guard an enum-array column decode. - - psycopg returns an enum array as a Python list only when the enum type is - registered on the connection (register_types); without it the value comes - back as the raw array text, which would iterate into bogus members. Fail - clearly instead. - """ - if isinstance(value, list): - return cast(list[object], value) - raise RuntimeError( - "enum array decoded as text; call register_types() on the connection " - "before decoding enum-array columns" - ) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/_register.py b/tests/golden_sync/src/specimen_sync_client/_generated/_register.py deleted file mode 100644 index f4ffb13..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/_register.py +++ /dev/null @@ -1,26 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from psycopg import Connection -from psycopg.types import TypeInfo -from psycopg.types.composite import CompositeInfo, register_composite - - -_COMPOSITE_TYPES = ("public.point2d", "public.tag_value",) -_ENUM_TYPES = ("public.mood",) - - -def register_types(conn: Connection[object]) -> None: - for name in _COMPOSITE_TYPES: - composite_info = CompositeInfo.fetch(conn, name) - if composite_info is None: - raise LookupError(f"composite type {name!r} not found; cannot register it") - register_composite(composite_info, conn) - for name in _ENUM_TYPES: - enum_info = TypeInfo.fetch(conn, name) - if enum_info is None: - raise LookupError(f"enum type {name!r} not found; cannot register it") - enum_info.register(conn) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/__init__.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/__init__.py deleted file mode 100644 index 3c3ae7f..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -"""Generated SQL statements.""" - -__all__: list[str] = [] diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/bump_specimen_revision.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/bump_specimen_revision.py deleted file mode 100644 index 5b1a178..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/bump_specimen_revision.py +++ /dev/null @@ -1,30 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from psycopg import Connection - -from .._runtime import execute_rows_affected as _execute_rows_affected - -SQL = """\ --- rows_affected: UPDATE without RETURNING, keyed by id; exercises the rowcount --- execution path. -UPDATE specimen -SET rev = rev + 1 -WHERE id = %(id)s -""" - -_SQL = SQL.encode() - - -def bump_specimen_revision( - conn: Connection[object], - *, - id: int | None, -) -> int: - params: dict[str, object] = { - "id": id, - } - return _execute_rows_affected(conn, _SQL, params) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_specimen.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_specimen.py deleted file mode 100644 index 8cdd370..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_specimen.py +++ /dev/null @@ -1,118 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from datetime import date, datetime -from decimal import Decimal -from typing import cast as _cast -from uuid import UUID - -from psycopg import Connection - -from .._core import JsonValue -from .._runtime import fetch_optional as _fetch_optional -from ..types.mood import Mood -from ..types.point_2_d import Point2D - - -@dataclass(frozen=True, slots=True) -class GetSpecimenRow: - id: int - pub_id: UUID - flag: bool - small: int - medium: int - large: int - ratio: float - precise: float - title: str - code: str - letter: str - born_on: date - created_at: datetime - amount: Decimal - blob: bytes - doc_json: JsonValue - doc_jsonb: JsonValue - maybe_text: str | None - maybe_int: int | None - maybe_uuid: UUID | None - maybe_ts: datetime | None - maybe_num: Decimal | None - tags: list[str | None] - related_ids: list[UUID | None] | None - grid: list[int | None] | None - feeling: Mood - origin: Point2D | None - label: str - rev: int - meta: JsonValue - - -def _decode_row(row: Mapping[str, object]) -> GetSpecimenRow: - return GetSpecimenRow( - id=_cast(int, row["id"]), - pub_id=_cast(UUID, row["pub_id"]), - flag=_cast(bool, row["flag"]), - small=_cast(int, row["small"]), - medium=_cast(int, row["medium"]), - large=_cast(int, row["large"]), - ratio=_cast(float, row["ratio"]), - precise=_cast(float, row["precise"]), - title=_cast(str, row["title"]), - code=_cast(str, row["code"]), - letter=_cast(str, row["letter"]), - born_on=_cast(date, row["born_on"]), - created_at=_cast(datetime, row["created_at"]), - amount=_cast(Decimal, row["amount"]), - blob=_cast(bytes, row["blob"]), - doc_json=_cast(JsonValue, row["doc_json"]), - doc_jsonb=_cast(JsonValue, row["doc_jsonb"]), - maybe_text=_cast(str | None, row["maybe_text"]), - maybe_int=_cast(int | None, row["maybe_int"]), - maybe_uuid=_cast(UUID | None, row["maybe_uuid"]), - maybe_ts=_cast(datetime | None, row["maybe_ts"]), - maybe_num=_cast(Decimal | None, row["maybe_num"]), - tags=_cast(list[str | None], row["tags"]), - related_ids=_cast(list[UUID | None] | None, row["related_ids"]), - grid=_cast(list[int | None] | None, row["grid"]), - feeling=Mood.pg_decode(row["feeling"]), - origin=None if row["origin"] is None else Point2D.pg_decode(row["origin"]), - label=_cast(str, row["label"]), - rev=_cast(int, row["rev"]), - meta=_cast(JsonValue, row["meta"]), - ) - - -SQL = """\ --- zero_or_one: select by pk with limit 1. -SELECT - id, pub_id, - flag, small, medium, large, ratio, precise, - title, code, letter, born_on, created_at, amount, blob, - doc_json, doc_jsonb, - maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, - tags, related_ids, grid, - feeling, origin, - label, rev, meta -FROM specimen -WHERE id = %(id)s -LIMIT 1 -""" - -_SQL = SQL.encode() - - -def get_specimen( - conn: Connection[object], - *, - id: int | None, -) -> GetSpecimenRow | None: - params: dict[str, object] = { - "id": id, - } - return _fetch_optional(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_tagged_item.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_tagged_item.py deleted file mode 100644 index 51f9b84..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/get_tagged_item.py +++ /dev/null @@ -1,53 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from typing import cast as _cast - -from psycopg import Connection - -from .._runtime import fetch_optional as _fetch_optional -from ..types.tag_value import TagValue - - -@dataclass(frozen=True, slots=True) -class GetTaggedItemRow: - id: int - name: str - tag: TagValue - - -def _decode_row(row: Mapping[str, object]) -> GetTaggedItemRow: - return GetTaggedItemRow( - id=_cast(int, row["id"]), - name=_cast(str, row["name"]), - tag=TagValue.pg_decode(row["tag"]), - ) - - -SQL = """\ --- zero_or_one: select by pk with limit 1; the single-field composite here is --- purely a result column (no parameter use). -SELECT - id, name, tag -FROM tagged_item -WHERE id = %(id)s -LIMIT 1 -""" - -_SQL = SQL.encode() - - -def get_tagged_item( - conn: Connection[object], - *, - id: int | None, -) -> GetTaggedItemRow | None: - params: dict[str, object] = { - "id": id, - } - return _fetch_optional(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py deleted file mode 100644 index 3c6e197..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_specimen.py +++ /dev/null @@ -1,192 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from datetime import date, datetime -from decimal import Decimal -from typing import cast as _cast -from uuid import UUID - -from psycopg import Connection -from psycopg.types.json import Json -from psycopg.types.json import Jsonb - -from .._core import JsonValue -from .._core import require_array as _require_array -from .._runtime import fetch_single as _fetch_single -from ..types.mood import Mood -from ..types.point_2_d import Point2D - - -@dataclass(frozen=True, slots=True) -class InsertSpecimenRow: - id: int - pub_id: UUID - flag: bool - small: int - medium: int - large: int - ratio: float - precise: float - title: str - code: str - letter: str - born_on: date - created_at: datetime - amount: Decimal - blob: bytes - doc_json: JsonValue - doc_jsonb: JsonValue - maybe_text: str | None - maybe_int: int | None - maybe_uuid: UUID | None - maybe_ts: datetime | None - maybe_num: Decimal | None - tags: list[str | None] - related_ids: list[UUID | None] | None - grid: list[int | None] | None - feeling: Mood - moods: list[Mood | None] | None - origin: Point2D | None - label: str - rev: int - meta: JsonValue - - -def _decode_row(row: Mapping[str, object]) -> InsertSpecimenRow: - return InsertSpecimenRow( - id=_cast(int, row["id"]), - pub_id=_cast(UUID, row["pub_id"]), - flag=_cast(bool, row["flag"]), - small=_cast(int, row["small"]), - medium=_cast(int, row["medium"]), - large=_cast(int, row["large"]), - ratio=_cast(float, row["ratio"]), - precise=_cast(float, row["precise"]), - title=_cast(str, row["title"]), - code=_cast(str, row["code"]), - letter=_cast(str, row["letter"]), - born_on=_cast(date, row["born_on"]), - created_at=_cast(datetime, row["created_at"]), - amount=_cast(Decimal, row["amount"]), - blob=_cast(bytes, row["blob"]), - doc_json=_cast(JsonValue, row["doc_json"]), - doc_jsonb=_cast(JsonValue, row["doc_jsonb"]), - maybe_text=_cast(str | None, row["maybe_text"]), - maybe_int=_cast(int | None, row["maybe_int"]), - maybe_uuid=_cast(UUID | None, row["maybe_uuid"]), - maybe_ts=_cast(datetime | None, row["maybe_ts"]), - maybe_num=_cast(Decimal | None, row["maybe_num"]), - tags=_cast(list[str | None], row["tags"]), - related_ids=_cast(list[UUID | None] | None, row["related_ids"]), - grid=_cast(list[int | None] | None, row["grid"]), - feeling=Mood.pg_decode(row["feeling"]), - moods=None if row["moods"] is None else [None if v is None else Mood.pg_decode(v) for v in _cast(list[str | None], _require_array(row["moods"]))], - origin=None if row["origin"] is None else Point2D.pg_decode(row["origin"]), - label=_cast(str, row["label"]), - rev=_cast(int, row["rev"]), - meta=_cast(JsonValue, row["meta"]), - ) - - -SQL = """\ --- single row: insert ... returning the full type surface. --- jsonb param ($doc_jsonb), enum param ($feeling), composite param ($origin). --- The domain columns (label, rev, meta) get literal/default values rather than --- parameters. pgn cannot bind a parameter to a checked domain column: a --- raw domain param is rejected, and a base-type-cast param fails the domain --- CHECK against the synthetic probe value. Domain mapping is still exercised --- through the RETURNING clause and the read queries below. -INSERT INTO specimen ( - flag, small, medium, large, ratio, precise, - title, code, letter, born_on, amount, blob, - doc_json, doc_jsonb, - maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, - tags, related_ids, grid, - feeling, moods, origin, - label, rev, meta -) -VALUES ( - %(flag)s, %(small)s, %(medium)s, %(large)s, %(ratio)s, %(precise)s, - %(title)s, %(code)s, %(letter)s, %(born_on)s, %(amount)s, %(blob)s, - %(doc_json)s::json, %(doc_jsonb)s::jsonb, - %(maybe_text)s, %(maybe_int)s, %(maybe_uuid)s, %(maybe_ts)s, %(maybe_num)s, - %(tags)s, %(related_ids)s, %(grid)s, - %(feeling)s, %(moods)s::mood[], %(origin)s, - 'specimen', 1, '{}'::jsonb -) -RETURNING - id, pub_id, - flag, small, medium, large, ratio, precise, - title, code, letter, born_on, created_at, amount, blob, - doc_json, doc_jsonb, - maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, - tags, related_ids, grid, - feeling, moods, origin, - label, rev, meta -""" - -_SQL = SQL.encode() - - -def insert_specimen( - conn: Connection[object], - *, - flag: bool, - small: int, - medium: int, - large: int, - ratio: float, - precise: float, - title: str, - code: str, - letter: str, - born_on: date, - amount: Decimal, - blob: bytes, - doc_json: JsonValue, - doc_jsonb: JsonValue, - maybe_text: str | None, - maybe_int: int | None, - maybe_uuid: UUID | None, - maybe_ts: datetime | None, - maybe_num: Decimal | None, - tags: list[str | None], - related_ids: list[UUID | None] | None, - grid: list[int | None] | None, - feeling: Mood, - moods: list[Mood | None] | None, - origin: Point2D | None, -) -> InsertSpecimenRow: - params: dict[str, object] = { - "flag": flag, - "small": small, - "medium": medium, - "large": large, - "ratio": ratio, - "precise": precise, - "title": title, - "code": code, - "letter": letter, - "born_on": born_on, - "amount": amount, - "blob": blob, - "doc_json": Json(doc_json), - "doc_jsonb": Jsonb(doc_jsonb), - "maybe_text": maybe_text, - "maybe_int": maybe_int, - "maybe_uuid": maybe_uuid, - "maybe_ts": maybe_ts, - "maybe_num": maybe_num, - "tags": tags, - "related_ids": related_ids, - "grid": grid, - "feeling": feeling.pg_encode(), - "moods": None if moods is None else [None if x is None else x.pg_encode() for x in moods], - "origin": None if origin is None else origin.pg_encode(), - } - return _fetch_single(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py deleted file mode 100644 index 0cae921..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/insert_tagged_item.py +++ /dev/null @@ -1,53 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from typing import cast as _cast - -from psycopg import Connection - -from .._runtime import fetch_single as _fetch_single -from ..types.tag_value import TagValue - - -@dataclass(frozen=True, slots=True) -class InsertTaggedItemRow: - id: int - name: str - tag: TagValue - - -def _decode_row(row: Mapping[str, object]) -> InsertTaggedItemRow: - return InsertTaggedItemRow( - id=_cast(int, row["id"]), - name=_cast(str, row["name"]), - tag=TagValue.pg_decode(row["tag"]), - ) - - -SQL = """\ --- single row: insert exercising a single-field composite as a parameter and --- (via RETURNING) as a result column, in the same statement. -INSERT INTO tagged_item (name, tag) -VALUES (%(name)s, %(tag)s) -RETURNING id, name, tag -""" - -_SQL = SQL.encode() - - -def insert_tagged_item( - conn: Connection[object], - *, - name: str, - tag: TagValue, -) -> InsertTaggedItemRow: - params: dict[str, object] = { - "name": name, - "tag": tag.pg_encode(), - } - return _fetch_single(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_class.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_class.py deleted file mode 100644 index 25a1908..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_class.py +++ /dev/null @@ -1,53 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from typing import cast as _cast - -from psycopg import Connection - -from .._runtime import fetch_many as _fetch_many - - -@dataclass(frozen=True, slots=True) -class ListSpecimensByClassRow: - id: int - title: str - - -def _decode_row(row: Mapping[str, object]) -> ListSpecimensByClassRow: - return ListSpecimensByClassRow( - id=_cast(int, row["id"]), - title=_cast(str, row["title"]), - ) - - -SQL = """\ --- Keyword-param coverage: the placeholder `class` is a Python reserved word, so --- the generated signature must use `class_` while the params-dict key and the --- %%(class)s placeholder keep the raw name. Without sanitization this query emits --- an un-importable signature, so its presence locks the keyword guard. -SELECT - id, - title -FROM specimen -WHERE title = %(class)s -ORDER BY id ASC -""" - -_SQL = SQL.encode() - - -def list_specimens_by_class( - conn: Connection[object], - *, - class_: str | None, -) -> list[ListSpecimensByClassRow]: - params: dict[str, object] = { - "class": class_, - } - return _fetch_many(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py deleted file mode 100644 index 3c882d6..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_feeling.py +++ /dev/null @@ -1,67 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from typing import cast as _cast -from uuid import UUID - -from psycopg import Connection - -from .._core import JsonValue -from .._runtime import fetch_many as _fetch_many -from ..types.mood import Mood -from ..types.point_2_d import Point2D - - -@dataclass(frozen=True, slots=True) -class ListSpecimensByFeelingRow: - id: int - pub_id: UUID - feeling: Mood - title: str - label: str - rev: int - origin: Point2D | None - tags: list[str | None] - meta: JsonValue - - -def _decode_row(row: Mapping[str, object]) -> ListSpecimensByFeelingRow: - return ListSpecimensByFeelingRow( - id=_cast(int, row["id"]), - pub_id=_cast(UUID, row["pub_id"]), - feeling=Mood.pg_decode(row["feeling"]), - title=_cast(str, row["title"]), - label=_cast(str, row["label"]), - rev=_cast(int, row["rev"]), - origin=None if row["origin"] is None else Point2D.pg_decode(row["origin"]), - tags=_cast(list[str | None], row["tags"]), - meta=_cast(JsonValue, row["meta"]), - ) - - -SQL = """\ --- many: select with order by. Enum parameter ($feeling). -SELECT - id, pub_id, feeling, title, label, rev, origin, tags, meta -FROM specimen -WHERE feeling = %(feeling)s -ORDER BY id ASC -""" - -_SQL = SQL.encode() - - -def list_specimens_by_feeling( - conn: Connection[object], - *, - feeling: Mood | None, -) -> list[ListSpecimensByFeelingRow]: - params: dict[str, object] = { - "feeling": None if feeling is None else feeling.pg_encode(), - } - return _fetch_many(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_ids.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_ids.py deleted file mode 100644 index 4e11126..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_ids.py +++ /dev/null @@ -1,55 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from typing import cast as _cast -from uuid import UUID - -from psycopg import Connection - -from .._runtime import fetch_many as _fetch_many -from ..types.mood import Mood - - -@dataclass(frozen=True, slots=True) -class ListSpecimensByIdsRow: - id: int - pub_id: UUID - feeling: Mood - title: str - - -def _decode_row(row: Mapping[str, object]) -> ListSpecimensByIdsRow: - return ListSpecimensByIdsRow( - id=_cast(int, row["id"]), - pub_id=_cast(UUID, row["pub_id"]), - feeling=Mood.pg_decode(row["feeling"]), - title=_cast(str, row["title"]), - ) - - -SQL = """\ --- many: array parameter via = any($pub_ids). -SELECT - id, pub_id, feeling, title -FROM specimen -WHERE pub_id = ANY(%(pub_ids)s) -ORDER BY id ASC -""" - -_SQL = SQL.encode() - - -def list_specimens_by_ids( - conn: Connection[object], - *, - pub_ids: list[UUID | None] | None, -) -> list[ListSpecimensByIdsRow]: - params: dict[str, object] = { - "pub_ids": pub_ids, - } - return _fetch_many(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py deleted file mode 100644 index 46381ac..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_by_moods.py +++ /dev/null @@ -1,58 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from typing import cast as _cast -from uuid import UUID - -from psycopg import Connection - -from .._core import require_array as _require_array -from .._runtime import fetch_many as _fetch_many -from ..types.mood import Mood - - -@dataclass(frozen=True, slots=True) -class ListSpecimensByMoodsRow: - id: int - pub_id: UUID - feeling: Mood - moods: list[Mood | None] | None - title: str - - -def _decode_row(row: Mapping[str, object]) -> ListSpecimensByMoodsRow: - return ListSpecimensByMoodsRow( - id=_cast(int, row["id"]), - pub_id=_cast(UUID, row["pub_id"]), - feeling=Mood.pg_decode(row["feeling"]), - moods=None if row["moods"] is None else [None if v is None else Mood.pg_decode(v) for v in _cast(list[str | None], _require_array(row["moods"]))], - title=_cast(str, row["title"]), - ) - - -SQL = """\ --- many: enum array parameter via = any($moods::mood[]); returns the enum array column. -SELECT - id, pub_id, feeling, moods, title -FROM specimen -WHERE feeling = ANY(%(moods)s::mood[]) -ORDER BY id ASC -""" - -_SQL = SQL.encode() - - -def list_specimens_by_moods( - conn: Connection[object], - *, - moods: list[Mood | None] | None, -) -> list[ListSpecimensByMoodsRow]: - params: dict[str, object] = { - "moods": None if moods is None else [None if x is None else x.pg_encode() for x in moods], - } - return _fetch_many(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_keyword_column.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_keyword_column.py deleted file mode 100644 index 9bc9195..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/list_specimens_keyword_column.py +++ /dev/null @@ -1,49 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from typing import cast as _cast - -from psycopg import Connection - -from .._runtime import fetch_many as _fetch_many - - -@dataclass(frozen=True, slots=True) -class ListSpecimensKeywordColumnRow: - id: int - class_: str - - -def _decode_row(row: Mapping[str, object]) -> ListSpecimensKeywordColumnRow: - return ListSpecimensKeywordColumnRow( - id=_cast(int, row["id"]), - class_=_cast(str, row["class"]), - ) - - -SQL = """\ --- Keyword result-column coverage: the column aliased to the reserved word class --- must emit a dataclass field and decode kwarg of class_ while the row lookup --- keeps the raw \"class\". Without sanitization the generated _rows.py has a --- SyntaxError, so this query locks the result-column guard (mirrors --- list_specimens_by_class for params). -SELECT - id, - title AS \"class\" -FROM specimen -ORDER BY id ASC -""" - -_SQL = SQL.encode() - - -def list_specimens_keyword_column( - conn: Connection[object], -) -> list[ListSpecimensKeywordColumnRow]: - params: dict[str, object] = {} - return _fetch_many(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/statements/search_specimens.py b/tests/golden_sync/src/specimen_sync_client/_generated/statements/search_specimens.py deleted file mode 100644 index 8130275..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/statements/search_specimens.py +++ /dev/null @@ -1,64 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from typing import cast as _cast - -from psycopg import Connection -from psycopg.types.json import Jsonb - -from .._core import JsonValue -from .._runtime import fetch_many as _fetch_many - - -@dataclass(frozen=True, slots=True) -class SearchSpecimensRow: - id: int - title: str - label: str - meta: JsonValue - - -def _decode_row(row: Mapping[str, object]) -> SearchSpecimensRow: - return SearchSpecimensRow( - id=_cast(int, row["id"]), - title=_cast(str, row["title"]), - label=_cast(str, row["label"]), - meta=_cast(JsonValue, row["meta"]), - ) - - -SQL = """\ --- many: nullable parameter via coalesce, jsonb containment parameter, and a --- domain comparison with the param cast to the base type (pgn cannot bind a --- domain parameter directly). -SELECT - id, title, label, meta -FROM specimen -WHERE - title ILIKE COALESCE(%(title_like)s, '%%') - AND meta @> %(meta_filter)s::jsonb - AND label = COALESCE(%(label)s::text, label) -ORDER BY id ASC -""" - -_SQL = SQL.encode() - - -def search_specimens( - conn: Connection[object], - *, - title_like: str | None, - meta_filter: JsonValue | None, - label: str | None, -) -> list[SearchSpecimensRow]: - params: dict[str, object] = { - "title_like": title_like, - "meta_filter": None if meta_filter is None else Jsonb(meta_filter), - "label": label, - } - return _fetch_many(conn, _SQL, params, _decode_row) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/types/__init__.py b/tests/golden_sync/src/specimen_sync_client/_generated/types/__init__.py deleted file mode 100644 index c8a6ba8..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/types/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from .mood import Mood as Mood -from .point_2_d import Point2D as Point2D -from .tag_value import TagValue as TagValue diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/types/mood.py b/tests/golden_sync/src/specimen_sync_client/_generated/types/mood.py deleted file mode 100644 index 3556da7..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/types/mood.py +++ /dev/null @@ -1,19 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from enum import StrEnum -from typing import cast - - -class Mood(StrEnum): - HAPPY = "happy" - SAD = "sad" - MEH = "meh" - - @staticmethod - def pg_decode(src: object) -> "Mood": - return Mood(cast(str, src)) - - def pg_encode(self) -> "Mood": - return self diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/types/point_2_d.py b/tests/golden_sync/src/specimen_sync_client/_generated/types/point_2_d.py deleted file mode 100644 index e212a40..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/types/point_2_d.py +++ /dev/null @@ -1,25 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from dataclasses import dataclass -from typing import cast - - -@dataclass(frozen=True, slots=True) -class Point2D: - """Decoding/encoding this composite requires register_types(conn) first. - - Without per-connection registration psycopg returns the value as a - raw string, which the generated pg_decode cannot splat into the dataclass. - """ - - x: float | None - y: float | None - - @staticmethod - def pg_decode(src: object) -> "Point2D": - return Point2D(*cast(tuple[float | None, float | None], src)) - - def pg_encode(self) -> tuple[float | None, float | None]: - return (self.x, self.y) diff --git a/tests/golden_sync/src/specimen_sync_client/_generated/types/tag_value.py b/tests/golden_sync/src/specimen_sync_client/_generated/types/tag_value.py deleted file mode 100644 index 13ff4b9..0000000 --- a/tests/golden_sync/src/specimen_sync_client/_generated/types/tag_value.py +++ /dev/null @@ -1,24 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from dataclasses import dataclass -from typing import cast - - -@dataclass(frozen=True, slots=True) -class TagValue: - """Decoding/encoding this composite requires register_types(conn) first. - - Without per-connection registration psycopg returns the value as a - raw string, which the generated pg_decode cannot splat into the dataclass. - """ - - value: str | None - - @staticmethod - def pg_decode(src: object) -> "TagValue": - return TagValue(*cast(tuple[str | None], src)) - - def pg_encode(self) -> tuple[str | None]: - return (self.value,) diff --git a/tests/golden_sync/src/specimen_sync_client/py.typed b/tests/golden_sync/src/specimen_sync_client/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_config_variants.py b/tests/test_config_variants.py index 9a46b07..056e11d 100644 --- a/tests/test_config_variants.py +++ b/tests/test_config_variants.py @@ -2,9 +2,8 @@ pgn's decode behavior for record types is undocumented, so each artifact in project1.pgn.yaml drives a different subset of `config` keys through the same -compile.dhall and the assertions below record what pgn 0.6.5 was observed to do, -not a documented contract. These variants are pinned by the directory/package -name and which surface (sync or async) they produce, not a full golden tree. +compile.dhall and the assertions below record what the pinned pgn was observed to +do, not a documented contract. These variants pin the additive sync surface. """ from __future__ import annotations @@ -40,52 +39,65 @@ def _package_dir(generated_tree: Path, artifact_key: str) -> Path: return packages[0] -def _is_sync(package: Path) -> bool: - """Whether the generated package's runtime module is the sync surface. +def _assert_surfaces(package: Path, *, emit_sync: bool) -> None: + generated = package / "_generated" + assert (package / "__init__.py").is_file() + assert "async def fetch_many" in (generated / "_runtime.py").read_text() - There is no `sync/` subdirectory to check anymore (config.sync selects - which content is rendered at the *same* `_runtime.py` path); the async - body defines `async def fetch_many`, the sync body defines `def - fetch_many` with no `async`. - """ - runtime = (package / "_generated" / "_runtime.py").read_text() - return "async def fetch_many" not in runtime + statements = sorted((generated / "statements").glob("*.py")) + statements = [path for path in statements if path.name != "__init__.py"] + assert statements + for statement in statements: + source = statement.read_text() + assert f"async def {statement.stem}(" in source + assert (f"def {statement.stem}_sync(" in source) is emit_sync + + assert (package / "sync" / "__init__.py").is_file() is emit_sync + assert (generated / "sync" / "_runtime.py").is_file() is emit_sync + assert not (generated / "sync" / "statements").exists() + assert not (generated / "sync" / "types").exists() + assert not (generated / "sync" / "_register.py").exists() + + +def test_main_config_emits_both_surfaces(generated_tree: Path) -> None: + package = _package_dir(generated_tree, "python") + assert package.name == "specimen_client" + _assert_surfaces(package, emit_sync=True) def test_name_only_config_derives_package_and_defaults_sync_off(generated_tree: Path) -> None: package = _package_dir(generated_tree, "python-name-only") assert package.name == "name_only_client" - assert not _is_sync(package) + _assert_surfaces(package, emit_sync=False) def test_sync_only_config_defaults_package_name_from_project(generated_tree: Path) -> None: package = _package_dir(generated_tree, "python-sync-only") assert package.name == "fixture" - assert _is_sync(package) + _assert_surfaces(package, emit_sync=True) + + +def test_explicit_false_omits_sync_additions(generated_tree: Path) -> None: + package = _package_dir(generated_tree, "python-explicit-false") + assert package.name == "fixture" + _assert_surfaces(package, emit_sync=False) def test_empty_config_object_defaults_both_fields(generated_tree: Path) -> None: package = _package_dir(generated_tree, "python-empty") assert package.name == "fixture" - assert not _is_sync(package) + _assert_surfaces(package, emit_sync=False) def test_absent_config_key_defaults_both_fields(generated_tree: Path) -> None: """No `config:` key at all decodes the same as `config: {}` (None Config).""" package = _package_dir(generated_tree, "python-bare") assert package.name == "fixture" - assert not _is_sync(package) - - -def test_unknown_config_key_is_ignored_not_rejected(generated_tree: Path) -> None: - """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 _is_sync(package) + _assert_surfaces(package, emit_sync=False) def test_null_value_decodes_as_absent_field(generated_tree: Path) -> None: - """`sync: null` decodes to None, same as omitting the key (default False).""" + """`emitSync: null` decodes to None, like omitting the key.""" package = _package_dir(generated_tree, "python-null") assert package.name == "null_client" - assert not _is_sync(package) + _assert_surfaces(package, emit_sync=False) diff --git a/tests/test_generated.py b/tests/test_generated.py index 7675c71..e553ed0 100644 --- a/tests/test_generated.py +++ b/tests/test_generated.py @@ -13,11 +13,13 @@ import asyncio import importlib +import inspect import json import subprocess import sys import uuid from collections.abc import Iterator +from contextlib import contextmanager from decimal import Decimal from datetime import date from pathlib import Path @@ -26,7 +28,7 @@ import pytest from psycopg.conninfo import make_conninfo -from tests._harness import FIXTURE_PROJECT, GOLDEN_DIR, GOLDEN_DIR_SYNC, HERE, ensure_droppable, run_pgn +from tests._harness import FIXTURE_PROJECT, GOLDEN_DIR, HERE, ensure_droppable, run_pgn HARNESS_ROOT = HERE.parent @@ -34,10 +36,25 @@ # __init__.py facade; the rest of the shell (pyproject.toml, py.typed) is # hand-written and lives in golden as a committed fixture, not produced by # generate. -GENERATED_SUBTREE = Path("src/specimen_client/_generated") -FACADE_INIT = Path("src/specimen_client/__init__.py") -GENERATED_SUBTREE_SYNC = Path("src/specimen_sync_client/_generated") -FACADE_INIT_SYNC = Path("src/specimen_sync_client/__init__.py") +GENERATED_PACKAGE = Path("src/specimen_client") +QUERY_NAMES = ( + "bump_specimen_revision", + "get_specimen", + "get_tagged_item", + "insert_specimen", + "insert_tagged_item", + "list_specimens_by_class", + "list_specimens_by_feeling", + "list_specimens_by_ids", + "list_specimens_by_moods", + "list_specimens_keyword_column", + "search_specimens", +) +ROW_NAMES = { + name: "".join(part.title() for part in name.split("_")) + "Row" + for name in QUERY_NAMES + if name != "bump_specimen_revision" +} def test_fixture_project_analyses_clean(pgn_bin: str, pgn_admin_url: str, fixture_copy: Path) -> None: @@ -78,31 +95,31 @@ def test_generate_produces_python_package(generated_tree: Path) -> None: assert list(generated_tree.rglob("*.py")), "generator produced no Python modules" -def _relative_files(root: Path) -> set[Path]: +def _relative_files(root: Path, *, exclude: frozenset[Path] = frozenset()) -> set[Path]: # Skip bytecode: the generator never emits it, but a local `import specimen_client` # leaves __pycache__ under golden and would false-fail the file-set comparison. return { p.relative_to(root) for p in root.rglob("*") - if p.is_file() and "__pycache__" not in p.parts + if p.is_file() and "__pycache__" not in p.parts and p.relative_to(root) not in exclude } def test_generated_matches_golden(generated_tree: Path) -> None: - """The generated subtree and the facade must equal golden's byte for byte. + """Every generated package file must equal golden byte for byte. - Generate produces the /_generated subtree and the package-root - __init__.py facade; the rest of the shell in golden is a hand-written + Generate produces the /_generated subtree plus the root and sync + facades; the rest of the shell in golden is a hand-written committed fixture and stays out of this comparison. Update flow when the generator legitimately changes: re-run `pgn generate` in tests/fixture-project - and rsync the fresh _generated subtree plus the facade into golden (see + and copy the fresh _generated subtree plus both facades into golden (see tests/golden/README.md), then review the diff. """ - produced_root = generated_tree / GENERATED_SUBTREE - golden_root = GOLDEN_DIR / GENERATED_SUBTREE + produced_root = generated_tree / GENERATED_PACKAGE + golden_root = GOLDEN_DIR / GENERATED_PACKAGE produced = _relative_files(produced_root) - golden = _relative_files(golden_root) + golden = _relative_files(golden_root, exclude=frozenset({Path("py.typed")})) missing = sorted(str(p) for p in golden - produced) extra = sorted(str(p) for p in produced - golden) @@ -114,58 +131,19 @@ def test_generated_matches_golden(generated_tree: Path) -> None: if (produced_root / rel).read_text() != (golden_root / rel).read_text(): mismatched.append(str(rel)) - if (generated_tree / FACADE_INIT).read_text() != (GOLDEN_DIR / FACADE_INIT).read_text(): - mismatched.append(str(FACADE_INIT)) - assert not mismatched, ( "generated output drifted from golden in: " + ", ".join(mismatched) - + "\nupdate via: rsync the fresh _generated subtree and the facade into golden (see tests/golden/README.md)" + + "\nupdate via: mise run golden (see tests/golden/README.md)" ) -def test_generated_sync_matches_golden(generated_tree: Path) -> None: - """Sync-surface counterpart of test_generated_matches_golden. +def test_generated_passes_basedpyright_strict(full_package: Path, tmp_path: Path) -> None: + """basedpyright strict on the fresh full package: zero errors and warnings. - generated_tree points at the "python" artifact; the sync surface's - output lives in the sibling "python_sync" artifact directory produced by - the same pgn generate call (see the generated_tree fixture). - """ - generated_tree_sync = generated_tree.parent.parent / "artifacts" / "python_sync" - produced_root = generated_tree_sync / GENERATED_SUBTREE_SYNC - golden_root = GOLDEN_DIR_SYNC / GENERATED_SUBTREE_SYNC - - produced = _relative_files(produced_root) - golden = _relative_files(golden_root) - - missing = sorted(str(p) for p in golden - produced) - extra = sorted(str(p) for p in produced - golden) - assert not missing, f"golden files not produced by the generator: {missing}" - assert not extra, f"generator emitted files absent from golden: {extra}" - - mismatched: list[str] = [] - for rel in sorted(produced, key=str): - if (produced_root / rel).read_text() != (golden_root / rel).read_text(): - mismatched.append(str(rel)) - - if (generated_tree_sync / FACADE_INIT_SYNC).read_text() != (GOLDEN_DIR_SYNC / FACADE_INIT_SYNC).read_text(): - mismatched.append(str(FACADE_INIT_SYNC)) - - assert not mismatched, ( - "generated sync output drifted from golden in: " - + ", ".join(mismatched) - + "\nupdate via: mise run golden (see tests/golden_sync/README.md)" - ) - - -def test_generated_passes_basedpyright_strict(tmp_path: Path) -> None: - """basedpyright strict on the FULL golden package: zero errors and warnings. - - The golden package (hand-written shell + the generated _generated subtree) - is the committed contract; test_generated_matches_golden proves the fresh - output equals golden's _generated subtree, so checking golden checks the - generator's output. psycopg resolves from the harness venv. The config scopes - the run to golden's `src` so the harness tests are not pulled in. + The full_package fixture overlays the fresh generated tree and both facades + onto the hand-written shell. psycopg resolves from the harness venv. The + config scopes the run to that package's `src` so harness tests stay excluded. """ config = tmp_path / "pyrightconfig.json" _ = config.write_text( @@ -173,7 +151,7 @@ def test_generated_passes_basedpyright_strict(tmp_path: Path) -> None: { "pythonVersion": "3.12", "typeCheckingMode": "strict", - "include": [str(GOLDEN_DIR / "src")], + "include": [str(full_package / "src")], "venvPath": str(HARNESS_ROOT), "venv": ".venv", "reportMissingModuleSource": False, @@ -193,38 +171,7 @@ def test_generated_passes_basedpyright_strict(tmp_path: Path) -> None: summary = json.loads(result.stdout)["summary"] # basedpyright exits 0 with filesAnalyzed=0 when the include path matches nothing, - # so without this the strict gate would pass vacuously if the golden src ever moved. - assert summary["filesAnalyzed"] > 0, f"basedpyright analyzed no files; bad include path?\n{result.stdout}" - assert summary["errorCount"] == 0 and summary["warningCount"] == 0, ( - f"basedpyright strict reported issues: {summary}\n{result.stdout}" - ) - - -def test_generated_sync_passes_basedpyright_strict(tmp_path: Path) -> None: - """basedpyright strict on the full sync-surface golden package.""" - config = tmp_path / "pyrightconfig.json" - _ = config.write_text( - json.dumps( - { - "pythonVersion": "3.12", - "typeCheckingMode": "strict", - "include": [str(GOLDEN_DIR_SYNC / "src")], - "venvPath": str(HARNESS_ROOT), - "venv": ".venv", - "reportMissingModuleSource": False, - } - ) - ) - result = subprocess.run( - ["basedpyright", "--project", str(config), "--outputjson"], - capture_output=True, - text=True, - ) - - if not result.stdout.strip(): - pytest.fail(f"basedpyright produced no JSON (exit {result.returncode}):\n{result.stderr}") - - summary = json.loads(result.stdout)["summary"] + # so without this the strict gate would pass vacuously if the package src moved. assert summary["filesAnalyzed"] > 0, f"basedpyright analyzed no files; bad include path?\n{result.stdout}" assert summary["errorCount"] == 0 and summary["warningCount"] == 0, ( f"basedpyright strict reported issues: {summary}\n{result.stdout}" @@ -270,27 +217,79 @@ def _apply_migrations(db_url: str) -> None: _ = conn.execute(migration.read_text().encode()) -def _import_client(full_package: Path): # noqa: ANN202 - dynamic module set - src = str(full_package / "src") - if src not in sys.path: - sys.path.insert(0, src) +def _clear_client_modules() -> None: for name in list(sys.modules): if name == "specimen_client" or name.startswith("specimen_client."): del sys.modules[name] - return importlib.import_module -def _import_client_sync(full_package_sync: Path): # noqa: ANN202 - dynamic module set - src = str(full_package_sync / "src") - if src not in sys.path: - sys.path.insert(0, src) - for name in list(sys.modules): - if name == "specimen_sync_client" or name.startswith("specimen_sync_client."): - del sys.modules[name] - return importlib.import_module +@contextmanager +def _client_modules(full_package: Path): # noqa: ANN202 - dynamic module set + src = str(full_package / "src") + original_path = sys.path.copy() + sys.path.insert(0, src) + _clear_client_modules() + try: + root = importlib.import_module("specimen_client") + sync = importlib.import_module("specimen_client.sync") + yield root, sync, importlib.import_module + finally: + _clear_client_modules() + sys.path[:] = original_path -def test_roundtrip_type_mappings(full_package: Path, roundtrip_db: str) -> None: +@pytest.fixture +def client_modules(full_package: Path): # noqa: ANN202 - dynamic module set + with _client_modules(full_package) as loaded: + yield loaded + + +def test_combined_public_api_identity_and_signatures(full_package: Path) -> None: + with _client_modules(full_package) as (root, sync, import_module): + assert root.sync is sync + + for name in QUERY_NAMES: + statement = import_module(f"specimen_client._generated.statements.{name}") + async_function = getattr(root, name) + sync_function = getattr(sync, name) + assert async_function is getattr(statement, name) + assert sync_function is getattr(statement, f"{name}_sync") + assert inspect.iscoroutinefunction(async_function) + assert not inspect.iscoroutinefunction(sync_function) + + async_signature = inspect.signature(async_function) + sync_signature = inspect.signature(sync_function) + assert async_signature.return_annotation == sync_signature.return_annotation + async_params = list(async_signature.parameters.values()) + sync_params = list(sync_signature.parameters.values()) + assert len(async_params) == len(sync_params) + for index, (async_param, sync_param) in enumerate(zip(async_params, sync_params, strict=True)): + assert async_param.name == sync_param.name + assert async_param.kind == sync_param.kind + assert async_param.default == sync_param.default + if index == 0: + assert async_param.annotation == "AsyncConnection[object]" + assert sync_param.annotation == "Connection[object]" + else: + assert async_param.annotation == sync_param.annotation + + row_name = ROW_NAMES.get(name) + if row_name is not None: + row_class = getattr(statement, row_name) + assert getattr(root, row_name) is row_class + assert getattr(sync, row_name) is row_class + + for name in ("Mood", "Point2D", "TagValue", "JsonValue", "NoRowError"): + assert getattr(root, name) is getattr(sync, name) + + register = import_module("specimen_client._generated._register") + assert root.register_types is register.register_types + assert sync.register_types is register.register_types_sync + assert "register_types" in root.__all__ + assert "register_types" in sync.__all__ + + +def test_roundtrip_type_mappings(client_modules, roundtrip_db: str) -> None: # noqa: ANN001 """INSERT then SELECT through the generated client, asserting the mappings. Exercises every generated statement and the full type surface: enum param + @@ -300,30 +299,16 @@ def test_roundtrip_type_mappings(full_package: Path, roundtrip_db: str) -> None: rows-affected helper. Runs against a throwaway pg0 database. """ _apply_migrations(roundtrip_db) - import_module = _import_client(full_package) - - register = import_module("specimen_client._generated._register") - mood_mod = import_module("specimen_client._generated.types.mood") - point_mod = import_module("specimen_client._generated.types.point_2_d") - insert = import_module("specimen_client._generated.statements.insert_specimen") - get = import_module("specimen_client._generated.statements.get_specimen") - by_feeling = import_module("specimen_client._generated.statements.list_specimens_by_feeling") - by_ids = import_module("specimen_client._generated.statements.list_specimens_by_ids") - by_moods = import_module("specimen_client._generated.statements.list_specimens_by_moods") - by_class = import_module("specimen_client._generated.statements.list_specimens_by_class") - by_kw_col = import_module("specimen_client._generated.statements.list_specimens_keyword_column") - search = import_module("specimen_client._generated.statements.search_specimens") - bump = import_module("specimen_client._generated.statements.bump_specimen_revision") - - Mood = mood_mod.Mood - Point2D = point_mod.Point2D + facade, _, _ = client_modules + Mood = facade.Mood + Point2D = facade.Point2D async def scenario() -> None: conn = await psycopg.AsyncConnection.connect(roundtrip_db, autocommit=True) try: - await register.register_types(conn) + await facade.register_types(conn) - inserted = await insert.insert_specimen( + inserted = await facade.insert_specimen( conn, doc_jsonb={"k": "v", "n": 1}, feeling=Mood.HAPPY, @@ -351,6 +336,7 @@ async def scenario() -> None: grid=None, moods=[Mood.HAPPY, None, Mood.SAD], ) + assert type(inserted) is facade.InsertSpecimenRow # enum column decodes to the generated StrEnum (identity, not just ==). assert isinstance(inserted.feeling, Mood) @@ -380,21 +366,21 @@ async def scenario() -> None: specimen_id = inserted.id # Optional: hit returns the row, miss returns None. - hit = await get.get_specimen(conn, id=specimen_id) + hit = await facade.get_specimen(conn, id=specimen_id) assert hit is not None assert hit.id == specimen_id assert isinstance(hit.feeling, Mood) assert isinstance(hit.origin, Point2D) assert hit.maybe_uuid is None - miss = await get.get_specimen(conn, id=specimen_id + 10_000) + miss = await facade.get_specimen(conn, id=specimen_id + 10_000) assert miss is None # Multiple with enum param + enum/composite column decode. - feeling_rows = await by_feeling.list_specimens_by_feeling(conn, feeling=Mood.HAPPY) + feeling_rows = await facade.list_specimens_by_feeling(conn, feeling=Mood.HAPPY) assert len(feeling_rows) == 1 assert feeling_rows[0].feeling is Mood.HAPPY assert isinstance(feeling_rows[0].origin, Point2D) - assert await by_feeling.list_specimens_by_feeling(conn, feeling=Mood.SAD) == [] + assert await facade.list_specimens_by_feeling(conn, feeling=Mood.SAD) == [] # Array param via ANY. This exercises the nullable-element branch # (list[T | None] | None). NOTE: the generator's non-null-element @@ -408,39 +394,41 @@ async def scenario() -> None: # does infer it) and is guarded by checks:pgn-generate plus call-site # type-checking in apps/backend and apps/ingest, which pass list[UUID]. # The generated client bodies are not strict-typechecked themselves. - id_rows = await by_ids.list_specimens_by_ids(conn, pub_ids=[inserted.pub_id]) + id_rows = await facade.list_specimens_by_ids(conn, pub_ids=[inserted.pub_id]) assert [r.pub_id for r in id_rows] == [inserted.pub_id] - assert await by_ids.list_specimens_by_ids(conn, pub_ids=[uuid.uuid4()]) == [] + assert await facade.list_specimens_by_ids(conn, pub_ids=[uuid.uuid4()]) == [] # Enum array param via ANY + enum array column decoded element-wise. - mood_rows = await by_moods.list_specimens_by_moods(conn, moods=[Mood.HAPPY]) + mood_rows = await facade.list_specimens_by_moods(conn, moods=[Mood.HAPPY]) assert [r.id for r in mood_rows] == [specimen_id] assert mood_rows[0].moods == [Mood.HAPPY, None, Mood.SAD] assert mood_rows[0].moods is not None assert mood_rows[0].moods[0] is Mood.HAPPY - assert await by_moods.list_specimens_by_moods(conn, moods=[Mood.SAD]) == [] + assert await facade.list_specimens_by_moods(conn, moods=[Mood.SAD]) == [] # Keyword-named param: class_ binds as %(class)s, so it must match by title. - class_rows = await by_class.list_specimens_by_class(conn, class_="alpha") + class_rows = await facade.list_specimens_by_class(conn, class_="alpha") assert [r.id for r in class_rows] == [specimen_id] - assert await by_class.list_specimens_by_class(conn, class_="nope") == [] + assert await facade.list_specimens_by_class(conn, class_="nope") == [] # Keyword-named result column: title AS "class" decodes to .class_. - kw_rows = await by_kw_col.list_specimens_keyword_column(conn) + kw_rows = await facade.list_specimens_keyword_column(conn) assert [r.id for r in kw_rows] == [specimen_id] assert kw_rows[0].class_ == "alpha" # jsonb containment param + the literal-`%` ILIKE branch (title_like=None). - search_all = await search.search_specimens(conn, title_like=None, meta_filter={}, label=None) + search_all = await facade.search_specimens(conn, title_like=None, meta_filter={}, label=None) assert [r.id for r in search_all] == [specimen_id] - search_hit = await search.search_specimens(conn, title_like="alp%", meta_filter={}, label="specimen") + search_hit = await facade.search_specimens( + conn, title_like="alp%", meta_filter={}, label="specimen" + ) assert [r.id for r in search_hit] == [specimen_id] assert isinstance(search_hit[0].meta, dict) # RowsAffected helper returns the count. - affected = await bump.bump_specimen_revision(conn, id=specimen_id) + affected = await facade.bump_specimen_revision(conn, id=specimen_id) assert affected == 1 - bumped = await get.get_specimen(conn, id=specimen_id) + bumped = await facade.get_specimen(conn, id=specimen_id) assert bumped is not None assert bumped.rev == 2 finally: @@ -449,14 +437,14 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_require_array_rejects_unregistered_enum_array(full_package: Path) -> None: +def test_require_array_rejects_unregistered_enum_array(client_modules) -> None: # noqa: ANN001 """require_array turns the unregistered enum-array form into a clear error. Without register_types psycopg returns an enum array as the raw array text, not a list; the generated enum-array decode wraps the value in require_array so it fails loudly instead of iterating a string into bogus members. """ - import_module = _import_client(full_package) + _, _, import_module = client_modules runtime = import_module("specimen_client._generated._runtime") assert runtime.require_array(["happy", "sad"]) == ["happy", "sad"] @@ -464,32 +452,18 @@ def test_require_array_rejects_unregistered_enum_array(full_package: Path) -> No _ = runtime.require_array("{happy,sad}") -def test_roundtrip_sync_surface(full_package_sync: Path, roundtrip_db: str) -> None: - """The sync surface, generated independently with config.sync = true. - - Drives the generated sync functions (psycopg.Connection, no await) end - to end, at the same unified paths the async surface uses (no `.sync.` - sub-path) — this package was generated standalone, not alongside async. - """ +def test_roundtrip_sync_surface(client_modules, roundtrip_db: str) -> None: # noqa: ANN001 + """Drive the additive sync public facade end to end.""" _apply_migrations(roundtrip_db) - import_module = _import_client_sync(full_package_sync) - - register = import_module("specimen_sync_client._generated._register") - mood_mod = import_module("specimen_sync_client._generated.types.mood") - point_mod = import_module("specimen_sync_client._generated.types.point_2_d") - insert = import_module("specimen_sync_client._generated.statements.insert_specimen") - get = import_module("specimen_sync_client._generated.statements.get_specimen") - by_moods = import_module("specimen_sync_client._generated.statements.list_specimens_by_moods") - bump = import_module("specimen_sync_client._generated.statements.bump_specimen_revision") - - Mood = mood_mod.Mood - Point2D = point_mod.Point2D + _, facade, _ = client_modules + Mood = facade.Mood + Point2D = facade.Point2D conn = psycopg.connect(roundtrip_db, autocommit=True) try: - register.register_types(conn) + facade.register_types(conn) - inserted = insert.insert_specimen( + inserted = facade.insert_specimen( conn, doc_jsonb={"k": "v", "n": 1}, feeling=Mood.HAPPY, @@ -517,6 +491,7 @@ def test_roundtrip_sync_surface(full_package_sync: Path, roundtrip_db: str) -> N grid=None, moods=[Mood.HAPPY, None, Mood.SAD], ) + assert type(inserted) is facade.InsertSpecimenRow assert isinstance(inserted.feeling, Mood) assert inserted.feeling is Mood.HAPPY assert isinstance(inserted.origin, Point2D) @@ -526,25 +501,25 @@ def test_roundtrip_sync_surface(full_package_sync: Path, roundtrip_db: str) -> N assert inserted.moods[0] is Mood.HAPPY specimen_id = inserted.id - hit = get.get_specimen(conn, id=specimen_id) + hit = facade.get_specimen(conn, id=specimen_id) assert hit is not None assert hit.id == specimen_id - assert get.get_specimen(conn, id=specimen_id + 10_000) is None + assert facade.get_specimen(conn, id=specimen_id + 10_000) is None - mood_rows = by_moods.list_specimens_by_moods(conn, moods=[Mood.HAPPY]) + mood_rows = facade.list_specimens_by_moods(conn, moods=[Mood.HAPPY]) assert [r.id for r in mood_rows] == [specimen_id] - assert by_moods.list_specimens_by_moods(conn, moods=[Mood.SAD]) == [] + assert facade.list_specimens_by_moods(conn, moods=[Mood.SAD]) == [] - affected = bump.bump_specimen_revision(conn, id=specimen_id) + affected = facade.bump_specimen_revision(conn, id=specimen_id) assert affected == 1 - bumped = get.get_specimen(conn, id=specimen_id) + bumped = facade.get_specimen(conn, id=specimen_id) assert bumped is not None assert bumped.rev == 2 finally: conn.close() -def test_roundtrip_single_field_composite(full_package: Path, roundtrip_db: str) -> None: +def test_roundtrip_single_field_composite(client_modules, roundtrip_db: str) -> None: # noqa: ANN001 """Regression test for compositeBind on a one-field composite. concatMapSep joins a single-element field list with no separator, so an @@ -554,56 +529,46 @@ def test_roundtrip_single_field_composite(full_package: Path, roundtrip_db: str) decode (RETURNING and a plain SELECT). """ _apply_migrations(roundtrip_db) - import_module = _import_client(full_package) - - register = import_module("specimen_client._generated._register") - tag_mod = import_module("specimen_client._generated.types.tag_value") - insert = import_module("specimen_client._generated.statements.insert_tagged_item") - get = import_module("specimen_client._generated.statements.get_tagged_item") - - TagValue = tag_mod.TagValue + facade, _, _ = client_modules + TagValue = facade.TagValue async def scenario() -> None: conn = await psycopg.AsyncConnection.connect(roundtrip_db, autocommit=True) try: - await register.register_types(conn) + await facade.register_types(conn) - inserted = await insert.insert_tagged_item(conn, name="widget", tag=TagValue(value="blue")) + inserted = await facade.insert_tagged_item(conn, name="widget", tag=TagValue(value="blue")) + assert type(inserted) is facade.InsertTaggedItemRow assert isinstance(inserted.tag, TagValue) assert inserted.tag == TagValue(value="blue") - hit = await get.get_tagged_item(conn, id=inserted.id) + hit = await facade.get_tagged_item(conn, id=inserted.id) assert hit is not None assert isinstance(hit.tag, TagValue) assert hit.tag == TagValue(value="blue") - assert await get.get_tagged_item(conn, id=inserted.id + 10_000) is None + assert await facade.get_tagged_item(conn, id=inserted.id + 10_000) is None finally: await conn.close() asyncio.run(scenario()) -def test_roundtrip_single_field_composite_sync(full_package_sync: Path, roundtrip_db: str) -> None: - """Sync-surface counterpart of test_roundtrip_single_field_composite.""" +def test_roundtrip_single_field_composite_sync(client_modules, roundtrip_db: str) -> None: # noqa: ANN001 + """Sync public-facade counterpart of the one-field composite regression.""" _apply_migrations(roundtrip_db) - import_module = _import_client_sync(full_package_sync) - - register = import_module("specimen_sync_client._generated._register") - tag_mod = import_module("specimen_sync_client._generated.types.tag_value") - insert = import_module("specimen_sync_client._generated.statements.insert_tagged_item") - get = import_module("specimen_sync_client._generated.statements.get_tagged_item") - - TagValue = tag_mod.TagValue + _, facade, _ = client_modules + TagValue = facade.TagValue conn = psycopg.connect(roundtrip_db, autocommit=True) try: - register.register_types(conn) + facade.register_types(conn) - inserted = insert.insert_tagged_item(conn, name="widget", tag=TagValue(value="blue")) + inserted = facade.insert_tagged_item(conn, name="widget", tag=TagValue(value="blue")) + assert type(inserted) is facade.InsertTaggedItemRow assert isinstance(inserted.tag, TagValue) assert inserted.tag == TagValue(value="blue") - hit = get.get_tagged_item(conn, id=inserted.id) + hit = facade.get_tagged_item(conn, id=inserted.id) assert hit is not None assert hit.tag == TagValue(value="blue") finally: diff --git a/tests/test_identifier_collisions.py b/tests/test_identifier_collisions.py index 41f5a69..ed4cc13 100644 --- a/tests/test_identifier_collisions.py +++ b/tests/test_identifier_collisions.py @@ -53,6 +53,7 @@ def _fresh_project(tmp_path: Path) -> tuple[Path, Path, str, str]: " gen: ../../src/package.dhall\n" " config:\n" f" packageName: {package_name}\n" + " emitSync: true\n" ) queries = { @@ -100,6 +101,12 @@ def _assert_private_query_canaries( ++ "\n" ++ PyIdent.querySafeName "_decode_row" ++ "\n" + ++ PyIdent.querySafeName "_fetch_many_sync" + ++ "\n" + ++ PyIdent.querySafeName "sync" + ++ "\n" + ++ PyIdent.querySafeName "register_types" + ++ "\n" } ] : Lude.Files.Type ) @@ -127,7 +134,13 @@ def _assert_private_query_canaries( result = run_pgn(pgn_bin, pgn_admin_url, canary, "generate") assert result.returncode == 0, f"query-name canary generation failed:\n{result.stdout}\n{result.stderr}" output = canary / "artifacts" / "python" / "query-safe-name.txt" - assert output.read_text().splitlines() == ["_types_query", "_decode_row_query"] + assert output.read_text().splitlines() == [ + "_types_query", + "_decode_row_query", + "_fetch_many_sync_query", + "sync_query", + "register_types_query", + ] @contextmanager @@ -169,19 +182,20 @@ def _clear_package_modules(import_name: str) -> None: del sys.modules[name] -def _facade_statement_exports(facade: Path) -> dict[str, str]: +def _facade_statement_exports(facade: Path, *, level: int, function_suffix: str) -> dict[str, str]: exports: dict[str, str] = {} tree = ast.parse(facade.read_text()) for node in tree.body: if not isinstance(node, ast.ImportFrom) or node.module is None: continue prefix = "_generated.statements." - if node.level != 1 or not node.module.startswith(prefix): + if node.level != level or not node.module.startswith(prefix): continue module_name = node.module.removeprefix(prefix) query_import = node.names[0] - assert query_import.asname == query_import.name - exports[module_name] = query_import.name + assert query_import.name == f"{module_name}{function_suffix}" + assert query_import.asname == module_name + exports[module_name] = query_import.asname return exports @@ -192,17 +206,30 @@ def _assert_statement_structure(statements: Path) -> None: for function_name, helper in HELPERS.items(): source = (statements / f"{function_name}.py").read_text() tree = ast.parse(source) - runtime_imports = [ + async_runtime_imports = [ node for node in tree.body if isinstance(node, ast.ImportFrom) and node.level == 2 and node.module == "_runtime" ] - assert len(runtime_imports) == 1 - assert [(alias.name, alias.asname) for alias in runtime_imports[0].names] == [ + assert len(async_runtime_imports) == 1 + assert [(alias.name, alias.asname) for alias in async_runtime_imports[0].names] == [ (helper, f"_{helper}") ] + sync_runtime_imports = [ + node + for node in tree.body + if isinstance(node, ast.ImportFrom) and node.level == 2 and node.module == "sync._runtime" + ] + assert len(sync_runtime_imports) == 1 + assert [(alias.name, alias.asname) for alias in sync_runtime_imports[0].names] == [ + (helper, f"_{helper}_sync") + ] assert f"from .._runtime import {helper} as _{helper}" in source + assert f"from ..sync._runtime import {helper} as _{helper}_sync" in source assert f"return await _{helper}(conn, _SQL, params, _decode_row)" in source + assert f"return _{helper}_sync(conn, _SQL, params, _decode_row)" in source + assert f"async def {function_name}(" in source + assert f"def {function_name}_sync(" in source assert source.count("def _decode_row(") == 1 assert "def decode_" not in source @@ -270,19 +297,27 @@ def test_identifier_collisions_roundtrip( statements = package_src / "_generated" / "statements" assert package_src.is_dir(), f"pgn did not generate package {package_name!r}" _assert_statement_structure(statements) - facade_exports = _facade_statement_exports(package_src / "__init__.py") - assert facade_exports == {name: name for name in EXPECTED_FUNCTIONS} + facade_exports = _facade_statement_exports( + package_src / "__init__.py", level=1, function_suffix="" + ) + sync_exports = _facade_statement_exports( + package_src / "sync" / "__init__.py", level=2, function_suffix="_sync" + ) + expected_exports = {name: name for name in EXPECTED_FUNCTIONS} + assert facade_exports == expected_exports + assert sync_exports == expected_exports assert not (statements / "date.py").exists() assert not (statements / "_types.py").exists() + assert not (package_src / "_generated" / "sync" / "statements").exists() + assert not (package_src / "_generated" / "sync" / "_register.py").exists() _assert_strict(package_src, tmp_path) sys.path.insert(0, str(generated_src)) _clear_package_modules(import_name) try: facade = importlib.import_module(import_name) - register = importlib.import_module(f"{import_name}._generated._register") - mood_module = importlib.import_module(f"{import_name}._generated.types.mood") - Mood = mood_module.Mood + sync_facade = importlib.import_module(f"{import_name}.sync") + Mood = facade.Mood with _scratch_database(pgn_admin_url) as db_url: _apply_migrations(project, db_url) @@ -290,7 +325,7 @@ def test_identifier_collisions_roundtrip( async def scenario() -> None: conn = await psycopg.AsyncConnection.connect(db_url, autocommit=True) try: - await register.register_types(conn) + await facade.register_types(conn) cast_row = await facade.cast(conn) require_array_row = await facade.require_array(conn) many_rows = await facade.fetch_many(conn) @@ -305,6 +340,19 @@ async def scenario() -> None: await conn.close() asyncio.run(scenario()) + + with psycopg.connect(db_url, autocommit=True) as conn: + sync_facade.register_types(conn) + cast_row = sync_facade.cast(conn) + require_array_row = sync_facade.require_array(conn) + many_rows = sync_facade.fetch_many(conn) + date_row = sync_facade.date_query(conn) + + assert cast_row.value == 1 + assert require_array_row.moods == [Mood.HAPPY] + assert require_array_row.moods[0] is Mood.HAPPY + assert [row.value for row in many_rows] == [1, 2] + assert date_row.value == date(2026, 7, 13) finally: _clear_package_modules(import_name) sys.path.remove(str(generated_src)) diff --git a/tests/test_unsupported_types.py b/tests/test_unsupported_types.py index 7a0013b..d6981d1 100644 --- a/tests/test_unsupported_types.py +++ b/tests/test_unsupported_types.py @@ -28,7 +28,6 @@ from tests._harness import ( FIXTURE_PROJECT, GOLDEN_DIR, - GOLDEN_DIR_SYNC, HERE, SRC_DIR, run_pgn, @@ -175,20 +174,20 @@ def _write_contract_probe( let Config = {{ packageName : Optional Text - , sync : Optional Bool + , emitSync : Optional Bool , onUnsupported : Optional OnUnsupported.Mode }} let Config/default = {{ packageName = None Text - , sync = None Bool + , emitSync = None Bool , onUnsupported = None OnUnsupported.Mode }} let interpreterConfig = {{ packageName = "contract-probe" , importName = "contract_probe" - , sync = False + , emitSync = False , onUnsupported = OnUnsupported.Mode.Fail }} @@ -474,18 +473,14 @@ def test_skip_unsupported_drops_offending_units_and_cascades( def test_custom_imports_are_unique_and_deterministic() -> None: - surfaces = [ - GOLDEN_DIR / "src" / "specimen_client" / "_generated" / "statements", - GOLDEN_DIR_SYNC / "src" / "specimen_sync_client" / "_generated" / "statements", - ] + statements = GOLDEN_DIR / "src" / "specimen_client" / "_generated" / "statements" custom_import = re.compile(r"^from \.\.types\.[a-zA-Z0-9_]+ import [a-zA-Z0-9_]+$", re.MULTILINE) - for statements in surfaces: - insert_imports = custom_import.findall((statements / "insert_specimen.py").read_text()) - assert insert_imports == [ - "from ..types.mood import Mood", - "from ..types.point_2_d import Point2D", - ] - for module in statements.glob("*.py"): - imports = custom_import.findall(module.read_text()) - assert len(imports) == len(set(imports)), f"duplicate custom import in {module}" + insert_imports = custom_import.findall((statements / "insert_specimen.py").read_text()) + assert insert_imports == [ + "from ..types.mood import Mood", + "from ..types.point_2_d import Point2D", + ] + for module in statements.glob("*.py"): + imports = custom_import.findall(module.read_text()) + assert len(imports) == len(set(imports)), f"duplicate custom import in {module}" From 375ba6f69c88d7beb7ff41c4695964da3170c5a6 Mon Sep 17 00:00:00 2001 From: Viacheslav Shvets Date: Mon, 13 Jul 2026 02:13:46 +0300 Subject: [PATCH 27/41] test(gen): prove psycopg adapter-backed rows Resolved psycopg: 3.3.4 Canaries: 16/16 passed across async and sync, covering scalar and array enums, enum grids, composite load/dump and arrays, nested composites, and sanitized rows. Strict: filesAnalyzed=1, errors=0, warnings=0, with generic BaseRowFactory inference and no unsafe casts. H1 verdict: CONFIRM. Exact Row, enum, composite, nested composite, and built-in list identities passed with async/sync parity. --- pyproject.toml | 2 +- tests/conftest.py | 37 +++ tests/test_generated.py | 36 +-- tests/test_psycopg_adapter_contract.py | 321 ++++++++++++++++++++++++ tests/typing/psycopg_adapter_runtime.py | 251 ++++++++++++++++++ uv.lock | 2 +- 6 files changed, 612 insertions(+), 37 deletions(-) create mode 100644 tests/test_psycopg_adapter_contract.py create mode 100644 tests/typing/psycopg_adapter_runtime.py diff --git a/pyproject.toml b/pyproject.toml index a958ad3..f946ed4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ dependencies = [] [dependency-groups] dev = [ "pytest>=8", - "psycopg[binary]>=3.2", + "psycopg[binary]>=3.3,<4", "basedpyright==1.39.7", ] diff --git a/tests/conftest.py b/tests/conftest.py index 4662c58..da57207 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,9 +9,13 @@ import shutil import subprocess +import uuid +from collections.abc import Iterator from pathlib import Path +import psycopg import pytest +from psycopg.conninfo import make_conninfo from tests._harness import ( FIXTURE_PROJECT, @@ -20,6 +24,7 @@ SRC_DIR, admin_database_url, effective_database_name, + ensure_droppable, run_pgn, ) @@ -56,6 +61,38 @@ def pgn_admin_url() -> str: return url +@pytest.fixture +def roundtrip_db(pgn_admin_url: str) -> Iterator[str]: + """A uniquely named throwaway database on pg0, dropped on teardown.""" + name = f"pgn_rt_{uuid.uuid4().hex[:12]}" + admin = psycopg.connect(pgn_admin_url, autocommit=True) + try: + # Encoding to bytes sidesteps psycopg's LiteralString-typed execute + # overload for these dynamic admin statements (the db name is a generated + # hex, not user input). + _ = admin.execute(f'CREATE DATABASE "{name}"'.encode()) + finally: + admin.close() + + # Rebuild via conninfo (not string surgery) so host/port/user/params survive, + # including a path-less admin URL the guard accepts. + target = make_conninfo(pgn_admin_url, dbname=name) + try: + yield target + finally: + admin = psycopg.connect(pgn_admin_url, autocommit=True) + try: + terminate = ( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " + "WHERE datname = %s AND pid <> pg_backend_pid()" + ) + _ = admin.execute(terminate.encode(), (name,)) + ensure_droppable(name) + _ = admin.execute(f'DROP DATABASE IF EXISTS "{name}"'.encode()) + finally: + admin.close() + + @pytest.fixture def fixture_copy(tmp_path: Path) -> Path: """A writable copy of the fixture pgn project under tmp_path.""" diff --git a/tests/test_generated.py b/tests/test_generated.py index e553ed0..1c59a14 100644 --- a/tests/test_generated.py +++ b/tests/test_generated.py @@ -18,7 +18,6 @@ import subprocess import sys import uuid -from collections.abc import Iterator from contextlib import contextmanager from decimal import Decimal from datetime import date @@ -26,9 +25,8 @@ import psycopg import pytest -from psycopg.conninfo import make_conninfo -from tests._harness import FIXTURE_PROJECT, GOLDEN_DIR, HERE, ensure_droppable, run_pgn +from tests._harness import FIXTURE_PROJECT, GOLDEN_DIR, HERE, run_pgn HARNESS_ROOT = HERE.parent @@ -178,38 +176,6 @@ def test_generated_passes_basedpyright_strict(full_package: Path, tmp_path: Path ) -@pytest.fixture -def roundtrip_db(pgn_admin_url: str) -> Iterator[str]: - """A uniquely named throwaway database on pg0, dropped on teardown.""" - name = f"pgn_rt_{uuid.uuid4().hex[:12]}" - admin = psycopg.connect(pgn_admin_url, autocommit=True) - try: - # Encoding to bytes sidesteps psycopg's LiteralString-typed execute - # overload for these dynamic admin statements (the db name is a generated - # hex, not user input). - _ = admin.execute(f'CREATE DATABASE "{name}"'.encode()) - finally: - admin.close() - - # Rebuild via conninfo (not string surgery) so host/port/user/params survive, - # including a path-less admin URL the guard accepts. - target = make_conninfo(pgn_admin_url, dbname=name) - try: - yield target - finally: - admin = psycopg.connect(pgn_admin_url, autocommit=True) - try: - terminate = ( - "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " - "WHERE datname = %s AND pid <> pg_backend_pid()" - ) - _ = admin.execute(terminate.encode(), (name,)) - ensure_droppable(name) - _ = admin.execute(f'DROP DATABASE IF EXISTS "{name}"'.encode()) - finally: - admin.close() - - def _apply_migrations(db_url: str) -> None: migrations = sorted((FIXTURE_PROJECT / "migrations").glob("*.sql"), key=lambda p: int(p.stem)) with psycopg.connect(db_url, autocommit=True) as conn: diff --git a/tests/test_psycopg_adapter_contract.py b/tests/test_psycopg_adapter_contract.py new file mode 100644 index 0000000..2a8ea70 --- /dev/null +++ b/tests/test_psycopg_adapter_contract.py @@ -0,0 +1,321 @@ +from __future__ import annotations + +import asyncio +import json +import subprocess +from pathlib import Path +from typing import LiteralString + +import psycopg +import pytest +from psycopg.rows import args_row + +from tests.typing.psycopg_adapter_runtime import ( + EnumArrayRow, + EnumGridRow, + EnumRow, + Leaf, + LeafArrayRow, + LeafRow, + Mood, + SanitizedRow, + Wrapper, + WrapperRow, + fetch_one_async, + fetch_one_sync, + register_adapter_types_async, + register_adapter_types_sync, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +STRICT_FIXTURE = REPO_ROOT / "tests" / "typing" / "psycopg_adapter_runtime.py" +SCHEMA_SQL: LiteralString = """ +CREATE TYPE public.m_adapter_mood AS ENUM ('happy', 'sad'); +CREATE TYPE public.z_adapter_leaf AS ( + "class" int8, + pg_decode text, + pg_encode text +); +CREATE TYPE public.a_adapter_wrapper AS ( + leaf public.z_adapter_leaf, + mood public.m_adapter_mood, + note text +); +""" + + +@pytest.fixture(scope="module") +def _strict_adapter_contract(tmp_path_factory: pytest.TempPathFactory) -> None: + root = tmp_path_factory.mktemp("adapter-typing") + config = root / "pyrightconfig.json" + _ = config.write_text( + json.dumps( + { + "pythonVersion": "3.12", + "typeCheckingMode": "strict", + "include": [str(STRICT_FIXTURE)], + "venvPath": str(REPO_ROOT), + "venv": ".venv", + "reportMissingModuleSource": False, + } + ) + ) + result = subprocess.run( + ["basedpyright", "--project", str(config), "--outputjson"], + capture_output=True, + text=True, + ) + if not result.stdout.strip(): + pytest.fail(f"basedpyright produced no JSON (exit {result.returncode}):\n{result.stderr}") + + summary = json.loads(result.stdout)["summary"] + assert summary["filesAnalyzed"] > 0, f"basedpyright analyzed no files:\n{result.stdout}" + assert summary["errorCount"] == 0 and summary["warningCount"] == 0, ( + f"basedpyright strict reported issues: {summary}\n{result.stdout}" + ) + + +def test_async_psycopg_adapter_contract( + _strict_adapter_contract: None, + roundtrip_db: str, +) -> None: + async def scenario() -> None: + conn = await psycopg.AsyncConnection.connect(roundtrip_db, autocommit=True) + try: + _ = await conn.execute(SCHEMA_SQL) + await register_adapter_types_async(conn) + + enum_row = await fetch_one_async( + conn, + "SELECT 'happy'::public.m_adapter_mood", + {}, + args_row(EnumRow), + ) + assert type(enum_row) is EnumRow + assert type(enum_row.mood) is Mood + assert enum_row.mood is Mood.HAPPY + + enum_array_row = await fetch_one_async( + conn, + "SELECT ARRAY['happy'::public.m_adapter_mood, NULL, 'sad'::public.m_adapter_mood]", + {}, + args_row(EnumArrayRow), + ) + assert type(enum_array_row) is EnumArrayRow + assert type(enum_array_row.moods) is list + assert len(enum_array_row.moods) == 3 + assert enum_array_row.moods[0] is Mood.HAPPY + assert enum_array_row.moods[1] is None + assert enum_array_row.moods[2] is Mood.SAD + + enum_grid_row = await fetch_one_async( + conn, + "SELECT '{{happy,sad},{sad,happy}}'::public.m_adapter_mood[][]", + {}, + args_row(EnumGridRow), + ) + assert type(enum_grid_row) is EnumGridRow + assert type(enum_grid_row.moods) is list + assert len(enum_grid_row.moods) == 2 + assert all(type(row) is list for row in enum_grid_row.moods) + assert enum_grid_row.moods[0][0] is Mood.HAPPY + assert enum_grid_row.moods[0][1] is Mood.SAD + assert enum_grid_row.moods[1][0] is Mood.SAD + assert enum_grid_row.moods[1][1] is Mood.HAPPY + + leaf_row = await fetch_one_async( + conn, + "SELECT ROW(11, 'decode', NULL)::public.z_adapter_leaf", + {}, + args_row(LeafRow), + ) + assert type(leaf_row) is LeafRow + assert type(leaf_row.value) is Leaf + assert leaf_row.value.class_ == 11 + assert leaf_row.value.pg_decode == "decode" + assert leaf_row.value.pg_encode is None + + leaf_value = Leaf(class_=12, pg_decode="parameter", pg_encode=None) + leaf_parameter_row = await fetch_one_async( + conn, + "SELECT %(value)s::public.z_adapter_leaf", + {"value": leaf_value}, + args_row(LeafRow), + ) + assert type(leaf_parameter_row) is LeafRow + assert type(leaf_parameter_row.value) is Leaf + assert leaf_parameter_row.value == leaf_value + assert leaf_parameter_row.value.pg_encode is None + + leaf_values = [ + Leaf(class_=13, pg_decode="first", pg_encode="encoded"), + Leaf(class_=14, pg_decode="second", pg_encode=None), + ] + leaf_array_row = await fetch_one_async( + conn, + "SELECT %(values)s::public.z_adapter_leaf[]", + {"values": leaf_values}, + args_row(LeafArrayRow), + ) + assert type(leaf_array_row) is LeafArrayRow + assert type(leaf_array_row.values) is list + assert all(type(value) is Leaf for value in leaf_array_row.values) + assert leaf_array_row.values == leaf_values + assert leaf_array_row.values[1].pg_encode is None + + wrapper_value = Wrapper( + leaf=Leaf(class_=15, pg_decode="nested", pg_encode=None), + mood=Mood.SAD, + note=None, + ) + wrapper_row = await fetch_one_async( + conn, + "SELECT %(value)s::public.a_adapter_wrapper", + {"value": wrapper_value}, + args_row(WrapperRow), + ) + assert type(wrapper_row) is WrapperRow + assert type(wrapper_row.value) is Wrapper + assert type(wrapper_row.value.leaf) is Leaf + assert wrapper_row.value == wrapper_value + assert wrapper_row.value.mood is Mood.SAD + assert wrapper_row.value.note is None + + sanitized_row = await fetch_one_async( + conn, + "SELECT 'keyword' AS \"class\", ROW(16, 'codec', NULL)::public.z_adapter_leaf AS value", + {}, + args_row(SanitizedRow), + ) + assert type(sanitized_row) is SanitizedRow + assert sanitized_row.class_ == "keyword" + assert type(sanitized_row.value) is Leaf + assert sanitized_row.value.class_ == 16 + assert sanitized_row.value.pg_decode == "codec" + assert sanitized_row.value.pg_encode is None + finally: + await conn.close() + + asyncio.run(scenario()) + + +def test_sync_psycopg_adapter_contract( + _strict_adapter_contract: None, + roundtrip_db: str, +) -> None: + conn = psycopg.connect(roundtrip_db, autocommit=True) + try: + _ = conn.execute(SCHEMA_SQL) + register_adapter_types_sync(conn) + + enum_row = fetch_one_sync( + conn, + "SELECT 'happy'::public.m_adapter_mood", + {}, + args_row(EnumRow), + ) + assert type(enum_row) is EnumRow + assert type(enum_row.mood) is Mood + assert enum_row.mood is Mood.HAPPY + + enum_array_row = fetch_one_sync( + conn, + "SELECT ARRAY['happy'::public.m_adapter_mood, NULL, 'sad'::public.m_adapter_mood]", + {}, + args_row(EnumArrayRow), + ) + assert type(enum_array_row) is EnumArrayRow + assert type(enum_array_row.moods) is list + assert len(enum_array_row.moods) == 3 + assert enum_array_row.moods[0] is Mood.HAPPY + assert enum_array_row.moods[1] is None + assert enum_array_row.moods[2] is Mood.SAD + + enum_grid_row = fetch_one_sync( + conn, + "SELECT '{{happy,sad},{sad,happy}}'::public.m_adapter_mood[][]", + {}, + args_row(EnumGridRow), + ) + assert type(enum_grid_row) is EnumGridRow + assert type(enum_grid_row.moods) is list + assert len(enum_grid_row.moods) == 2 + assert all(type(row) is list for row in enum_grid_row.moods) + assert enum_grid_row.moods[0][0] is Mood.HAPPY + assert enum_grid_row.moods[0][1] is Mood.SAD + assert enum_grid_row.moods[1][0] is Mood.SAD + assert enum_grid_row.moods[1][1] is Mood.HAPPY + + leaf_row = fetch_one_sync( + conn, + "SELECT ROW(11, 'decode', NULL)::public.z_adapter_leaf", + {}, + args_row(LeafRow), + ) + assert type(leaf_row) is LeafRow + assert type(leaf_row.value) is Leaf + assert leaf_row.value.class_ == 11 + assert leaf_row.value.pg_decode == "decode" + assert leaf_row.value.pg_encode is None + + leaf_value = Leaf(class_=12, pg_decode="parameter", pg_encode=None) + leaf_parameter_row = fetch_one_sync( + conn, + "SELECT %(value)s::public.z_adapter_leaf", + {"value": leaf_value}, + args_row(LeafRow), + ) + assert type(leaf_parameter_row) is LeafRow + assert type(leaf_parameter_row.value) is Leaf + assert leaf_parameter_row.value == leaf_value + assert leaf_parameter_row.value.pg_encode is None + + leaf_values = [ + Leaf(class_=13, pg_decode="first", pg_encode="encoded"), + Leaf(class_=14, pg_decode="second", pg_encode=None), + ] + leaf_array_row = fetch_one_sync( + conn, + "SELECT %(values)s::public.z_adapter_leaf[]", + {"values": leaf_values}, + args_row(LeafArrayRow), + ) + assert type(leaf_array_row) is LeafArrayRow + assert type(leaf_array_row.values) is list + assert all(type(value) is Leaf for value in leaf_array_row.values) + assert leaf_array_row.values == leaf_values + assert leaf_array_row.values[1].pg_encode is None + + wrapper_value = Wrapper( + leaf=Leaf(class_=15, pg_decode="nested", pg_encode=None), + mood=Mood.SAD, + note=None, + ) + wrapper_row = fetch_one_sync( + conn, + "SELECT %(value)s::public.a_adapter_wrapper", + {"value": wrapper_value}, + args_row(WrapperRow), + ) + assert type(wrapper_row) is WrapperRow + assert type(wrapper_row.value) is Wrapper + assert type(wrapper_row.value.leaf) is Leaf + assert wrapper_row.value == wrapper_value + assert wrapper_row.value.mood is Mood.SAD + assert wrapper_row.value.note is None + + sanitized_row = fetch_one_sync( + conn, + "SELECT 'keyword' AS \"class\", ROW(16, 'codec', NULL)::public.z_adapter_leaf AS value", + {}, + args_row(SanitizedRow), + ) + assert type(sanitized_row) is SanitizedRow + assert sanitized_row.class_ == "keyword" + assert type(sanitized_row.value) is Leaf + assert sanitized_row.value.class_ == 16 + assert sanitized_row.value.pg_decode == "codec" + assert sanitized_row.value.pg_encode is None + finally: + conn.close() diff --git a/tests/typing/psycopg_adapter_runtime.py b/tests/typing/psycopg_adapter_runtime.py new file mode 100644 index 0000000..35dfa4a --- /dev/null +++ b/tests/typing/psycopg_adapter_runtime.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import keyword +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, fields, is_dataclass +from enum import StrEnum +from typing import Any, LiteralString, TypeVar + +from psycopg import AsyncConnection, Connection +from psycopg.rows import BaseRowFactory, args_row +from psycopg.types.composite import CompositeInfo, register_composite +from psycopg.types.enum import EnumInfo, register_enum + + +class Mood(StrEnum): + HAPPY = "happy" + SAD = "sad" + + +@dataclass(frozen=True, slots=True) +class Leaf: + class_: int + pg_decode: str + pg_encode: str | None + + +@dataclass(frozen=True, slots=True) +class Wrapper: + leaf: Leaf + mood: Mood + note: str | None + + +@dataclass(frozen=True, slots=True) +class EnumRow: + mood: Mood + + +@dataclass(frozen=True, slots=True) +class EnumArrayRow: + moods: list[Mood | None] + + +@dataclass(frozen=True, slots=True) +class EnumGridRow: + moods: list[list[Mood]] + + +@dataclass(frozen=True, slots=True) +class LeafRow: + value: Leaf + + +@dataclass(frozen=True, slots=True) +class LeafArrayRow: + values: list[Leaf] + + +@dataclass(frozen=True, slots=True) +class WrapperRow: + value: Wrapper + + +@dataclass(frozen=True, slots=True) +class SanitizedRow: + class_: str + value: Leaf + + +_T = TypeVar("_T") +_ObjectMaker = Callable[[Sequence[Any], CompositeInfo], _T] +_SequenceMaker = Callable[[_T, CompositeInfo], Sequence[Any]] + + +def _python_name(name: str) -> str: + if name == "class": + assert keyword.iskeyword(name) + return "class_" + return name + + +def dataclass_callbacks(cls: type[_T]) -> tuple[_ObjectMaker[_T], _SequenceMaker[_T]]: + if not is_dataclass(cls): + raise TypeError(f"{cls.__name__} must be a dataclass") + + model_fields = fields(cls) + model_names = tuple(field.name for field in model_fields) + + def make_object(values: Sequence[Any], info: CompositeInfo) -> _T: + names = tuple(_python_name(name) for name in info.field_names) + assert names == model_names + assert len(values) == len(model_fields) + return cls(**dict(zip(names, values, strict=True))) + + def make_sequence(obj: _T, info: CompositeInfo) -> Sequence[Any]: + names = tuple(_python_name(name) for name in info.field_names) + assert names == model_names + return tuple(getattr(obj, field.name) for field in model_fields) + + return make_object, make_sequence + + +_leaf_make_object, _leaf_make_sequence = dataclass_callbacks(Leaf) +_wrapper_make_object, _wrapper_make_sequence = dataclass_callbacks(Wrapper) + + +async def register_adapter_types_async(conn: AsyncConnection[object]) -> None: + mood_info = await EnumInfo.fetch(conn, "public.m_adapter_mood") + assert mood_info is not None + register_enum( + mood_info, + conn, + Mood, + mapping={member: member.value for member in Mood}, + ) + assert mood_info.enum is Mood + + # Register dependencies before the alphabetically earlier wrapper type. + leaf_info = await CompositeInfo.fetch(conn, "public.z_adapter_leaf") + assert leaf_info is not None + register_composite( + leaf_info, + conn, + Leaf, + make_object=_leaf_make_object, + make_sequence=_leaf_make_sequence, + ) + assert leaf_info.python_type is Leaf + + wrapper_info = await CompositeInfo.fetch(conn, "public.a_adapter_wrapper") + assert wrapper_info is not None + register_composite( + wrapper_info, + conn, + Wrapper, + make_object=_wrapper_make_object, + make_sequence=_wrapper_make_sequence, + ) + assert wrapper_info.python_type is Wrapper + + +def register_adapter_types_sync(conn: Connection[object]) -> None: + mood_info = EnumInfo.fetch(conn, "public.m_adapter_mood") + assert mood_info is not None + register_enum( + mood_info, + conn, + Mood, + mapping={member: member.value for member in Mood}, + ) + assert mood_info.enum is Mood + + leaf_info = CompositeInfo.fetch(conn, "public.z_adapter_leaf") + assert leaf_info is not None + register_composite( + leaf_info, + conn, + Leaf, + make_object=_leaf_make_object, + make_sequence=_leaf_make_sequence, + ) + assert leaf_info.python_type is Leaf + + wrapper_info = CompositeInfo.fetch(conn, "public.a_adapter_wrapper") + assert wrapper_info is not None + register_composite( + wrapper_info, + conn, + Wrapper, + make_object=_wrapper_make_object, + make_sequence=_wrapper_make_sequence, + ) + assert wrapper_info.python_type is Wrapper + + +async def fetch_one_async( + conn: AsyncConnection[object], + sql: LiteralString, + params: Mapping[str, object], + row_factory: BaseRowFactory[_T], +) -> _T: + async with conn.cursor(row_factory=row_factory) as cursor: + await cursor.execute(sql, params) + row = await cursor.fetchone() + if row is None: + raise RuntimeError("query returned no row") + return row + + +def fetch_one_sync( + conn: Connection[object], + sql: LiteralString, + params: Mapping[str, object], + row_factory: BaseRowFactory[_T], +) -> _T: + with conn.cursor(row_factory=row_factory) as cursor: + cursor.execute(sql, params) + row = cursor.fetchone() + if row is None: + raise RuntimeError("query returned no row") + return row + + +async def prove_async_row_inference(conn: AsyncConnection[object]) -> None: + enum_row: EnumRow = await fetch_one_async(conn, "SELECT NULL", {}, args_row(EnumRow)) + enum_array_row: EnumArrayRow = await fetch_one_async( + conn, "SELECT NULL", {}, args_row(EnumArrayRow) + ) + enum_grid_row: EnumGridRow = await fetch_one_async( + conn, "SELECT NULL", {}, args_row(EnumGridRow) + ) + leaf_row: LeafRow = await fetch_one_async(conn, "SELECT NULL", {}, args_row(LeafRow)) + leaf_array_row: LeafArrayRow = await fetch_one_async( + conn, "SELECT NULL", {}, args_row(LeafArrayRow) + ) + wrapper_row: WrapperRow = await fetch_one_async( + conn, "SELECT NULL", {}, args_row(WrapperRow) + ) + sanitized_row: SanitizedRow = await fetch_one_async( + conn, "SELECT NULL, NULL", {}, args_row(SanitizedRow) + ) + _ = ( + enum_row, + enum_array_row, + enum_grid_row, + leaf_row, + leaf_array_row, + wrapper_row, + sanitized_row, + ) + + +def prove_sync_row_inference(conn: Connection[object]) -> None: + enum_row: EnumRow = fetch_one_sync(conn, "SELECT NULL", {}, args_row(EnumRow)) + enum_array_row: EnumArrayRow = fetch_one_sync(conn, "SELECT NULL", {}, args_row(EnumArrayRow)) + enum_grid_row: EnumGridRow = fetch_one_sync(conn, "SELECT NULL", {}, args_row(EnumGridRow)) + leaf_row: LeafRow = fetch_one_sync(conn, "SELECT NULL", {}, args_row(LeafRow)) + leaf_array_row: LeafArrayRow = fetch_one_sync(conn, "SELECT NULL", {}, args_row(LeafArrayRow)) + wrapper_row: WrapperRow = fetch_one_sync(conn, "SELECT NULL", {}, args_row(WrapperRow)) + sanitized_row: SanitizedRow = fetch_one_sync( + conn, "SELECT NULL, NULL", {}, args_row(SanitizedRow) + ) + _ = ( + enum_row, + enum_array_row, + enum_grid_row, + leaf_row, + leaf_array_row, + wrapper_row, + sanitized_row, + ) diff --git a/uv.lock b/uv.lock index f2a9658..3bd32d6 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ dev = [ [package.metadata.requires-dev] dev = [ { name = "basedpyright", specifier = "==1.39.7" }, - { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3,<4" }, { name = "pytest", specifier = ">=8" }, ] From bac8f95d180d8390062d533426018b309dc4e26b Mon Sep 17 00:00:00 2001 From: Viacheslav Shvets Date: Mon, 13 Jul 2026 02:47:46 +0300 Subject: [PATCH 28/41] refactor(gen): register generated postgres value types H1 CONFIRM. Validated the adapter contract with psycopg 3.3.4. Support scalar and rank <=2 enum values, scalar and rank <=1 composite values, and nested scalar composites. Reject enum rank >2, composite rank >1, and custom-array composite fields. Register shared generated classes dependency-first in async and sync order: Mood -> Point2D -> TagValue -> ZCodecPayload -> ACodecWrapper. Tests: collision 1 passed with enum-only strict 0/0; fresh signatures 1 passed and all four applied paths unchanged; golden regenerated; generated 11 passed; unsupported 15 passed; adapter 2 passed; full live-PostgreSQL suite 36 passed with 0 failed or skipped. --- .github/scripts/build-contract-shell.sh | 2 +- .github/workflows/ci.yml | 2 +- DESIGN.md | 4 +- README.md | 4 +- mise.toml | 128 ++++++++ src/Interpreters/CustomType.dhall | 148 +++++++--- src/Interpreters/Member.dhall | 69 +---- src/Interpreters/ParamsMember.dhall | 42 +-- src/Interpreters/Project.dhall | 192 ++++++++---- src/Templates/CompositeModule.dhall | 58 +--- src/Templates/EnumModule.dhall | 16 - src/Templates/RegisterModule.dhall | 278 ++++++++++++------ tests/fixture-project/migrations/1.sql | 15 + .../queries/get_specimen.sig1.pgn.yaml | 11 + .../fixture-project/queries/get_specimen.sql | 2 +- .../queries/insert_specimen.sig1.pgn.yaml | 22 ++ .../queries/insert_specimen.sql | 6 +- .../public/a_codec_wrapper.sig1.pgn.yaml | 10 + .../public/z_codec_payload.sig1.pgn.yaml | 10 + tests/golden/pyproject.toml | 2 +- tests/golden/src/specimen_client/__init__.py | 4 + .../specimen_client/_generated/_register.py | 177 +++++++++-- .../_generated/statements/get_specimen.py | 14 +- .../_generated/statements/get_tagged_item.py | 2 +- .../_generated/statements/insert_specimen.py | 45 ++- .../statements/insert_tagged_item.py | 6 +- .../statements/list_specimens_by_feeling.py | 8 +- .../statements/list_specimens_by_ids.py | 2 +- .../statements/list_specimens_by_moods.py | 9 +- .../_generated/types/__init__.py | 2 + .../_generated/types/a_codec_wrapper.py | 15 + .../specimen_client/_generated/types/mood.py | 8 - .../_generated/types/point_2_d.py | 14 - .../_generated/types/tag_value.py | 14 - .../_generated/types/z_codec_payload.py | 12 + .../src/specimen_client/sync/__init__.py | 4 + tests/test_generated.py | 110 ++++++- tests/test_identifier_collisions.py | 42 ++- tests/test_unsupported_types.py | 143 ++++++--- 39 files changed, 1155 insertions(+), 497 deletions(-) create mode 100644 tests/fixture-project/types/public/a_codec_wrapper.sig1.pgn.yaml create mode 100644 tests/fixture-project/types/public/z_codec_payload.sig1.pgn.yaml create mode 100644 tests/golden/src/specimen_client/_generated/types/a_codec_wrapper.py create mode 100644 tests/golden/src/specimen_client/_generated/types/z_codec_payload.py diff --git a/.github/scripts/build-contract-shell.sh b/.github/scripts/build-contract-shell.sh index 67429c5..bb3cd78 100755 --- a/.github/scripts/build-contract-shell.sh +++ b/.github/scripts/build-contract-shell.sh @@ -31,7 +31,7 @@ build-backend = "hatchling.build" name = "${pkg_name//_/-}" version = "0.0.0" requires-python = ">=3.12" -dependencies = ["psycopg>=3.2"] +dependencies = ["psycopg>=3.3,<4"] [tool.hatch.build.targets.wheel] packages = ["src/$pkg_name"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b200b7c..bc1a1b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -171,7 +171,7 @@ jobs: set -euo pipefail mise x -- uv venv contract-shell/.venv - mise x -- uv pip install --python contract-shell/.venv/bin/python "psycopg[binary]>=3.2" basedpyright + mise x -- uv pip install --python contract-shell/.venv/bin/python "psycopg[binary]>=3.3,<4" basedpyright - name: basedpyright strict on the generated package shell: bash diff --git a/DESIGN.md b/DESIGN.md index 8e01d61..7212d92 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -13,7 +13,7 @@ output paths either way. Constraints that bound every decision below: -- Generated code depends ONLY on `psycopg>=3.2` and the stdlib. No pydantic, +- Generated code depends ONLY on `psycopg>=3.3,<4` and the stdlib. No pydantic, no third-party codec library. - Generated code passes `basedpyright` strict with zero errors/warnings and `ruff` clean. @@ -726,7 +726,7 @@ application code, not anywhere near `_generated`. ## 16. Deliberate driver scope -psycopg `>=3.2` is the target and the only supported driver. The runtime +psycopg `>=3.3,<4` is the target and the only supported driver. The runtime helpers, the `dict_row` factory, `Jsonb` wrapping, `CompositeInfo`/`TypeInfo` registration, and the `AsyncConnection`/`Connection` split are all psycopg-specific by design. Abstracting over other drivers (asyncpg, a diff --git a/README.md b/README.md index a72eb9e..d805a45 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ pgn reads a project's SQL queries and custom types from a live PostgreSQL, infers parameter and result types, then hands them to this generator, which emits a typed Python client: async (`psycopg.AsyncConnection`) by default, or sync (`psycopg.Connection`) when `config.sync` is `True`, at the same -unified output paths either way. Generated code depends on `psycopg>=3.2` +unified output paths either way. Generated code depends on `psycopg>=3.3,<4` and the stdlib only, nothing else. Every emitted file starts with `# @generated by python.gen (pGenie). DO NOT @@ -25,7 +25,7 @@ yours to use under your own project's terms, no attribution required. - **Fully typed, end to end.** Parameter and result types are inferred from your live PostgreSQL by preparing the real queries, and every emitted file passes `basedpyright` strict with zero errors or warnings. -- **Zero runtime dependencies.** Generated code needs `psycopg>=3.2` and the +- **Zero runtime dependencies.** Generated code needs `psycopg>=3.3,<4` and the stdlib, nothing else: no pydantic, no codec libraries, no framework lock-in. ## Quickstart diff --git a/mise.toml b/mise.toml index 850d91e..fc91362 100644 --- a/mise.toml +++ b/mise.toml @@ -59,3 +59,131 @@ cp artifacts/python/src/specimen_client/sync/__init__.py "$root/tests/golden/src rm -rf "$root/tests/golden_sync" echo "combined golden refreshed; review with: git diff tests/golden" ''' + +[tasks.signatures] +description = "Check or apply an explicit allowlist of freshly analyzed fixture signatures" +run = ''' +#!/usr/bin/env bash +set -euo pipefail +root="$(pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/tests" +cp -R "$root/src" "$tmp/src" +cp -R "$root/tests/fixture-project" "$tmp/tests/fixture-project" +fixture="$tmp/tests/fixture-project" +rm -f "$fixture/freeze1.pgn.yaml" +rm -rf "$fixture/artifacts" +find "$fixture" -type f -name '*.sig1.pgn.yaml' -delete + +marker=' # The variants below' +before_markers="$(grep -Fc "$marker" "$fixture/project1.pgn.yaml" || true)" +if [[ "$before_markers" != 1 ]]; then + echo "expected exactly one project variant marker, found $before_markers" >&2 + exit 1 +fi +cp "$fixture/project1.pgn.yaml" "$tmp/project1.pgn.yaml.before" + +( + cd "$fixture" + pgn --database-url "${PGN_TEST_DATABASE_URL:-postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable}" analyse +) + +after_markers="$(grep -Fc "$marker" "$fixture/project1.pgn.yaml" || true)" +if [[ "$after_markers" != 1 ]]; then + echo "project variant marker changed during analysis" >&2 + exit 1 +fi +cmp "$tmp/project1.pgn.yaml.before" "$fixture/project1.pgn.yaml" + +mode="${PGN_SIGNATURE_MODE:-check}" +allowlist="${PGN_SIGNATURE_PATHS:-}" +if [[ -z "$allowlist" ]]; then + echo "PGN_SIGNATURE_PATHS must explicitly list the expected changed signatures" >&2 + exit 1 +fi +if [[ "$mode" != check && "$mode" != apply ]]; then + echo "PGN_SIGNATURE_MODE must be check or apply" >&2 + exit 1 +fi + +mise exec -- python - "$root/tests/fixture-project" "$fixture" "$mode" "$allowlist" <<'PY' +from __future__ import annotations + +import difflib +import shutil +import sys +from pathlib import Path, PurePosixPath + +committed = Path(sys.argv[1]) +fresh = Path(sys.argv[2]) +mode = sys.argv[3] +raw_allowlist = sys.argv[4] + +allowed_items = raw_allowlist.split() +if len(allowed_items) != len(set(allowed_items)): + raise SystemExit("PGN_SIGNATURE_PATHS contains duplicate paths") + +allowed: set[PurePosixPath] = set() +for item in allowed_items: + path = PurePosixPath(item) + if path.is_absolute() or ".." in path.parts or "\\" in item: + raise SystemExit(f"unsafe signature path: {item}") + if not path.parts or path.parts[0] not in {"queries", "types"}: + raise SystemExit(f"signature path must be under queries/ or types/: {item}") + if not item.endswith(".sig1.pgn.yaml") or path.as_posix() != item: + raise SystemExit(f"not a normalized signature path: {item}") + allowed.add(path) + + +def signatures(root: Path) -> dict[PurePosixPath, Path]: + return { + PurePosixPath(path.relative_to(root).as_posix()): path + for path in root.rglob("*.sig1.pgn.yaml") + } + + +old = signatures(committed) +new = signatures(fresh) +deletions = sorted(old.keys() - new.keys(), key=str) +if deletions: + raise SystemExit("fresh analysis deleted signatures: " + ", ".join(map(str, deletions))) + +changed = { + path + for path in old.keys() | new.keys() + if path not in old or path not in new or old[path].read_bytes() != new[path].read_bytes() +} +unexpected = sorted(changed - allowed, key=str) +if unexpected: + raise SystemExit("unexpected signature drift: " + ", ".join(map(str, unexpected))) + +not_changed = sorted(allowed - changed, key=str) +if not_changed: + labels = [] + for path in not_changed: + state = "unchanged" if path in old and path in new else "missing" + labels.append(f"{path} ({state})") + raise SystemExit("allowlisted signatures did not change: " + ", ".join(labels)) + +for path in sorted(changed, key=str): + before = old[path].read_text().splitlines(keepends=True) if path in old else [] + after = new[path].read_text().splitlines(keepends=True) + sys.stdout.writelines( + difflib.unified_diff( + before, + after, + fromfile=str(path) if path in old else "/dev/null", + tofile=str(path), + ) + ) + +if mode == "check": + raise SystemExit("signature differences found; review them, then rerun with PGN_SIGNATURE_MODE=apply") + +for path in sorted(changed, key=str): + destination = committed / Path(path.as_posix()) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(new[path], destination) +PY +''' diff --git a/src/Interpreters/CustomType.dhall b/src/Interpreters/CustomType.dhall index c4785f1..1396890 100644 --- a/src/Interpreters/CustomType.dhall +++ b/src/Interpreters/CustomType.dhall @@ -27,11 +27,6 @@ let Input = Model.CustomType let TypeKind = < Enum | Composite > --- moduleName/pgSchema/pgName mirror the input Name/CustomType so Project.dhall's --- combineOutputs can derive the facade export, types/__init__ export, and the --- composite/enum registration name from this Output alone (the surviving list --- after Skip filtering), without a second, separately-threaded List --- Model.CustomType parameter. let Output = { modulePath : Text , moduleContent : Text @@ -40,11 +35,10 @@ let Output = , pgSchema : Text , pgName : Text , kind : TypeKind + , order : Natural + , dependencies : List Natural } --- 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). let renderExtraImports = \(imports : ImportSet.Type) -> let datetimeNames = @@ -60,6 +54,15 @@ let renderExtraImports = ", " datetimeNames}" ] + let customLines = + Prelude.List.map + ImportSet.CustomImport + Text + ( \(custom : ImportSet.CustomImport) -> + "from .${custom.moduleName} import ${custom.className}" + ) + (ImportSet.sortedCustoms imports) + in (if imports.uuid then [ "from uuid import UUID" ] else [] : List Text) # datetimeLine # ( if imports.decimal @@ -67,9 +70,10 @@ let renderExtraImports = else [] : List Text ) # ( if imports.jsonValue - then [ "from .._runtime import JsonValue" ] + then [ "from .._core import JsonValue" ] else [] : List Text ) + # customLines let run = \(config : Config) -> @@ -95,18 +99,42 @@ let run = ) variants - in Lude.Compiled.ok - Output - { modulePath - , moduleContent = - EnumModule.run - { typeName, variants = templateVariants } - , typeName - , moduleName - , pgSchema = input.pgSchema - , pgName = input.pgName - , kind = TypeKind.Enum + in merge + { Enum = + \(order : Natural) -> + Lude.Compiled.ok + Output + { modulePath + , moduleContent = + EnumModule.run + { typeName + , variants = templateVariants + } + , typeName + , moduleName + , pgSchema = input.pgSchema + , pgName = input.pgName + , kind = TypeKind.Enum + , order + , dependencies = [] : List Natural + } + , Composite = + \ ( _ + : { fields : List CustomKind.CompositeField + , order : Natural + } + ) -> + Lude.Compiled.report + Output + [ input.pgName ] + "Custom type lookup kind is inconsistent with enum definition" + , Absent = + Lude.Compiled.report + Output + [ input.pgName ] + "Custom type not found in project customTypes" } + (lookup input.name) , Composite = \(members : List Model.Member) -> let compiledMembers @@ -121,10 +149,17 @@ let run = MemberGen.run config lookup m , Custom = \(name : Model.Name) -> - Lude.Compiled.report - MemberGen.Output - [ m.pgName, name.inSnakeCase ] - "Nested custom type members are not supported before PostgreSQL adapter verification" + Prelude.Optional.fold + Model.ArraySettings + m.value.arraySettings + (Lude.Compiled.Type MemberGen.Output) + ( \(_ : Model.ArraySettings) -> + Lude.Compiled.report + MemberGen.Output + [ m.pgName, name.inSnakeCase ] + "Custom array fields inside a composite type are not supported" + ) + (MemberGen.run config lookup m) } m.value.scalar ) @@ -154,22 +189,57 @@ let run = ) memberOutputs - in { modulePath - , moduleContent = - CompositeModule.run - { typeName - , extraImports = - renderExtraImports combinedImports - , fields - } - , typeName - , moduleName - , pgSchema = input.pgSchema - , pgName = input.pgName - , kind = TypeKind.Composite - } + let dependencies = + Prelude.List.map + ImportSet.CustomImport + Natural + ( \(custom : ImportSet.CustomImport) -> + custom.dedupKey + ) + (ImportSet.sortedCustoms combinedImports) + + in merge + { Composite = + \ ( composite + : { fields : + List CustomKind.CompositeField + , order : Natural + } + ) -> + Lude.Compiled.ok + Output + { modulePath + , moduleContent = + CompositeModule.run + { typeName + , extraImports = + renderExtraImports + combinedImports + , fields + } + , typeName + , moduleName + , pgSchema = input.pgSchema + , pgName = input.pgName + , kind = TypeKind.Composite + , order = composite.order + , dependencies + } + , Enum = + \(_ : Natural) -> + Lude.Compiled.report + Output + [ input.pgName ] + "Custom type lookup kind is inconsistent with composite definition" + , Absent = + Lude.Compiled.report + Output + [ input.pgName ] + "Custom type not found in project customTypes" + } + (lookup input.name) - in Lude.Compiled.map + in Lude.Compiled.flatMap (List MemberGen.Output) Output assemble diff --git a/src/Interpreters/Member.dhall b/src/Interpreters/Member.dhall index 556143d..ca81f50 100644 --- a/src/Interpreters/Member.dhall +++ b/src/Interpreters/Member.dhall @@ -86,24 +86,23 @@ let run = let mkOutput = \(customImports : ImportSet.Type) -> - \(decodeExpr : Text -> Text) -> { fieldName , pgName = input.pgName , pyType , isNullable = input.isNullable , imports = - ImportSet.combine baseImports customImports - , decodeExpr + ImportSet.combineAll + [ baseImports + , customImports + , ImportSet.cast + ] + , decodeExpr = passthroughDecode } - let wrapNullable = - \(call : Text -> Text) -> - \(src : Text) -> - if input.isNullable - then "None if ${src} is None else ${call src}" - else call src + let dimsAtMostTwo = + Natural/isZero (Natural/subtract 2 value.dims) - let dimsIsOne = + let dimsAtMostOne = Natural/isZero (Natural/subtract 1 value.dims) in merge @@ -113,51 +112,16 @@ let run = ImportSet.customEnum (customImport order) - in if Natural/isZero value.dims + in if dimsAtMostTwo then Lude.Compiled.ok Output - ( mkOutput - enumImport - ( wrapNullable - ( \(src : Text) -> - "${typeName}.pg_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}.pg_decode(v)" - else "${typeName}.pg_decode(v)" - - let arrayImports = - ImportSet.combineAll - [ enumImport - , ImportSet.enumArray - , ImportSet.cast - ] - - in Lude.Compiled.ok - Output - ( mkOutput - arrayImports - ( wrapNullable - ( \(src : Text) -> - "[${elemDecode} for v in _cast(${elemCast}, _require_array(${src}))]" - ) - ) - ) + (mkOutput enumImport) else Lude.Compiled.report Output [ input.pgName , name.inSnakeCase ] - "Array of an enum with dimensionality > 1 is not supported" + "Array of an enum with dimensionality > 2 is not supported" , Composite = \ ( composite : { fields : @@ -165,25 +129,20 @@ let run = , order : Natural } ) -> - if Natural/isZero value.dims + if dimsAtMostOne then Lude.Compiled.ok Output ( mkOutput ( ImportSet.customComposite (customImport composite.order) ) - ( wrapNullable - ( \(src : Text) -> - "${typeName}.pg_decode(${src})" - ) - ) ) else Lude.Compiled.report Output [ input.pgName , name.inSnakeCase ] - "Array of a composite type is not supported (element-wise decode is unimplemented)" + "Array of a composite type with dimensionality > 1 is not supported" , Absent = Lude.Compiled.report Output diff --git a/src/Interpreters/ParamsMember.dhall b/src/Interpreters/ParamsMember.dhall index 2c90b05..8a8d30c 100644 --- a/src/Interpreters/ParamsMember.dhall +++ b/src/Interpreters/ParamsMember.dhall @@ -272,24 +272,10 @@ let run = , order } - let scalarEncode = - if input.isNullable - then "None if ${fieldName} is None else ${fieldName}.pg_encode()" - else "${fieldName}.pg_encode()" + let dimsAtMostTwo = + Natural/isZero (Natural/subtract 2 value.dims) - let arrayElemEncode = - if value.elementIsNullable - then "None if x is None else x.pg_encode()" - else "x.pg_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 dimsIsOne = + let dimsAtMostOne = Natural/isZero (Natural/subtract 1 value.dims) in merge @@ -299,17 +285,7 @@ let run = ImportSet.customEnum (customImport order) - in if Natural/isZero value.dims - then Lude.Compiled.ok - Output - ( mkOutput - ( ImportSet.combine - value.imports - enumImport - ) - scalarEncode - ) - else if dimsIsOne + in if dimsAtMostTwo then Lude.Compiled.ok Output ( mkOutput @@ -317,14 +293,14 @@ let run = value.imports enumImport ) - arrayEncode + fieldName ) else Lude.Compiled.report Output [ input.pgName , name.inSnakeCase ] - "Array of an enum parameter with dimensionality > 1 is not supported" + "Array of an enum parameter with dimensionality > 2 is not supported" , Composite = \ ( composite : { fields : @@ -332,7 +308,7 @@ let run = , order : Natural } ) -> - if Natural/isZero value.dims + if dimsAtMostOne then Lude.Compiled.ok Output ( mkOutput @@ -342,12 +318,12 @@ let run = (customImport composite.order) ) ) - scalarEncode + fieldName ) else Lude.Compiled.report Output [ input.pgName, name.inSnakeCase ] - "Array of a composite type as a parameter is not supported" + "Array of a composite type parameter with dimensionality > 1 is not supported" , Absent = Lude.Compiled.report Output diff --git a/src/Interpreters/Project.dhall b/src/Interpreters/Project.dhall index 2f610a9..f614527 100644 --- a/src/Interpreters/Project.dhall +++ b/src/Interpreters/Project.dhall @@ -145,34 +145,94 @@ let buildLookup = ) (\(_ : Model.Name) -> CustomKind.TypeKind.Absent) --- 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 --- there is no second List Model.CustomType parameter to thread through. -let compositePgNames = +let sameOrder = + \(left : Natural) -> + \(right : Natural) -> + Natural/isZero (Natural/subtract left right) + && Natural/isZero (Natural/subtract right left) + +let containsOrder = + \(order : Natural) -> \(customTypes : List CustomTypeGen.Output) -> - Prelude.List.map + Prelude.List.any CustomTypeGen.Output - Text - (\(ct : CustomTypeGen.Output) -> "${ct.pgSchema}.${ct.pgName}") - ( Prelude.List.filter - CustomTypeGen.Output - (\(ct : CustomTypeGen.Output) -> merge { Composite = True, Enum = False } ct.kind) - customTypes + (\(custom : CustomTypeGen.Output) -> sameOrder order custom.order) + customTypes + +let dependenciesReady = + \(emitted : List CustomTypeGen.Output) -> + \(custom : CustomTypeGen.Output) -> + Prelude.Bool.not + ( Prelude.List.any + Natural + (\(dependency : Natural) -> Prelude.Bool.not (containsOrder dependency emitted)) + custom.dependencies ) --- Enum TypeInfos are registered too so enum arrays parse (see RegisterModule). -let enumPgNames = +let RegistrationState = + { emitted : List CustomTypeGen.Output + , remaining : List CustomTypeGen.Output + } + +let registrationOrder = \(customTypes : List CustomTypeGen.Output) -> - Prelude.List.map - CustomTypeGen.Output - Text - (\(ct : CustomTypeGen.Output) -> "${ct.pgSchema}.${ct.pgName}") - ( Prelude.List.filter - CustomTypeGen.Output - (\(ct : CustomTypeGen.Output) -> merge { Composite = False, Enum = True } ct.kind) - customTypes - ) + let enums = + Prelude.List.filter + CustomTypeGen.Output + ( \(custom : CustomTypeGen.Output) -> + merge { Enum = True, Composite = False } custom.kind + ) + customTypes + + let composites = + Prelude.List.filter + CustomTypeGen.Output + ( \(custom : CustomTypeGen.Output) -> + merge { Enum = False, Composite = True } custom.kind + ) + customTypes + + let pass = + \(state : RegistrationState) -> + let ready = + Prelude.List.filter + CustomTypeGen.Output + (dependenciesReady state.emitted) + state.remaining + + let blocked = + Prelude.List.filter + CustomTypeGen.Output + ( \(custom : CustomTypeGen.Output) -> + Prelude.Bool.not + (dependenciesReady state.emitted custom) + ) + state.remaining + + in { emitted = state.emitted # ready, remaining = blocked } + + let ordered = + Natural/fold + (Prelude.List.length CustomTypeGen.Output customTypes) + RegistrationState + pass + { emitted = enums, remaining = composites } + + let unresolved = + Prelude.Text.concatMapSep + ", " + CustomTypeGen.Output + ( \(custom : CustomTypeGen.Output) -> + "${custom.pgSchema}.${custom.pgName}" + ) + ordered.remaining + + in if Prelude.List.null CustomTypeGen.Output ordered.remaining + then Lude.Compiled.ok (List CustomTypeGen.Output) ordered.emitted + else Lude.Compiled.report + (List CustomTypeGen.Output) + [ "register_types" ] + "Unresolved or cyclic custom type dependencies: ${unresolved}" let combineOutputs = \(config : ResolvedConfig) -> @@ -187,6 +247,9 @@ let combineOutputs = -- entries are built from this, not input.customTypes, so a skipped -- type leaves no dangling export. \(customTypes : List CustomTypeGen.Output) -> + -- Same compiled custom types in dependency-first order, used only by the + -- shared register module. File and facade order remains project order. + \(registrationTypes : 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 -- hand-written and committed once, never overwritten by generation. @@ -284,25 +347,30 @@ let combineOutputs = } ] - let compositeNames = compositePgNames customTypes - - let enumNames = enumPgNames customTypes - let hasCustomRegistration = Prelude.Bool.not - ( Prelude.Bool.and - [ Prelude.List.null Text compositeNames - , Prelude.List.null Text enumNames - ] + (Prelude.List.null CustomTypeGen.Output registrationTypes) + + let registrationEntries = + Prelude.List.map + CustomTypeGen.Output + RegisterModule.CustomType + ( \(custom : CustomTypeGen.Output) -> + { typeName = custom.typeName + , moduleName = custom.moduleName + , pgSchema = custom.pgSchema + , pgName = custom.pgName + , kind = custom.kind + } ) + registrationTypes let registerFiles = if hasCustomRegistration then [ { path = srcPrefix ++ "_register.py" , content = RegisterModule.run - { compositeNames - , enumNames + { customTypes = registrationEntries , emitSync = config.emitSync } } @@ -415,25 +483,23 @@ let run = let skip = merge { Fail = False, Skip = True } resolvedConfig.onUnsupported - let rawLookup = buildLookup input.customTypes - - let typeSucceeds - : Model.CustomType -> Bool - = \(ct : Model.CustomType) -> + let typeSucceedsWith = + \(candidateLookup : CustomKind.Lookup) -> + \(ct : Model.CustomType) -> merge { Ok = \(_ : { value : CustomTypeGen.Output, warnings : List Report }) -> True , Err = \(_ : Report) -> False } - (CustomTypeGen.run resolvedConfig rawLookup ct) + (CustomTypeGen.run resolvedConfig candidateLookup 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 -- (CustomType.run itself does not). - let typeWarning - : Model.CustomType -> Optional Report - = \(ct : Model.CustomType) -> + let typeWarningWith = + \(candidateLookup : CustomKind.Lookup) -> + \(ct : Model.CustomType) -> merge { Ok = \(_ : { value : CustomTypeGen.Output, warnings : List Report }) -> @@ -441,17 +507,26 @@ let run = , Err = \(err : Report) -> Some { path = [ ct.name.inSnakeCase ] # err.path, message = err.message } } - (CustomTypeGen.run resolvedConfig rawLookup ct) + (CustomTypeGen.run resolvedConfig candidateLookup ct) - -- A skipped custom type resolves to Absent for any query that - -- references it, and that query's own Member/ParamsMember - -- resolution fails with "Custom type not found" -- caught the same - -- way any other unsupported shape is, cascading the skip onto every - -- dependent query. + -- Nested custom support requires transitive closure: after one type is + -- removed, composites depending on it must be reconsidered against the + -- smaller lookup. Each bounded pass can only remove survivors. let effectiveCustomTypes : List Model.CustomType = if skip - then Prelude.List.filter Model.CustomType typeSucceeds input.customTypes + then Natural/fold + (Prelude.List.length Model.CustomType input.customTypes) + (List Model.CustomType) + ( \(survivors : List Model.CustomType) -> + let candidateLookup = buildLookup survivors + + in Prelude.List.filter + Model.CustomType + (typeSucceedsWith candidateLookup) + survivors + ) + input.customTypes else input.customTypes let lookup = buildLookup effectiveCustomTypes @@ -506,12 +581,25 @@ let run = ) effectiveQueries + let registrationTypesForCombine + : Lude.Compiled.Type (List CustomTypeGen.Output) + = Lude.Compiled.flatMap + (List CustomTypeGen.Output) + (List CustomTypeGen.Output) + registrationOrder + typesForCombine + let skipWarnings : List Report = if skip then Prelude.List.unpackOptionals Report - (Prelude.List.map Model.CustomType (Optional Report) typeWarning input.customTypes) + ( Prelude.List.map + Model.CustomType + (Optional Report) + (typeWarningWith lookup) + input.customTypes + ) # Prelude.List.unpackOptionals Report (Prelude.List.map QueryCheck (Optional Report) (\(qc : QueryCheck) -> qc.warning) queryChecks) @@ -519,13 +607,15 @@ let run = let combined : Lude.Compiled.Type Output - = Lude.Compiled.map2 + = Lude.Compiled.map3 (List QueryGen.Output) (List CustomTypeGen.Output) + (List CustomTypeGen.Output) Output (combineOutputs resolvedConfig input) queriesForCombine typesForCombine + registrationTypesForCombine in Lude.Compiled.appendWarnings Output skipWarnings combined diff --git a/src/Templates/CompositeModule.dhall b/src/Templates/CompositeModule.dhall index d2f9dc7..fdfa84a 100644 --- a/src/Templates/CompositeModule.dhall +++ b/src/Templates/CompositeModule.dhall @@ -20,60 +20,11 @@ let run = ) 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})" - - -- pg_decode/pg_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. - -- - -- Named `pg_decode`/`pg_encode` rather than `_decode`/`_encode`: every - -- caller lives in a different generated module - -- (Member.dhall/ParamsMember.dhall), so a leading underscore only - -- earns a basedpyright strict reportPrivateUsage error, not real - -- privacy. Plain `decode`/`encode` was tried first and rejected: - -- EnumModule.dhall's generated class subclasses StrEnum, and `encode` - -- there collides with `str.encode`'s incompatible signature - -- (reportIncompatibleMethodOverride). The `pg_` prefix keeps both - -- generated shapes on one shared name with no collision either way. - let codecMethods = - "\n" - ++ " @staticmethod\n" - ++ " def pg_decode(src: object) -> \"${params.typeName}\":\n" - ++ " return ${params.typeName}(*cast(tuple[${fieldTypesJoined}], src))\n" - ++ "\n" - ++ " def pg_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" + then "from dataclasses import dataclass" else '' from dataclasses import dataclass - from typing import cast ${Prelude.Text.concatSep "\n" params.extraImports}'' @@ -83,14 +34,7 @@ let run = @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 pg_decode cannot splat into the dataclass. - """ - ${fieldLines} - ${codecMethods} '' in Sdk.Sigs.template Params run /\ { Field } diff --git a/src/Templates/EnumModule.dhall b/src/Templates/EnumModule.dhall index 4618932..1ebf486 100644 --- a/src/Templates/EnumModule.dhall +++ b/src/Templates/EnumModule.dhall @@ -35,28 +35,12 @@ let run = ) params.variants - -- pg_decode/pg_encode, not decode/encode or _decode/_encode: see - -- CompositeModule.dhall's codecMethods comment. The `pg_` prefix - -- matters here specifically — this class subclasses StrEnum, so a - -- plain `encode` would override `str.encode`'s incompatible - -- signature (reportIncompatibleMethodOverride). - let codecMethods = - "\n" - ++ " @staticmethod\n" - ++ " def pg_decode(src: object) -> \"${params.typeName}\":\n" - ++ " return ${params.typeName}(cast(str, src))\n" - ++ "\n" - ++ " def pg_encode(self) -> \"${params.typeName}\":\n" - ++ " return self" - in '' from enum import StrEnum - from typing import cast class ${params.typeName}(StrEnum): ${memberLines} - ${codecMethods} '' in Sdk.Sigs.template Params run /\ { Variant } diff --git a/src/Templates/RegisterModule.dhall b/src/Templates/RegisterModule.dhall index 1bbd150..d92c15c 100644 --- a/src/Templates/RegisterModule.dhall +++ b/src/Templates/RegisterModule.dhall @@ -2,110 +2,210 @@ let Prelude = ../Deps/Prelude.dhall let Sdk = ../Deps/Sdk.dhall --- Per-connection type registration, emitted once. 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 --- frozen dataclass. An enum scalar already decodes as text, but an enum ARRAY --- does not parse without the type registered, so each enum's TypeInfo is --- registered too (elements stay text and the generated decode rebuilds the --- StrEnum). Call once per connection before using the statements. Emitted only --- when the project has composites or enums. -let Params = - { compositeNames : List Text - , enumNames : List Text - , emitSync : Bool +let TypeKind = < Enum | Composite > + +let CustomType = + { typeName : Text + , moduleName : Text + , pgSchema : Text + , pgName : Text + , kind : TypeKind } -let tupleLiteral = - \(names : List Text) -> - "(" - ++ Prelude.Text.concatMapSep ", " Text (\(n : Text) -> "\"${n}\"") names - ++ ",)" +let Params = { customTypes : List CustomType, emitSync : Bool } -let compositeLoop = - \(awaitKw : Text) -> - [ " for name in _COMPOSITE_TYPES:" - , " composite_info = ${awaitKw}CompositeInfo.fetch(conn, name)" - , " if composite_info is None:" - , " raise LookupError(f\"composite type {name!r} not found; cannot register it\")" - , " register_composite(composite_info, conn)" - ] - -let enumLoop = +let classImport = + \(custom : CustomType) -> + "from .types.${custom.moduleName} import ${custom.typeName}" + +let pgNameConstant = + \(custom : CustomType) -> "_${custom.moduleName}_pg_name" + +let makeObjectName = + \(custom : CustomType) -> "_${custom.moduleName}_make_object" + +let makeSequenceName = + \(custom : CustomType) -> "_${custom.moduleName}_make_sequence" + +let metadata = + \(custom : CustomType) -> + let pgName = "${custom.pgSchema}.${custom.pgName}" + + let constant = "${pgNameConstant custom} = \"${pgName}\"" + + in merge + { Enum = constant + , Composite = + constant + ++ "\n" + ++ "${makeObjectName custom}, ${makeSequenceName custom} = _dataclass_callbacks(${custom.typeName})" + } + custom.kind + +let registration = \(awaitKw : Text) -> - [ " for name in _ENUM_TYPES:" - , " enum_info = ${awaitKw}TypeInfo.fetch(conn, name)" - , " if enum_info is None:" - , " raise LookupError(f\"enum type {name!r} not found; cannot register it\")" - , " enum_info.register(conn)" - ] + \(custom : CustomType) -> + let infoName = "${custom.moduleName}_info" + + in merge + { Enum = + " ${infoName} = ${awaitKw}EnumInfo.fetch(conn, ${pgNameConstant custom})\n" + ++ " if ${infoName} is None:\n" + ++ " raise LookupError(f\"enum type {${pgNameConstant custom}!r} not found; cannot register it\")\n" + ++ " register_enum(\n" + ++ " ${infoName},\n" + ++ " conn,\n" + ++ " ${custom.typeName},\n" + ++ " mapping={member: member.value for member in ${custom.typeName}},\n" + ++ " )" + , Composite = + " ${infoName} = ${awaitKw}CompositeInfo.fetch(conn, ${pgNameConstant custom})\n" + ++ " if ${infoName} is None:\n" + ++ " raise LookupError(f\"composite type {${pgNameConstant custom}!r} not found; cannot register it\")\n" + ++ " register_composite(\n" + ++ " ${infoName},\n" + ++ " conn,\n" + ++ " ${custom.typeName},\n" + ++ " make_object=${makeObjectName custom},\n" + ++ " make_sequence=${makeSequenceName custom},\n" + ++ " )" + } + custom.kind let run = \(params : Params) -> let hasComposites = - Prelude.Bool.not (Prelude.List.null Text params.compositeNames) - - let hasEnums = Prelude.Bool.not (Prelude.List.null Text params.enumNames) - - let importLines = - [ "from __future__ import annotations" - , "" - , if params.emitSync - then "from psycopg import AsyncConnection, Connection" - else "from psycopg import AsyncConnection" - ] - # ( if hasEnums - then [ "from psycopg.types import TypeInfo" ] - else [] : List Text - ) - # ( if hasComposites - then [ "from psycopg.types.composite import CompositeInfo, register_composite" - ] - else [] : List Text + Prelude.List.any + CustomType + ( \(custom : CustomType) -> + merge { Enum = False, Composite = True } custom.kind ) + params.customTypes - let constantLines = - ( if hasComposites - then [ "_COMPOSITE_TYPES = ${tupleLiteral params.compositeNames}" ] - else [] : List Text - ) - # ( if hasEnums - then [ "_ENUM_TYPES = ${tupleLiteral params.enumNames}" ] - else [] : List Text + let hasEnums = + Prelude.List.any + CustomType + ( \(custom : CustomType) -> + merge { Enum = True, Composite = False } custom.kind ) + params.customTypes - let asyncBodyLines = - ( if hasComposites - then compositeLoop "await " - else [] : List Text - ) - # (if hasEnums then enumLoop "await " else [] : List Text) + let classImports = + Prelude.Text.concatMapSep + "\n" + CustomType + classImport + params.customTypes + + let metadataLines = + Prelude.Text.concatMapSep + "\n" + CustomType + metadata + params.customTypes + + let asyncRegistrations = + Prelude.Text.concatMapSep + "\n" + CustomType + (registration "await ") + params.customTypes + + let syncRegistrations = + Prelude.Text.concatMapSep + "\n" + CustomType + (registration "") + params.customTypes + + let connectionImport = + if params.emitSync + then "from psycopg import AsyncConnection, Connection" + else "from psycopg import AsyncConnection" + + let compositeImports = + if hasComposites + then '' + import keyword + from collections.abc import Callable, Sequence + from dataclasses import fields, is_dataclass + from typing import Any, TypeVar + '' + else "" - let syncBodyLines = + let adapterImports = ( if hasComposites - then compositeLoop "" - else [] : List Text + then "from psycopg.types.composite import CompositeInfo, register_composite\n" + else "" ) - # (if hasEnums then enumLoop "" else [] : List Text) + ++ ( if hasEnums + then "from psycopg.types.enum import EnumInfo, register_enum\n" + else "" + ) + + let compositeCallbacks = + if hasComposites + then '' + + + _T = TypeVar("_T") + _ObjectMaker = Callable[[Sequence[Any], CompositeInfo], _T] + _SequenceMaker = Callable[[_T, CompositeInfo], Sequence[Any]] + + + def _python_name(name: str) -> str: + if keyword.iskeyword(name): + return f"{name}_" + return name + + + def _dataclass_callbacks(cls: type[_T]) -> tuple[_ObjectMaker[_T], _SequenceMaker[_T]]: + if not is_dataclass(cls): + raise TypeError(f"{cls.__name__} must be a dataclass") + + model_fields = fields(cls) + model_names = tuple(field.name for field in model_fields) + + def make_object(values: Sequence[Any], info: CompositeInfo) -> _T: + names = tuple(_python_name(name) for name in info.field_names) + assert names == model_names + assert len(values) == len(model_fields) + return cls(**dict(zip(names, values, strict=True))) + + def make_sequence(obj: _T, info: CompositeInfo) -> Sequence[Any]: + names = tuple(_python_name(name) for name in info.field_names) + assert names == model_names + return tuple(getattr(obj, field.name) for field in model_fields) + + return make_object, make_sequence + '' + else "" let syncFunction = if params.emitSync - then [ "", "" ] - # [ "def register_types_sync(conn: Connection[object]) -> None:" - ] - # syncBodyLines - else [] : List Text - - let allLines = - importLines - # [ "", "" ] - # constantLines - # [ "", "" ] - # [ "async def register_types(conn: AsyncConnection[object]) -> None:" - ] - # asyncBodyLines - # syncFunction - - in Prelude.Text.concatSep "\n" allLines ++ "\n" - -in Sdk.Sigs.template Params run + then '' + + + def register_types_sync(conn: Connection[object]) -> None: + ${syncRegistrations}'' + else "" + + in '' + from __future__ import annotations + + ${compositeImports} + ${connectionImport} + ${adapterImports} + + ${classImports} + ${compositeCallbacks} + + + ${metadataLines} + + + async def register_types(conn: AsyncConnection[object]) -> None: + ${asyncRegistrations}${syncFunction} + '' + +in Sdk.Sigs.template Params run /\ { CustomType, TypeKind } diff --git a/tests/fixture-project/migrations/1.sql b/tests/fixture-project/migrations/1.sql index ec0f36c..d4ee87e 100644 --- a/tests/fixture-project/migrations/1.sql +++ b/tests/fixture-project/migrations/1.sql @@ -5,6 +5,18 @@ create type mood as enum ('happy', 'sad', 'meh'); +create type z_codec_payload as ( + "class" int8, + pg_decode text, + pg_encode text +); + +create type a_codec_wrapper as ( + payload z_codec_payload, + feeling mood, + note text +); + create type point2d as ( x float8, y float8 @@ -79,6 +91,9 @@ create table specimen ( -- composite origin point2d, + codec_payload z_codec_payload not null, + codec_payloads z_codec_payload[] not null, + codec_wrapper a_codec_wrapper, -- domains label display_name not null, diff --git a/tests/fixture-project/queries/get_specimen.sig1.pgn.yaml b/tests/fixture-project/queries/get_specimen.sig1.pgn.yaml index 583383d..71ef063 100644 --- a/tests/fixture-project/queries/get_specimen.sig1.pgn.yaml +++ b/tests/fixture-project/queries/get_specimen.sig1.pgn.yaml @@ -93,6 +93,17 @@ result: origin: type: point_2_d not_null: false + codec_payload: + type: z_codec_payload + not_null: true + codec_payloads: + type: z_codec_payload + not_null: true + dims: 1 + element_not_null: false + codec_wrapper: + type: a_codec_wrapper + not_null: false label: type: text not_null: true diff --git a/tests/fixture-project/queries/get_specimen.sql b/tests/fixture-project/queries/get_specimen.sql index c18d7ce..d643be7 100644 --- a/tests/fixture-project/queries/get_specimen.sql +++ b/tests/fixture-project/queries/get_specimen.sql @@ -6,7 +6,7 @@ SELECT doc_json, doc_jsonb, maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, tags, related_ids, grid, - feeling, origin, + feeling, origin, codec_payload, codec_payloads, codec_wrapper, label, rev, meta FROM specimen WHERE id = $id diff --git a/tests/fixture-project/queries/insert_specimen.sig1.pgn.yaml b/tests/fixture-project/queries/insert_specimen.sig1.pgn.yaml index 32e1164..17a29ad 100644 --- a/tests/fixture-project/queries/insert_specimen.sig1.pgn.yaml +++ b/tests/fixture-project/queries/insert_specimen.sig1.pgn.yaml @@ -83,6 +83,17 @@ parameters: origin: type: point_2_d not_null: false + codec_payload: + type: z_codec_payload + not_null: true + codec_payloads: + type: z_codec_payload + not_null: true + dims: 1 + element_not_null: false + codec_wrapper: + type: a_codec_wrapper + not_null: false result: cardinality: one columns: @@ -178,6 +189,17 @@ result: origin: type: point_2_d not_null: false + codec_payload: + type: z_codec_payload + not_null: true + codec_payloads: + type: z_codec_payload + not_null: true + dims: 1 + element_not_null: false + codec_wrapper: + type: a_codec_wrapper + not_null: false label: type: text not_null: true diff --git a/tests/fixture-project/queries/insert_specimen.sql b/tests/fixture-project/queries/insert_specimen.sql index b5bea55..213f7fd 100644 --- a/tests/fixture-project/queries/insert_specimen.sql +++ b/tests/fixture-project/queries/insert_specimen.sql @@ -11,7 +11,7 @@ INSERT INTO specimen ( doc_json, doc_jsonb, maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, tags, related_ids, grid, - feeling, moods, origin, + feeling, moods, origin, codec_payload, codec_payloads, codec_wrapper, label, rev, meta ) VALUES ( @@ -20,7 +20,7 @@ VALUES ( $doc_json::json, $doc_jsonb::jsonb, $maybe_text, $maybe_int, $maybe_uuid, $maybe_ts, $maybe_num, $tags, $related_ids, $grid, - $feeling, $moods::mood[], $origin, + $feeling, $moods::mood[], $origin, $codec_payload, $codec_payloads, $codec_wrapper, 'specimen', 1, '{}'::jsonb ) RETURNING @@ -30,5 +30,5 @@ RETURNING doc_json, doc_jsonb, maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, tags, related_ids, grid, - feeling, moods, origin, + feeling, moods, origin, codec_payload, codec_payloads, codec_wrapper, label, rev, meta diff --git a/tests/fixture-project/types/public/a_codec_wrapper.sig1.pgn.yaml b/tests/fixture-project/types/public/a_codec_wrapper.sig1.pgn.yaml new file mode 100644 index 0000000..7a406e1 --- /dev/null +++ b/tests/fixture-project/types/public/a_codec_wrapper.sig1.pgn.yaml @@ -0,0 +1,10 @@ +composite: + payload: + type: z_codec_payload + not_null: false + feeling: + type: mood + not_null: false + note: + type: text + not_null: false diff --git a/tests/fixture-project/types/public/z_codec_payload.sig1.pgn.yaml b/tests/fixture-project/types/public/z_codec_payload.sig1.pgn.yaml new file mode 100644 index 0000000..e6b2c8d --- /dev/null +++ b/tests/fixture-project/types/public/z_codec_payload.sig1.pgn.yaml @@ -0,0 +1,10 @@ +composite: + class: + type: int8 + not_null: false + pg_decode: + type: text + not_null: false + pg_encode: + type: text + not_null: false diff --git a/tests/golden/pyproject.toml b/tests/golden/pyproject.toml index 512f22f..a414476 100644 --- a/tests/golden/pyproject.toml +++ b/tests/golden/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" name = "specimen-client" version = "0.0.0" requires-python = ">=3.12" -dependencies = ["psycopg>=3.2"] +dependencies = ["psycopg>=3.3,<4"] [tool.hatch.build.targets.wheel] packages = ["src/specimen_client"] diff --git a/tests/golden/src/specimen_client/__init__.py b/tests/golden/src/specimen_client/__init__.py index ac5e76d..fb14e5c 100644 --- a/tests/golden/src/specimen_client/__init__.py +++ b/tests/golden/src/specimen_client/__init__.py @@ -4,9 +4,11 @@ from ._generated._core import JsonValue as JsonValue, NoRowError as NoRowError +from ._generated.types.a_codec_wrapper import ACodecWrapper as ACodecWrapper from ._generated.types.mood import Mood as Mood from ._generated.types.point_2_d import Point2D as Point2D from ._generated.types.tag_value import TagValue as TagValue +from ._generated.types.z_codec_payload import ZCodecPayload as ZCodecPayload from ._generated.statements.bump_specimen_revision import bump_specimen_revision as bump_specimen_revision from ._generated.statements.get_specimen import get_specimen as get_specimen, GetSpecimenRow as GetSpecimenRow @@ -27,9 +29,11 @@ __all__ = [ "JsonValue", "NoRowError", + "ACodecWrapper", "Mood", "Point2D", "TagValue", + "ZCodecPayload", "GetSpecimenRow", "GetTaggedItemRow", "InsertSpecimenRow", diff --git a/tests/golden/src/specimen_client/_generated/_register.py b/tests/golden/src/specimen_client/_generated/_register.py index be3da54..e441181 100644 --- a/tests/golden/src/specimen_client/_generated/_register.py +++ b/tests/golden/src/specimen_client/_generated/_register.py @@ -4,36 +4,165 @@ from __future__ import annotations +import keyword +from collections.abc import Callable, Sequence +from dataclasses import fields, is_dataclass +from typing import Any, TypeVar + from psycopg import AsyncConnection, Connection -from psycopg.types import TypeInfo from psycopg.types.composite import CompositeInfo, register_composite +from psycopg.types.enum import EnumInfo, register_enum -_COMPOSITE_TYPES = ("public.point2d", "public.tag_value",) -_ENUM_TYPES = ("public.mood",) +from .types.mood import Mood +from .types.point_2_d import Point2D +from .types.tag_value import TagValue +from .types.z_codec_payload import ZCodecPayload +from .types.a_codec_wrapper import ACodecWrapper -async def register_types(conn: AsyncConnection[object]) -> None: - for name in _COMPOSITE_TYPES: - composite_info = await CompositeInfo.fetch(conn, name) - if composite_info is None: - raise LookupError(f"composite type {name!r} not found; cannot register it") - register_composite(composite_info, conn) - for name in _ENUM_TYPES: - enum_info = await TypeInfo.fetch(conn, name) - if enum_info is None: - raise LookupError(f"enum type {name!r} not found; cannot register it") - enum_info.register(conn) +_T = TypeVar("_T") +_ObjectMaker = Callable[[Sequence[Any], CompositeInfo], _T] +_SequenceMaker = Callable[[_T, CompositeInfo], Sequence[Any]] + + +def _python_name(name: str) -> str: + if keyword.iskeyword(name): + return f"{name}_" + return name + + +def _dataclass_callbacks(cls: type[_T]) -> tuple[_ObjectMaker[_T], _SequenceMaker[_T]]: + if not is_dataclass(cls): + raise TypeError(f"{cls.__name__} must be a dataclass") + + model_fields = fields(cls) + model_names = tuple(field.name for field in model_fields) + + def make_object(values: Sequence[Any], info: CompositeInfo) -> _T: + names = tuple(_python_name(name) for name in info.field_names) + assert names == model_names + assert len(values) == len(model_fields) + return cls(**dict(zip(names, values, strict=True))) + def make_sequence(obj: _T, info: CompositeInfo) -> Sequence[Any]: + names = tuple(_python_name(name) for name in info.field_names) + assert names == model_names + return tuple(getattr(obj, field.name) for field in model_fields) + + return make_object, make_sequence + + + +_mood_pg_name = "public.mood" +_point_2_d_pg_name = "public.point2d" +_point_2_d_make_object, _point_2_d_make_sequence = _dataclass_callbacks(Point2D) +_tag_value_pg_name = "public.tag_value" +_tag_value_make_object, _tag_value_make_sequence = _dataclass_callbacks(TagValue) +_z_codec_payload_pg_name = "public.z_codec_payload" +_z_codec_payload_make_object, _z_codec_payload_make_sequence = _dataclass_callbacks(ZCodecPayload) +_a_codec_wrapper_pg_name = "public.a_codec_wrapper" +_a_codec_wrapper_make_object, _a_codec_wrapper_make_sequence = _dataclass_callbacks(ACodecWrapper) + + +async def register_types(conn: AsyncConnection[object]) -> None: + mood_info = await EnumInfo.fetch(conn, _mood_pg_name) + if mood_info is None: + raise LookupError(f"enum type {_mood_pg_name!r} not found; cannot register it") + register_enum( + mood_info, + conn, + Mood, + mapping={member: member.value for member in Mood}, + ) + point_2_d_info = await CompositeInfo.fetch(conn, _point_2_d_pg_name) + if point_2_d_info is None: + raise LookupError(f"composite type {_point_2_d_pg_name!r} not found; cannot register it") + register_composite( + point_2_d_info, + conn, + Point2D, + make_object=_point_2_d_make_object, + make_sequence=_point_2_d_make_sequence, + ) + tag_value_info = await CompositeInfo.fetch(conn, _tag_value_pg_name) + if tag_value_info is None: + raise LookupError(f"composite type {_tag_value_pg_name!r} not found; cannot register it") + register_composite( + tag_value_info, + conn, + TagValue, + make_object=_tag_value_make_object, + make_sequence=_tag_value_make_sequence, + ) + z_codec_payload_info = await CompositeInfo.fetch(conn, _z_codec_payload_pg_name) + if z_codec_payload_info is None: + raise LookupError(f"composite type {_z_codec_payload_pg_name!r} not found; cannot register it") + register_composite( + z_codec_payload_info, + conn, + ZCodecPayload, + make_object=_z_codec_payload_make_object, + make_sequence=_z_codec_payload_make_sequence, + ) + a_codec_wrapper_info = await CompositeInfo.fetch(conn, _a_codec_wrapper_pg_name) + if a_codec_wrapper_info is None: + raise LookupError(f"composite type {_a_codec_wrapper_pg_name!r} not found; cannot register it") + register_composite( + a_codec_wrapper_info, + conn, + ACodecWrapper, + make_object=_a_codec_wrapper_make_object, + make_sequence=_a_codec_wrapper_make_sequence, + ) def register_types_sync(conn: Connection[object]) -> None: - for name in _COMPOSITE_TYPES: - composite_info = CompositeInfo.fetch(conn, name) - if composite_info is None: - raise LookupError(f"composite type {name!r} not found; cannot register it") - register_composite(composite_info, conn) - for name in _ENUM_TYPES: - enum_info = TypeInfo.fetch(conn, name) - if enum_info is None: - raise LookupError(f"enum type {name!r} not found; cannot register it") - enum_info.register(conn) + mood_info = EnumInfo.fetch(conn, _mood_pg_name) + if mood_info is None: + raise LookupError(f"enum type {_mood_pg_name!r} not found; cannot register it") + register_enum( + mood_info, + conn, + Mood, + mapping={member: member.value for member in Mood}, + ) + point_2_d_info = CompositeInfo.fetch(conn, _point_2_d_pg_name) + if point_2_d_info is None: + raise LookupError(f"composite type {_point_2_d_pg_name!r} not found; cannot register it") + register_composite( + point_2_d_info, + conn, + Point2D, + make_object=_point_2_d_make_object, + make_sequence=_point_2_d_make_sequence, + ) + tag_value_info = CompositeInfo.fetch(conn, _tag_value_pg_name) + if tag_value_info is None: + raise LookupError(f"composite type {_tag_value_pg_name!r} not found; cannot register it") + register_composite( + tag_value_info, + conn, + TagValue, + make_object=_tag_value_make_object, + make_sequence=_tag_value_make_sequence, + ) + z_codec_payload_info = CompositeInfo.fetch(conn, _z_codec_payload_pg_name) + if z_codec_payload_info is None: + raise LookupError(f"composite type {_z_codec_payload_pg_name!r} not found; cannot register it") + register_composite( + z_codec_payload_info, + conn, + ZCodecPayload, + make_object=_z_codec_payload_make_object, + make_sequence=_z_codec_payload_make_sequence, + ) + a_codec_wrapper_info = CompositeInfo.fetch(conn, _a_codec_wrapper_pg_name) + if a_codec_wrapper_info is None: + raise LookupError(f"composite type {_a_codec_wrapper_pg_name!r} not found; cannot register it") + register_composite( + a_codec_wrapper_info, + conn, + ACodecWrapper, + make_object=_a_codec_wrapper_make_object, + make_sequence=_a_codec_wrapper_make_sequence, + ) diff --git a/tests/golden/src/specimen_client/_generated/statements/get_specimen.py b/tests/golden/src/specimen_client/_generated/statements/get_specimen.py index 37a3673..f75f4d7 100644 --- a/tests/golden/src/specimen_client/_generated/statements/get_specimen.py +++ b/tests/golden/src/specimen_client/_generated/statements/get_specimen.py @@ -16,8 +16,10 @@ from .._core import JsonValue from .._runtime import fetch_optional as _fetch_optional from ..sync._runtime import fetch_optional as _fetch_optional_sync +from ..types.a_codec_wrapper import ACodecWrapper from ..types.mood import Mood from ..types.point_2_d import Point2D +from ..types.z_codec_payload import ZCodecPayload @dataclass(frozen=True, slots=True) @@ -49,6 +51,9 @@ class GetSpecimenRow: grid: list[int | None] | None feeling: Mood origin: Point2D | None + codec_payload: ZCodecPayload + codec_payloads: list[ZCodecPayload | None] + codec_wrapper: ACodecWrapper | None label: str rev: int meta: JsonValue @@ -81,8 +86,11 @@ def _decode_row(row: Mapping[str, object]) -> GetSpecimenRow: tags=_cast(list[str | None], row["tags"]), related_ids=_cast(list[UUID | None] | None, row["related_ids"]), grid=_cast(list[int | None] | None, row["grid"]), - feeling=Mood.pg_decode(row["feeling"]), - origin=None if row["origin"] is None else Point2D.pg_decode(row["origin"]), + feeling=_cast(Mood, row["feeling"]), + origin=_cast(Point2D | None, row["origin"]), + codec_payload=_cast(ZCodecPayload, row["codec_payload"]), + codec_payloads=_cast(list[ZCodecPayload | None], row["codec_payloads"]), + codec_wrapper=_cast(ACodecWrapper | None, row["codec_wrapper"]), label=_cast(str, row["label"]), rev=_cast(int, row["rev"]), meta=_cast(JsonValue, row["meta"]), @@ -98,7 +106,7 @@ def _decode_row(row: Mapping[str, object]) -> GetSpecimenRow: doc_json, doc_jsonb, maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, tags, related_ids, grid, - feeling, origin, + feeling, origin, codec_payload, codec_payloads, codec_wrapper, label, rev, meta FROM specimen WHERE id = %(id)s diff --git a/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py b/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py index ec8c873..68565df 100644 --- a/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py +++ b/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py @@ -26,7 +26,7 @@ def _decode_row(row: Mapping[str, object]) -> GetTaggedItemRow: return GetTaggedItemRow( id=_cast(int, row["id"]), name=_cast(str, row["name"]), - tag=TagValue.pg_decode(row["tag"]), + tag=_cast(TagValue, row["tag"]), ) diff --git a/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py b/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py index 15ab613..e976f0b 100644 --- a/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py +++ b/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py @@ -16,11 +16,12 @@ from psycopg.types.json import Jsonb from .._core import JsonValue -from .._core import require_array as _require_array from .._runtime import fetch_single as _fetch_single from ..sync._runtime import fetch_single as _fetch_single_sync +from ..types.a_codec_wrapper import ACodecWrapper from ..types.mood import Mood from ..types.point_2_d import Point2D +from ..types.z_codec_payload import ZCodecPayload @dataclass(frozen=True, slots=True) @@ -53,6 +54,9 @@ class InsertSpecimenRow: feeling: Mood moods: list[Mood | None] | None origin: Point2D | None + codec_payload: ZCodecPayload + codec_payloads: list[ZCodecPayload | None] + codec_wrapper: ACodecWrapper | None label: str rev: int meta: JsonValue @@ -85,9 +89,12 @@ def _decode_row(row: Mapping[str, object]) -> InsertSpecimenRow: tags=_cast(list[str | None], row["tags"]), related_ids=_cast(list[UUID | None] | None, row["related_ids"]), grid=_cast(list[int | None] | None, row["grid"]), - feeling=Mood.pg_decode(row["feeling"]), - moods=None if row["moods"] is None else [None if v is None else Mood.pg_decode(v) for v in _cast(list[str | None], _require_array(row["moods"]))], - origin=None if row["origin"] is None else Point2D.pg_decode(row["origin"]), + feeling=_cast(Mood, row["feeling"]), + moods=_cast(list[Mood | None] | None, row["moods"]), + origin=_cast(Point2D | None, row["origin"]), + codec_payload=_cast(ZCodecPayload, row["codec_payload"]), + codec_payloads=_cast(list[ZCodecPayload | None], row["codec_payloads"]), + codec_wrapper=_cast(ACodecWrapper | None, row["codec_wrapper"]), label=_cast(str, row["label"]), rev=_cast(int, row["rev"]), meta=_cast(JsonValue, row["meta"]), @@ -108,7 +115,7 @@ def _decode_row(row: Mapping[str, object]) -> InsertSpecimenRow: doc_json, doc_jsonb, maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, tags, related_ids, grid, - feeling, moods, origin, + feeling, moods, origin, codec_payload, codec_payloads, codec_wrapper, label, rev, meta ) VALUES ( @@ -117,7 +124,7 @@ def _decode_row(row: Mapping[str, object]) -> InsertSpecimenRow: %(doc_json)s::json, %(doc_jsonb)s::jsonb, %(maybe_text)s, %(maybe_int)s, %(maybe_uuid)s, %(maybe_ts)s, %(maybe_num)s, %(tags)s, %(related_ids)s, %(grid)s, - %(feeling)s, %(moods)s::mood[], %(origin)s, + %(feeling)s, %(moods)s::mood[], %(origin)s, %(codec_payload)s, %(codec_payloads)s, %(codec_wrapper)s, 'specimen', 1, '{}'::jsonb ) RETURNING @@ -127,7 +134,7 @@ def _decode_row(row: Mapping[str, object]) -> InsertSpecimenRow: doc_json, doc_jsonb, maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, tags, related_ids, grid, - feeling, moods, origin, + feeling, moods, origin, codec_payload, codec_payloads, codec_wrapper, label, rev, meta """ @@ -162,6 +169,9 @@ async def insert_specimen( feeling: Mood, moods: list[Mood | None] | None, origin: Point2D | None, + codec_payload: ZCodecPayload, + codec_payloads: list[ZCodecPayload | None], + codec_wrapper: ACodecWrapper | None, ) -> InsertSpecimenRow: params: dict[str, object] = { "flag": flag, @@ -186,9 +196,12 @@ async def insert_specimen( "tags": tags, "related_ids": related_ids, "grid": grid, - "feeling": feeling.pg_encode(), - "moods": None if moods is None else [None if x is None else x.pg_encode() for x in moods], - "origin": None if origin is None else origin.pg_encode(), + "feeling": feeling, + "moods": moods, + "origin": origin, + "codec_payload": codec_payload, + "codec_payloads": codec_payloads, + "codec_wrapper": codec_wrapper, } return await _fetch_single(conn, _SQL, params, _decode_row) @@ -221,6 +234,9 @@ def insert_specimen_sync( feeling: Mood, moods: list[Mood | None] | None, origin: Point2D | None, + codec_payload: ZCodecPayload, + codec_payloads: list[ZCodecPayload | None], + codec_wrapper: ACodecWrapper | None, ) -> InsertSpecimenRow: params: dict[str, object] = { "flag": flag, @@ -245,8 +261,11 @@ def insert_specimen_sync( "tags": tags, "related_ids": related_ids, "grid": grid, - "feeling": feeling.pg_encode(), - "moods": None if moods is None else [None if x is None else x.pg_encode() for x in moods], - "origin": None if origin is None else origin.pg_encode(), + "feeling": feeling, + "moods": moods, + "origin": origin, + "codec_payload": codec_payload, + "codec_payloads": codec_payloads, + "codec_wrapper": codec_wrapper, } return _fetch_single_sync(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py b/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py index a9e6a0c..edd5496 100644 --- a/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py +++ b/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py @@ -26,7 +26,7 @@ def _decode_row(row: Mapping[str, object]) -> InsertTaggedItemRow: return InsertTaggedItemRow( id=_cast(int, row["id"]), name=_cast(str, row["name"]), - tag=TagValue.pg_decode(row["tag"]), + tag=_cast(TagValue, row["tag"]), ) @@ -49,7 +49,7 @@ async def insert_tagged_item( ) -> InsertTaggedItemRow: params: dict[str, object] = { "name": name, - "tag": tag.pg_encode(), + "tag": tag, } return await _fetch_single(conn, _SQL, params, _decode_row) @@ -62,6 +62,6 @@ def insert_tagged_item_sync( ) -> InsertTaggedItemRow: params: dict[str, object] = { "name": name, - "tag": tag.pg_encode(), + "tag": tag, } return _fetch_single_sync(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py index b99ddf0..39c5f78 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py @@ -35,11 +35,11 @@ def _decode_row(row: Mapping[str, object]) -> ListSpecimensByFeelingRow: return ListSpecimensByFeelingRow( id=_cast(int, row["id"]), pub_id=_cast(UUID, row["pub_id"]), - feeling=Mood.pg_decode(row["feeling"]), + feeling=_cast(Mood, row["feeling"]), title=_cast(str, row["title"]), label=_cast(str, row["label"]), rev=_cast(int, row["rev"]), - origin=None if row["origin"] is None else Point2D.pg_decode(row["origin"]), + origin=_cast(Point2D | None, row["origin"]), tags=_cast(list[str | None], row["tags"]), meta=_cast(JsonValue, row["meta"]), ) @@ -63,7 +63,7 @@ async def list_specimens_by_feeling( feeling: Mood | None, ) -> list[ListSpecimensByFeelingRow]: params: dict[str, object] = { - "feeling": None if feeling is None else feeling.pg_encode(), + "feeling": feeling, } return await _fetch_many(conn, _SQL, params, _decode_row) @@ -74,6 +74,6 @@ def list_specimens_by_feeling_sync( feeling: Mood | None, ) -> list[ListSpecimensByFeelingRow]: params: dict[str, object] = { - "feeling": None if feeling is None else feeling.pg_encode(), + "feeling": feeling, } return _fetch_many_sync(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py index f15f826..8548066 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py @@ -28,7 +28,7 @@ def _decode_row(row: Mapping[str, object]) -> ListSpecimensByIdsRow: return ListSpecimensByIdsRow( id=_cast(int, row["id"]), pub_id=_cast(UUID, row["pub_id"]), - feeling=Mood.pg_decode(row["feeling"]), + feeling=_cast(Mood, row["feeling"]), title=_cast(str, row["title"]), ) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py index 0c222ec..e0eb6eb 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py @@ -11,7 +11,6 @@ from psycopg import AsyncConnection, Connection -from .._core import require_array as _require_array from .._runtime import fetch_many as _fetch_many from ..sync._runtime import fetch_many as _fetch_many_sync from ..types.mood import Mood @@ -30,8 +29,8 @@ def _decode_row(row: Mapping[str, object]) -> ListSpecimensByMoodsRow: return ListSpecimensByMoodsRow( id=_cast(int, row["id"]), pub_id=_cast(UUID, row["pub_id"]), - feeling=Mood.pg_decode(row["feeling"]), - moods=None if row["moods"] is None else [None if v is None else Mood.pg_decode(v) for v in _cast(list[str | None], _require_array(row["moods"]))], + feeling=_cast(Mood, row["feeling"]), + moods=_cast(list[Mood | None] | None, row["moods"]), title=_cast(str, row["title"]), ) @@ -54,7 +53,7 @@ async def list_specimens_by_moods( moods: list[Mood | None] | None, ) -> list[ListSpecimensByMoodsRow]: params: dict[str, object] = { - "moods": None if moods is None else [None if x is None else x.pg_encode() for x in moods], + "moods": moods, } return await _fetch_many(conn, _SQL, params, _decode_row) @@ -65,6 +64,6 @@ def list_specimens_by_moods_sync( moods: list[Mood | None] | None, ) -> list[ListSpecimensByMoodsRow]: params: dict[str, object] = { - "moods": None if moods is None else [None if x is None else x.pg_encode() for x in moods], + "moods": moods, } return _fetch_many_sync(conn, _SQL, params, _decode_row) diff --git a/tests/golden/src/specimen_client/_generated/types/__init__.py b/tests/golden/src/specimen_client/_generated/types/__init__.py index c8a6ba8..f0ebe7a 100644 --- a/tests/golden/src/specimen_client/_generated/types/__init__.py +++ b/tests/golden/src/specimen_client/_generated/types/__init__.py @@ -2,6 +2,8 @@ # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 +from .a_codec_wrapper import ACodecWrapper as ACodecWrapper from .mood import Mood as Mood from .point_2_d import Point2D as Point2D from .tag_value import TagValue as TagValue +from .z_codec_payload import ZCodecPayload as ZCodecPayload diff --git a/tests/golden/src/specimen_client/_generated/types/a_codec_wrapper.py b/tests/golden/src/specimen_client/_generated/types/a_codec_wrapper.py new file mode 100644 index 0000000..001a7f9 --- /dev/null +++ b/tests/golden/src/specimen_client/_generated/types/a_codec_wrapper.py @@ -0,0 +1,15 @@ +# @generated by python.gen (pGenie). DO NOT EDIT. +# SPDX-FileCopyrightText: 2026 Viacheslav Shvets +# SPDX-License-Identifier: MIT-0 + +from dataclasses import dataclass + +from .mood import Mood +from .z_codec_payload import ZCodecPayload + + +@dataclass(frozen=True, slots=True) +class ACodecWrapper: + payload: ZCodecPayload | None + feeling: Mood | None + note: str | None diff --git a/tests/golden/src/specimen_client/_generated/types/mood.py b/tests/golden/src/specimen_client/_generated/types/mood.py index 3556da7..ece63e0 100644 --- a/tests/golden/src/specimen_client/_generated/types/mood.py +++ b/tests/golden/src/specimen_client/_generated/types/mood.py @@ -3,17 +3,9 @@ # SPDX-License-Identifier: MIT-0 from enum import StrEnum -from typing import cast class Mood(StrEnum): HAPPY = "happy" SAD = "sad" MEH = "meh" - - @staticmethod - def pg_decode(src: object) -> "Mood": - return Mood(cast(str, src)) - - def pg_encode(self) -> "Mood": - return self diff --git a/tests/golden/src/specimen_client/_generated/types/point_2_d.py b/tests/golden/src/specimen_client/_generated/types/point_2_d.py index e212a40..15bd25d 100644 --- a/tests/golden/src/specimen_client/_generated/types/point_2_d.py +++ b/tests/golden/src/specimen_client/_generated/types/point_2_d.py @@ -3,23 +3,9 @@ # SPDX-License-Identifier: MIT-0 from dataclasses import dataclass -from typing import cast @dataclass(frozen=True, slots=True) class Point2D: - """Decoding/encoding this composite requires register_types(conn) first. - - Without per-connection registration psycopg returns the value as a - raw string, which the generated pg_decode cannot splat into the dataclass. - """ - x: float | None y: float | None - - @staticmethod - def pg_decode(src: object) -> "Point2D": - return Point2D(*cast(tuple[float | None, float | None], src)) - - def pg_encode(self) -> tuple[float | None, float | None]: - return (self.x, self.y) diff --git a/tests/golden/src/specimen_client/_generated/types/tag_value.py b/tests/golden/src/specimen_client/_generated/types/tag_value.py index 13ff4b9..ec2a89e 100644 --- a/tests/golden/src/specimen_client/_generated/types/tag_value.py +++ b/tests/golden/src/specimen_client/_generated/types/tag_value.py @@ -3,22 +3,8 @@ # SPDX-License-Identifier: MIT-0 from dataclasses import dataclass -from typing import cast @dataclass(frozen=True, slots=True) class TagValue: - """Decoding/encoding this composite requires register_types(conn) first. - - Without per-connection registration psycopg returns the value as a - raw string, which the generated pg_decode cannot splat into the dataclass. - """ - value: str | None - - @staticmethod - def pg_decode(src: object) -> "TagValue": - return TagValue(*cast(tuple[str | None], src)) - - def pg_encode(self) -> tuple[str | None]: - return (self.value,) diff --git a/tests/golden/src/specimen_client/_generated/types/z_codec_payload.py b/tests/golden/src/specimen_client/_generated/types/z_codec_payload.py new file mode 100644 index 0000000..be381c5 --- /dev/null +++ b/tests/golden/src/specimen_client/_generated/types/z_codec_payload.py @@ -0,0 +1,12 @@ +# @generated by python.gen (pGenie). DO NOT EDIT. +# SPDX-FileCopyrightText: 2026 Viacheslav Shvets +# SPDX-License-Identifier: MIT-0 + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class ZCodecPayload: + class_: int | None + pg_decode: str | None + pg_encode: str | None diff --git a/tests/golden/src/specimen_client/sync/__init__.py b/tests/golden/src/specimen_client/sync/__init__.py index aadd327..f0bb83e 100644 --- a/tests/golden/src/specimen_client/sync/__init__.py +++ b/tests/golden/src/specimen_client/sync/__init__.py @@ -4,9 +4,11 @@ from .._generated._core import JsonValue as JsonValue, NoRowError as NoRowError +from .._generated.types.a_codec_wrapper import ACodecWrapper as ACodecWrapper from .._generated.types.mood import Mood as Mood from .._generated.types.point_2_d import Point2D as Point2D from .._generated.types.tag_value import TagValue as TagValue +from .._generated.types.z_codec_payload import ZCodecPayload as ZCodecPayload from .._generated.statements.bump_specimen_revision import bump_specimen_revision_sync as bump_specimen_revision from .._generated.statements.get_specimen import get_specimen_sync as get_specimen, GetSpecimenRow as GetSpecimenRow @@ -25,9 +27,11 @@ __all__ = [ "JsonValue", "NoRowError", + "ACodecWrapper", "Mood", "Point2D", "TagValue", + "ZCodecPayload", "GetSpecimenRow", "GetTaggedItemRow", "InsertSpecimenRow", diff --git a/tests/test_generated.py b/tests/test_generated.py index 1c59a14..745e0b8 100644 --- a/tests/test_generated.py +++ b/tests/test_generated.py @@ -245,7 +245,15 @@ def test_combined_public_api_identity_and_signatures(full_package: Path) -> None assert getattr(root, row_name) is row_class assert getattr(sync, row_name) is row_class - for name in ("Mood", "Point2D", "TagValue", "JsonValue", "NoRowError"): + for name in ( + "ACodecWrapper", + "JsonValue", + "Mood", + "NoRowError", + "Point2D", + "TagValue", + "ZCodecPayload", + ): assert getattr(root, name) is getattr(sync, name) register = import_module("specimen_client._generated._register") @@ -254,6 +262,16 @@ def test_combined_public_api_identity_and_signatures(full_package: Path) -> None assert "register_types" in root.__all__ assert "register_types" in sync.__all__ + source = Path(register.__file__).read_text() + child_async = source.index("z_codec_payload_info = await CompositeInfo.fetch") + parent_async = source.index("a_codec_wrapper_info = await CompositeInfo.fetch") + child_sync = source.index("z_codec_payload_info = CompositeInfo.fetch") + parent_sync = source.index("a_codec_wrapper_info = CompositeInfo.fetch") + assert child_async < parent_async + assert child_sync < parent_sync + assert source.count("from .types.z_codec_payload import ZCodecPayload") == 1 + assert source.count("from .types.a_codec_wrapper import ACodecWrapper") == 1 + def test_roundtrip_type_mappings(client_modules, roundtrip_db: str) -> None: # noqa: ANN001 """INSERT then SELECT through the generated client, asserting the mappings. @@ -268,12 +286,25 @@ def test_roundtrip_type_mappings(client_modules, roundtrip_db: str) -> None: # facade, _, _ = client_modules Mood = facade.Mood Point2D = facade.Point2D + ZCodecPayload = facade.ZCodecPayload + ACodecWrapper = facade.ACodecWrapper async def scenario() -> None: conn = await psycopg.AsyncConnection.connect(roundtrip_db, autocommit=True) try: await facade.register_types(conn) + codec_payload = ZCodecPayload(class_=41, pg_decode="scalar", pg_encode=None) + codec_payloads = [ + ZCodecPayload(class_=42, pg_decode="first", pg_encode="encoded"), + ZCodecPayload(class_=43, pg_decode="second", pg_encode=None), + ] + codec_wrapper = ACodecWrapper( + payload=ZCodecPayload(class_=44, pg_decode="nested", pg_encode=None), + feeling=Mood.SAD, + note=None, + ) + inserted = await facade.insert_specimen( conn, doc_jsonb={"k": "v", "n": 1}, @@ -301,6 +332,9 @@ async def scenario() -> None: related_ids=None, grid=None, moods=[Mood.HAPPY, None, Mood.SAD], + codec_payload=codec_payload, + codec_payloads=codec_payloads, + codec_wrapper=codec_wrapper, ) assert type(inserted) is facade.InsertSpecimenRow @@ -323,6 +357,20 @@ async def scenario() -> None: assert inserted.moods is not None assert inserted.moods[0] is Mood.HAPPY assert inserted.moods[2] is Mood.SAD + assert type(inserted.codec_payload) is ZCodecPayload + assert inserted.codec_payload == codec_payload + assert inserted.codec_payload.class_ == 41 + assert inserted.codec_payload.pg_decode == "scalar" + assert inserted.codec_payload.pg_encode is None + assert type(inserted.codec_payloads) is list + assert all(type(value) is ZCodecPayload for value in inserted.codec_payloads) + assert inserted.codec_payloads == codec_payloads + assert type(inserted.codec_wrapper) is ACodecWrapper + assert inserted.codec_wrapper is not None + assert type(inserted.codec_wrapper.payload) is ZCodecPayload + assert inserted.codec_wrapper == codec_wrapper + assert inserted.codec_wrapper.feeling is Mood.SAD + assert inserted.codec_wrapper.note is None # domain-backed columns map to their base Python types. assert inserted.label == "specimen" assert inserted.rev == 1 @@ -338,6 +386,14 @@ async def scenario() -> None: assert isinstance(hit.feeling, Mood) assert isinstance(hit.origin, Point2D) assert hit.maybe_uuid is None + assert type(hit.codec_payload) is ZCodecPayload + assert hit.codec_payload == codec_payload + assert all(type(value) is ZCodecPayload for value in hit.codec_payloads) + assert type(hit.codec_wrapper) is ACodecWrapper + assert hit.codec_wrapper is not None + assert type(hit.codec_wrapper.payload) is ZCodecPayload + assert hit.codec_wrapper.feeling is Mood.SAD + assert hit.codec_wrapper.note is None miss = await facade.get_specimen(conn, id=specimen_id + 10_000) assert miss is None @@ -403,19 +459,16 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_require_array_rejects_unregistered_enum_array(client_modules) -> None: # noqa: ANN001 - """require_array turns the unregistered enum-array form into a clear error. - - Without register_types psycopg returns an enum array as the raw array text, not - a list; the generated enum-array decode wraps the value in require_array so it - fails loudly instead of iterating a string into bogus members. - """ +def test_custom_rows_keep_typed_passthrough_until_c3(client_modules) -> None: # noqa: ANN001 _, _, import_module = client_modules - runtime = import_module("specimen_client._generated._runtime") + statement = import_module("specimen_client._generated.statements.insert_specimen") + source = Path(statement.__file__).read_text() - assert runtime.require_array(["happy", "sad"]) == ["happy", "sad"] - with pytest.raises(RuntimeError, match="register_types"): - _ = runtime.require_array("{happy,sad}") + assert "def _decode_row" in source + assert '_cast(ZCodecPayload, row["codec_payload"])' in source + assert '_cast(list[ZCodecPayload | None], row["codec_payloads"])' in source + assert '.pg_decode(' not in source + assert '.pg_encode(' not in source def test_roundtrip_sync_surface(client_modules, roundtrip_db: str) -> None: # noqa: ANN001 @@ -424,11 +477,24 @@ def test_roundtrip_sync_surface(client_modules, roundtrip_db: str) -> None: # n _, facade, _ = client_modules Mood = facade.Mood Point2D = facade.Point2D + ZCodecPayload = facade.ZCodecPayload + ACodecWrapper = facade.ACodecWrapper conn = psycopg.connect(roundtrip_db, autocommit=True) try: facade.register_types(conn) + codec_payload = ZCodecPayload(class_=51, pg_decode="sync", pg_encode=None) + codec_payloads = [ + ZCodecPayload(class_=52, pg_decode="first", pg_encode="encoded"), + ZCodecPayload(class_=53, pg_decode="second", pg_encode=None), + ] + codec_wrapper = ACodecWrapper( + payload=ZCodecPayload(class_=54, pg_decode="nested", pg_encode=None), + feeling=Mood.SAD, + note=None, + ) + inserted = facade.insert_specimen( conn, doc_jsonb={"k": "v", "n": 1}, @@ -456,6 +522,9 @@ def test_roundtrip_sync_surface(client_modules, roundtrip_db: str) -> None: # n related_ids=None, grid=None, moods=[Mood.HAPPY, None, Mood.SAD], + codec_payload=codec_payload, + codec_payloads=codec_payloads, + codec_wrapper=codec_wrapper, ) assert type(inserted) is facade.InsertSpecimenRow assert isinstance(inserted.feeling, Mood) @@ -465,11 +534,28 @@ def test_roundtrip_sync_surface(client_modules, roundtrip_db: str) -> None: # n assert inserted.moods == [Mood.HAPPY, None, Mood.SAD] assert inserted.moods is not None assert inserted.moods[0] is Mood.HAPPY + assert type(inserted.codec_payload) is ZCodecPayload + assert inserted.codec_payload == codec_payload + assert inserted.codec_payload.pg_encode is None + assert type(inserted.codec_payloads) is list + assert all(type(value) is ZCodecPayload for value in inserted.codec_payloads) + assert inserted.codec_payloads == codec_payloads + assert type(inserted.codec_wrapper) is ACodecWrapper + assert inserted.codec_wrapper is not None + assert type(inserted.codec_wrapper.payload) is ZCodecPayload + assert inserted.codec_wrapper.feeling is Mood.SAD + assert inserted.codec_wrapper.note is None specimen_id = inserted.id hit = facade.get_specimen(conn, id=specimen_id) assert hit is not None assert hit.id == specimen_id + assert type(hit.codec_payload) is ZCodecPayload + assert all(type(value) is ZCodecPayload for value in hit.codec_payloads) + assert type(hit.codec_wrapper) is ACodecWrapper + assert hit.codec_wrapper is not None + assert type(hit.codec_wrapper.payload) is ZCodecPayload + assert hit.codec_wrapper.feeling is Mood.SAD assert facade.get_specimen(conn, id=specimen_id + 10_000) is None mood_rows = facade.list_specimens_by_moods(conn, moods=[Mood.HAPPY]) diff --git a/tests/test_identifier_collisions.py b/tests/test_identifier_collisions.py index ed4cc13..339e8b4 100644 --- a/tests/test_identifier_collisions.py +++ b/tests/test_identifier_collisions.py @@ -86,6 +86,8 @@ def _assert_private_query_canaries( let PyIdent = ./Structures/PyIdent.dhall +let RegisterModule = ./Templates/RegisterModule.dhall + let Config = { packageName : Optional Text } let Config/default = { packageName = None Text } @@ -95,7 +97,7 @@ def _assert_private_query_canaries( \(_ : Model.Project) -> Lude.Compiled.ok Lude.Files.Type - ( [ { path = "query-safe-name.txt" + ( [ { path = "query-safe-name.txt" , content = PyIdent.querySafeName "_types" ++ "\n" @@ -108,8 +110,23 @@ def _assert_private_query_canaries( ++ PyIdent.querySafeName "register_types" ++ "\n" } - ] : Lude.Files.Type - ) + ] + # [ { path = "composite-only-register.py" + , content = + RegisterModule.run + { customTypes = + [ { typeName = "CompositeOnly" + , moduleName = "composite_only" + , pgSchema = "public" + , pgName = "composite_only" + , kind = RegisterModule.TypeKind.Composite + } + ] + , emitSync = True + } + } + ] : Lude.Files.Type + ) in Sdk.Sigs.generator Config Config/default run ''' @@ -141,6 +158,12 @@ def _assert_private_query_canaries( "sync_query", "register_types_query", ] + composite_register = (canary / "artifacts" / "python" / "composite-only-register.py").read_text() + assert "CompositeInfo" in composite_register + assert "register_composite" in composite_register + assert "_dataclass_callbacks" in composite_register + assert "EnumInfo" not in composite_register + assert "register_enum" not in composite_register @contextmanager @@ -248,12 +271,11 @@ def _assert_statement_structure(statements: Path) -> None: for alias in node.names if alias.name == "require_array" ] - expected_core = [("require_array", "_require_array")] if function_name == "require_array" else [] - assert [(alias.name, alias.asname) for alias in core_require_imports] == expected_core + assert not core_require_imports require_array_source = (statements / "require_array.py").read_text() - assert "from .._core import require_array as _require_array" in require_array_source - assert "_cast(list[str | None], _require_array(row[\"moods\"]))" in require_array_source + assert "from .._core import require_array as _require_array" not in require_array_source + assert '_cast(list[Mood | None] | None, row["moods"])' in require_array_source assert "from datetime import date" in (statements / "date_query.py").read_text() @@ -310,6 +332,12 @@ def test_identifier_collisions_roundtrip( assert not (statements / "_types.py").exists() assert not (package_src / "_generated" / "sync" / "statements").exists() assert not (package_src / "_generated" / "sync" / "_register.py").exists() + register_source = (package_src / "_generated" / "_register.py").read_text() + assert "EnumInfo" in register_source + assert "register_enum" in register_source + assert "CompositeInfo" not in register_source + assert "register_composite" not in register_source + assert "_dataclass_callbacks" not in register_source _assert_strict(package_src, tmp_path) sys.path.insert(0, str(generated_src)) diff --git a/tests/test_unsupported_types.py b/tests/test_unsupported_types.py index d6981d1..a8aa073 100644 --- a/tests/test_unsupported_types.py +++ b/tests/test_unsupported_types.py @@ -239,54 +239,54 @@ def _write_contract_probe( id="missing-custom", ), pytest.param( - "nested_custom", + "custom_array_member", "CustomType", - "enum", - 0, + "composite", + 1, True, - "Nested custom type members are not supported before PostgreSQL adapter verification", + "Custom array fields inside a composite type are not supported", ("fixture", "probe_value"), - id="nested-custom-member", + id="custom-array-member", ), pytest.param( - "composite_array_result", + "composite_rank_two_result", "Member", "composite", - 1, + 2, False, - "Array of a composite type is not supported (element-wise decode is unimplemented)", + "Array of a composite type with dimensionality > 1 is not supported", ("fixture", "probe_value"), - id="composite-array-result", + id="composite-rank-two-result", ), pytest.param( - "composite_array_parameter", + "composite_rank_two_parameter", "ParamsMember", "composite", - 1, + 2, False, - "Array of a composite type as a parameter is not supported", + "Array of a composite type parameter with dimensionality > 1 is not supported", ("fixture", "probe_value"), - id="composite-array-parameter", + id="composite-rank-two-parameter", ), pytest.param( - "enum_rank_two_result", + "enum_rank_three_result", "Member", "enum", - 2, + 3, False, - "Array of an enum with dimensionality > 1 is not supported", + "Array of an enum with dimensionality > 2 is not supported", ("fixture", "probe_value"), - id="enum-rank-two-result", + id="enum-rank-three-result", ), pytest.param( - "enum_rank_two_parameter", + "enum_rank_three_parameter", "ParamsMember", "enum", - 2, + 3, False, - "Array of an enum parameter with dimensionality > 1 is not supported", + "Array of an enum parameter with dimensionality > 2 is not supported", ("fixture", "probe_value"), - id="enum-rank-two-parameter", + id="enum-rank-three-parameter", ), ], ) @@ -331,6 +331,63 @@ def test_custom_shape_contracts_fail_loudly( assert rendered_path == path_tokens, f"expected exact path {path_tokens}, got {rendered_path}:\n{plain}" +@pytest.mark.parametrize( + ("case_id", "interpreter", "lookup_kind", "dimensionality", "nested"), + [ + pytest.param("nested_scalar", "CustomType", "composite", 0, True, id="nested-scalar"), + pytest.param("enum_rank_two_result", "Member", "enum", 2, False, id="enum-rank-two-result"), + pytest.param( + "enum_rank_two_parameter", + "ParamsMember", + "enum", + 2, + False, + id="enum-rank-two-parameter", + ), + pytest.param( + "composite_array_result", + "Member", + "composite", + 1, + False, + id="composite-array-result", + ), + pytest.param( + "composite_array_parameter", + "ParamsMember", + "composite", + 1, + False, + id="composite-array-parameter", + ), + ], +) +def test_custom_shape_contracts_succeed( + pgn_bin: str, + pgn_admin_url: str, + tmp_path: Path, + case_id: str, + interpreter: str, + lookup_kind: str, + dimensionality: int, + nested: bool, +) -> None: + root, project = _fresh_project(tmp_path) + for query_file in (project / "queries").iterdir(): + query_file.unlink() + _write_contract_probe( + root, + interpreter=interpreter, + lookup_kind=lookup_kind, + dimensionality=dimensionality, + nested=nested, + ) + _write_single_artifact(project, "../../src/contract-probe.dhall", f"probe-{case_id}") + + result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") + assert result.returncode == 0, f"expected {case_id} to succeed:\n{_combined_output(result)}" + + def test_skip_unsupported_drops_offending_units_and_cascades( pgn_bin: str, pgn_admin_url: str, tmp_path: Path ) -> None: @@ -338,12 +395,10 @@ def test_skip_unsupported_drops_offending_units_and_cascades( Three independent failures in one project: a money result column and a jsonb[] param each doom their own statement (Primitive.dhall / ParamsMember - .dhall); a composite nesting another composite dooms the custom type - itself (CustomType.dhall rejects nested custom members directly) and, - because the lookup rebuilt from the surviving types resolves it to Absent, - the query selecting that composite column - cascades into a skip too. Generation must still succeed, the surviving - statements and all 3 fixture types must be unaffected, no generated file + .dhall); a composite containing a custom array dooms that type, its scalar + custom parent, its grandparent, and the dependent query through the bounded + survivor closure. Generation must still succeed, the surviving statements + and all 5 fixture types must be unaffected, no generated file may reference a skipped name, the package must still import, and basedpyright strict must still pass on the result -- the same gate the golden package is held to. @@ -372,14 +427,21 @@ def test_skip_unsupported_drops_offending_units_and_cascades( "SELECT 'happy'::mood AS feeling\n" ) _ = (project / "migrations" / "2.sql").write_text( - "create type wrapped_point as (\n" - " label text,\n" - " origin point2d\n" + "create type z_bad_leaf as (\n" + " feelings mood[]\n" + ");\n" + "\n" + "create type m_bad_parent as (\n" + " leaf z_bad_leaf\n" + ");\n" + "\n" + "create type a_bad_grandparent as (\n" + " parent m_bad_parent\n" ");\n" "\n" "create table nested_probe (\n" " id int8 primary key generated always as identity,\n" - " wrapped wrapped_point not null\n" + " wrapped a_bad_grandparent not null\n" ");\n" ) _ = (project / "queries" / "probe_nested_composite.sql").write_text( @@ -396,7 +458,7 @@ def test_skip_unsupported_drops_offending_units_and_cascades( for marker in ( "unsupported type", "json/jsonb array as a parameter is not supported", - "nested custom type members are not supported before postgresql adapter verification", + "custom array fields inside a composite type are not supported", "custom type not found in project customtypes", ): assert marker in combined, f"expected pgn to surface the warning, {marker!r} missing from output" @@ -408,11 +470,12 @@ def test_skip_unsupported_drops_offending_units_and_cascades( for name in ("probe_unsupported", "probe_json_array", "probe_nested_composite"): assert not (src / "statements" / f"{name}.py").exists(), f"{name} should have been skipped" - assert not (src / "types" / "wrapped_point.py").exists(), "wrapped_point should have been skipped" + for name in ("z_bad_leaf", "m_bad_parent", "a_bad_grandparent"): + assert not (src / "types" / f"{name}.py").exists(), f"{name} should have been skipped" for name in kept_statements + ["probe_custom_only"]: assert (src / "statements" / f"{name}.py").is_file(), f"{name} should not have been skipped" - for name in ("mood", "point_2_d", "tag_value"): + for name in ("a_codec_wrapper", "mood", "point_2_d", "tag_value", "z_codec_payload"): assert (src / "types" / f"{name}.py").is_file(), f"{name} should not have been skipped" generated_python = {path: path.read_text() for path in package_src.rglob("*.py")} @@ -420,8 +483,12 @@ def test_skip_unsupported_drops_offending_units_and_cascades( "probe_unsupported", "probe_json_array", "probe_nested_composite", - "wrapped_point", - "WrappedPoint", + "z_bad_leaf", + "ZBadLeaf", + "m_bad_parent", + "MBadParent", + "a_bad_grandparent", + "ABadGrandparent", ): assert not any(orphan in text for text in generated_python.values()), ( f"surviving generated output references skipped {orphan}" @@ -429,7 +496,7 @@ def test_skip_unsupported_drops_offending_units_and_cascades( custom_only = (src / "statements" / "probe_custom_only.py").read_text() assert "from ..types.mood import Mood" in custom_only - assert "from typing import cast" not in custom_only + assert "from typing import cast as _cast" in custom_only src_root = str(generated / "src") sys.path.insert(0, src_root) @@ -478,8 +545,10 @@ def test_custom_imports_are_unique_and_deterministic() -> None: insert_imports = custom_import.findall((statements / "insert_specimen.py").read_text()) assert insert_imports == [ + "from ..types.a_codec_wrapper import ACodecWrapper", "from ..types.mood import Mood", "from ..types.point_2_d import Point2D", + "from ..types.z_codec_payload import ZCodecPayload", ] for module in statements.glob("*.py"): imports = custom_import.findall(module.read_text()) From 3647a9c2150e8425f733e9bc74717c2a34557d81 Mon Sep 17 00:00:00 2001 From: Viacheslav Shvets Date: Mon, 13 Jul 2026 03:23:54 +0300 Subject: [PATCH 29/41] refactor(gen): construct rows with psycopg row factories H1 CONFIRM. Validated with psycopg 3.3.4 that adapter-backed enum, composite, array, and nested-composite values keep their exact generated identities in async and sync paths, with no generated decoder or cast layer. H2 CONFIRM. The combined package contains 25 Python files and 1,326 total lines. Its 11 statement files contain 802 lines and exactly 11 SQL assignments. Tests: generated plus collision 13 passed; unsupported 15 passed; adapter 2 passed; full live-PostgreSQL suite 37 passed with 0 failed or skipped; basedpyright strict 0 errors and 0 warnings. --- src/Interpreters/Member.dhall | 40 ++--- src/Interpreters/ParamsMember.dhall | 5 +- src/Interpreters/Query.dhall | 2 - src/Interpreters/Result.dhall | 6 +- src/Interpreters/ResultColumns.dhall | 18 +-- src/Interpreters/Value.dhall | 18 ++- src/Structures/ImportSet.dhall | 21 +-- src/Structures/PyIdent.dhall | 8 +- src/Structures/Surface.dhall | 3 - src/Templates/CoreModule.dhall | 35 +--- src/Templates/RuntimeModule.dhall | 98 +++++------ src/Templates/StatementModule.dhall | 102 +++--------- .../src/specimen_client/_generated/_core.py | 26 +-- .../specimen_client/_generated/_runtime.py | 42 +++-- .../statements/bump_specimen_revision.py | 6 +- .../_generated/statements/get_specimen.py | 95 +++-------- .../_generated/statements/get_tagged_item.py | 32 ++-- .../_generated/statements/insert_specimen.py | 152 ++++++------------ .../statements/insert_tagged_item.py | 36 ++--- .../statements/list_specimens_by_class.py | 27 +--- .../statements/list_specimens_by_feeling.py | 53 ++---- .../statements/list_specimens_by_ids.py | 35 ++-- .../statements/list_specimens_by_moods.py | 42 ++--- .../list_specimens_keyword_column.py | 27 +--- .../_generated/statements/search_specimens.py | 33 ++-- .../_generated/sync/_runtime.py | 42 +++-- tests/test_generated.py | 75 ++++++++- tests/test_identifier_collisions.py | 43 ++--- tests/test_unsupported_types.py | 41 +++-- 29 files changed, 437 insertions(+), 726 deletions(-) diff --git a/src/Interpreters/Member.dhall b/src/Interpreters/Member.dhall index ca81f50..48e92ec 100644 --- a/src/Interpreters/Member.dhall +++ b/src/Interpreters/Member.dhall @@ -23,19 +23,10 @@ let Config = 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 Output = { fieldName : Text, pyType : Text, imports : ImportSet.Type } -let run = +let runWithPrefix = + \(prefix : Text) -> \(config : Config) -> \(lookup : CustomKind.Lookup) -> \(input : Input) -> @@ -49,25 +40,17 @@ let run = \(value : Value.Output) -> let nullableSuffix = if input.isNullable then " | None" else "" - let pyType = value.pyType ++ nullableSuffix - - let castTarget = pyType + let pyType = Value.qualifyCustom prefix value ++ nullableSuffix 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 = ImportSet.combine baseImports ImportSet.cast - , decodeExpr = passthroughDecode + , imports = baseImports } , Custom = Prelude.Optional.fold @@ -87,16 +70,9 @@ let run = let mkOutput = \(customImports : ImportSet.Type) -> { fieldName - , pgName = input.pgName , pyType - , isNullable = input.isNullable , imports = - ImportSet.combineAll - [ baseImports - , customImports - , ImportSet.cast - ] - , decodeExpr = passthroughDecode + ImportSet.combine baseImports customImports } let dimsAtMostTwo = @@ -168,4 +144,6 @@ let run = in Lude.Compiled.flatMap Value.Output Output buildOutput compiledValue -in { Input, Output, run } +let run = runWithPrefix "" + +in { Input, Output, run, runWithPrefix } diff --git a/src/Interpreters/ParamsMember.dhall b/src/Interpreters/ParamsMember.dhall index 8a8d30c..ecd4d82 100644 --- a/src/Interpreters/ParamsMember.dhall +++ b/src/Interpreters/ParamsMember.dhall @@ -29,7 +29,6 @@ let Output = , pyType : Text , imports : ImportSet.Type , bindExpr : Text - , needsJsonbImport : Bool } -- True when the primitive is exactly json or jsonb (every other variant is @@ -242,7 +241,8 @@ let run = let buildOutput = \(value : Value.Output) -> let pyType = - value.pyType ++ (if input.isNullable then " | None" else "") + Value.qualifyCustom "_db_types." value + ++ (if input.isNullable then " | None" else "") let jsonImport = if needsJsonbImport @@ -257,7 +257,6 @@ let run = , pyType , imports = ImportSet.combine typeImports jsonImport , bindExpr - , needsJsonbImport } in Prelude.Optional.fold diff --git a/src/Interpreters/Query.dhall b/src/Interpreters/Query.dhall index 04e143c..a69d0e0 100644 --- a/src/Interpreters/Query.dhall +++ b/src/Interpreters/Query.dhall @@ -96,7 +96,6 @@ let render = ( \(rc : ResultModule.RowClass) -> { className = rc.name , fieldsBlock = rc.fieldsBlock - , decodeBlock = rc.decodeBlock } ) result.rowClass @@ -112,7 +111,6 @@ let render = { functionName , returnType = result.returnType , helperName = result.helperName - , callsDecode = result.callsDecode , sqlLiteral = fragments.sqlLiteral , rowDef , paramSigLines diff --git a/src/Interpreters/Result.dhall b/src/Interpreters/Result.dhall index bc48049..0da872d 100644 --- a/src/Interpreters/Result.dhall +++ b/src/Interpreters/Result.dhall @@ -29,14 +29,13 @@ let Config = let Input = Model.Result -let RowClass = { name : Text, fieldsBlock : Text, decodeBlock : Text } +let RowClass = { name : Text, fieldsBlock : Text } let Output = { returnType : Text , helperName : Text , rowClass : Optional RowClass , imports : ImportSet.Type - , callsDecode : Bool } let noResult @@ -47,7 +46,6 @@ let noResult , helperName , rowClass = None RowClass , imports = ImportSet.empty - , callsDecode = False } let cardinalityShape @@ -85,10 +83,8 @@ let rowsOutput = , rowClass = Some { name = config.rowClassName , fieldsBlock = cols.fieldsBlock - , decodeBlock = cols.decodeBlock } , imports = cols.imports - , callsDecode = True } ) ( ResultColumns.run diff --git a/src/Interpreters/ResultColumns.dhall b/src/Interpreters/ResultColumns.dhall index 3111242..0fda1b5 100644 --- a/src/Interpreters/ResultColumns.dhall +++ b/src/Interpreters/ResultColumns.dhall @@ -23,30 +23,18 @@ let Config = let Input = List Model.Member -let Output = { fieldsBlock : Text, decodeBlock : Text, imports : ImportSet.Type } +let Output = { fieldsBlock : Text, imports : ImportSet.Type } let renderField : Member.Output -> Text = \(col : Member.Output) -> col.fieldName ++ ": " ++ col.pyType -let renderDecodeKwarg - : Member.Output -> Text - = \(col : Member.Output) -> - let src = "row[\"" ++ col.pgName ++ "\"]" - - in col.fieldName ++ "=" ++ col.decodeExpr src ++ "," - let assemble : List Member.Output -> Output = \(columns : List Member.Output) -> + -- args_row is positional, so Row field order must match pgn columns. { fieldsBlock = Prelude.Text.concatMapSep "\n" Member.Output renderField columns - , decodeBlock = - Prelude.Text.concatMapSep - "\n" - Member.Output - renderDecodeKwarg - columns , imports = ImportSet.combineAll ( Prelude.List.map @@ -72,7 +60,7 @@ let run = Compiled.nest Member.Output member.pgName - (Member.run config lookup member) + (Member.runWithPrefix "_db_types." config lookup member) ) input ) diff --git a/src/Interpreters/Value.dhall b/src/Interpreters/Value.dhall index e1c8d6a..3d26b3b 100644 --- a/src/Interpreters/Value.dhall +++ b/src/Interpreters/Value.dhall @@ -69,4 +69,20 @@ let run = ) (Scalar.run config input.scalar) -in Sdk.Sigs.interpreter Config Input Output run +let qualifyCustom + : Text -> Output -> Text + = \(prefix : Text) -> + \(value : Output) -> + Prelude.Optional.fold + Model.Name + value.scalar.customRef + Text + ( \(name : Model.Name) -> + Text/replace + name.inPascalCase + (prefix ++ name.inPascalCase) + value.pyType + ) + value.pyType + +in Sdk.Sigs.interpreter Config Input Output run /\ { qualifyCustom } diff --git a/src/Structures/ImportSet.dhall b/src/Structures/ImportSet.dhall index d59b748..db84a61 100644 --- a/src/Structures/ImportSet.dhall +++ b/src/Structures/ImportSet.dhall @@ -14,8 +14,6 @@ let Self = , jsonb : Bool , json : Bool , jsonValue : Bool - , enumArray : Bool - , needsCast : Bool , customTypes : List CustomImport } @@ -29,8 +27,6 @@ let base = , jsonb = False , json = False , jsonValue = False - , enumArray = False - , needsCast = False , customTypes = [] : List CustomImport } @@ -74,14 +70,6 @@ let jsonValue : Self = base // { jsonValue = True } -let enumArray - : Self - = base // { enumArray = True } - -let cast - : Self - = base // { needsCast = True } - let custom : CustomImport -> Self = \(c : CustomImport) -> base // { customTypes = [ c ] } @@ -187,8 +175,6 @@ let combine = , jsonb = left.jsonb || right.jsonb , json = left.json || right.json , jsonValue = left.jsonValue || right.jsonValue - , enumArray = left.enumArray || right.enumArray - , needsCast = left.needsCast || right.needsCast , customTypes = dedupCustoms (left.customTypes # right.customTypes) } @@ -200,6 +186,10 @@ let sortedCustoms : Self -> List CustomImport = \(self : Self) -> sortCustoms self.customTypes +let hasCustom + : Self -> Bool + = \(self : Self) -> Prelude.Bool.not (Prelude.List.null CustomImport self.customTypes) + in { Type = Self , CustomImport , empty @@ -212,12 +202,11 @@ in { Type = Self , jsonb , json , jsonValue - , enumArray - , cast , custom , customEnum , customComposite , combine , combineAll , sortedCustoms + , hasCustom } diff --git a/src/Structures/PyIdent.dhall b/src/Structures/PyIdent.dhall index cd1cc02..a340b30 100644 --- a/src/Structures/PyIdent.dhall +++ b/src/Structures/PyIdent.dhall @@ -85,8 +85,8 @@ let pySafeName = sanitizeAgainst pythonKeywords let moduleReservedNames = - [ "_cast" - , "_require_array" + [ "_db_types" + , "_args_row" , "_fetch_optional" , "_fetch_single" , "_fetch_many" @@ -97,8 +97,6 @@ let moduleReservedNames = , "_fetch_many_sync" , "_execute_rows_affected_sync" , "_execute_void_sync" - , "_decode_row" - , "_types" , "sync" , "register_types" , "date" @@ -114,7 +112,7 @@ let parameterReservedNames = , "decode" , "cur" , "row" - , "_decode_row" + , "_args_row" , "_fetch_optional" , "_fetch_single" , "_fetch_many" diff --git a/src/Structures/Surface.dhall b/src/Structures/Surface.dhall index 4afc519..b9c2577 100644 --- a/src/Structures/Surface.dhall +++ b/src/Structures/Surface.dhall @@ -8,7 +8,6 @@ let Surface = , runtimePrefix : Text , helperSuffix : Text , corePrefix : Text - , typesPrefix : Text } let async @@ -20,7 +19,6 @@ let async , runtimePrefix = ".._runtime" , helperSuffix = "" , corePrefix = ".._core" - , typesPrefix = "..types" } let sync @@ -32,7 +30,6 @@ let sync , runtimePrefix = "..sync._runtime" , helperSuffix = "_sync" , corePrefix = ".._core" - , typesPrefix = "..types" } in { Type = Surface, async, sync } diff --git a/src/Templates/CoreModule.dhall b/src/Templates/CoreModule.dhall index 5868f55..4ed8056 100644 --- a/src/Templates/CoreModule.dhall +++ b/src/Templates/CoreModule.dhall @@ -1,46 +1,21 @@ 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 --- async and sync surfaces: the JsonValue alias, the NoRowError/DecodeError --- exceptions, and the require_array decode guard. It performs no I/O, so there is --- exactly one copy regardless of surface. The two _runtime.py modules re-export --- JsonValue/NoRowError/require_array from here so off-contract imports keep --- working, and the statement modules and facades import these --- names from _core directly. +-- The surface-agnostic core is emitted once per generated package. let content = '' - """Shared types and decode helpers, surface-agnostic; no I/O.""" + """Shared types and errors, surface-agnostic; no I/O.""" from __future__ import annotations - from typing import cast - type JsonValue = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] class NoRowError(RuntimeError): """A single-row query returned no rows.""" - - class DecodeError(RuntimeError): - """A row value failed to decode into its target type.""" - - - def require_array(value: object) -> list[object]: - """Guard an enum-array column decode. - - psycopg returns an enum array as a Python list only when the enum type is - registered on the connection (register_types); without it the value comes - back as the raw array text, which would iterate into bogus members. Fail - clearly instead. - """ - if isinstance(value, list): - return cast(list[object], value) - raise RuntimeError( - "enum array decoded as text; call register_types() on the connection " - "before decoding enum-array columns" - ) + def __init__(self, sql: str) -> None: + self.sql = sql + super().__init__(f"single-row query returned no rows: {sql}") '' in Sdk.Sigs.template {} (\(_ : {}) -> content) diff --git a/src/Templates/RuntimeModule.dhall b/src/Templates/RuntimeModule.dhall index 5e0cc1d..b05e98a 100644 --- a/src/Templates/RuntimeModule.dhall +++ b/src/Templates/RuntimeModule.dhall @@ -1,70 +1,60 @@ 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 --- design's hard "basedpyright strict, zero warnings" constraint forces: the --- unused Sequence import is dropped, and each cursor.execute result is bound to --- `_` so reportUnusedCallResult stays quiet. This module is I/O-only: the shared --- JsonValue/NoRowError/require_array names live in _core and are re-exported here --- so off-contract `from .._runtime import ...` keeps working. +-- The fixed async runtime is emitted once per generated package. let content = '' from __future__ import annotations - from collections.abc import Callable, Mapping - from typing import TypeVar + from typing import LiteralString, TypeVar from psycopg import AsyncConnection - from psycopg.rows import dict_row + from psycopg.rows import BaseRowFactory - from ._core import JsonValue as JsonValue, NoRowError as NoRowError, require_array as require_array + from ._core import NoRowError _T = TypeVar("_T") - _Row = Mapping[str, object] - _Params = Mapping[str, object] + _Params = dict[str, object] async def fetch_optional( conn: AsyncConnection[object], - sql: bytes, + sql: LiteralString, params: _Params, - decode: Callable[[_Row], _T], + row_factory: BaseRowFactory[_T], ) -> _T | None: - async with conn.cursor(row_factory=dict_row) as cur: + async with conn.cursor(row_factory=row_factory) as cur: _ = await cur.execute(sql, params) - row = await cur.fetchone() - return None if row is None else decode(row) + return await cur.fetchone() async def fetch_single( conn: AsyncConnection[object], - sql: bytes, + sql: LiteralString, params: _Params, - decode: Callable[[_Row], _T], + row_factory: BaseRowFactory[_T], ) -> _T: - async with conn.cursor(row_factory=dict_row) as cur: + async with conn.cursor(row_factory=row_factory) as cur: _ = await cur.execute(sql, params) row = await cur.fetchone() if row is None: - raise NoRowError(sql.decode()) - return decode(row) + raise NoRowError(sql) + return row async def fetch_many( conn: AsyncConnection[object], - sql: bytes, + sql: LiteralString, params: _Params, - decode: Callable[[_Row], _T], + row_factory: BaseRowFactory[_T], ) -> list[_T]: - async with conn.cursor(row_factory=dict_row) as cur: + async with conn.cursor(row_factory=row_factory) as cur: _ = await cur.execute(sql, params) - rows = await cur.fetchall() - return [decode(row) for row in rows] + return await cur.fetchall() async def execute_rows_affected( conn: AsyncConnection[object], - sql: bytes, + sql: LiteralString, params: _Params, ) -> int: async with conn.cursor() as cur: @@ -74,76 +64,68 @@ let content = async def execute_void( conn: AsyncConnection[object], - sql: bytes, + sql: LiteralString, params: _Params, ) -> None: async with conn.cursor() as cur: _ = await cur.execute(sql, params) '' --- The sync runtime lives at `_generated/sync/_runtime.py`. The five helpers --- are the same shape with `def`/`Connection`/`with`/no- --- `await`. JsonValue/NoRowError/require_array are re-exported from _core so --- both surfaces share one canonical identity rather than two equal-but- --- distinct definitions. +-- The sync runtime mirrors the async helpers. let syncContent = '' from __future__ import annotations - from collections.abc import Callable, Mapping - from typing import TypeVar + from typing import LiteralString, TypeVar from psycopg import Connection - from psycopg.rows import dict_row + from psycopg.rows import BaseRowFactory - from .._core import JsonValue as JsonValue, NoRowError as NoRowError, require_array as require_array + from .._core import NoRowError _T = TypeVar("_T") - _Row = Mapping[str, object] - _Params = Mapping[str, object] + _Params = dict[str, object] def fetch_optional( conn: Connection[object], - sql: bytes, + sql: LiteralString, params: _Params, - decode: Callable[[_Row], _T], + row_factory: BaseRowFactory[_T], ) -> _T | None: - with conn.cursor(row_factory=dict_row) as cur: + with conn.cursor(row_factory=row_factory) as cur: _ = cur.execute(sql, params) - row = cur.fetchone() - return None if row is None else decode(row) + return cur.fetchone() def fetch_single( conn: Connection[object], - sql: bytes, + sql: LiteralString, params: _Params, - decode: Callable[[_Row], _T], + row_factory: BaseRowFactory[_T], ) -> _T: - with conn.cursor(row_factory=dict_row) as cur: + with conn.cursor(row_factory=row_factory) as cur: _ = cur.execute(sql, params) row = cur.fetchone() if row is None: - raise NoRowError(sql.decode()) - return decode(row) + raise NoRowError(sql) + return row def fetch_many( conn: Connection[object], - sql: bytes, + sql: LiteralString, params: _Params, - decode: Callable[[_Row], _T], + row_factory: BaseRowFactory[_T], ) -> list[_T]: - with conn.cursor(row_factory=dict_row) as cur: + with conn.cursor(row_factory=row_factory) as cur: _ = cur.execute(sql, params) - rows = cur.fetchall() - return [decode(row) for row in rows] + return cur.fetchall() def execute_rows_affected( conn: Connection[object], - sql: bytes, + sql: LiteralString, params: _Params, ) -> int: with conn.cursor() as cur: @@ -153,7 +135,7 @@ let syncContent = def execute_void( conn: Connection[object], - sql: bytes, + sql: LiteralString, params: _Params, ) -> None: with conn.cursor() as cur: diff --git a/src/Templates/StatementModule.dhall b/src/Templates/StatementModule.dhall index 38e2ad2..dc5672c 100644 --- a/src/Templates/StatementModule.dhall +++ b/src/Templates/StatementModule.dhall @@ -8,19 +8,8 @@ let ImportSet = ../Structures/ImportSet.dhall let Surface = ../Structures/Surface.dhall --- A query's frozen Row dataclass plus its module-level decode function, --- rendered directly into that query's own statement module (there is a --- strict 1:1 relationship between a query and its Row -- no cross-statement --- sharing -- so co-locating them costs nothing and removes the need for a --- shared _rows.py import). -let RowDef = - { className : Text - , fieldsBlock : Text - , decodeBlock : Text - } +let RowDef = { className : Text, fieldsBlock : Text } --- Prefix every line (including the first) with `n` spaces, leaving blank lines --- untouched so trailing whitespace never appears. let indentAll : Natural -> Text -> Text = \(n : Natural) -> @@ -37,29 +26,13 @@ let renderRow ++ row.className ++ ":\n" ++ indentAll 4 row.fieldsBlock - ++ "\n\n\n" - ++ "def " - ++ "_decode_row" - ++ "(row: Mapping[str, object]) -> " - ++ row.className - ++ ":\n" - ++ " return " - ++ row.className - ++ "(\n" - ++ indentAll 8 row.decodeBlock - ++ "\n )" - --- "" when the query returns no rows (Void/RowsAffected); otherwise the --- rendered class + decode function, framed with the same "\n\n\n" (two --- blank lines) PEP8 spacing _rows.py used between consecutive row defs, on --- both sides -- matching the two-blank-lines-before/after convention for a --- top-level class or function. + let renderRowBlock : Optional RowDef -> Text = \(rowDef : Optional RowDef) -> merge { None = "" - , Some = \(row : RowDef) -> "\n" ++ renderRow row ++ "\n\n\n" + , Some = \(row : RowDef) -> "\n\n\n" ++ renderRow row } rowDef @@ -68,16 +41,10 @@ let hasRow = \(rowDef : Optional RowDef) -> merge { None = False, Some = \(_ : RowDef) -> True } rowDef --- A canonical statement module owns its Row, decoder, SQL, async function, and --- optional adjacent sync function. `imports` carries both the parameter --- type imports and the result-column imports merged into one set --- (Interpreters/Query.dhall combines them), since both now live in this one --- file. let Params = { functionName : Text , returnType : Text , helperName : Text - , callsDecode : Bool , sqlLiteral : Text , rowDef : Optional RowDef , paramSigLines : List Text @@ -92,7 +59,6 @@ let importLineIf : Bool -> Text -> List Text = \(cond : Bool) -> \(line : Text) -> if cond then [ line ] else [] : List Text --- "from datetime import ..." collapses the four datetime members into one line. let datetimeImport : ImportSet.Type -> List Text = \(imports : ImportSet.Type) -> @@ -120,18 +86,6 @@ let coreImport then [ "from ${corePrefix} import JsonValue" ] else [] : List Text -let customImportLines - : Text -> ImportSet.Type -> List Text - = \(typesPrefix : Text) -> - \(imports : ImportSet.Type) -> - Prelude.List.map - ImportSet.CustomImport - Text - ( \(c : ImportSet.CustomImport) -> - "from ${typesPrefix}.${c.moduleName} import ${c.className}" - ) - (ImportSet.sortedCustoms imports) - let renderImports : Params -> Text = \(params : Params) -> @@ -144,11 +98,9 @@ let renderImports let syncSurface = params.syncSurface let stdlibBlock = - importLineIf rowIsPresent "from collections.abc import Mapping" - # importLineIf rowIsPresent "from dataclasses import dataclass" + importLineIf rowIsPresent "from dataclasses import dataclass" # datetimeImport imports # importLineIf imports.decimal "from decimal import Decimal" - # importLineIf imports.needsCast "from typing import cast as _cast" # importLineIf imports.uuid "from uuid import UUID" let connectionNames = @@ -157,6 +109,9 @@ let renderImports let psycopgBlock = [ "from psycopg import " ++ Prelude.Text.concatSep ", " connectionNames ] + # importLineIf + rowIsPresent + "from psycopg.rows import args_row as _args_row" # importLineIf imports.json "from psycopg.types.json import Json" @@ -166,15 +121,14 @@ let renderImports let localBlock = coreImport asyncSurface.corePrefix imports.jsonValue - # importLineIf - imports.enumArray - "from ${asyncSurface.corePrefix} import require_array as _require_array" # [ runtimeImport asyncSurface params.helperName ] # ( if params.emitSync then [ runtimeImport syncSurface params.helperName ] else [] : List Text ) - # customImportLines asyncSurface.typesPrefix imports + # importLineIf + (ImportSet.hasCustom imports) + "from .. import types as _db_types" let groups = [ [ "from __future__ import annotations" ] @@ -186,15 +140,15 @@ let renderImports let nonEmptyGroups = Prelude.List.filter (List Text) - ( \(g : List Text) -> - Prelude.Bool.not (Prelude.List.null Text g) + ( \(group : List Text) -> + Prelude.Bool.not (Prelude.List.null Text group) ) groups in Prelude.Text.concatMapSep "\n\n" (List Text) - (\(g : List Text) -> Prelude.Text.concatSep "\n" g) + (\(group : List Text) -> Prelude.Text.concatSep "\n" group) nonEmptyGroups let renderSignature @@ -224,8 +178,6 @@ let renderSignature ++ params.returnType ++ ":" --- Emit the dict multi-line with a magic trailing comma so ruff keeps it --- expanded at any width, which keeps the generated file format-stable. let renderParamsDict : Params -> Text = \(params : Params) -> @@ -242,13 +194,16 @@ let renderCall : Params -> Surface.Type -> Text = \(params : Params) -> \(surface : Surface.Type) -> - let await = surface.awaitKw - let helper = "_" ++ params.helperName ++ surface.helperSuffix - in if params.callsDecode - then "return ${await}${helper}(conn, _SQL, params, _decode_row)" - else "return ${await}${helper}(conn, _SQL, params)" + let rowFactory = + merge + { None = "" + , Some = \(row : RowDef) -> ", _args_row(${row.className})" + } + params.rowDef + + in "return ${surface.awaitKw}${helper}(conn, SQL, params${rowFactory})" let renderFunction : Params -> Surface.Type -> Text @@ -264,18 +219,11 @@ in Sdk.Sigs.template Params ( \(params : Params) -> renderImports params - ++ "\n\n" - ++ renderRowBlock params.rowDef - -- The leading backslash after the opening quotes keeps the first SQL - -- line flush (no blank line); the newline before the closing quotes is - -- the only deviation from the raw text, a harmless trailing newline for - -- psycopg. - ++ "SQL = \"\"\"\\\n" + ++ "\n\nSQL = \"\"\"\\\n" ++ params.sqlLiteral - -- Encode once at import; the helpers take bytes so each call skips a - -- per-query str->bytes allocation (psycopg auto-prepare keys on the - -- bytes value, so equal bytes still hit the prepared-statement cache). - ++ "\n\"\"\"\n\n_SQL = SQL.encode()\n\n\n" + ++ "\n\"\"\"" + ++ renderRowBlock params.rowDef + ++ "\n\n\n" ++ renderFunction params params.asyncSurface ++ ( if params.emitSync then "\n\n\n" ++ renderFunction params params.syncSurface diff --git a/tests/golden/src/specimen_client/_generated/_core.py b/tests/golden/src/specimen_client/_generated/_core.py index 8927292..0c68c54 100644 --- a/tests/golden/src/specimen_client/_generated/_core.py +++ b/tests/golden/src/specimen_client/_generated/_core.py @@ -2,34 +2,16 @@ # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 -"""Shared types and decode helpers, surface-agnostic; no I/O.""" +"""Shared types and errors, surface-agnostic; no I/O.""" from __future__ import annotations -from typing import cast - type JsonValue = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] class NoRowError(RuntimeError): """A single-row query returned no rows.""" - -class DecodeError(RuntimeError): - """A row value failed to decode into its target type.""" - - -def require_array(value: object) -> list[object]: - """Guard an enum-array column decode. - - psycopg returns an enum array as a Python list only when the enum type is - registered on the connection (register_types); without it the value comes - back as the raw array text, which would iterate into bogus members. Fail - clearly instead. - """ - if isinstance(value, list): - return cast(list[object], value) - raise RuntimeError( - "enum array decoded as text; call register_types() on the connection " - "before decoding enum-array columns" - ) + def __init__(self, sql: str) -> None: + self.sql = sql + super().__init__(f"single-row query returned no rows: {sql}") diff --git a/tests/golden/src/specimen_client/_generated/_runtime.py b/tests/golden/src/specimen_client/_generated/_runtime.py index a31fc84..0bdbbcb 100644 --- a/tests/golden/src/specimen_client/_generated/_runtime.py +++ b/tests/golden/src/specimen_client/_generated/_runtime.py @@ -4,60 +4,56 @@ from __future__ import annotations -from collections.abc import Callable, Mapping -from typing import TypeVar +from typing import LiteralString, TypeVar from psycopg import AsyncConnection -from psycopg.rows import dict_row +from psycopg.rows import BaseRowFactory -from ._core import JsonValue as JsonValue, NoRowError as NoRowError, require_array as require_array +from ._core import NoRowError _T = TypeVar("_T") -_Row = Mapping[str, object] -_Params = Mapping[str, object] +_Params = dict[str, object] async def fetch_optional( conn: AsyncConnection[object], - sql: bytes, + sql: LiteralString, params: _Params, - decode: Callable[[_Row], _T], + row_factory: BaseRowFactory[_T], ) -> _T | None: - async with conn.cursor(row_factory=dict_row) as cur: + async with conn.cursor(row_factory=row_factory) as cur: _ = await cur.execute(sql, params) - row = await cur.fetchone() - return None if row is None else decode(row) + return await cur.fetchone() async def fetch_single( conn: AsyncConnection[object], - sql: bytes, + sql: LiteralString, params: _Params, - decode: Callable[[_Row], _T], + row_factory: BaseRowFactory[_T], ) -> _T: - async with conn.cursor(row_factory=dict_row) as cur: + async with conn.cursor(row_factory=row_factory) as cur: _ = await cur.execute(sql, params) row = await cur.fetchone() if row is None: - raise NoRowError(sql.decode()) - return decode(row) + raise NoRowError(sql) + return row async def fetch_many( conn: AsyncConnection[object], - sql: bytes, + sql: LiteralString, params: _Params, - decode: Callable[[_Row], _T], + row_factory: BaseRowFactory[_T], ) -> list[_T]: - async with conn.cursor(row_factory=dict_row) as cur: + async with conn.cursor(row_factory=row_factory) as cur: _ = await cur.execute(sql, params) - rows = await cur.fetchall() - return [decode(row) for row in rows] + return await cur.fetchall() async def execute_rows_affected( conn: AsyncConnection[object], - sql: bytes, + sql: LiteralString, params: _Params, ) -> int: async with conn.cursor() as cur: @@ -67,7 +63,7 @@ async def execute_rows_affected( async def execute_void( conn: AsyncConnection[object], - sql: bytes, + sql: LiteralString, params: _Params, ) -> None: async with conn.cursor() as cur: diff --git a/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py b/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py index 6d27de3..318203b 100644 --- a/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py +++ b/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py @@ -17,8 +17,6 @@ WHERE id = %(id)s """ -_SQL = SQL.encode() - async def bump_specimen_revision( conn: AsyncConnection[object], @@ -28,7 +26,7 @@ async def bump_specimen_revision( params: dict[str, object] = { "id": id, } - return await _execute_rows_affected(conn, _SQL, params) + return await _execute_rows_affected(conn, SQL, params) def bump_specimen_revision_sync( @@ -39,4 +37,4 @@ def bump_specimen_revision_sync( params: dict[str, object] = { "id": id, } - return _execute_rows_affected_sync(conn, _SQL, params) + return _execute_rows_affected_sync(conn, SQL, params) diff --git a/tests/golden/src/specimen_client/_generated/statements/get_specimen.py b/tests/golden/src/specimen_client/_generated/statements/get_specimen.py index f75f4d7..c4cd613 100644 --- a/tests/golden/src/specimen_client/_generated/statements/get_specimen.py +++ b/tests/golden/src/specimen_client/_generated/statements/get_specimen.py @@ -4,22 +4,34 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from datetime import date, datetime from decimal import Decimal -from typing import cast as _cast from uuid import UUID from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row from .._core import JsonValue from .._runtime import fetch_optional as _fetch_optional from ..sync._runtime import fetch_optional as _fetch_optional_sync -from ..types.a_codec_wrapper import ACodecWrapper -from ..types.mood import Mood -from ..types.point_2_d import Point2D -from ..types.z_codec_payload import ZCodecPayload +from .. import types as _db_types + +SQL = """\ +-- zero_or_one: select by pk with limit 1. +SELECT + id, pub_id, + flag, small, medium, large, ratio, precise, + title, code, letter, born_on, created_at, amount, blob, + doc_json, doc_jsonb, + maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, + tags, related_ids, grid, + feeling, origin, codec_payload, codec_payloads, codec_wrapper, + label, rev, meta +FROM specimen +WHERE id = %(id)s +LIMIT 1 +""" @dataclass(frozen=True, slots=True) @@ -49,73 +61,16 @@ class GetSpecimenRow: tags: list[str | None] related_ids: list[UUID | None] | None grid: list[int | None] | None - feeling: Mood - origin: Point2D | None - codec_payload: ZCodecPayload - codec_payloads: list[ZCodecPayload | None] - codec_wrapper: ACodecWrapper | None + feeling: _db_types.Mood + origin: _db_types.Point2D | None + codec_payload: _db_types.ZCodecPayload + codec_payloads: list[_db_types.ZCodecPayload | None] + codec_wrapper: _db_types.ACodecWrapper | None label: str rev: int meta: JsonValue -def _decode_row(row: Mapping[str, object]) -> GetSpecimenRow: - return GetSpecimenRow( - id=_cast(int, row["id"]), - pub_id=_cast(UUID, row["pub_id"]), - flag=_cast(bool, row["flag"]), - small=_cast(int, row["small"]), - medium=_cast(int, row["medium"]), - large=_cast(int, row["large"]), - ratio=_cast(float, row["ratio"]), - precise=_cast(float, row["precise"]), - title=_cast(str, row["title"]), - code=_cast(str, row["code"]), - letter=_cast(str, row["letter"]), - born_on=_cast(date, row["born_on"]), - created_at=_cast(datetime, row["created_at"]), - amount=_cast(Decimal, row["amount"]), - blob=_cast(bytes, row["blob"]), - doc_json=_cast(JsonValue, row["doc_json"]), - doc_jsonb=_cast(JsonValue, row["doc_jsonb"]), - maybe_text=_cast(str | None, row["maybe_text"]), - maybe_int=_cast(int | None, row["maybe_int"]), - maybe_uuid=_cast(UUID | None, row["maybe_uuid"]), - maybe_ts=_cast(datetime | None, row["maybe_ts"]), - maybe_num=_cast(Decimal | None, row["maybe_num"]), - tags=_cast(list[str | None], row["tags"]), - related_ids=_cast(list[UUID | None] | None, row["related_ids"]), - grid=_cast(list[int | None] | None, row["grid"]), - feeling=_cast(Mood, row["feeling"]), - origin=_cast(Point2D | None, row["origin"]), - codec_payload=_cast(ZCodecPayload, row["codec_payload"]), - codec_payloads=_cast(list[ZCodecPayload | None], row["codec_payloads"]), - codec_wrapper=_cast(ACodecWrapper | None, row["codec_wrapper"]), - label=_cast(str, row["label"]), - rev=_cast(int, row["rev"]), - meta=_cast(JsonValue, row["meta"]), - ) - - -SQL = """\ --- zero_or_one: select by pk with limit 1. -SELECT - id, pub_id, - flag, small, medium, large, ratio, precise, - title, code, letter, born_on, created_at, amount, blob, - doc_json, doc_jsonb, - maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, - tags, related_ids, grid, - feeling, origin, codec_payload, codec_payloads, codec_wrapper, - label, rev, meta -FROM specimen -WHERE id = %(id)s -LIMIT 1 -""" - -_SQL = SQL.encode() - - async def get_specimen( conn: AsyncConnection[object], *, @@ -124,7 +79,7 @@ async def get_specimen( params: dict[str, object] = { "id": id, } - return await _fetch_optional(conn, _SQL, params, _decode_row) + return await _fetch_optional(conn, SQL, params, _args_row(GetSpecimenRow)) def get_specimen_sync( @@ -135,4 +90,4 @@ def get_specimen_sync( params: dict[str, object] = { "id": id, } - return _fetch_optional_sync(conn, _SQL, params, _decode_row) + return _fetch_optional_sync(conn, SQL, params, _args_row(GetSpecimenRow)) diff --git a/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py b/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py index 68565df..cca86ee 100644 --- a/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py +++ b/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py @@ -4,31 +4,14 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass -from typing import cast as _cast from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row from .._runtime import fetch_optional as _fetch_optional from ..sync._runtime import fetch_optional as _fetch_optional_sync -from ..types.tag_value import TagValue - - -@dataclass(frozen=True, slots=True) -class GetTaggedItemRow: - id: int - name: str - tag: TagValue - - -def _decode_row(row: Mapping[str, object]) -> GetTaggedItemRow: - return GetTaggedItemRow( - id=_cast(int, row["id"]), - name=_cast(str, row["name"]), - tag=_cast(TagValue, row["tag"]), - ) - +from .. import types as _db_types SQL = """\ -- zero_or_one: select by pk with limit 1; the single-field composite here is @@ -40,7 +23,12 @@ def _decode_row(row: Mapping[str, object]) -> GetTaggedItemRow: LIMIT 1 """ -_SQL = SQL.encode() + +@dataclass(frozen=True, slots=True) +class GetTaggedItemRow: + id: int + name: str + tag: _db_types.TagValue async def get_tagged_item( @@ -51,7 +39,7 @@ async def get_tagged_item( params: dict[str, object] = { "id": id, } - return await _fetch_optional(conn, _SQL, params, _decode_row) + return await _fetch_optional(conn, SQL, params, _args_row(GetTaggedItemRow)) def get_tagged_item_sync( @@ -62,4 +50,4 @@ def get_tagged_item_sync( params: dict[str, object] = { "id": id, } - return _fetch_optional_sync(conn, _SQL, params, _decode_row) + return _fetch_optional_sync(conn, SQL, params, _args_row(GetTaggedItemRow)) diff --git a/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py b/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py index e976f0b..d218f1f 100644 --- a/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py +++ b/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py @@ -4,102 +4,20 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from datetime import date, datetime from decimal import Decimal -from typing import cast as _cast from uuid import UUID from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row from psycopg.types.json import Json from psycopg.types.json import Jsonb from .._core import JsonValue from .._runtime import fetch_single as _fetch_single from ..sync._runtime import fetch_single as _fetch_single_sync -from ..types.a_codec_wrapper import ACodecWrapper -from ..types.mood import Mood -from ..types.point_2_d import Point2D -from ..types.z_codec_payload import ZCodecPayload - - -@dataclass(frozen=True, slots=True) -class InsertSpecimenRow: - id: int - pub_id: UUID - flag: bool - small: int - medium: int - large: int - ratio: float - precise: float - title: str - code: str - letter: str - born_on: date - created_at: datetime - amount: Decimal - blob: bytes - doc_json: JsonValue - doc_jsonb: JsonValue - maybe_text: str | None - maybe_int: int | None - maybe_uuid: UUID | None - maybe_ts: datetime | None - maybe_num: Decimal | None - tags: list[str | None] - related_ids: list[UUID | None] | None - grid: list[int | None] | None - feeling: Mood - moods: list[Mood | None] | None - origin: Point2D | None - codec_payload: ZCodecPayload - codec_payloads: list[ZCodecPayload | None] - codec_wrapper: ACodecWrapper | None - label: str - rev: int - meta: JsonValue - - -def _decode_row(row: Mapping[str, object]) -> InsertSpecimenRow: - return InsertSpecimenRow( - id=_cast(int, row["id"]), - pub_id=_cast(UUID, row["pub_id"]), - flag=_cast(bool, row["flag"]), - small=_cast(int, row["small"]), - medium=_cast(int, row["medium"]), - large=_cast(int, row["large"]), - ratio=_cast(float, row["ratio"]), - precise=_cast(float, row["precise"]), - title=_cast(str, row["title"]), - code=_cast(str, row["code"]), - letter=_cast(str, row["letter"]), - born_on=_cast(date, row["born_on"]), - created_at=_cast(datetime, row["created_at"]), - amount=_cast(Decimal, row["amount"]), - blob=_cast(bytes, row["blob"]), - doc_json=_cast(JsonValue, row["doc_json"]), - doc_jsonb=_cast(JsonValue, row["doc_jsonb"]), - maybe_text=_cast(str | None, row["maybe_text"]), - maybe_int=_cast(int | None, row["maybe_int"]), - maybe_uuid=_cast(UUID | None, row["maybe_uuid"]), - maybe_ts=_cast(datetime | None, row["maybe_ts"]), - maybe_num=_cast(Decimal | None, row["maybe_num"]), - tags=_cast(list[str | None], row["tags"]), - related_ids=_cast(list[UUID | None] | None, row["related_ids"]), - grid=_cast(list[int | None] | None, row["grid"]), - feeling=_cast(Mood, row["feeling"]), - moods=_cast(list[Mood | None] | None, row["moods"]), - origin=_cast(Point2D | None, row["origin"]), - codec_payload=_cast(ZCodecPayload, row["codec_payload"]), - codec_payloads=_cast(list[ZCodecPayload | None], row["codec_payloads"]), - codec_wrapper=_cast(ACodecWrapper | None, row["codec_wrapper"]), - label=_cast(str, row["label"]), - rev=_cast(int, row["rev"]), - meta=_cast(JsonValue, row["meta"]), - ) - +from .. import types as _db_types SQL = """\ -- single row: insert ... returning the full type surface. @@ -138,7 +56,43 @@ def _decode_row(row: Mapping[str, object]) -> InsertSpecimenRow: label, rev, meta """ -_SQL = SQL.encode() + +@dataclass(frozen=True, slots=True) +class InsertSpecimenRow: + id: int + pub_id: UUID + flag: bool + small: int + medium: int + large: int + ratio: float + precise: float + title: str + code: str + letter: str + born_on: date + created_at: datetime + amount: Decimal + blob: bytes + doc_json: JsonValue + doc_jsonb: JsonValue + maybe_text: str | None + maybe_int: int | None + maybe_uuid: UUID | None + maybe_ts: datetime | None + maybe_num: Decimal | None + tags: list[str | None] + related_ids: list[UUID | None] | None + grid: list[int | None] | None + feeling: _db_types.Mood + moods: list[_db_types.Mood | None] | None + origin: _db_types.Point2D | None + codec_payload: _db_types.ZCodecPayload + codec_payloads: list[_db_types.ZCodecPayload | None] + codec_wrapper: _db_types.ACodecWrapper | None + label: str + rev: int + meta: JsonValue async def insert_specimen( @@ -166,12 +120,12 @@ async def insert_specimen( tags: list[str | None], related_ids: list[UUID | None] | None, grid: list[int | None] | None, - feeling: Mood, - moods: list[Mood | None] | None, - origin: Point2D | None, - codec_payload: ZCodecPayload, - codec_payloads: list[ZCodecPayload | None], - codec_wrapper: ACodecWrapper | None, + feeling: _db_types.Mood, + moods: list[_db_types.Mood | None] | None, + origin: _db_types.Point2D | None, + codec_payload: _db_types.ZCodecPayload, + codec_payloads: list[_db_types.ZCodecPayload | None], + codec_wrapper: _db_types.ACodecWrapper | None, ) -> InsertSpecimenRow: params: dict[str, object] = { "flag": flag, @@ -203,7 +157,7 @@ async def insert_specimen( "codec_payloads": codec_payloads, "codec_wrapper": codec_wrapper, } - return await _fetch_single(conn, _SQL, params, _decode_row) + return await _fetch_single(conn, SQL, params, _args_row(InsertSpecimenRow)) def insert_specimen_sync( @@ -231,12 +185,12 @@ def insert_specimen_sync( tags: list[str | None], related_ids: list[UUID | None] | None, grid: list[int | None] | None, - feeling: Mood, - moods: list[Mood | None] | None, - origin: Point2D | None, - codec_payload: ZCodecPayload, - codec_payloads: list[ZCodecPayload | None], - codec_wrapper: ACodecWrapper | None, + feeling: _db_types.Mood, + moods: list[_db_types.Mood | None] | None, + origin: _db_types.Point2D | None, + codec_payload: _db_types.ZCodecPayload, + codec_payloads: list[_db_types.ZCodecPayload | None], + codec_wrapper: _db_types.ACodecWrapper | None, ) -> InsertSpecimenRow: params: dict[str, object] = { "flag": flag, @@ -268,4 +222,4 @@ def insert_specimen_sync( "codec_payloads": codec_payloads, "codec_wrapper": codec_wrapper, } - return _fetch_single_sync(conn, _SQL, params, _decode_row) + return _fetch_single_sync(conn, SQL, params, _args_row(InsertSpecimenRow)) diff --git a/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py b/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py index edd5496..7064b40 100644 --- a/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py +++ b/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py @@ -4,31 +4,14 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass -from typing import cast as _cast from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row from .._runtime import fetch_single as _fetch_single from ..sync._runtime import fetch_single as _fetch_single_sync -from ..types.tag_value import TagValue - - -@dataclass(frozen=True, slots=True) -class InsertTaggedItemRow: - id: int - name: str - tag: TagValue - - -def _decode_row(row: Mapping[str, object]) -> InsertTaggedItemRow: - return InsertTaggedItemRow( - id=_cast(int, row["id"]), - name=_cast(str, row["name"]), - tag=_cast(TagValue, row["tag"]), - ) - +from .. import types as _db_types SQL = """\ -- single row: insert exercising a single-field composite as a parameter and @@ -38,30 +21,35 @@ def _decode_row(row: Mapping[str, object]) -> InsertTaggedItemRow: RETURNING id, name, tag """ -_SQL = SQL.encode() + +@dataclass(frozen=True, slots=True) +class InsertTaggedItemRow: + id: int + name: str + tag: _db_types.TagValue async def insert_tagged_item( conn: AsyncConnection[object], *, name: str, - tag: TagValue, + tag: _db_types.TagValue, ) -> InsertTaggedItemRow: params: dict[str, object] = { "name": name, "tag": tag, } - return await _fetch_single(conn, _SQL, params, _decode_row) + return await _fetch_single(conn, SQL, params, _args_row(InsertTaggedItemRow)) def insert_tagged_item_sync( conn: Connection[object], *, name: str, - tag: TagValue, + tag: _db_types.TagValue, ) -> InsertTaggedItemRow: params: dict[str, object] = { "name": name, "tag": tag, } - return _fetch_single_sync(conn, _SQL, params, _decode_row) + return _fetch_single_sync(conn, SQL, params, _args_row(InsertTaggedItemRow)) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py index 8b0546f..2dcdfc4 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py @@ -4,29 +4,14 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass -from typing import cast as _cast from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row from .._runtime import fetch_many as _fetch_many from ..sync._runtime import fetch_many as _fetch_many_sync - -@dataclass(frozen=True, slots=True) -class ListSpecimensByClassRow: - id: int - title: str - - -def _decode_row(row: Mapping[str, object]) -> ListSpecimensByClassRow: - return ListSpecimensByClassRow( - id=_cast(int, row["id"]), - title=_cast(str, row["title"]), - ) - - SQL = """\ -- Keyword-param coverage: the placeholder `class` is a Python reserved word, so -- the generated signature must use `class_` while the params-dict key and the @@ -40,7 +25,11 @@ def _decode_row(row: Mapping[str, object]) -> ListSpecimensByClassRow: ORDER BY id ASC """ -_SQL = SQL.encode() + +@dataclass(frozen=True, slots=True) +class ListSpecimensByClassRow: + id: int + title: str async def list_specimens_by_class( @@ -51,7 +40,7 @@ async def list_specimens_by_class( params: dict[str, object] = { "class": class_, } - return await _fetch_many(conn, _SQL, params, _decode_row) + return await _fetch_many(conn, SQL, params, _args_row(ListSpecimensByClassRow)) def list_specimens_by_class_sync( @@ -62,4 +51,4 @@ def list_specimens_by_class_sync( params: dict[str, object] = { "class": class_, } - return _fetch_many_sync(conn, _SQL, params, _decode_row) + return _fetch_many_sync(conn, SQL, params, _args_row(ListSpecimensByClassRow)) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py index 39c5f78..b537d0b 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py @@ -4,76 +4,57 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass -from typing import cast as _cast from uuid import UUID from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row from .._core import JsonValue from .._runtime import fetch_many as _fetch_many from ..sync._runtime import fetch_many as _fetch_many_sync -from ..types.mood import Mood -from ..types.point_2_d import Point2D +from .. import types as _db_types + +SQL = """\ +-- many: select with order by. Enum parameter ($feeling). +SELECT + id, pub_id, feeling, title, label, rev, origin, tags, meta +FROM specimen +WHERE feeling = %(feeling)s +ORDER BY id ASC +""" @dataclass(frozen=True, slots=True) class ListSpecimensByFeelingRow: id: int pub_id: UUID - feeling: Mood + feeling: _db_types.Mood title: str label: str rev: int - origin: Point2D | None + origin: _db_types.Point2D | None tags: list[str | None] meta: JsonValue -def _decode_row(row: Mapping[str, object]) -> ListSpecimensByFeelingRow: - return ListSpecimensByFeelingRow( - id=_cast(int, row["id"]), - pub_id=_cast(UUID, row["pub_id"]), - feeling=_cast(Mood, row["feeling"]), - title=_cast(str, row["title"]), - label=_cast(str, row["label"]), - rev=_cast(int, row["rev"]), - origin=_cast(Point2D | None, row["origin"]), - tags=_cast(list[str | None], row["tags"]), - meta=_cast(JsonValue, row["meta"]), - ) - - -SQL = """\ --- many: select with order by. Enum parameter ($feeling). -SELECT - id, pub_id, feeling, title, label, rev, origin, tags, meta -FROM specimen -WHERE feeling = %(feeling)s -ORDER BY id ASC -""" - -_SQL = SQL.encode() - - async def list_specimens_by_feeling( conn: AsyncConnection[object], *, - feeling: Mood | None, + feeling: _db_types.Mood | None, ) -> list[ListSpecimensByFeelingRow]: params: dict[str, object] = { "feeling": feeling, } - return await _fetch_many(conn, _SQL, params, _decode_row) + return await _fetch_many(conn, SQL, params, _args_row(ListSpecimensByFeelingRow)) def list_specimens_by_feeling_sync( conn: Connection[object], *, - feeling: Mood | None, + feeling: _db_types.Mood | None, ) -> list[ListSpecimensByFeelingRow]: params: dict[str, object] = { "feeling": feeling, } - return _fetch_many_sync(conn, _SQL, params, _decode_row) + return _fetch_many_sync(conn, SQL, params, _args_row(ListSpecimensByFeelingRow)) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py index 8548066..9fa06ca 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py @@ -4,34 +4,15 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass -from typing import cast as _cast from uuid import UUID from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row from .._runtime import fetch_many as _fetch_many from ..sync._runtime import fetch_many as _fetch_many_sync -from ..types.mood import Mood - - -@dataclass(frozen=True, slots=True) -class ListSpecimensByIdsRow: - id: int - pub_id: UUID - feeling: Mood - title: str - - -def _decode_row(row: Mapping[str, object]) -> ListSpecimensByIdsRow: - return ListSpecimensByIdsRow( - id=_cast(int, row["id"]), - pub_id=_cast(UUID, row["pub_id"]), - feeling=_cast(Mood, row["feeling"]), - title=_cast(str, row["title"]), - ) - +from .. import types as _db_types SQL = """\ -- many: array parameter via = any($pub_ids). @@ -42,7 +23,13 @@ def _decode_row(row: Mapping[str, object]) -> ListSpecimensByIdsRow: ORDER BY id ASC """ -_SQL = SQL.encode() + +@dataclass(frozen=True, slots=True) +class ListSpecimensByIdsRow: + id: int + pub_id: UUID + feeling: _db_types.Mood + title: str async def list_specimens_by_ids( @@ -53,7 +40,7 @@ async def list_specimens_by_ids( params: dict[str, object] = { "pub_ids": pub_ids, } - return await _fetch_many(conn, _SQL, params, _decode_row) + return await _fetch_many(conn, SQL, params, _args_row(ListSpecimensByIdsRow)) def list_specimens_by_ids_sync( @@ -64,4 +51,4 @@ def list_specimens_by_ids_sync( params: dict[str, object] = { "pub_ids": pub_ids, } - return _fetch_many_sync(conn, _SQL, params, _decode_row) + return _fetch_many_sync(conn, SQL, params, _args_row(ListSpecimensByIdsRow)) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py index e0eb6eb..61c1e68 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py @@ -4,36 +4,15 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass -from typing import cast as _cast from uuid import UUID from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row from .._runtime import fetch_many as _fetch_many from ..sync._runtime import fetch_many as _fetch_many_sync -from ..types.mood import Mood - - -@dataclass(frozen=True, slots=True) -class ListSpecimensByMoodsRow: - id: int - pub_id: UUID - feeling: Mood - moods: list[Mood | None] | None - title: str - - -def _decode_row(row: Mapping[str, object]) -> ListSpecimensByMoodsRow: - return ListSpecimensByMoodsRow( - id=_cast(int, row["id"]), - pub_id=_cast(UUID, row["pub_id"]), - feeling=_cast(Mood, row["feeling"]), - moods=_cast(list[Mood | None] | None, row["moods"]), - title=_cast(str, row["title"]), - ) - +from .. import types as _db_types SQL = """\ -- many: enum array parameter via = any($moods::mood[]); returns the enum array column. @@ -44,26 +23,33 @@ def _decode_row(row: Mapping[str, object]) -> ListSpecimensByMoodsRow: ORDER BY id ASC """ -_SQL = SQL.encode() + +@dataclass(frozen=True, slots=True) +class ListSpecimensByMoodsRow: + id: int + pub_id: UUID + feeling: _db_types.Mood + moods: list[_db_types.Mood | None] | None + title: str async def list_specimens_by_moods( conn: AsyncConnection[object], *, - moods: list[Mood | None] | None, + moods: list[_db_types.Mood | None] | None, ) -> list[ListSpecimensByMoodsRow]: params: dict[str, object] = { "moods": moods, } - return await _fetch_many(conn, _SQL, params, _decode_row) + return await _fetch_many(conn, SQL, params, _args_row(ListSpecimensByMoodsRow)) def list_specimens_by_moods_sync( conn: Connection[object], *, - moods: list[Mood | None] | None, + moods: list[_db_types.Mood | None] | None, ) -> list[ListSpecimensByMoodsRow]: params: dict[str, object] = { "moods": moods, } - return _fetch_many_sync(conn, _SQL, params, _decode_row) + return _fetch_many_sync(conn, SQL, params, _args_row(ListSpecimensByMoodsRow)) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py index 6c7ab32..13b4043 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py @@ -4,29 +4,14 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass -from typing import cast as _cast from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row from .._runtime import fetch_many as _fetch_many from ..sync._runtime import fetch_many as _fetch_many_sync - -@dataclass(frozen=True, slots=True) -class ListSpecimensKeywordColumnRow: - id: int - class_: str - - -def _decode_row(row: Mapping[str, object]) -> ListSpecimensKeywordColumnRow: - return ListSpecimensKeywordColumnRow( - id=_cast(int, row["id"]), - class_=_cast(str, row["class"]), - ) - - SQL = """\ -- Keyword result-column coverage: the column aliased to the reserved word class -- must emit a dataclass field and decode kwarg of class_ while the row lookup @@ -40,18 +25,22 @@ def _decode_row(row: Mapping[str, object]) -> ListSpecimensKeywordColumnRow: ORDER BY id ASC """ -_SQL = SQL.encode() + +@dataclass(frozen=True, slots=True) +class ListSpecimensKeywordColumnRow: + id: int + class_: str async def list_specimens_keyword_column( conn: AsyncConnection[object], ) -> list[ListSpecimensKeywordColumnRow]: params: dict[str, object] = {} - return await _fetch_many(conn, _SQL, params, _decode_row) + return await _fetch_many(conn, SQL, params, _args_row(ListSpecimensKeywordColumnRow)) def list_specimens_keyword_column_sync( conn: Connection[object], ) -> list[ListSpecimensKeywordColumnRow]: params: dict[str, object] = {} - return _fetch_many_sync(conn, _SQL, params, _decode_row) + return _fetch_many_sync(conn, SQL, params, _args_row(ListSpecimensKeywordColumnRow)) diff --git a/tests/golden/src/specimen_client/_generated/statements/search_specimens.py b/tests/golden/src/specimen_client/_generated/statements/search_specimens.py index 8cd04ca..7b24a90 100644 --- a/tests/golden/src/specimen_client/_generated/statements/search_specimens.py +++ b/tests/golden/src/specimen_client/_generated/statements/search_specimens.py @@ -4,35 +4,16 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass -from typing import cast as _cast from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row from psycopg.types.json import Jsonb from .._core import JsonValue from .._runtime import fetch_many as _fetch_many from ..sync._runtime import fetch_many as _fetch_many_sync - -@dataclass(frozen=True, slots=True) -class SearchSpecimensRow: - id: int - title: str - label: str - meta: JsonValue - - -def _decode_row(row: Mapping[str, object]) -> SearchSpecimensRow: - return SearchSpecimensRow( - id=_cast(int, row["id"]), - title=_cast(str, row["title"]), - label=_cast(str, row["label"]), - meta=_cast(JsonValue, row["meta"]), - ) - - SQL = """\ -- many: nullable parameter via coalesce, jsonb containment parameter, and a -- domain comparison with the param cast to the base type (pgn cannot bind a @@ -47,7 +28,13 @@ def _decode_row(row: Mapping[str, object]) -> SearchSpecimensRow: ORDER BY id ASC """ -_SQL = SQL.encode() + +@dataclass(frozen=True, slots=True) +class SearchSpecimensRow: + id: int + title: str + label: str + meta: JsonValue async def search_specimens( @@ -62,7 +49,7 @@ async def search_specimens( "meta_filter": None if meta_filter is None else Jsonb(meta_filter), "label": label, } - return await _fetch_many(conn, _SQL, params, _decode_row) + return await _fetch_many(conn, SQL, params, _args_row(SearchSpecimensRow)) def search_specimens_sync( @@ -77,4 +64,4 @@ def search_specimens_sync( "meta_filter": None if meta_filter is None else Jsonb(meta_filter), "label": label, } - return _fetch_many_sync(conn, _SQL, params, _decode_row) + return _fetch_many_sync(conn, SQL, params, _args_row(SearchSpecimensRow)) diff --git a/tests/golden/src/specimen_client/_generated/sync/_runtime.py b/tests/golden/src/specimen_client/_generated/sync/_runtime.py index 312a249..5d56e6f 100644 --- a/tests/golden/src/specimen_client/_generated/sync/_runtime.py +++ b/tests/golden/src/specimen_client/_generated/sync/_runtime.py @@ -4,60 +4,56 @@ from __future__ import annotations -from collections.abc import Callable, Mapping -from typing import TypeVar +from typing import LiteralString, TypeVar from psycopg import Connection -from psycopg.rows import dict_row +from psycopg.rows import BaseRowFactory -from .._core import JsonValue as JsonValue, NoRowError as NoRowError, require_array as require_array +from .._core import NoRowError _T = TypeVar("_T") -_Row = Mapping[str, object] -_Params = Mapping[str, object] +_Params = dict[str, object] def fetch_optional( conn: Connection[object], - sql: bytes, + sql: LiteralString, params: _Params, - decode: Callable[[_Row], _T], + row_factory: BaseRowFactory[_T], ) -> _T | None: - with conn.cursor(row_factory=dict_row) as cur: + with conn.cursor(row_factory=row_factory) as cur: _ = cur.execute(sql, params) - row = cur.fetchone() - return None if row is None else decode(row) + return cur.fetchone() def fetch_single( conn: Connection[object], - sql: bytes, + sql: LiteralString, params: _Params, - decode: Callable[[_Row], _T], + row_factory: BaseRowFactory[_T], ) -> _T: - with conn.cursor(row_factory=dict_row) as cur: + with conn.cursor(row_factory=row_factory) as cur: _ = cur.execute(sql, params) row = cur.fetchone() if row is None: - raise NoRowError(sql.decode()) - return decode(row) + raise NoRowError(sql) + return row def fetch_many( conn: Connection[object], - sql: bytes, + sql: LiteralString, params: _Params, - decode: Callable[[_Row], _T], + row_factory: BaseRowFactory[_T], ) -> list[_T]: - with conn.cursor(row_factory=dict_row) as cur: + with conn.cursor(row_factory=row_factory) as cur: _ = cur.execute(sql, params) - rows = cur.fetchall() - return [decode(row) for row in rows] + return cur.fetchall() def execute_rows_affected( conn: Connection[object], - sql: bytes, + sql: LiteralString, params: _Params, ) -> int: with conn.cursor() as cur: @@ -67,7 +63,7 @@ def execute_rows_affected( def execute_void( conn: Connection[object], - sql: bytes, + sql: LiteralString, params: _Params, ) -> None: with conn.cursor() as cur: diff --git a/tests/test_generated.py b/tests/test_generated.py index 745e0b8..d788a25 100644 --- a/tests/test_generated.py +++ b/tests/test_generated.py @@ -12,6 +12,7 @@ from __future__ import annotations import asyncio +import ast import importlib import inspect import json @@ -459,14 +460,80 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_custom_rows_keep_typed_passthrough_until_c3(client_modules) -> None: # noqa: ANN001 +def test_statement_modules_use_psycopg_row_factories(generated_tree: Path) -> None: + generated = generated_tree / GENERATED_PACKAGE / "_generated" + statements = generated / "statements" + paths = sorted(path for path in statements.glob("*.py") if path.name != "__init__.py") + assert [path.stem for path in paths] == sorted(QUERY_NAMES) + assert not (generated / "sync" / "statements").exists() + + sql_count = 0 + for path in paths: + source = path.read_text() + tree = ast.parse(source) + assignments = [ + node + for node in tree.body + if isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == "SQL" for target in node.targets) + ] + assert len(assignments) == 1 + sql_count += len(assignments) + assert "_SQL" not in source + assert "_decode_row" not in source + assert "_cast" not in source + assert "_require_array" not in source + + row_name = ROW_NAMES.get(path.stem) + row_classes = [node for node in tree.body if isinstance(node, ast.ClassDef) and node.name.endswith("Row")] + assert [node.name for node in row_classes] == ([] if row_name is None else [row_name]) + + args_row_imports = [ + alias + for node in tree.body + if isinstance(node, ast.ImportFrom) and node.module == "psycopg.rows" + for alias in node.names + if alias.name == "args_row" and alias.asname == "_args_row" + ] + assert len(args_row_imports) == (0 if row_name is None else 1) + + if row_name is not None: + row_factory_calls = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "_args_row" + ] + assert len(row_factory_calls) == 2 + assert all( + len(call.args) == 1 + and isinstance(call.args[0], ast.Name) + and call.args[0].id == row_name + for call in row_factory_calls + ) + + if "_db_types." in source: + assert source.count("from .. import types as _db_types") == 1 + assert "from ..types." not in source + + assert sql_count == 11 + wrapper_source = (generated / "types" / "a_codec_wrapper.py").read_text() + assert "from .z_codec_payload import ZCodecPayload" in wrapper_source + assert "from .mood import Mood" in wrapper_source + + +def test_custom_rows_use_qualified_annotations(client_modules) -> None: # noqa: ANN001 _, _, import_module = client_modules statement = import_module("specimen_client._generated.statements.insert_specimen") source = Path(statement.__file__).read_text() - assert "def _decode_row" in source - assert '_cast(ZCodecPayload, row["codec_payload"])' in source - assert '_cast(list[ZCodecPayload | None], row["codec_payloads"])' in source + assert "_decode_row" not in source + assert "_cast" not in source + assert "from .. import types as _db_types" in source + assert "codec_payload: _db_types.ZCodecPayload" in source + assert "codec_payloads: list[_db_types.ZCodecPayload | None]" in source + assert "_args_row(InsertSpecimenRow)" in source assert '.pg_decode(' not in source assert '.pg_encode(' not in source diff --git a/tests/test_identifier_collisions.py b/tests/test_identifier_collisions.py index 339e8b4..c9ea4fa 100644 --- a/tests/test_identifier_collisions.py +++ b/tests/test_identifier_collisions.py @@ -99,9 +99,9 @@ def _assert_private_query_canaries( Lude.Files.Type ( [ { path = "query-safe-name.txt" , content = - PyIdent.querySafeName "_types" + PyIdent.querySafeName "_db_types" ++ "\n" - ++ PyIdent.querySafeName "_decode_row" + ++ PyIdent.querySafeName "_args_row" ++ "\n" ++ PyIdent.querySafeName "_fetch_many_sync" ++ "\n" @@ -152,8 +152,8 @@ def _assert_private_query_canaries( assert result.returncode == 0, f"query-name canary generation failed:\n{result.stdout}\n{result.stderr}" output = canary / "artifacts" / "python" / "query-safe-name.txt" assert output.read_text().splitlines() == [ - "_types_query", - "_decode_row_query", + "_db_types_query", + "_args_row_query", "_fetch_many_sync_query", "sync_query", "register_types_query", @@ -249,33 +249,24 @@ def _assert_statement_structure(statements: Path) -> None: ] assert f"from .._runtime import {helper} as _{helper}" in source assert f"from ..sync._runtime import {helper} as _{helper}_sync" in source - assert f"return await _{helper}(conn, _SQL, params, _decode_row)" in source - assert f"return _{helper}_sync(conn, _SQL, params, _decode_row)" in source + row_classes = [node.name for node in tree.body if isinstance(node, ast.ClassDef)] + assert len(row_classes) == 1 + row_name = row_classes[0] + assert f"return await _{helper}(conn, SQL, params, _args_row({row_name}))" in source + assert f"return _{helper}_sync(conn, SQL, params, _args_row({row_name}))" in source assert f"async def {function_name}(" in source assert f"def {function_name}_sync(" in source - assert source.count("def _decode_row(") == 1 + assert source.count("from psycopg.rows import args_row as _args_row") == 1 assert "def decode_" not in source - - cast_imports = [ - node - for node in tree.body - if isinstance(node, ast.ImportFrom) and node.level == 0 and node.module == "typing" - ] - assert len(cast_imports) == 1 - assert [(alias.name, alias.asname) for alias in cast_imports[0].names] == [("cast", "_cast")] - - core_require_imports = [ - alias - for node in tree.body - if isinstance(node, ast.ImportFrom) and node.level == 2 and node.module == "_core" - for alias in node.names - if alias.name == "require_array" - ] - assert not core_require_imports + assert "_SQL" not in source + assert "_decode_row" not in source + assert "_cast" not in source + assert "_require_array" not in source require_array_source = (statements / "require_array.py").read_text() - assert "from .._core import require_array as _require_array" not in require_array_source - assert '_cast(list[Mood | None] | None, row["moods"])' in require_array_source + assert require_array_source.count("from .. import types as _db_types") == 1 + assert "moods: list[_db_types.Mood | None] | None" in require_array_source + assert "_args_row(RequireArrayRow)" in require_array_source assert "from datetime import date" in (statements / "date_query.py").read_text() diff --git a/tests/test_unsupported_types.py b/tests/test_unsupported_types.py index a8aa073..7049194 100644 --- a/tests/test_unsupported_types.py +++ b/tests/test_unsupported_types.py @@ -495,8 +495,9 @@ def test_skip_unsupported_drops_offending_units_and_cascades( ) custom_only = (src / "statements" / "probe_custom_only.py").read_text() - assert "from ..types.mood import Mood" in custom_only - assert "from typing import cast as _cast" in custom_only + assert "from .. import types as _db_types" in custom_only + assert "_db_types.Mood" in custom_only + assert "_args_row(ProbeCustomOnlyRow)" in custom_only src_root = str(generated / "src") sys.path.insert(0, src_root) @@ -539,17 +540,29 @@ def test_skip_unsupported_drops_offending_units_and_cascades( ) -def test_custom_imports_are_unique_and_deterministic() -> None: +def test_statement_custom_types_use_one_qualified_namespace_import() -> None: statements = GOLDEN_DIR / "src" / "specimen_client" / "_generated" / "statements" - custom_import = re.compile(r"^from \.\.types\.[a-zA-Z0-9_]+ import [a-zA-Z0-9_]+$", re.MULTILINE) - - insert_imports = custom_import.findall((statements / "insert_specimen.py").read_text()) - assert insert_imports == [ - "from ..types.a_codec_wrapper import ACodecWrapper", - "from ..types.mood import Mood", - "from ..types.point_2_d import Point2D", - "from ..types.z_codec_payload import ZCodecPayload", - ] + namespace_import = "from .. import types as _db_types" for module in statements.glob("*.py"): - imports = custom_import.findall(module.read_text()) - assert len(imports) == len(set(imports)), f"duplicate custom import in {module}" + source = module.read_text() + assert not re.search(r"^from \.\.types\.", source, re.MULTILINE), ( + f"direct custom type import in {module}" + ) + if "_db_types." in source: + assert source.count(namespace_import) == 1, ( + f"expected one custom type namespace import in {module}" + ) + + insert_specimen = (statements / "insert_specimen.py").read_text() + for annotation in ( + "feeling: _db_types.Mood", + "moods: list[_db_types.Mood | None] | None", + "origin: _db_types.Point2D | None", + "codec_payload: _db_types.ZCodecPayload", + "codec_payloads: list[_db_types.ZCodecPayload | None]", + "codec_wrapper: _db_types.ACodecWrapper | None", + ): + assert annotation in insert_specimen + + insert_tagged_item = (statements / "insert_tagged_item.py").read_text() + assert "tag: _db_types.TagValue" in insert_tagged_item From e7d1e6b75ce6163b72f13575767c744d5669396a Mon Sep 17 00:00:00 2001 From: Viacheslav Shvets Date: Mon, 13 Jul 2026 06:54:59 +0300 Subject: [PATCH 30/41] style(gen): emit ruff-clean python --- .github/workflows/ci.yml | 30 +++- mise.toml | 8 + pyproject.toml | 13 ++ src/Interpreters/Project.dhall | 13 +- src/Templates/FacadeModule.dhall | 154 +++++++++++------- src/Templates/RegisterModule.dhall | 55 +++---- src/Templates/RuntimeModule.dhall | 42 +++-- src/Templates/StatementModule.dhall | 32 ++-- tests/_generated_quality.py | 55 +++++++ tests/_harness.py | 13 +- tests/conftest.py | 3 +- tests/golden/pyproject.toml | 3 - tests/golden/src/specimen_client/__init__.py | 125 +++++++++++--- .../specimen_client/_generated/__init__.py | 2 +- .../src/specimen_client/_generated/_core.py | 2 +- .../specimen_client/_generated/_register.py | 57 +++---- .../specimen_client/_generated/_runtime.py | 23 ++- .../_generated/statements/__init__.py | 2 +- .../statements/bump_specimen_revision.py | 2 +- .../_generated/statements/get_specimen.py | 4 +- .../_generated/statements/get_tagged_item.py | 4 +- .../_generated/statements/insert_specimen.py | 7 +- .../statements/insert_tagged_item.py | 4 +- .../statements/list_specimens_by_class.py | 2 +- .../statements/list_specimens_by_feeling.py | 4 +- .../statements/list_specimens_by_ids.py | 4 +- .../statements/list_specimens_by_moods.py | 4 +- .../list_specimens_keyword_column.py | 2 +- .../_generated/statements/search_specimens.py | 2 +- .../_generated/sync/_runtime.py | 23 ++- .../_generated/types/__init__.py | 2 +- .../_generated/types/a_codec_wrapper.py | 2 +- .../specimen_client/_generated/types/mood.py | 2 +- .../_generated/types/point_2_d.py | 2 +- .../_generated/types/tag_value.py | 2 +- .../_generated/types/z_codec_payload.py | 2 +- .../src/specimen_client/sync/__init__.py | 118 +++++++++++--- tests/test_generated.py | 39 ++--- tests/test_generated_quality.py | 53 ++++++ tests/test_identifier_collisions.py | 48 ++---- tests/test_psycopg_adapter_contract.py | 1 - tests/test_unsupported_types.py | 41 ++--- tests/typing/psycopg_adapter_runtime.py | 49 ++---- uv.lock | 27 +++ 44 files changed, 680 insertions(+), 402 deletions(-) create mode 100644 tests/_generated_quality.py create mode 100644 tests/test_generated_quality.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc1a1b7..c7abd77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,7 +83,10 @@ jobs: fi - name: Sync the harness venv - run: mise x -- uv sync + run: mise run install + + - name: Lint the harness and committed generated package + run: mise run lint # Hosted runners cannot fit a full 7-artifact pgn generate (~31 GB peak # RSS measured locally); keep only the "python" artifact for this CI @@ -94,7 +97,7 @@ jobs: run: | set -euo pipefail - python3 - <<'EOF' + mise exec -- python - <<'EOF' from pathlib import Path p = Path("tests/fixture-project/project1.pgn.yaml") @@ -166,12 +169,27 @@ jobs: - name: Install mise-managed tools uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 - - name: Create a venv and install psycopg + basedpyright + - name: Create a venv and install contract tools + run: | + set -euo pipefail + + mise exec -- uv venv contract-shell/.venv + mise exec -- uv pip install --python contract-shell/.venv/bin/python \ + "psycopg[binary]>=3.3,<4" \ + "basedpyright==1.39.7" \ + "ruff==0.15.20" + + - name: Ruff check the generated package + shell: bash run: | set -euo pipefail - mise x -- uv venv contract-shell/.venv - mise x -- uv pip install --python contract-shell/.venv/bin/python "psycopg[binary]>=3.3,<4" basedpyright + mise exec -- contract-shell/.venv/bin/ruff check \ + --config "$PWD/pyproject.toml" \ + contract-shell/src + mise exec -- contract-shell/.venv/bin/ruff format --check \ + --config "$PWD/pyproject.toml" \ + contract-shell/src - name: basedpyright strict on the generated package shell: bash @@ -189,7 +207,7 @@ jobs: } JSON - output="$(contract-shell/.venv/bin/basedpyright --project contract-pyrightconfig.json --outputjson)" || true + output="$(mise exec -- contract-shell/.venv/bin/basedpyright --project contract-pyrightconfig.json --outputjson)" || true echo "$output" | jq . files=$(jq '.summary.filesAnalyzed' <<<"$output") diff --git a/mise.toml b/mise.toml index fc91362..0706e6b 100644 --- a/mise.toml +++ b/mise.toml @@ -14,6 +14,14 @@ description = "Run the generator harness tests" depends = ["install"] run = "uv run pytest tests -q" +[tasks.lint] +description = "Check Python lint and formatting" +depends = ["install"] +run = ''' +uv run ruff check --config pyproject.toml tests wheel +uv run ruff format --check --config pyproject.toml tests wheel +''' + # Cold-cache generate benchmark: current gen (`as Source` Deps) vs the same # tree with pre-as-Source Deps, same pgn binary, fresh dhall cache per run. # Needs a reachable Postgres (PGN_TEST_DATABASE_URL). See bench/as-source.sh diff --git a/pyproject.toml b/pyproject.toml index f946ed4..79edd51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,21 @@ dev = [ "pytest>=8", "psycopg[binary]>=3.3,<4", "basedpyright==1.39.7", + "ruff==0.15.20", ] +[tool.ruff] +target-version = "py312" +line-length = 120 +preview = false + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "UP", "B", "SIM", "RUF"] +preview = false + +[tool.ruff.format] +preview = false + [tool.uv] # This project relies on mise for the Python interpreter, matching the root. python-preference = "only-system" diff --git a/src/Interpreters/Project.dhall b/src/Interpreters/Project.dhall index f614527..8c0dce3 100644 --- a/src/Interpreters/Project.dhall +++ b/src/Interpreters/Project.dhall @@ -56,17 +56,12 @@ let Input = Model.Project let Output = Lude.Files.Type --- Header of every emitted .py file. The @generated + DO NOT EDIT convention --- (Go/Phabricator/linguist) marks the file as machine-written; tooling and --- reviewers key on it. The SPDX pair (REUSE convention) licenses the emitted --- file itself as MIT-0: the template text it reproduces is the generator --- author's copyright, granted here attribution-free so the containing --- project can use it under its own terms and compliance scanners see a --- standard permissive id instead of the generator's license. --- Prepended once here so it applies to all modules. +-- Header of every emitted .py file. The marker truthfully warns that another +-- generation replaces manual changes. The SPDX pair (REUSE convention) +-- licenses the emitted file itself as MIT-0. let generatedHeader = '' - # @generated by python.gen (pGenie). DO NOT EDIT. + # @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 diff --git a/src/Templates/FacadeModule.dhall b/src/Templates/FacadeModule.dhall index 8c65751..48acb98 100644 --- a/src/Templates/FacadeModule.dhall +++ b/src/Templates/FacadeModule.dhall @@ -23,6 +23,22 @@ let Params = -- names as exports of the package root, not unused imports. let alias = \(name : Text) -> "${name} as ${name}" +let importBlock = + \(source : Text) -> + \(entry : Text) -> + "from ${source} import (\n" + ++ " ${entry},\n" + ++ ")" + +let importBlocks = + \(source : Text) -> + \(entries : List Text) -> + Prelude.Text.concatMapSep + "\n" + Text + (\(entry : Text) -> importBlock source entry) + entries + let rowNames : List StatementExport -> List Text = \(statements : List StatementExport) -> @@ -44,46 +60,46 @@ let runtimeNames = [ "JsonValue", "NoRowError" ] let run = \(params : Params) -> let runtimeBlock = - "from ${params.generatedPrefix}._core import " - ++ Prelude.Text.concatMapSep - ", " - Text - alias - runtimeNames + importBlocks + "${params.generatedPrefix}._core" + (Prelude.List.map Text Text alias runtimeNames) let typeBlock = Prelude.Text.concatMapSep "\n" TypeExport ( \(t : TypeExport) -> - "from ${params.generatedPrefix}.types.${t.moduleName} import ${alias t.className}" + importBlock + "${params.generatedPrefix}.types.${t.moduleName}" + (alias t.className) ) params.types let rows = rowNames params.statements - -- A query's Row class now lives in that query's own statement - -- module (there is no shared _rows module anymore), so the row - -- alias, when present, rides on the same import line as the - -- statement function itself: "from ...statements.fn import fn as - -- fn, RowCls as RowCls" -- one line per statement, not two separate - -- import blocks. + -- A query's Row class lives in its statement module. Keep the row + -- alias before the function alias, matching the canonical import order. let statementBlock = Prelude.Text.concatMapSep "\n" StatementExport ( \(s : StatementExport) -> - let rowSuffix = + let rowEntries = merge - { None = "", Some = \(row : Text) -> ", ${alias row}" } + { None = [] : List Text + , Some = \(row : Text) -> [ alias row ] + } s.rowClassName - in "from ${params.generatedPrefix}.statements.${s.functionName} import " - ++ s.functionName - ++ params.functionSuffix - ++ " as " - ++ s.functionName - ++ rowSuffix + let functionEntry = + s.functionName + ++ params.functionSuffix + ++ " as " + ++ s.functionName + + in importBlocks + "${params.generatedPrefix}.statements.${s.functionName}" + (rowEntries # [ functionEntry ]) ) params.statements @@ -92,61 +108,85 @@ let run = { None = [] : List Text , Some = \(source : Text) -> - [ "from ${params.generatedPrefix}._register import ${source} as register_types" + [ importBlock + "${params.generatedPrefix}._register" + "${source} as register_types" ] } params.registrationSource let syncBlock = - if params.includeSyncModule then [ "from . import sync as sync" ] else [] : List Text + if params.includeSyncModule + then [ importBlock "." "sync as sync" ] + else [] : List Text let importGroups = - [ runtimeBlock ] - # ( if Prelude.List.null TypeExport params.types - then [] : List Text - else [ typeBlock ] - ) + syncBlock + # [ runtimeBlock ] + # registrationBlock # ( if Prelude.List.null StatementExport params.statements then [] : List Text else [ statementBlock ] ) - # registrationBlock - # syncBlock + # ( if Prelude.List.null TypeExport params.types + then [] : List Text + else [ typeBlock ] + ) - let importSection = Prelude.Text.concatSep "\n\n" importGroups + let importSection = Prelude.Text.concatSep "\n" importGroups - let allNames = - runtimeNames - # Prelude.List.map - TypeExport - Text - (\(t : TypeExport) -> t.className) - params.types - # rows - # Prelude.List.map - StatementExport - Text - (\(s : StatementExport) -> s.functionName) - params.statements - # ( merge - { None = [] : List Text - , Some = \(_ : Text) -> [ "register_types" ] - } - params.registrationSource - ) - # (if params.includeSyncModule then [ "sync" ] else [] : List Text) + let renderAllBlock = + \(operator : Text) -> + \(names : List Text) -> + "__all__ ${operator} [\n" + ++ Prelude.Text.concatMap + Text + (\(name : Text) -> " \"${name}\",\n") + names + ++ "]" - let allEntries = - Prelude.Text.concatMap + let typeNames = + Prelude.List.map + TypeExport Text - (\(name : Text) -> " \"${name}\",\n") - allNames + (\(t : TypeExport) -> t.className) + params.types + + let functionNames = + Prelude.List.map + StatementExport + Text + (\(s : StatementExport) -> s.functionName) + params.statements + + let registrationNames = + merge + { None = [] : List Text + , Some = \(_ : Text) -> [ "register_types" ] + } + params.registrationSource + + let syncNames = + if params.includeSyncModule then [ "sync" ] else [] : List Text + + let extraAllGroups = + Prelude.List.filter + (List Text) + (\(group : List Text) -> Prelude.Bool.not (Prelude.List.null Text group)) + [ typeNames, rows, functionNames, registrationNames, syncNames ] + + let allBlocks = + [ renderAllBlock "=" runtimeNames ] + # Prelude.List.map + (List Text) + Text + (renderAllBlock "+=") + extraAllGroups in '' ${importSection} - __all__ = [ - ${allEntries}] + ${Prelude.Text.concatSep "\n" allBlocks} '' in Sdk.Sigs.template Params run /\ { StatementExport, TypeExport } diff --git a/src/Templates/RegisterModule.dhall b/src/Templates/RegisterModule.dhall index d92c15c..e81af66 100644 --- a/src/Templates/RegisterModule.dhall +++ b/src/Templates/RegisterModule.dhall @@ -14,10 +14,6 @@ let CustomType = let Params = { customTypes : List CustomType, emitSync : Bool } -let classImport = - \(custom : CustomType) -> - "from .types.${custom.moduleName} import ${custom.typeName}" - let pgNameConstant = \(custom : CustomType) -> "_${custom.moduleName}_pg_name" @@ -38,7 +34,7 @@ let metadata = , Composite = constant ++ "\n" - ++ "${makeObjectName custom}, ${makeSequenceName custom} = _dataclass_callbacks(${custom.typeName})" + ++ "${makeObjectName custom}, ${makeSequenceName custom} = _dataclass_callbacks(_db_types.${custom.typeName})" } custom.kind @@ -55,8 +51,8 @@ let registration = ++ " register_enum(\n" ++ " ${infoName},\n" ++ " conn,\n" - ++ " ${custom.typeName},\n" - ++ " mapping={member: member.value for member in ${custom.typeName}},\n" + ++ " _db_types.${custom.typeName},\n" + ++ " mapping={member: member.value for member in _db_types.${custom.typeName}},\n" ++ " )" , Composite = " ${infoName} = ${awaitKw}CompositeInfo.fetch(conn, ${pgNameConstant custom})\n" @@ -65,7 +61,7 @@ let registration = ++ " register_composite(\n" ++ " ${infoName},\n" ++ " conn,\n" - ++ " ${custom.typeName},\n" + ++ " _db_types.${custom.typeName},\n" ++ " make_object=${makeObjectName custom},\n" ++ " make_sequence=${makeSequenceName custom},\n" ++ " )" @@ -90,13 +86,6 @@ let run = ) params.customTypes - let classImports = - Prelude.Text.concatMapSep - "\n" - CustomType - classImport - params.customTypes - let metadataLines = Prelude.Text.concatMapSep "\n" @@ -129,28 +118,28 @@ let run = import keyword from collections.abc import Callable, Sequence from dataclasses import fields, is_dataclass - from typing import Any, TypeVar + from typing import Any '' else "" - let adapterImports = - ( if hasComposites - then "from psycopg.types.composite import CompositeInfo, register_composite\n" - else "" - ) - ++ ( if hasEnums - then "from psycopg.types.enum import EnumInfo, register_enum\n" - else "" + let adapterImportLines = + ( if hasComposites + then [ "from psycopg.types.composite import CompositeInfo, register_composite" ] + else [] : List Text + ) + # ( if hasEnums + then [ "from psycopg.types.enum import EnumInfo, register_enum" ] + else [] : List Text ) + let adapterImports = Prelude.Text.concatSep "\n" adapterImportLines + let compositeCallbacks = if hasComposites then '' - - _T = TypeVar("_T") - _ObjectMaker = Callable[[Sequence[Any], CompositeInfo], _T] - _SequenceMaker = Callable[[_T, CompositeInfo], Sequence[Any]] + type _ObjectMaker[T] = Callable[[Sequence[Any], CompositeInfo], T] + type _SequenceMaker[T] = Callable[[T, CompositeInfo], Sequence[Any]] def _python_name(name: str) -> str: @@ -159,20 +148,20 @@ let run = return name - def _dataclass_callbacks(cls: type[_T]) -> tuple[_ObjectMaker[_T], _SequenceMaker[_T]]: + def _dataclass_callbacks[T](cls: type[T]) -> tuple[_ObjectMaker[T], _SequenceMaker[T]]: if not is_dataclass(cls): raise TypeError(f"{cls.__name__} must be a dataclass") model_fields = fields(cls) model_names = tuple(field.name for field in model_fields) - def make_object(values: Sequence[Any], info: CompositeInfo) -> _T: + def make_object(values: Sequence[Any], info: CompositeInfo) -> T: names = tuple(_python_name(name) for name in info.field_names) assert names == model_names assert len(values) == len(model_fields) return cls(**dict(zip(names, values, strict=True))) - def make_sequence(obj: _T, info: CompositeInfo) -> Sequence[Any]: + def make_sequence(obj: T, info: CompositeInfo) -> Sequence[Any]: names = tuple(_python_name(name) for name in info.field_names) assert names == model_names return tuple(getattr(obj, field.name) for field in model_fields) @@ -186,6 +175,7 @@ let run = then '' + def register_types_sync(conn: Connection[object]) -> None: ${syncRegistrations}'' else "" @@ -197,10 +187,9 @@ let run = ${connectionImport} ${adapterImports} - ${classImports} + from . import types as _db_types ${compositeCallbacks} - ${metadataLines} diff --git a/src/Templates/RuntimeModule.dhall b/src/Templates/RuntimeModule.dhall index b05e98a..b9f3c73 100644 --- a/src/Templates/RuntimeModule.dhall +++ b/src/Templates/RuntimeModule.dhall @@ -5,34 +5,33 @@ let content = '' from __future__ import annotations - from typing import LiteralString, TypeVar + from typing import LiteralString from psycopg import AsyncConnection from psycopg.rows import BaseRowFactory from ._core import NoRowError - _T = TypeVar("_T") _Params = dict[str, object] - async def fetch_optional( + async def fetch_optional[T]( conn: AsyncConnection[object], sql: LiteralString, params: _Params, - row_factory: BaseRowFactory[_T], - ) -> _T | None: + row_factory: BaseRowFactory[T], + ) -> T | None: async with conn.cursor(row_factory=row_factory) as cur: _ = await cur.execute(sql, params) return await cur.fetchone() - async def fetch_single( + async def fetch_single[T]( conn: AsyncConnection[object], sql: LiteralString, params: _Params, - row_factory: BaseRowFactory[_T], - ) -> _T: + row_factory: BaseRowFactory[T], + ) -> T: async with conn.cursor(row_factory=row_factory) as cur: _ = await cur.execute(sql, params) row = await cur.fetchone() @@ -41,12 +40,12 @@ let content = return row - async def fetch_many( + async def fetch_many[T]( conn: AsyncConnection[object], sql: LiteralString, params: _Params, - row_factory: BaseRowFactory[_T], - ) -> list[_T]: + row_factory: BaseRowFactory[T], + ) -> list[T]: async with conn.cursor(row_factory=row_factory) as cur: _ = await cur.execute(sql, params) return await cur.fetchall() @@ -76,34 +75,33 @@ let syncContent = '' from __future__ import annotations - from typing import LiteralString, TypeVar + from typing import LiteralString from psycopg import Connection from psycopg.rows import BaseRowFactory from .._core import NoRowError - _T = TypeVar("_T") _Params = dict[str, object] - def fetch_optional( + def fetch_optional[T]( conn: Connection[object], sql: LiteralString, params: _Params, - row_factory: BaseRowFactory[_T], - ) -> _T | None: + row_factory: BaseRowFactory[T], + ) -> T | None: with conn.cursor(row_factory=row_factory) as cur: _ = cur.execute(sql, params) return cur.fetchone() - def fetch_single( + def fetch_single[T]( conn: Connection[object], sql: LiteralString, params: _Params, - row_factory: BaseRowFactory[_T], - ) -> _T: + row_factory: BaseRowFactory[T], + ) -> T: with conn.cursor(row_factory=row_factory) as cur: _ = cur.execute(sql, params) row = cur.fetchone() @@ -112,12 +110,12 @@ let syncContent = return row - def fetch_many( + def fetch_many[T]( conn: Connection[object], sql: LiteralString, params: _Params, - row_factory: BaseRowFactory[_T], - ) -> list[_T]: + row_factory: BaseRowFactory[T], + ) -> list[T]: with conn.cursor(row_factory=row_factory) as cur: _ = cur.execute(sql, params) return cur.fetchall() diff --git a/src/Templates/StatementModule.dhall b/src/Templates/StatementModule.dhall index dc5672c..7f8cd9a 100644 --- a/src/Templates/StatementModule.dhall +++ b/src/Templates/StatementModule.dhall @@ -108,27 +108,31 @@ let renderImports # (if params.emitSync then [ syncSurface.connType ] else [] : List Text) let psycopgBlock = - [ "from psycopg import " ++ Prelude.Text.concatSep ", " connectionNames ] - # importLineIf - rowIsPresent - "from psycopg.rows import args_row as _args_row" - # importLineIf - imports.json - "from psycopg.types.json import Json" - # importLineIf - imports.jsonb - "from psycopg.types.json import Jsonb" + let jsonNames = + importLineIf imports.json "Json" + # importLineIf imports.jsonb "Jsonb" + + let jsonImport = + if Prelude.List.null Text jsonNames + then [] : List Text + else [ "from psycopg.types.json import " ++ Prelude.Text.concatSep ", " jsonNames ] + + in [ "from psycopg import " ++ Prelude.Text.concatSep ", " connectionNames ] + # importLineIf + rowIsPresent + "from psycopg.rows import args_row as _args_row" + # jsonImport let localBlock = - coreImport asyncSurface.corePrefix imports.jsonValue + importLineIf + (ImportSet.hasCustom imports) + "from .. import types as _db_types" + # coreImport asyncSurface.corePrefix imports.jsonValue # [ runtimeImport asyncSurface params.helperName ] # ( if params.emitSync then [ runtimeImport syncSurface params.helperName ] else [] : List Text ) - # importLineIf - (ImportSet.hasCustom imports) - "from .. import types as _db_types" let groups = [ [ "from __future__ import annotations" ] diff --git a/tests/_generated_quality.py b/tests/_generated_quality.py new file mode 100644 index 0000000..5824757 --- /dev/null +++ b/tests/_generated_quality.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import ast +import io +import tokenize +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class LongLine: + path: Path + line_number: int + length: int + + def label(self) -> str: + return f"{self.path}:{self.line_number}:{self.length}" + + +def _module_sql_interior_lines(source: str) -> set[int]: + tree = ast.parse(source) + spans = { + (node.value.lineno, node.value.col_offset, node.value.end_lineno, node.value.end_col_offset) + for node in tree.body + if isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and node.targets[0].id == "SQL" + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + and node.value.end_lineno is not None + and node.value.end_col_offset is not None + } + string_tokens = ( + token for token in tokenize.generate_tokens(io.StringIO(source).readline) if token.type == tokenize.STRING + ) + return { + line_number + for token in string_tokens + if (*token.start, *token.end) in spans + for line_number in range(token.start[0] + 1, token.end[0]) + } + + +def generated_line_length_issues(path: Path, *, limit: int = 120) -> tuple[list[LongLine], list[LongLine]]: + source = path.read_text() + sql_interior = _module_sql_interior_lines(source) + authored: list[LongLine] = [] + sql: list[LongLine] = [] + for line_number, line in enumerate(source.splitlines(), start=1): + if len(line) <= limit: + continue + issue = LongLine(path, line_number, len(line)) + (sql if line_number in sql_interior else authored).append(issue) + return authored, sql diff --git a/tests/_harness.py b/tests/_harness.py index 9e5ffbd..35a3719 100644 --- a/tests/_harness.py +++ b/tests/_harness.py @@ -11,6 +11,7 @@ import signal import subprocess import threading +from contextlib import suppress from pathlib import Path import psycopg @@ -66,13 +67,7 @@ def effective_database_name(url: str) -> str: # them here, else a path-less, dbname-less URL with PGDATABASE set to a protected # name would slip past while libpq connected to the protected DB. Empty (OS-user # fallback only) returns "" and the caller rejects it. - return str( - info.get("dbname") - or os.environ.get("PGDATABASE") - or info.get("user") - or os.environ.get("PGUSER") - or "" - ) + return str(info.get("dbname") or os.environ.get("PGDATABASE") or info.get("user") or os.environ.get("PGUSER") or "") def _rss_gb(pid: int) -> float | None: @@ -116,10 +111,8 @@ def watchdog() -> None: state["peak_gb"] = max(float(state["peak_gb"]), rss) # type: ignore[arg-type] if rss > budget_gb: state["killed"] = True - try: + with suppress(ProcessLookupError, PermissionError): os.killpg(os.getpgid(proc.pid), signal.SIGKILL) - except (ProcessLookupError, PermissionError): - pass return if done.wait(2.0): # normal exit signalled, or poll interval elapsed return diff --git a/tests/conftest.py b/tests/conftest.py index da57207..78eed32 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -83,8 +83,7 @@ def roundtrip_db(pgn_admin_url: str) -> Iterator[str]: admin = psycopg.connect(pgn_admin_url, autocommit=True) try: terminate = ( - "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " - "WHERE datname = %s AND pid <> pg_backend_pid()" + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = %s AND pid <> pg_backend_pid()" ) _ = admin.execute(terminate.encode(), (name,)) ensure_droppable(name) diff --git a/tests/golden/pyproject.toml b/tests/golden/pyproject.toml index a414476..3c0372a 100644 --- a/tests/golden/pyproject.toml +++ b/tests/golden/pyproject.toml @@ -10,6 +10,3 @@ dependencies = ["psycopg>=3.3,<4"] [tool.hatch.build.targets.wheel] packages = ["src/specimen_client"] - -[tool.ruff] -exclude = ["src/specimen_client/_generated"] diff --git a/tests/golden/src/specimen_client/__init__.py b/tests/golden/src/specimen_client/__init__.py index fb14e5c..55bb24e 100644 --- a/tests/golden/src/specimen_client/__init__.py +++ b/tests/golden/src/specimen_client/__init__.py @@ -1,39 +1,110 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 -from ._generated._core import JsonValue as JsonValue, NoRowError as NoRowError - -from ._generated.types.a_codec_wrapper import ACodecWrapper as ACodecWrapper -from ._generated.types.mood import Mood as Mood -from ._generated.types.point_2_d import Point2D as Point2D -from ._generated.types.tag_value import TagValue as TagValue -from ._generated.types.z_codec_payload import ZCodecPayload as ZCodecPayload - -from ._generated.statements.bump_specimen_revision import bump_specimen_revision as bump_specimen_revision -from ._generated.statements.get_specimen import get_specimen as get_specimen, GetSpecimenRow as GetSpecimenRow -from ._generated.statements.get_tagged_item import get_tagged_item as get_tagged_item, GetTaggedItemRow as GetTaggedItemRow -from ._generated.statements.insert_specimen import insert_specimen as insert_specimen, InsertSpecimenRow as InsertSpecimenRow -from ._generated.statements.insert_tagged_item import insert_tagged_item as insert_tagged_item, InsertTaggedItemRow as InsertTaggedItemRow -from ._generated.statements.list_specimens_by_class import list_specimens_by_class as list_specimens_by_class, ListSpecimensByClassRow as ListSpecimensByClassRow -from ._generated.statements.list_specimens_by_feeling import list_specimens_by_feeling as list_specimens_by_feeling, ListSpecimensByFeelingRow as ListSpecimensByFeelingRow -from ._generated.statements.list_specimens_by_ids import list_specimens_by_ids as list_specimens_by_ids, ListSpecimensByIdsRow as ListSpecimensByIdsRow -from ._generated.statements.list_specimens_by_moods import list_specimens_by_moods as list_specimens_by_moods, ListSpecimensByMoodsRow as ListSpecimensByMoodsRow -from ._generated.statements.list_specimens_keyword_column import list_specimens_keyword_column as list_specimens_keyword_column, ListSpecimensKeywordColumnRow as ListSpecimensKeywordColumnRow -from ._generated.statements.search_specimens import search_specimens as search_specimens, SearchSpecimensRow as SearchSpecimensRow - -from ._generated._register import register_types as register_types - -from . import sync as sync +from . import ( + sync as sync, +) +from ._generated._core import ( + JsonValue as JsonValue, +) +from ._generated._core import ( + NoRowError as NoRowError, +) +from ._generated._register import ( + register_types as register_types, +) +from ._generated.statements.bump_specimen_revision import ( + bump_specimen_revision as bump_specimen_revision, +) +from ._generated.statements.get_specimen import ( + GetSpecimenRow as GetSpecimenRow, +) +from ._generated.statements.get_specimen import ( + get_specimen as get_specimen, +) +from ._generated.statements.get_tagged_item import ( + GetTaggedItemRow as GetTaggedItemRow, +) +from ._generated.statements.get_tagged_item import ( + get_tagged_item as get_tagged_item, +) +from ._generated.statements.insert_specimen import ( + InsertSpecimenRow as InsertSpecimenRow, +) +from ._generated.statements.insert_specimen import ( + insert_specimen as insert_specimen, +) +from ._generated.statements.insert_tagged_item import ( + InsertTaggedItemRow as InsertTaggedItemRow, +) +from ._generated.statements.insert_tagged_item import ( + insert_tagged_item as insert_tagged_item, +) +from ._generated.statements.list_specimens_by_class import ( + ListSpecimensByClassRow as ListSpecimensByClassRow, +) +from ._generated.statements.list_specimens_by_class import ( + list_specimens_by_class as list_specimens_by_class, +) +from ._generated.statements.list_specimens_by_feeling import ( + ListSpecimensByFeelingRow as ListSpecimensByFeelingRow, +) +from ._generated.statements.list_specimens_by_feeling import ( + list_specimens_by_feeling as list_specimens_by_feeling, +) +from ._generated.statements.list_specimens_by_ids import ( + ListSpecimensByIdsRow as ListSpecimensByIdsRow, +) +from ._generated.statements.list_specimens_by_ids import ( + list_specimens_by_ids as list_specimens_by_ids, +) +from ._generated.statements.list_specimens_by_moods import ( + ListSpecimensByMoodsRow as ListSpecimensByMoodsRow, +) +from ._generated.statements.list_specimens_by_moods import ( + list_specimens_by_moods as list_specimens_by_moods, +) +from ._generated.statements.list_specimens_keyword_column import ( + ListSpecimensKeywordColumnRow as ListSpecimensKeywordColumnRow, +) +from ._generated.statements.list_specimens_keyword_column import ( + list_specimens_keyword_column as list_specimens_keyword_column, +) +from ._generated.statements.search_specimens import ( + SearchSpecimensRow as SearchSpecimensRow, +) +from ._generated.statements.search_specimens import ( + search_specimens as search_specimens, +) +from ._generated.types.a_codec_wrapper import ( + ACodecWrapper as ACodecWrapper, +) +from ._generated.types.mood import ( + Mood as Mood, +) +from ._generated.types.point_2_d import ( + Point2D as Point2D, +) +from ._generated.types.tag_value import ( + TagValue as TagValue, +) +from ._generated.types.z_codec_payload import ( + ZCodecPayload as ZCodecPayload, +) __all__ = [ "JsonValue", "NoRowError", +] +__all__ += [ "ACodecWrapper", "Mood", "Point2D", "TagValue", "ZCodecPayload", +] +__all__ += [ "GetSpecimenRow", "GetTaggedItemRow", "InsertSpecimenRow", @@ -44,6 +115,8 @@ "ListSpecimensByMoodsRow", "ListSpecimensKeywordColumnRow", "SearchSpecimensRow", +] +__all__ += [ "bump_specimen_revision", "get_specimen", "get_tagged_item", @@ -55,6 +128,10 @@ "list_specimens_by_moods", "list_specimens_keyword_column", "search_specimens", +] +__all__ += [ "register_types", +] +__all__ += [ "sync", ] diff --git a/tests/golden/src/specimen_client/_generated/__init__.py b/tests/golden/src/specimen_client/_generated/__init__.py index 0abb185..be7142b 100644 --- a/tests/golden/src/specimen_client/_generated/__init__.py +++ b/tests/golden/src/specimen_client/_generated/__init__.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 diff --git a/tests/golden/src/specimen_client/_generated/_core.py b/tests/golden/src/specimen_client/_generated/_core.py index 0c68c54..a19b9a0 100644 --- a/tests/golden/src/specimen_client/_generated/_core.py +++ b/tests/golden/src/specimen_client/_generated/_core.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 diff --git a/tests/golden/src/specimen_client/_generated/_register.py b/tests/golden/src/specimen_client/_generated/_register.py index e441181..0a3e2fa 100644 --- a/tests/golden/src/specimen_client/_generated/_register.py +++ b/tests/golden/src/specimen_client/_generated/_register.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 @@ -7,23 +7,16 @@ import keyword from collections.abc import Callable, Sequence from dataclasses import fields, is_dataclass -from typing import Any, TypeVar +from typing import Any from psycopg import AsyncConnection, Connection from psycopg.types.composite import CompositeInfo, register_composite from psycopg.types.enum import EnumInfo, register_enum +from . import types as _db_types -from .types.mood import Mood -from .types.point_2_d import Point2D -from .types.tag_value import TagValue -from .types.z_codec_payload import ZCodecPayload -from .types.a_codec_wrapper import ACodecWrapper - - -_T = TypeVar("_T") -_ObjectMaker = Callable[[Sequence[Any], CompositeInfo], _T] -_SequenceMaker = Callable[[_T, CompositeInfo], Sequence[Any]] +type _ObjectMaker[T] = Callable[[Sequence[Any], CompositeInfo], T] +type _SequenceMaker[T] = Callable[[T, CompositeInfo], Sequence[Any]] def _python_name(name: str) -> str: @@ -32,20 +25,20 @@ def _python_name(name: str) -> str: return name -def _dataclass_callbacks(cls: type[_T]) -> tuple[_ObjectMaker[_T], _SequenceMaker[_T]]: +def _dataclass_callbacks[T](cls: type[T]) -> tuple[_ObjectMaker[T], _SequenceMaker[T]]: if not is_dataclass(cls): raise TypeError(f"{cls.__name__} must be a dataclass") model_fields = fields(cls) model_names = tuple(field.name for field in model_fields) - def make_object(values: Sequence[Any], info: CompositeInfo) -> _T: + def make_object(values: Sequence[Any], info: CompositeInfo) -> T: names = tuple(_python_name(name) for name in info.field_names) assert names == model_names assert len(values) == len(model_fields) return cls(**dict(zip(names, values, strict=True))) - def make_sequence(obj: _T, info: CompositeInfo) -> Sequence[Any]: + def make_sequence(obj: T, info: CompositeInfo) -> Sequence[Any]: names = tuple(_python_name(name) for name in info.field_names) assert names == model_names return tuple(getattr(obj, field.name) for field in model_fields) @@ -53,16 +46,15 @@ def make_sequence(obj: _T, info: CompositeInfo) -> Sequence[Any]: return make_object, make_sequence - _mood_pg_name = "public.mood" _point_2_d_pg_name = "public.point2d" -_point_2_d_make_object, _point_2_d_make_sequence = _dataclass_callbacks(Point2D) +_point_2_d_make_object, _point_2_d_make_sequence = _dataclass_callbacks(_db_types.Point2D) _tag_value_pg_name = "public.tag_value" -_tag_value_make_object, _tag_value_make_sequence = _dataclass_callbacks(TagValue) +_tag_value_make_object, _tag_value_make_sequence = _dataclass_callbacks(_db_types.TagValue) _z_codec_payload_pg_name = "public.z_codec_payload" -_z_codec_payload_make_object, _z_codec_payload_make_sequence = _dataclass_callbacks(ZCodecPayload) +_z_codec_payload_make_object, _z_codec_payload_make_sequence = _dataclass_callbacks(_db_types.ZCodecPayload) _a_codec_wrapper_pg_name = "public.a_codec_wrapper" -_a_codec_wrapper_make_object, _a_codec_wrapper_make_sequence = _dataclass_callbacks(ACodecWrapper) +_a_codec_wrapper_make_object, _a_codec_wrapper_make_sequence = _dataclass_callbacks(_db_types.ACodecWrapper) async def register_types(conn: AsyncConnection[object]) -> None: @@ -72,8 +64,8 @@ async def register_types(conn: AsyncConnection[object]) -> None: register_enum( mood_info, conn, - Mood, - mapping={member: member.value for member in Mood}, + _db_types.Mood, + mapping={member: member.value for member in _db_types.Mood}, ) point_2_d_info = await CompositeInfo.fetch(conn, _point_2_d_pg_name) if point_2_d_info is None: @@ -81,7 +73,7 @@ async def register_types(conn: AsyncConnection[object]) -> None: register_composite( point_2_d_info, conn, - Point2D, + _db_types.Point2D, make_object=_point_2_d_make_object, make_sequence=_point_2_d_make_sequence, ) @@ -91,7 +83,7 @@ async def register_types(conn: AsyncConnection[object]) -> None: register_composite( tag_value_info, conn, - TagValue, + _db_types.TagValue, make_object=_tag_value_make_object, make_sequence=_tag_value_make_sequence, ) @@ -101,7 +93,7 @@ async def register_types(conn: AsyncConnection[object]) -> None: register_composite( z_codec_payload_info, conn, - ZCodecPayload, + _db_types.ZCodecPayload, make_object=_z_codec_payload_make_object, make_sequence=_z_codec_payload_make_sequence, ) @@ -111,11 +103,12 @@ async def register_types(conn: AsyncConnection[object]) -> None: register_composite( a_codec_wrapper_info, conn, - ACodecWrapper, + _db_types.ACodecWrapper, make_object=_a_codec_wrapper_make_object, make_sequence=_a_codec_wrapper_make_sequence, ) + def register_types_sync(conn: Connection[object]) -> None: mood_info = EnumInfo.fetch(conn, _mood_pg_name) if mood_info is None: @@ -123,8 +116,8 @@ def register_types_sync(conn: Connection[object]) -> None: register_enum( mood_info, conn, - Mood, - mapping={member: member.value for member in Mood}, + _db_types.Mood, + mapping={member: member.value for member in _db_types.Mood}, ) point_2_d_info = CompositeInfo.fetch(conn, _point_2_d_pg_name) if point_2_d_info is None: @@ -132,7 +125,7 @@ def register_types_sync(conn: Connection[object]) -> None: register_composite( point_2_d_info, conn, - Point2D, + _db_types.Point2D, make_object=_point_2_d_make_object, make_sequence=_point_2_d_make_sequence, ) @@ -142,7 +135,7 @@ def register_types_sync(conn: Connection[object]) -> None: register_composite( tag_value_info, conn, - TagValue, + _db_types.TagValue, make_object=_tag_value_make_object, make_sequence=_tag_value_make_sequence, ) @@ -152,7 +145,7 @@ def register_types_sync(conn: Connection[object]) -> None: register_composite( z_codec_payload_info, conn, - ZCodecPayload, + _db_types.ZCodecPayload, make_object=_z_codec_payload_make_object, make_sequence=_z_codec_payload_make_sequence, ) @@ -162,7 +155,7 @@ def register_types_sync(conn: Connection[object]) -> None: register_composite( a_codec_wrapper_info, conn, - ACodecWrapper, + _db_types.ACodecWrapper, make_object=_a_codec_wrapper_make_object, make_sequence=_a_codec_wrapper_make_sequence, ) diff --git a/tests/golden/src/specimen_client/_generated/_runtime.py b/tests/golden/src/specimen_client/_generated/_runtime.py index 0bdbbcb..625870a 100644 --- a/tests/golden/src/specimen_client/_generated/_runtime.py +++ b/tests/golden/src/specimen_client/_generated/_runtime.py @@ -1,37 +1,36 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 from __future__ import annotations -from typing import LiteralString, TypeVar +from typing import LiteralString from psycopg import AsyncConnection from psycopg.rows import BaseRowFactory from ._core import NoRowError -_T = TypeVar("_T") _Params = dict[str, object] -async def fetch_optional( +async def fetch_optional[T]( conn: AsyncConnection[object], sql: LiteralString, params: _Params, - row_factory: BaseRowFactory[_T], -) -> _T | None: + row_factory: BaseRowFactory[T], +) -> T | None: async with conn.cursor(row_factory=row_factory) as cur: _ = await cur.execute(sql, params) return await cur.fetchone() -async def fetch_single( +async def fetch_single[T]( conn: AsyncConnection[object], sql: LiteralString, params: _Params, - row_factory: BaseRowFactory[_T], -) -> _T: + row_factory: BaseRowFactory[T], +) -> T: async with conn.cursor(row_factory=row_factory) as cur: _ = await cur.execute(sql, params) row = await cur.fetchone() @@ -40,12 +39,12 @@ async def fetch_single( return row -async def fetch_many( +async def fetch_many[T]( conn: AsyncConnection[object], sql: LiteralString, params: _Params, - row_factory: BaseRowFactory[_T], -) -> list[_T]: + row_factory: BaseRowFactory[T], +) -> list[T]: async with conn.cursor(row_factory=row_factory) as cur: _ = await cur.execute(sql, params) return await cur.fetchall() diff --git a/tests/golden/src/specimen_client/_generated/statements/__init__.py b/tests/golden/src/specimen_client/_generated/statements/__init__.py index 3c3ae7f..d33de0b 100644 --- a/tests/golden/src/specimen_client/_generated/statements/__init__.py +++ b/tests/golden/src/specimen_client/_generated/statements/__init__.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 diff --git a/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py b/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py index 318203b..8872bdd 100644 --- a/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py +++ b/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 diff --git a/tests/golden/src/specimen_client/_generated/statements/get_specimen.py b/tests/golden/src/specimen_client/_generated/statements/get_specimen.py index c4cd613..5e0d9c9 100644 --- a/tests/golden/src/specimen_client/_generated/statements/get_specimen.py +++ b/tests/golden/src/specimen_client/_generated/statements/get_specimen.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 @@ -12,10 +12,10 @@ from psycopg import AsyncConnection, Connection from psycopg.rows import args_row as _args_row +from .. import types as _db_types from .._core import JsonValue from .._runtime import fetch_optional as _fetch_optional from ..sync._runtime import fetch_optional as _fetch_optional_sync -from .. import types as _db_types SQL = """\ -- zero_or_one: select by pk with limit 1. diff --git a/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py b/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py index cca86ee..ad56306 100644 --- a/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py +++ b/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 @@ -9,9 +9,9 @@ from psycopg import AsyncConnection, Connection from psycopg.rows import args_row as _args_row +from .. import types as _db_types from .._runtime import fetch_optional as _fetch_optional from ..sync._runtime import fetch_optional as _fetch_optional_sync -from .. import types as _db_types SQL = """\ -- zero_or_one: select by pk with limit 1; the single-field composite here is diff --git a/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py b/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py index d218f1f..73aad01 100644 --- a/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py +++ b/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 @@ -11,13 +11,12 @@ from psycopg import AsyncConnection, Connection from psycopg.rows import args_row as _args_row -from psycopg.types.json import Json -from psycopg.types.json import Jsonb +from psycopg.types.json import Json, Jsonb +from .. import types as _db_types from .._core import JsonValue from .._runtime import fetch_single as _fetch_single from ..sync._runtime import fetch_single as _fetch_single_sync -from .. import types as _db_types SQL = """\ -- single row: insert ... returning the full type surface. diff --git a/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py b/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py index 7064b40..1185d21 100644 --- a/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py +++ b/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 @@ -9,9 +9,9 @@ from psycopg import AsyncConnection, Connection from psycopg.rows import args_row as _args_row +from .. import types as _db_types from .._runtime import fetch_single as _fetch_single from ..sync._runtime import fetch_single as _fetch_single_sync -from .. import types as _db_types SQL = """\ -- single row: insert exercising a single-field composite as a parameter and diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py index 2dcdfc4..9551b8e 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py index b537d0b..211cb97 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 @@ -10,10 +10,10 @@ from psycopg import AsyncConnection, Connection from psycopg.rows import args_row as _args_row +from .. import types as _db_types from .._core import JsonValue from .._runtime import fetch_many as _fetch_many from ..sync._runtime import fetch_many as _fetch_many_sync -from .. import types as _db_types SQL = """\ -- many: select with order by. Enum parameter ($feeling). diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py index 9fa06ca..f120f87 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 @@ -10,9 +10,9 @@ from psycopg import AsyncConnection, Connection from psycopg.rows import args_row as _args_row +from .. import types as _db_types from .._runtime import fetch_many as _fetch_many from ..sync._runtime import fetch_many as _fetch_many_sync -from .. import types as _db_types SQL = """\ -- many: array parameter via = any($pub_ids). diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py index 61c1e68..545c28d 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 @@ -10,9 +10,9 @@ from psycopg import AsyncConnection, Connection from psycopg.rows import args_row as _args_row +from .. import types as _db_types from .._runtime import fetch_many as _fetch_many from ..sync._runtime import fetch_many as _fetch_many_sync -from .. import types as _db_types SQL = """\ -- many: enum array parameter via = any($moods::mood[]); returns the enum array column. diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py index 13b4043..cc63ea0 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 diff --git a/tests/golden/src/specimen_client/_generated/statements/search_specimens.py b/tests/golden/src/specimen_client/_generated/statements/search_specimens.py index 7b24a90..8cfb55a 100644 --- a/tests/golden/src/specimen_client/_generated/statements/search_specimens.py +++ b/tests/golden/src/specimen_client/_generated/statements/search_specimens.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 diff --git a/tests/golden/src/specimen_client/_generated/sync/_runtime.py b/tests/golden/src/specimen_client/_generated/sync/_runtime.py index 5d56e6f..98b8fca 100644 --- a/tests/golden/src/specimen_client/_generated/sync/_runtime.py +++ b/tests/golden/src/specimen_client/_generated/sync/_runtime.py @@ -1,37 +1,36 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 from __future__ import annotations -from typing import LiteralString, TypeVar +from typing import LiteralString from psycopg import Connection from psycopg.rows import BaseRowFactory from .._core import NoRowError -_T = TypeVar("_T") _Params = dict[str, object] -def fetch_optional( +def fetch_optional[T]( conn: Connection[object], sql: LiteralString, params: _Params, - row_factory: BaseRowFactory[_T], -) -> _T | None: + row_factory: BaseRowFactory[T], +) -> T | None: with conn.cursor(row_factory=row_factory) as cur: _ = cur.execute(sql, params) return cur.fetchone() -def fetch_single( +def fetch_single[T]( conn: Connection[object], sql: LiteralString, params: _Params, - row_factory: BaseRowFactory[_T], -) -> _T: + row_factory: BaseRowFactory[T], +) -> T: with conn.cursor(row_factory=row_factory) as cur: _ = cur.execute(sql, params) row = cur.fetchone() @@ -40,12 +39,12 @@ def fetch_single( return row -def fetch_many( +def fetch_many[T]( conn: Connection[object], sql: LiteralString, params: _Params, - row_factory: BaseRowFactory[_T], -) -> list[_T]: + row_factory: BaseRowFactory[T], +) -> list[T]: with conn.cursor(row_factory=row_factory) as cur: _ = cur.execute(sql, params) return cur.fetchall() diff --git a/tests/golden/src/specimen_client/_generated/types/__init__.py b/tests/golden/src/specimen_client/_generated/types/__init__.py index f0ebe7a..aa326cf 100644 --- a/tests/golden/src/specimen_client/_generated/types/__init__.py +++ b/tests/golden/src/specimen_client/_generated/types/__init__.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 diff --git a/tests/golden/src/specimen_client/_generated/types/a_codec_wrapper.py b/tests/golden/src/specimen_client/_generated/types/a_codec_wrapper.py index 001a7f9..ef4894b 100644 --- a/tests/golden/src/specimen_client/_generated/types/a_codec_wrapper.py +++ b/tests/golden/src/specimen_client/_generated/types/a_codec_wrapper.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 diff --git a/tests/golden/src/specimen_client/_generated/types/mood.py b/tests/golden/src/specimen_client/_generated/types/mood.py index ece63e0..588f71a 100644 --- a/tests/golden/src/specimen_client/_generated/types/mood.py +++ b/tests/golden/src/specimen_client/_generated/types/mood.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 diff --git a/tests/golden/src/specimen_client/_generated/types/point_2_d.py b/tests/golden/src/specimen_client/_generated/types/point_2_d.py index 15bd25d..3cbe493 100644 --- a/tests/golden/src/specimen_client/_generated/types/point_2_d.py +++ b/tests/golden/src/specimen_client/_generated/types/point_2_d.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 diff --git a/tests/golden/src/specimen_client/_generated/types/tag_value.py b/tests/golden/src/specimen_client/_generated/types/tag_value.py index ec2a89e..9a456ce 100644 --- a/tests/golden/src/specimen_client/_generated/types/tag_value.py +++ b/tests/golden/src/specimen_client/_generated/types/tag_value.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 diff --git a/tests/golden/src/specimen_client/_generated/types/z_codec_payload.py b/tests/golden/src/specimen_client/_generated/types/z_codec_payload.py index be381c5..3d7b1a3 100644 --- a/tests/golden/src/specimen_client/_generated/types/z_codec_payload.py +++ b/tests/golden/src/specimen_client/_generated/types/z_codec_payload.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 diff --git a/tests/golden/src/specimen_client/sync/__init__.py b/tests/golden/src/specimen_client/sync/__init__.py index f0bb83e..27d3289 100644 --- a/tests/golden/src/specimen_client/sync/__init__.py +++ b/tests/golden/src/specimen_client/sync/__init__.py @@ -1,37 +1,107 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 -from .._generated._core import JsonValue as JsonValue, NoRowError as NoRowError - -from .._generated.types.a_codec_wrapper import ACodecWrapper as ACodecWrapper -from .._generated.types.mood import Mood as Mood -from .._generated.types.point_2_d import Point2D as Point2D -from .._generated.types.tag_value import TagValue as TagValue -from .._generated.types.z_codec_payload import ZCodecPayload as ZCodecPayload - -from .._generated.statements.bump_specimen_revision import bump_specimen_revision_sync as bump_specimen_revision -from .._generated.statements.get_specimen import get_specimen_sync as get_specimen, GetSpecimenRow as GetSpecimenRow -from .._generated.statements.get_tagged_item import get_tagged_item_sync as get_tagged_item, GetTaggedItemRow as GetTaggedItemRow -from .._generated.statements.insert_specimen import insert_specimen_sync as insert_specimen, InsertSpecimenRow as InsertSpecimenRow -from .._generated.statements.insert_tagged_item import insert_tagged_item_sync as insert_tagged_item, InsertTaggedItemRow as InsertTaggedItemRow -from .._generated.statements.list_specimens_by_class import list_specimens_by_class_sync as list_specimens_by_class, ListSpecimensByClassRow as ListSpecimensByClassRow -from .._generated.statements.list_specimens_by_feeling import list_specimens_by_feeling_sync as list_specimens_by_feeling, ListSpecimensByFeelingRow as ListSpecimensByFeelingRow -from .._generated.statements.list_specimens_by_ids import list_specimens_by_ids_sync as list_specimens_by_ids, ListSpecimensByIdsRow as ListSpecimensByIdsRow -from .._generated.statements.list_specimens_by_moods import list_specimens_by_moods_sync as list_specimens_by_moods, ListSpecimensByMoodsRow as ListSpecimensByMoodsRow -from .._generated.statements.list_specimens_keyword_column import list_specimens_keyword_column_sync as list_specimens_keyword_column, ListSpecimensKeywordColumnRow as ListSpecimensKeywordColumnRow -from .._generated.statements.search_specimens import search_specimens_sync as search_specimens, SearchSpecimensRow as SearchSpecimensRow - -from .._generated._register import register_types_sync as register_types +from .._generated._core import ( + JsonValue as JsonValue, +) +from .._generated._core import ( + NoRowError as NoRowError, +) +from .._generated._register import ( + register_types_sync as register_types, +) +from .._generated.statements.bump_specimen_revision import ( + bump_specimen_revision_sync as bump_specimen_revision, +) +from .._generated.statements.get_specimen import ( + GetSpecimenRow as GetSpecimenRow, +) +from .._generated.statements.get_specimen import ( + get_specimen_sync as get_specimen, +) +from .._generated.statements.get_tagged_item import ( + GetTaggedItemRow as GetTaggedItemRow, +) +from .._generated.statements.get_tagged_item import ( + get_tagged_item_sync as get_tagged_item, +) +from .._generated.statements.insert_specimen import ( + InsertSpecimenRow as InsertSpecimenRow, +) +from .._generated.statements.insert_specimen import ( + insert_specimen_sync as insert_specimen, +) +from .._generated.statements.insert_tagged_item import ( + InsertTaggedItemRow as InsertTaggedItemRow, +) +from .._generated.statements.insert_tagged_item import ( + insert_tagged_item_sync as insert_tagged_item, +) +from .._generated.statements.list_specimens_by_class import ( + ListSpecimensByClassRow as ListSpecimensByClassRow, +) +from .._generated.statements.list_specimens_by_class import ( + list_specimens_by_class_sync as list_specimens_by_class, +) +from .._generated.statements.list_specimens_by_feeling import ( + ListSpecimensByFeelingRow as ListSpecimensByFeelingRow, +) +from .._generated.statements.list_specimens_by_feeling import ( + list_specimens_by_feeling_sync as list_specimens_by_feeling, +) +from .._generated.statements.list_specimens_by_ids import ( + ListSpecimensByIdsRow as ListSpecimensByIdsRow, +) +from .._generated.statements.list_specimens_by_ids import ( + list_specimens_by_ids_sync as list_specimens_by_ids, +) +from .._generated.statements.list_specimens_by_moods import ( + ListSpecimensByMoodsRow as ListSpecimensByMoodsRow, +) +from .._generated.statements.list_specimens_by_moods import ( + list_specimens_by_moods_sync as list_specimens_by_moods, +) +from .._generated.statements.list_specimens_keyword_column import ( + ListSpecimensKeywordColumnRow as ListSpecimensKeywordColumnRow, +) +from .._generated.statements.list_specimens_keyword_column import ( + list_specimens_keyword_column_sync as list_specimens_keyword_column, +) +from .._generated.statements.search_specimens import ( + SearchSpecimensRow as SearchSpecimensRow, +) +from .._generated.statements.search_specimens import ( + search_specimens_sync as search_specimens, +) +from .._generated.types.a_codec_wrapper import ( + ACodecWrapper as ACodecWrapper, +) +from .._generated.types.mood import ( + Mood as Mood, +) +from .._generated.types.point_2_d import ( + Point2D as Point2D, +) +from .._generated.types.tag_value import ( + TagValue as TagValue, +) +from .._generated.types.z_codec_payload import ( + ZCodecPayload as ZCodecPayload, +) __all__ = [ "JsonValue", "NoRowError", +] +__all__ += [ "ACodecWrapper", "Mood", "Point2D", "TagValue", "ZCodecPayload", +] +__all__ += [ "GetSpecimenRow", "GetTaggedItemRow", "InsertSpecimenRow", @@ -42,6 +112,8 @@ "ListSpecimensByMoodsRow", "ListSpecimensKeywordColumnRow", "SearchSpecimensRow", +] +__all__ += [ "bump_specimen_revision", "get_specimen", "get_tagged_item", @@ -53,5 +125,7 @@ "list_specimens_by_moods", "list_specimens_keyword_column", "search_specimens", +] +__all__ += [ "register_types", ] diff --git a/tests/test_generated.py b/tests/test_generated.py index d788a25..9eb4f97 100644 --- a/tests/test_generated.py +++ b/tests/test_generated.py @@ -11,8 +11,8 @@ from __future__ import annotations -import asyncio import ast +import asyncio import importlib import inspect import json @@ -20,8 +20,8 @@ import sys import uuid from contextlib import contextmanager -from decimal import Decimal from datetime import date +from decimal import Decimal from pathlib import Path import psycopg @@ -191,7 +191,7 @@ def _clear_client_modules() -> None: @contextmanager -def _client_modules(full_package: Path): # noqa: ANN202 - dynamic module set +def _client_modules(full_package: Path): src = str(full_package / "src") original_path = sys.path.copy() sys.path.insert(0, src) @@ -206,7 +206,7 @@ def _client_modules(full_package: Path): # noqa: ANN202 - dynamic module set @pytest.fixture -def client_modules(full_package: Path): # noqa: ANN202 - dynamic module set +def client_modules(full_package: Path): with _client_modules(full_package) as loaded: yield loaded @@ -270,11 +270,12 @@ def test_combined_public_api_identity_and_signatures(full_package: Path) -> None parent_sync = source.index("a_codec_wrapper_info = CompositeInfo.fetch") assert child_async < parent_async assert child_sync < parent_sync - assert source.count("from .types.z_codec_payload import ZCodecPayload") == 1 - assert source.count("from .types.a_codec_wrapper import ACodecWrapper") == 1 + assert source.count("from . import types as _db_types") == 1 + assert "_dataclass_callbacks(_db_types.ZCodecPayload)" in source + assert "_dataclass_callbacks(_db_types.ACodecWrapper)" in source -def test_roundtrip_type_mappings(client_modules, roundtrip_db: str) -> None: # noqa: ANN001 +def test_roundtrip_type_mappings(client_modules, roundtrip_db: str) -> None: """INSERT then SELECT through the generated client, asserting the mappings. Exercises every generated statement and the full type surface: enum param + @@ -442,9 +443,7 @@ async def scenario() -> None: # jsonb containment param + the literal-`%` ILIKE branch (title_like=None). search_all = await facade.search_specimens(conn, title_like=None, meta_filter={}, label=None) assert [r.id for r in search_all] == [specimen_id] - search_hit = await facade.search_specimens( - conn, title_like="alp%", meta_filter={}, label="specimen" - ) + search_hit = await facade.search_specimens(conn, title_like="alp%", meta_filter={}, label="specimen") assert [r.id for r in search_hit] == [specimen_id] assert isinstance(search_hit[0].meta, dict) @@ -501,15 +500,11 @@ def test_statement_modules_use_psycopg_row_factories(generated_tree: Path) -> No row_factory_calls = [ node for node in ast.walk(tree) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == "_args_row" + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "_args_row" ] assert len(row_factory_calls) == 2 assert all( - len(call.args) == 1 - and isinstance(call.args[0], ast.Name) - and call.args[0].id == row_name + len(call.args) == 1 and isinstance(call.args[0], ast.Name) and call.args[0].id == row_name for call in row_factory_calls ) @@ -523,7 +518,7 @@ def test_statement_modules_use_psycopg_row_factories(generated_tree: Path) -> No assert "from .mood import Mood" in wrapper_source -def test_custom_rows_use_qualified_annotations(client_modules) -> None: # noqa: ANN001 +def test_custom_rows_use_qualified_annotations(client_modules) -> None: _, _, import_module = client_modules statement = import_module("specimen_client._generated.statements.insert_specimen") source = Path(statement.__file__).read_text() @@ -534,11 +529,11 @@ def test_custom_rows_use_qualified_annotations(client_modules) -> None: # noqa: assert "codec_payload: _db_types.ZCodecPayload" in source assert "codec_payloads: list[_db_types.ZCodecPayload | None]" in source assert "_args_row(InsertSpecimenRow)" in source - assert '.pg_decode(' not in source - assert '.pg_encode(' not in source + assert ".pg_decode(" not in source + assert ".pg_encode(" not in source -def test_roundtrip_sync_surface(client_modules, roundtrip_db: str) -> None: # noqa: ANN001 +def test_roundtrip_sync_surface(client_modules, roundtrip_db: str) -> None: """Drive the additive sync public facade end to end.""" _apply_migrations(roundtrip_db) _, facade, _ = client_modules @@ -638,7 +633,7 @@ def test_roundtrip_sync_surface(client_modules, roundtrip_db: str) -> None: # n conn.close() -def test_roundtrip_single_field_composite(client_modules, roundtrip_db: str) -> None: # noqa: ANN001 +def test_roundtrip_single_field_composite(client_modules, roundtrip_db: str) -> None: """Regression test for compositeBind on a one-field composite. concatMapSep joins a single-element field list with no separator, so an @@ -672,7 +667,7 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_roundtrip_single_field_composite_sync(client_modules, roundtrip_db: str) -> None: # noqa: ANN001 +def test_roundtrip_single_field_composite_sync(client_modules, roundtrip_db: str) -> None: """Sync public-facade counterpart of the one-field composite regression.""" _apply_migrations(roundtrip_db) _, facade, _ = client_modules diff --git a/tests/test_generated_quality.py b/tests/test_generated_quality.py new file mode 100644 index 0000000..ed1300e --- /dev/null +++ b/tests/test_generated_quality.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +from tests._generated_quality import generated_line_length_issues + +REPO_ROOT = Path(__file__).resolve().parents[1] +RUFF_CONFIG = REPO_ROOT / "pyproject.toml" +GENERATED_MARKER = "# @generated by python.gen (pGenie); regeneration overwrites manual changes." + + +def _generated_python(root: Path) -> list[Path]: + return sorted(root.rglob("*.py")) + + +def test_fresh_generated_python_passes_ruff(full_package: Path) -> None: + generated_src = full_package / "src" + for command in (("check",), ("format", "--check")): + result = subprocess.run( + ["ruff", *command, "--config", str(RUFF_CONFIG), str(generated_src)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"ruff {' '.join(command)} failed:\n{result.stdout}{result.stderr}" + + +def test_fresh_generated_python_has_exact_marker_and_bounded_authored_lines(full_package: Path) -> None: + paths = _generated_python(full_package / "src") + assert paths, "fresh generation produced no Python files" + + authored = [] + for path in paths: + assert path.read_text().splitlines()[0] == GENERATED_MARKER + path_authored, _ = generated_line_length_issues(path) + authored.extend(path_authored) + + assert not authored, "generated authored lines exceed 120 characters:\n" + "\n".join( + issue.label() for issue in authored + ) + + +def test_line_length_helper_excludes_only_module_sql_token_interiors(tmp_path: Path) -> None: + path = tmp_path / "sample.py" + sql_body = "S" * 121 + other_body = "O" * 121 + python_value = "P" * 121 + _ = path.write_text(f'SQL = """\\\n{sql_body}\n"""\nOTHER = """\\\n{other_body}\n"""\nvalue = {python_value!r}\n') + + authored, sql = generated_line_length_issues(path) + + assert [issue.line_number for issue in sql] == [2] + assert [issue.line_number for issue in authored] == [5, 7] diff --git a/tests/test_identifier_collisions.py b/tests/test_identifier_collisions.py index c9ea4fa..951780d 100644 --- a/tests/test_identifier_collisions.py +++ b/tests/test_identifier_collisions.py @@ -4,8 +4,8 @@ from __future__ import annotations -import asyncio import ast +import asyncio import importlib import json import shutil @@ -59,11 +59,7 @@ def _fresh_project(tmp_path: Path) -> tuple[Path, Path, str, str]: queries = { "cast.sql": "SELECT 1::int8 AS value\n", "require_array.sql": "SELECT ARRAY['happy'::mood] AS moods\n", - "fetch_many.sql": ( - "SELECT value\n" - "FROM (VALUES (1::int8), (2::int8)) AS rows(value)\n" - "ORDER BY value\n" - ), + "fetch_many.sql": ("SELECT value\nFROM (VALUES (1::int8), (2::int8)) AS rows(value)\nORDER BY value\n"), "date.sql": "SELECT DATE '2026-07-13' AS value\n", } for name, sql in queries.items(): @@ -72,12 +68,10 @@ def _fresh_project(tmp_path: Path) -> tuple[Path, Path, str, str]: return root, project, package_name, import_name -def _assert_private_query_canaries( - root: Path, project: Path, pgn_bin: str, pgn_admin_url: str -) -> None: +def _assert_private_query_canaries(root: Path, project: Path, pgn_bin: str, pgn_admin_url: str) -> None: # pgn rejects leading-underscore query filenames before invoking a generator, # so exercise those policy inputs through a pgn-executed synthetic generator. - wrapper = r''' + wrapper = r""" let Lude = ./Deps/Lude.dhall let Model = ./Deps/Contract.dhall @@ -129,7 +123,7 @@ def _assert_private_query_canaries( ) in Sdk.Sigs.generator Config Config/default run -''' +""" _ = (root / "src" / "query-safe-name-probe.dhall").write_text(wrapper) canary = shutil.copytree(project, root / "tests" / "query-safe-name-project") @@ -182,8 +176,7 @@ def _scratch_database(admin_url: str) -> Iterator[str]: admin = psycopg.connect(admin_url, autocommit=True) try: terminate = ( - "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " - "WHERE datname = %s AND pid <> pg_backend_pid()" + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = %s AND pid <> pg_backend_pid()" ) _ = admin.execute(terminate.encode(), (name,)) ensure_droppable(name) @@ -215,9 +208,14 @@ def _facade_statement_exports(facade: Path, *, level: int, function_suffix: str) if node.level != level or not node.module.startswith(prefix): continue module_name = node.module.removeprefix(prefix) - query_import = node.names[0] - assert query_import.name == f"{module_name}{function_suffix}" + expected_name = f"{module_name}{function_suffix}" + query_imports = [alias for alias in node.names if alias.name == expected_name] + if not query_imports: + continue + assert len(query_imports) == 1 + query_import = query_imports[0] assert query_import.asname == module_name + assert module_name not in exports exports[module_name] = query_import.asname return exports @@ -235,18 +233,14 @@ def _assert_statement_structure(statements: Path) -> None: if isinstance(node, ast.ImportFrom) and node.level == 2 and node.module == "_runtime" ] assert len(async_runtime_imports) == 1 - assert [(alias.name, alias.asname) for alias in async_runtime_imports[0].names] == [ - (helper, f"_{helper}") - ] + assert [(alias.name, alias.asname) for alias in async_runtime_imports[0].names] == [(helper, f"_{helper}")] sync_runtime_imports = [ node for node in tree.body if isinstance(node, ast.ImportFrom) and node.level == 2 and node.module == "sync._runtime" ] assert len(sync_runtime_imports) == 1 - assert [(alias.name, alias.asname) for alias in sync_runtime_imports[0].names] == [ - (helper, f"_{helper}_sync") - ] + assert [(alias.name, alias.asname) for alias in sync_runtime_imports[0].names] == [(helper, f"_{helper}_sync")] assert f"from .._runtime import {helper} as _{helper}" in source assert f"from ..sync._runtime import {helper} as _{helper}_sync" in source row_classes = [node.name for node in tree.body if isinstance(node, ast.ClassDef)] @@ -297,9 +291,7 @@ def _assert_strict(package_src: Path, tmp_path: Path) -> None: ) -def test_identifier_collisions_roundtrip( - pgn_bin: str, pgn_admin_url: str, tmp_path: Path -) -> None: +def test_identifier_collisions_roundtrip(pgn_bin: str, pgn_admin_url: str, tmp_path: Path) -> None: root, project, package_name, import_name = _fresh_project(tmp_path) _assert_private_query_canaries(root, project, pgn_bin, pgn_admin_url) result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") @@ -310,12 +302,8 @@ def test_identifier_collisions_roundtrip( statements = package_src / "_generated" / "statements" assert package_src.is_dir(), f"pgn did not generate package {package_name!r}" _assert_statement_structure(statements) - facade_exports = _facade_statement_exports( - package_src / "__init__.py", level=1, function_suffix="" - ) - sync_exports = _facade_statement_exports( - package_src / "sync" / "__init__.py", level=2, function_suffix="_sync" - ) + facade_exports = _facade_statement_exports(package_src / "__init__.py", level=1, function_suffix="") + sync_exports = _facade_statement_exports(package_src / "sync" / "__init__.py", level=2, function_suffix="_sync") expected_exports = {name: name for name in EXPECTED_FUNCTIONS} assert facade_exports == expected_exports assert sync_exports == expected_exports diff --git a/tests/test_psycopg_adapter_contract.py b/tests/test_psycopg_adapter_contract.py index 2a8ea70..e9484fd 100644 --- a/tests/test_psycopg_adapter_contract.py +++ b/tests/test_psycopg_adapter_contract.py @@ -27,7 +27,6 @@ register_adapter_types_sync, ) - REPO_ROOT = Path(__file__).resolve().parents[1] STRICT_FIXTURE = REPO_ROOT / "tests" / "typing" / "psycopg_adapter_runtime.py" SCHEMA_SQL: LiteralString = """ diff --git a/tests/test_unsupported_types.py b/tests/test_unsupported_types.py index 7049194..bb24bf9 100644 --- a/tests/test_unsupported_types.py +++ b/tests/test_unsupported_types.py @@ -103,9 +103,7 @@ def test_json_array_param_fails_loudly(pgn_bin: str, pgn_admin_url: str, tmp_pat # A jsonb[] param has no faithful psycopg bind (Jsonb wraps a scalar, not # element-wise); the generator must reject it, not emit an unwrapped list. - _ = (project / "queries" / "probe_json_array.sql").write_text( - "SELECT cardinality($payloads::jsonb []) AS n\n" - ) + _ = (project / "queries" / "probe_json_array.sql").write_text("SELECT cardinality($payloads::jsonb []) AS n\n") result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") @@ -132,16 +130,9 @@ def _write_contract_probe( lookup = { "absent": "CustomKind.TypeKind.Absent", "enum": "CustomKind.TypeKind.Enum 0", - "composite": ( - "CustomKind.TypeKind.Composite " - "{ fields = [] : List CustomKind.CompositeField, order = 0 }" - ), + "composite": ("CustomKind.TypeKind.Composite { fields = [] : List CustomKind.CompositeField, order = 0 }"), }[lookup_kind] - target_input = ( - "customType" - if nested - else "member" - ) + target_input = "customType" if nested else "member" custom_type = ( """ let customType @@ -388,9 +379,7 @@ def test_custom_shape_contracts_succeed( assert result.returncode == 0, f"expected {case_id} to succeed:\n{_combined_output(result)}" -def test_skip_unsupported_drops_offending_units_and_cascades( - pgn_bin: str, pgn_admin_url: str, tmp_path: Path -) -> None: +def test_skip_unsupported_drops_offending_units_and_cascades(pgn_bin: str, pgn_admin_url: str, tmp_path: Path) -> None: """onUnsupported: Skip drops only the smallest self-consistent unit. Three independent failures in one project: a money result column and a @@ -420,12 +409,8 @@ def test_skip_unsupported_drops_offending_units_and_cascades( _write_single_artifact(project, "../../src/package.dhall", "skip-cascade", "Skip") _ = (project / "queries" / "probe_unsupported.sql").write_text("SELECT 1::money AS amount\n") - _ = (project / "queries" / "probe_json_array.sql").write_text( - "SELECT cardinality($payloads::jsonb []) AS n\n" - ) - _ = (project / "queries" / "probe_custom_only.sql").write_text( - "SELECT 'happy'::mood AS feeling\n" - ) + _ = (project / "queries" / "probe_json_array.sql").write_text("SELECT cardinality($payloads::jsonb []) AS n\n") + _ = (project / "queries" / "probe_custom_only.sql").write_text("SELECT 'happy'::mood AS feeling\n") _ = (project / "migrations" / "2.sql").write_text( "create type z_bad_leaf as (\n" " feelings mood[]\n" @@ -444,9 +429,7 @@ def test_skip_unsupported_drops_offending_units_and_cascades( " wrapped a_bad_grandparent not null\n" ");\n" ) - _ = (project / "queries" / "probe_nested_composite.sql").write_text( - "SELECT wrapped FROM nested_probe LIMIT 1\n" - ) + _ = (project / "queries" / "probe_nested_composite.sql").write_text("SELECT wrapped FROM nested_probe LIMIT 1\n") result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") assert result.returncode == 0, f"Skip mode must still succeed:\n{result.stdout}\n{result.stderr}" @@ -473,7 +456,7 @@ def test_skip_unsupported_drops_offending_units_and_cascades( for name in ("z_bad_leaf", "m_bad_parent", "a_bad_grandparent"): assert not (src / "types" / f"{name}.py").exists(), f"{name} should have been skipped" - for name in kept_statements + ["probe_custom_only"]: + for name in [*kept_statements, "probe_custom_only"]: assert (src / "statements" / f"{name}.py").is_file(), f"{name} should not have been skipped" for name in ("a_codec_wrapper", "mood", "point_2_d", "tag_value", "z_codec_payload"): assert (src / "types" / f"{name}.py").is_file(), f"{name} should not have been skipped" @@ -545,13 +528,9 @@ def test_statement_custom_types_use_one_qualified_namespace_import() -> None: namespace_import = "from .. import types as _db_types" for module in statements.glob("*.py"): source = module.read_text() - assert not re.search(r"^from \.\.types\.", source, re.MULTILINE), ( - f"direct custom type import in {module}" - ) + assert not re.search(r"^from \.\.types\.", source, re.MULTILINE), f"direct custom type import in {module}" if "_db_types." in source: - assert source.count(namespace_import) == 1, ( - f"expected one custom type namespace import in {module}" - ) + assert source.count(namespace_import) == 1, f"expected one custom type namespace import in {module}" insert_specimen = (statements / "insert_specimen.py").read_text() for annotation in ( diff --git a/tests/typing/psycopg_adapter_runtime.py b/tests/typing/psycopg_adapter_runtime.py index 35dfa4a..5562b24 100644 --- a/tests/typing/psycopg_adapter_runtime.py +++ b/tests/typing/psycopg_adapter_runtime.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, fields, is_dataclass from enum import StrEnum -from typing import Any, LiteralString, TypeVar +from typing import Any, LiteralString from psycopg import AsyncConnection, Connection from psycopg.rows import BaseRowFactory, args_row @@ -67,9 +67,8 @@ class SanitizedRow: value: Leaf -_T = TypeVar("_T") -_ObjectMaker = Callable[[Sequence[Any], CompositeInfo], _T] -_SequenceMaker = Callable[[_T, CompositeInfo], Sequence[Any]] +type _ObjectMaker[T] = Callable[[Sequence[Any], CompositeInfo], T] +type _SequenceMaker[T] = Callable[[T, CompositeInfo], Sequence[Any]] def _python_name(name: str) -> str: @@ -79,20 +78,20 @@ def _python_name(name: str) -> str: return name -def dataclass_callbacks(cls: type[_T]) -> tuple[_ObjectMaker[_T], _SequenceMaker[_T]]: +def dataclass_callbacks[T](cls: type[T]) -> tuple[_ObjectMaker[T], _SequenceMaker[T]]: if not is_dataclass(cls): raise TypeError(f"{cls.__name__} must be a dataclass") model_fields = fields(cls) model_names = tuple(field.name for field in model_fields) - def make_object(values: Sequence[Any], info: CompositeInfo) -> _T: + def make_object(values: Sequence[Any], info: CompositeInfo) -> T: names = tuple(_python_name(name) for name in info.field_names) assert names == model_names assert len(values) == len(model_fields) return cls(**dict(zip(names, values, strict=True))) - def make_sequence(obj: _T, info: CompositeInfo) -> Sequence[Any]: + def make_sequence(obj: T, info: CompositeInfo) -> Sequence[Any]: names = tuple(_python_name(name) for name in info.field_names) assert names == model_names return tuple(getattr(obj, field.name) for field in model_fields) @@ -173,12 +172,12 @@ def register_adapter_types_sync(conn: Connection[object]) -> None: assert wrapper_info.python_type is Wrapper -async def fetch_one_async( +async def fetch_one_async[T]( conn: AsyncConnection[object], sql: LiteralString, params: Mapping[str, object], - row_factory: BaseRowFactory[_T], -) -> _T: + row_factory: BaseRowFactory[T], +) -> T: async with conn.cursor(row_factory=row_factory) as cursor: await cursor.execute(sql, params) row = await cursor.fetchone() @@ -187,12 +186,12 @@ async def fetch_one_async( return row -def fetch_one_sync( +def fetch_one_sync[T]( conn: Connection[object], sql: LiteralString, params: Mapping[str, object], - row_factory: BaseRowFactory[_T], -) -> _T: + row_factory: BaseRowFactory[T], +) -> T: with conn.cursor(row_factory=row_factory) as cursor: cursor.execute(sql, params) row = cursor.fetchone() @@ -203,22 +202,12 @@ def fetch_one_sync( async def prove_async_row_inference(conn: AsyncConnection[object]) -> None: enum_row: EnumRow = await fetch_one_async(conn, "SELECT NULL", {}, args_row(EnumRow)) - enum_array_row: EnumArrayRow = await fetch_one_async( - conn, "SELECT NULL", {}, args_row(EnumArrayRow) - ) - enum_grid_row: EnumGridRow = await fetch_one_async( - conn, "SELECT NULL", {}, args_row(EnumGridRow) - ) + enum_array_row: EnumArrayRow = await fetch_one_async(conn, "SELECT NULL", {}, args_row(EnumArrayRow)) + enum_grid_row: EnumGridRow = await fetch_one_async(conn, "SELECT NULL", {}, args_row(EnumGridRow)) leaf_row: LeafRow = await fetch_one_async(conn, "SELECT NULL", {}, args_row(LeafRow)) - leaf_array_row: LeafArrayRow = await fetch_one_async( - conn, "SELECT NULL", {}, args_row(LeafArrayRow) - ) - wrapper_row: WrapperRow = await fetch_one_async( - conn, "SELECT NULL", {}, args_row(WrapperRow) - ) - sanitized_row: SanitizedRow = await fetch_one_async( - conn, "SELECT NULL, NULL", {}, args_row(SanitizedRow) - ) + leaf_array_row: LeafArrayRow = await fetch_one_async(conn, "SELECT NULL", {}, args_row(LeafArrayRow)) + wrapper_row: WrapperRow = await fetch_one_async(conn, "SELECT NULL", {}, args_row(WrapperRow)) + sanitized_row: SanitizedRow = await fetch_one_async(conn, "SELECT NULL, NULL", {}, args_row(SanitizedRow)) _ = ( enum_row, enum_array_row, @@ -237,9 +226,7 @@ def prove_sync_row_inference(conn: Connection[object]) -> None: leaf_row: LeafRow = fetch_one_sync(conn, "SELECT NULL", {}, args_row(LeafRow)) leaf_array_row: LeafArrayRow = fetch_one_sync(conn, "SELECT NULL", {}, args_row(LeafArrayRow)) wrapper_row: WrapperRow = fetch_one_sync(conn, "SELECT NULL", {}, args_row(WrapperRow)) - sanitized_row: SanitizedRow = fetch_one_sync( - conn, "SELECT NULL, NULL", {}, args_row(SanitizedRow) - ) + sanitized_row: SanitizedRow = fetch_one_sync(conn, "SELECT NULL, NULL", {}, args_row(SanitizedRow)) _ = ( enum_row, enum_array_row, diff --git a/uv.lock b/uv.lock index 3bd32d6..8329095 100644 --- a/uv.lock +++ b/uv.lock @@ -67,6 +67,7 @@ dev = [ { name = "basedpyright" }, { name = "psycopg", extra = ["binary"] }, { name = "pytest" }, + { name = "ruff" }, ] [package.metadata] @@ -76,6 +77,7 @@ dev = [ { name = "basedpyright", specifier = "==1.39.7" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3,<4" }, { name = "pytest", specifier = ">=8" }, + { name = "ruff", specifier = "==0.15.20" }, ] [[package]] @@ -170,6 +172,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From 5ac6d495d4adf222a62c79cb0c20cc0722edd82d Mon Sep 17 00:00:00 2001 From: Viacheslav Shvets Date: Mon, 13 Jul 2026 07:24:00 +0300 Subject: [PATCH 31/41] test(gen): lock takeover-ready output contracts --- tests/conftest.py | 2 +- tests/test_takeover_contract.py | 582 ++++++++++++++++++++++++++++++++ 2 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 tests/test_takeover_contract.py diff --git a/tests/conftest.py b/tests/conftest.py index 78eed32..e34a02f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,7 +43,7 @@ def pgn_bin() -> str: out = subprocess.run(["mise", "which", "pgn"], cwd=HERE, capture_output=True, text=True) path = out.stdout.strip() if out.returncode != 0 or not path: - pytest.skip("pgn binary not resolvable via mise") + pytest.fail("pgn 0.9.1 is required but is not resolvable via PATH or mise") return path diff --git a/tests/test_takeover_contract.py b/tests/test_takeover_contract.py new file mode 100644 index 0000000..593fcbf --- /dev/null +++ b/tests/test_takeover_contract.py @@ -0,0 +1,582 @@ +from __future__ import annotations + +import ast +import asyncio +import importlib +import inspect +import sys +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from datetime import date +from decimal import Decimal +from pathlib import Path +from types import ModuleType + +import psycopg + +from tests._generated_quality import generated_line_length_issues +from tests._harness import FIXTURE_PROJECT + +PACKAGE = Path("src/specimen_client") +STATEMENTS = frozenset( + { + "bump_specimen_revision", + "get_specimen", + "get_tagged_item", + "insert_specimen", + "insert_tagged_item", + "list_specimens_by_class", + "list_specimens_by_feeling", + "list_specimens_by_ids", + "list_specimens_by_moods", + "list_specimens_keyword_column", + "search_specimens", + } +) +ROWS = { + "get_specimen": "GetSpecimenRow", + "get_tagged_item": "GetTaggedItemRow", + "insert_specimen": "InsertSpecimenRow", + "insert_tagged_item": "InsertTaggedItemRow", + "list_specimens_by_class": "ListSpecimensByClassRow", + "list_specimens_by_feeling": "ListSpecimensByFeelingRow", + "list_specimens_by_ids": "ListSpecimensByIdsRow", + "list_specimens_by_moods": "ListSpecimensByMoodsRow", + "list_specimens_keyword_column": "ListSpecimensKeywordColumnRow", + "search_specimens": "SearchSpecimensRow", +} +CUSTOM_MODULES = { + "ACodecWrapper": "a_codec_wrapper", + "Mood": "mood", + "Point2D": "point_2_d", + "TagValue": "tag_value", + "ZCodecPayload": "z_codec_payload", +} +CORE_TYPES = ("JsonValue", "NoRowError") +MARKER = "# @generated by python.gen (pGenie); regeneration overwrites manual changes." +SPDX = ( + "# SPDX-FileCopyrightText: 2026 Viacheslav Shvets", + "# SPDX-License-Identifier: MIT-0", +) + + +def _generated_package(generated_tree: Path) -> Path: + return generated_tree / PACKAGE + + +def _python_paths(generated_tree: Path) -> list[Path]: + return sorted(_generated_package(generated_tree).rglob("*.py")) + + +def _statement_paths(generated_tree: Path) -> dict[str, Path]: + statements = _generated_package(generated_tree) / "_generated" / "statements" + return {path.stem: path for path in statements.glob("*.py") if path.name != "__init__.py"} + + +def _parse(path: Path) -> ast.Module: + return ast.parse(path.read_text(), filename=str(path)) + + +def _sql_assignments(tree: ast.Module) -> list[ast.expr]: + assignments: list[ast.expr] = [] + for node in tree.body: + if isinstance(node, ast.Assign): + if any(isinstance(target, ast.Name) and target.id == "SQL" for target in node.targets): + assignments.append(node.value) + elif ( + isinstance(node, ast.AnnAssign) + and isinstance(node.target, ast.Name) + and node.target.id == "SQL" + and node.value is not None + ): + assignments.append(node.value) + return assignments + + +def _top_function(tree: ast.Module, name: str) -> ast.FunctionDef | ast.AsyncFunctionDef: + matches = [ + node for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name + ] + assert len(matches) == 1, f"expected one top-level function {name}, found {len(matches)}" + return matches[0] + + +def _call_leaf(call: ast.Call) -> str | None: + if isinstance(call.func, ast.Name): + return call.func.id + if isinstance(call.func, ast.Attribute): + return call.func.attr + return None + + +def _args_row_calls(node: ast.AST) -> list[ast.Call]: + return [ + child + for child in ast.walk(node) + if isinstance(child, ast.Call) and isinstance(child.func, ast.Name) and child.func.id == "_args_row" + ] + + +def _annotations(tree: ast.Module) -> Iterator[ast.expr]: + for node in ast.walk(tree): + if isinstance(node, (ast.arg, ast.AnnAssign)) and node.annotation is not None: + yield node.annotation + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.returns is not None: + yield node.returns + + +def _remove_client_modules() -> None: + for name in list(sys.modules): + if name == "specimen_client" or name.startswith("specimen_client."): + del sys.modules[name] + + +@contextmanager +def _client_modules( + full_package: Path, +) -> Iterator[tuple[ModuleType, ModuleType, Callable[[str], ModuleType]]]: + original_path = sys.path.copy() + previous = { + name: module + for name, module in sys.modules.items() + if name == "specimen_client" or name.startswith("specimen_client.") + } + sys.path.insert(0, str(full_package / "src")) + _remove_client_modules() + try: + root = importlib.import_module("specimen_client") + sync = importlib.import_module("specimen_client.sync") + yield root, sync, importlib.import_module + finally: + _remove_client_modules() + sys.modules.update(previous) + sys.path[:] = original_path + + +def _apply_migrations(db_url: str) -> None: + migrations = sorted((FIXTURE_PROJECT / "migrations").glob("*.sql"), key=lambda path: int(path.stem)) + assert migrations, "fixture migrations are required for the registration canary" + with psycopg.connect(db_url, autocommit=True) as conn: + for migration in migrations: + _ = conn.execute(migration.read_text().encode()) + + +def test_canonical_fresh_tree(generated_tree: Path) -> None: + generated_src = generated_tree / "src" + packages = sorted(path.name for path in generated_src.iterdir() if path.is_dir()) + assert packages == ["specimen_client"] + + package = _generated_package(generated_tree) + generated = package / "_generated" + assert set(_statement_paths(generated_tree)) == STATEMENTS + assert (package / "__init__.py").is_file() + assert (package / "sync" / "__init__.py").is_file() + assert [path.relative_to(package) for path in package.rglob("_register.py")] == [Path("_generated/_register.py")] + assert [path.relative_to(package) for path in package.rglob("types") if path.is_dir()] == [Path("_generated/types")] + + sync_generated = generated / "sync" + for relative in (Path("statements"), Path("types"), Path("_register.py")): + assert not (sync_generated / relative).exists() + assert not list(generated_tree.rglob("golden_sync")) + assert not list(package.rglob("_rows.py")) + + +def test_statement_ast_contract(generated_tree: Path) -> None: + paths = _statement_paths(generated_tree) + assert set(paths) == STATEMENTS + + for stem in sorted(STATEMENTS): + tree = _parse(paths[stem]) + sql = _sql_assignments(tree) + assert len(sql) == 1 + assert isinstance(sql[0], ast.Constant) and isinstance(sql[0].value, str) + + async_function = _top_function(tree, stem) + sync_function = _top_function(tree, f"{stem}_sync") + assert isinstance(async_function, ast.AsyncFunctionDef) + assert isinstance(sync_function, ast.FunctionDef) + + expected_row = ROWS.get(stem) + row_classes = [node for node in tree.body if isinstance(node, ast.ClassDef) and node.name.endswith("Row")] + assert [node.name for node in row_classes] == ([] if expected_row is None else [expected_row]) + + imports = [ + alias + for node in tree.body + if isinstance(node, ast.ImportFrom) and node.module == "psycopg.rows" and node.level == 0 + for alias in node.names + if alias.name == "args_row" and alias.asname == "_args_row" + ] + expected_count = int(expected_row is not None) + assert len(imports) == expected_count + if expected_row is None: + assert not _args_row_calls(tree) + continue + + row_class = row_classes[0] + assert len(row_class.decorator_list) == 1 + decorator = row_class.decorator_list[0] + assert isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Name) + assert decorator.func.id == "dataclass" and not decorator.args + assert {keyword.arg: ast.literal_eval(keyword.value) for keyword in decorator.keywords} == { + "frozen": True, + "slots": True, + } + + for function in (async_function, sync_function): + calls = _args_row_calls(function) + assert len(calls) == 1 + assert len(calls[0].args) == 1 and not calls[0].keywords + assert isinstance(calls[0].args[0], ast.Name) and calls[0].args[0].id == expected_row + returns = [node for node in function.body if isinstance(node, ast.Return)] + assert len(returns) == 1 and returns[0].value is not None + returned = returns[0].value.value if isinstance(returns[0].value, ast.Await) else returns[0].value + assert isinstance(returned, ast.Call) and calls[0] in returned.args + + +def test_adapter_strategy_ast_contract(generated_tree: Path) -> None: + forbidden_functions = {"_decode_row", "pg_decode", "pg_encode", "_cast", "model_dump", "model_validate"} + forbidden_calls = forbidden_functions | {"cast", "parse_obj"} + + for path in _python_paths(generated_tree): + tree = _parse(path) + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + assert node.name not in forbidden_functions and not node.name.startswith("decode_") + elif isinstance(node, ast.Call): + leaf = _call_leaf(node) + assert leaf not in forbidden_calls and not (leaf or "").startswith("decode_") + elif isinstance(node, ast.ImportFrom) and node.module == "typing": + assert all(alias.name != "cast" for alias in node.names) + + for stem, path in _statement_paths(generated_tree).items(): + tree = _parse(path) + row_imports = [ + (alias.name, alias.asname) + for node in tree.body + if isinstance(node, ast.ImportFrom) and node.module == "psycopg.rows" and node.level == 0 + for alias in node.names + ] + assert row_imports == ([] if stem == "bump_specimen_revision" else [("args_row", "_args_row")]) + + custom_references = [ + node + for annotation in _annotations(tree) + for node in ast.walk(annotation) + if isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id == "_db_types" + and node.attr in CUSTOM_MODULES + ] + bare_custom = [ + node + for annotation in _annotations(tree) + for node in ast.walk(annotation) + if isinstance(node, ast.Name) and node.id in CUSTOM_MODULES + ] + assert not bare_custom + + namespace_imports = [ + alias + for node in tree.body + if isinstance(node, ast.ImportFrom) and node.level == 2 and node.module is None + for alias in node.names + if alias.name == "types" and alias.asname == "_db_types" + ] + assert len(namespace_imports) == int(bool(custom_references)) + direct_leaf_imports = [ + node + for node in tree.body + if isinstance(node, ast.ImportFrom) + and node.level > 0 + and (node.module == "types" or (node.module or "").startswith("types.")) + ] + assert not direct_leaf_imports + + +def test_public_facade_identities_and_signatures(full_package: Path) -> None: + with _client_modules(full_package) as (root, sync, import_module): + assert root.sync is sync + for stem in sorted(STATEMENTS): + statement = import_module(f"specimen_client._generated.statements.{stem}") + async_function = getattr(root, stem) + sync_function = getattr(sync, stem) + assert async_function is getattr(statement, stem) + assert sync_function is getattr(statement, f"{stem}_sync") + assert inspect.iscoroutinefunction(async_function) + assert not inspect.iscoroutinefunction(sync_function) + + async_signature = inspect.signature(async_function) + sync_signature = inspect.signature(sync_function) + assert async_signature.return_annotation == sync_signature.return_annotation + async_parameters = list(async_signature.parameters.values()) + sync_parameters = list(sync_signature.parameters.values()) + assert len(async_parameters) == len(sync_parameters) + for index, (async_parameter, sync_parameter) in enumerate( + zip(async_parameters, sync_parameters, strict=True) + ): + assert async_parameter.name == sync_parameter.name + assert async_parameter.kind == sync_parameter.kind + assert async_parameter.default == sync_parameter.default + if index == 0: + assert async_parameter.annotation == "AsyncConnection[object]" + assert sync_parameter.annotation == "Connection[object]" + else: + assert async_parameter.annotation == sync_parameter.annotation + + row_name = ROWS.get(stem) + if row_name is not None: + canonical_row = getattr(statement, row_name) + assert getattr(root, row_name) is canonical_row + assert getattr(sync, row_name) is canonical_row + + core = import_module("specimen_client._generated._core") + for name in CORE_TYPES: + assert getattr(root, name) is getattr(sync, name) is getattr(core, name) + for name, leaf in CUSTOM_MODULES.items(): + module = import_module(f"specimen_client._generated.types.{leaf}") + assert getattr(root, name) is getattr(sync, name) is getattr(module, name) + + +def test_generated_import_allowlist(generated_tree: Path) -> None: + allowed_roots = sys.stdlib_module_names | {"__future__", "psycopg", "specimen_client"} + violations: list[str] = [] + + for path in _python_paths(generated_tree): + tree = _parse(path) + importlib_aliases: set[str] = set() + import_module_aliases: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + root = alias.name.partition(".")[0] + if root not in allowed_roots: + violations.append(f"{path}: absolute import {alias.name}") + if alias.name == "importlib": + importlib_aliases.add(alias.asname or alias.name) + elif isinstance(node, ast.ImportFrom) and node.level == 0: + root = (node.module or "").partition(".")[0] + if root not in allowed_roots: + violations.append(f"{path}: absolute import from {node.module}") + if node.module == "importlib": + import_module_aliases.update( + alias.asname or alias.name for alias in node.names if alias.name == "import_module" + ) + + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if isinstance(node.func, ast.Name) and node.func.id == "__import__": + violations.append(f"{path}:{node.lineno}: dynamic __import__") + if isinstance(node.func, ast.Name) and node.func.id in import_module_aliases: + violations.append(f"{path}:{node.lineno}: dynamic import_module") + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "import_module" + and isinstance(node.func.value, ast.Name) + and node.func.value.id in importlib_aliases + ): + violations.append(f"{path}:{node.lineno}: dynamic importlib.import_module") + + assert not violations, "generated import boundary violations:\n" + "\n".join(violations) + + +def test_registration_static_and_api_contract(generated_tree: Path, full_package: Path) -> None: + package = _generated_package(generated_tree) + register_path = package / "_generated" / "_register.py" + assert list(package.rglob("_register.py")) == [register_path] + assert not (package / "_generated" / "sync" / "_register.py").exists() + + tree = _parse(register_path) + metadata: dict[str, str] = {} + callback_types: list[str] = [] + for node in tree.body: + if isinstance(node, ast.Assign) and len(node.targets) == 1: + target = node.targets[0] + if isinstance(target, ast.Name) and target.id.endswith("_pg_name"): + assert isinstance(node.value, ast.Constant) and isinstance(node.value.value, str) + metadata[target.id] = node.value.value + if ( + isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Name) + and node.value.func.id == "_dataclass_callbacks" + ): + argument = node.value.args[0] + assert isinstance(argument, ast.Attribute) and isinstance(argument.value, ast.Name) + assert argument.value.id == "_db_types" + callback_types.append(argument.attr) + + assert metadata == { + "_mood_pg_name": "public.mood", + "_point_2_d_pg_name": "public.point2d", + "_tag_value_pg_name": "public.tag_value", + "_z_codec_payload_pg_name": "public.z_codec_payload", + "_a_codec_wrapper_pg_name": "public.a_codec_wrapper", + } + assert callback_types == ["Point2D", "TagValue", "ZCodecPayload", "ACodecWrapper"] + + async_register = _top_function(tree, "register_types") + sync_register = _top_function(tree, "register_types_sync") + assert isinstance(async_register, ast.AsyncFunctionDef) + assert isinstance(sync_register, ast.FunctionDef) + expected_fetch_order = [ + "_mood_pg_name", + "_point_2_d_pg_name", + "_tag_value_pg_name", + "_z_codec_payload_pg_name", + "_a_codec_wrapper_pg_name", + ] + for function in (async_register, sync_register): + fetch_calls = sorted( + ( + node + for node in ast.walk(function) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "fetch" + ), + key=lambda node: node.lineno, + ) + fetch_metadata = [ + call.args[1].id for call in fetch_calls if len(call.args) == 2 and isinstance(call.args[1], ast.Name) + ] + assert fetch_metadata == expected_fetch_order + + with _client_modules(full_package) as (root, sync, import_module): + register = import_module("specimen_client._generated._register") + assert root.register_types is register.register_types + assert sync.register_types is register.register_types_sync + assert "register_types" in root.__all__ + assert "register_types" in sync.__all__ + assert inspect.iscoroutinefunction(root.register_types) + assert not inspect.iscoroutinefunction(sync.register_types) + + +def test_registration_roundtrip_two_surfaces(full_package: Path, roundtrip_db: str) -> None: + _apply_migrations(roundtrip_db) + with _client_modules(full_package) as (root, sync, _): + + async def write_and_read() -> tuple[int, int, int]: + conn = await psycopg.AsyncConnection.connect(roundtrip_db, autocommit=True) + try: + await root.register_types(conn) + tagged = await root.insert_tagged_item( + conn, + name="takeover-tag", + tag=root.TagValue(value="blue"), + ) + tagged_hit = await root.get_tagged_item(conn, id=tagged.id) + assert type(tagged) is root.InsertTaggedItemRow + assert type(tagged_hit) is root.GetTaggedItemRow + assert type(tagged_hit.tag) is root.TagValue + + payload = root.ZCodecPayload(class_=71, pg_decode="takeover", pg_encode=None) + wrapper = root.ACodecWrapper( + payload=root.ZCodecPayload(class_=72, pg_decode="nested", pg_encode=None), + feeling=root.Mood.SAD, + note="canary", + ) + specimen = await root.insert_specimen( + conn, + doc_jsonb={"contract": "takeover"}, + feeling=root.Mood.HAPPY, + origin=root.Point2D(x=3.0, y=4.0), + flag=True, + small=1, + medium=2, + large=3, + ratio=0.5, + precise=0.25, + title="takeover-specimen", + code="T-1", + letter="t", + born_on=date(2026, 7, 13), + amount=Decimal("7.13"), + blob=b"takeover", + doc_json=["canary"], + maybe_text=None, + maybe_int=None, + maybe_uuid=None, + maybe_ts=None, + maybe_num=None, + tags=["contract"], + related_ids=None, + grid=None, + moods=[root.Mood.HAPPY, None], + codec_payload=payload, + codec_payloads=[payload], + codec_wrapper=wrapper, + ) + specimen_hit = await root.get_specimen(conn, id=specimen.id) + assert type(specimen) is root.InsertSpecimenRow + assert type(specimen_hit) is root.GetSpecimenRow + assert type(specimen_hit.codec_payload) is root.ZCodecPayload + assert type(specimen_hit.codec_wrapper) is root.ACodecWrapper + assert type(specimen_hit.codec_wrapper.payload) is root.ZCodecPayload + return tagged.id, specimen.id, conn.info.backend_pid + finally: + await conn.close() + + tagged_id, specimen_id, async_backend_pid = asyncio.run(write_and_read()) + with psycopg.connect(roundtrip_db, autocommit=True) as conn: + assert conn.info.backend_pid != async_backend_pid + sync.register_types(conn) + tagged_hit = sync.get_tagged_item(conn, id=tagged_id) + specimen_hit = sync.get_specimen(conn, id=specimen_id) + assert type(tagged_hit) is sync.GetTaggedItemRow is root.GetTaggedItemRow + assert type(tagged_hit.tag) is sync.TagValue is root.TagValue + assert type(specimen_hit) is sync.GetSpecimenRow is root.GetSpecimenRow + assert type(specimen_hit.codec_wrapper) is sync.ACodecWrapper is root.ACodecWrapper + assert type(specimen_hit.codec_wrapper.payload) is sync.ZCodecPayload is root.ZCodecPayload + + +def test_fresh_output_h2_budget(generated_tree: Path) -> None: + paths = _python_paths(generated_tree) + statement_paths = _statement_paths(generated_tree) + sql_count = sum(len(_sql_assignments(_parse(path))) for path in statement_paths.values()) + metrics = { + "python_files": len(paths), + "total_lines": sum(len(path.read_text().splitlines()) for path in paths), + "statement_modules": len(statement_paths), + "statement_lines": sum(len(path.read_text().splitlines()) for path in statement_paths.values()), + "sql_assignments": sql_count, + } + + assert metrics["python_files"] <= 26, metrics + assert metrics["total_lines"] <= 1500, metrics + assert metrics["statement_modules"] == 11, metrics + assert metrics["statement_lines"] <= 900, metrics + assert metrics["sql_assignments"] == 11, metrics + assert set(statement_paths) == STATEMENTS + assert not (_generated_package(generated_tree) / "_generated" / "sync" / "statements").exists() + + +def test_generated_file_quality_and_required_generator(generated_tree: Path) -> None: + paths = _python_paths(generated_tree) + authored = [] + sql = [] + for path in paths: + lines = path.read_text().splitlines() + assert lines[:3] == [MARKER, *SPDX] + path_authored, path_sql = generated_line_length_issues(path) + authored.extend(path_authored) + sql.extend(path_sql) + + assert not authored, "authored generated lines exceed 120 characters:\n" + "\n".join( + issue.label() for issue in authored + ) + assert not sql, "SQL generated lines exceed 120 characters:\n" + "\n".join(issue.label() for issue in sql) + assert not list(_generated_package(generated_tree).rglob("_rows.py")) + + bypass = "s" + "kip" + assert bypass not in Path(__file__).read_text() + conftest_tree = _parse(Path(__file__).with_name("conftest.py")) + pgn_fixture = _top_function(conftest_tree, "pgn_bin") + pytest_calls = [ + node.func.attr + for node in ast.walk(pgn_fixture) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "pytest" + ] + assert pytest_calls.count("fail") == 1 + assert bypass not in pytest_calls From 1a5de196db7d8274f13c074a78bdd08c52e080f2 Mon Sep 17 00:00:00 2001 From: Viacheslav Shvets Date: Mon, 13 Jul 2026 07:49:38 +0300 Subject: [PATCH 32/41] docs(gen): document takeover-ready clients --- .github/workflows/ci.yml | 20 +- CHANGELOG.md | 189 ++- DESIGN.md | 1052 ++++++----------- README.md | 489 ++++---- bench/as-source.sh | 30 +- bench/generate.sh | 23 +- docs/upstream-asks.md | 159 +-- fixtures/Exhaustive.dhall | 7 +- mise.toml | 12 +- src/Interpreters/Query.dhall | 16 +- .../queries/list_specimens_keyword_column.sql | 10 +- tests/golden/README.md | 58 +- .../list_specimens_keyword_column.py | 10 +- wheel/NOTICE | 2 +- wheel/pyproject.toml | 3 +- 15 files changed, 738 insertions(+), 1342 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7abd77..5d75a2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,23 +124,9 @@ jobs: with: ref: ${{ inputs.ref || github.sha }} - # 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 - # sha256 pins go with it: an as-Source pin hashes the import's source, - # not the normalized expression a plain import verifies against, so after - # the strip the pins would be checked against the wrong algorithm. - - name: Strip `as Source` and its source-hash pins for the action's dhall - shell: bash - run: | - set -euo pipefail - 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: 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. + # A plain, standard-Dhall evaluator cannot run this because the local + # Project.buildLookup uses Text/equal, a builtin from pgn's forked Dhall. + # The pinned action bundles that same fork. - name: Generate output from Dhall uses: nikita-volkov/dhall-directory-tree.github-action@60a18dc647d6daea805263ea0fed7bb8011f3bcd # v2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index ae969ef..87564c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,116 +1,79 @@ # Upcoming -- **Breaking:** `emitSync` is gone. In its place, `sync : Optional Bool` - (default `False`) picks exactly one surface per generate — async or sync — - emitted at the same unified paths either way (no more `sync/` subdirectory, - no more second package-root facade). Previously `emitSync: true` added a - second, nested sync tree alongside the always-emitted async one; a project - that needs both surfaces now generates two artifacts against this same - `gen:` with different `packageName`s, one with `sync: true` and one - without. See `docs/plans/2026-07-12-configurable-sync-output.md` for the - full rationale and migration shape. `tests/golden_sync/` is a new committed - golden fixture (`specimen_sync_client`) exercising the sync surface - end-to-end (basedpyright strict + round-trip), alongside the existing - `tests/golden/` (`specimen_client`, now async-only). +- Finalized one additive package surface. Async functions remain at the package + root for every configuration. `emitSync: true` adds `.sync`, a sync + runtime, and adjacent sync functions in the same canonical statement modules. + SQL, Row classes, custom types, core errors, and registration stay shared, so + async and sync facades expose exact model identities. -- `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 - the observed RSS. A single-artifact generate peaks ~35 GB on the reference - machine; an unbounded run once hit ~80 GB and had to be emergency-killed, so - the budget keeps a runaway generate from taking down the host. -- Emitted packages gained a surface-agnostic `_generated/_core.py` that owns the - shared names (the `JsonValue` alias, `NoRowError`, a new `DecodeError`, and the - `require_array` decode guard) with no I/O. Both `_runtime.py` modules are now - I/O-only and re-export `JsonValue`/`NoRowError`/`require_array` from `_core` so - off-contract `from ._runtime import ...` keeps working; `_rows.py`, the - statement modules, and the facades import the shared names from `_core` - directly. -- The release wheel now ships its GPL compliance files inside the artifact: - the build fetches the GPLv3 text into `COPYING` (sha256-pinned) and bundles - the committed `wheel/NOTICE` describing the composition; both land in - `dist-info/licenses` and the release job asserts their presence in the - wheel and the sdist. The repository LICENSE (MIT) is no longer copied into - the wheel, where it misstated the artifact's license. -- Emitted files now carry REUSE-style SPDX header lines - (`SPDX-FileCopyrightText`, `SPDX-License-Identifier: MIT-0`) right after - the `@generated` marker: license scanners in consuming projects see a - standard permissive id for the generated code instead of guessing its - provenance. -- Added a `pgenie-python-gen` wheel channel (`wheel/`): the release build bundles - the resolved generator as an installable package with a `path`/`url`/`vendor` - CLI; publication to PyPI ships wired but disabled. -- Keyword escaping (`PyIdent.dhall`) no longer needs the fork-only - `Text/equal` builtin; it's rewritten against a `Text/replace`-based marker - trick, since pgn's embedded `Text/replace` doesn't match a needle spanning - a concatenation boundary, which is what the java.gen-style delimiter trick - relied on. -- The generator config is now fully optional, folded through a single - defaults record in `compile.dhall` (`packageName` from the project name, - `emitSync` off, `onUnsupported` `Fail`). A project can omit `config:` - entirely, pass `config: {}`, or set any subset of the keys. -- gen-sdk pinned to `v0.10.2`. -- Fixed single-field composite param binding: it rendered as `(x.field)`, - parentheses around a bare expression, not a one-element Python tuple. - Now renders `(x.field,)`. Covered by a dedicated fixture composite - (`tag_value`) exercised as both a param and a result column. -- Added `onUnsupported: Fail | Skip`. `Fail` (default) is the existing - loud-abort behavior. `Skip` drops the smallest self-consistent unit (a - statement or a custom type, cascading to anything that references it) and - keeps generating the rest. -- Renamed `demos/` to `fixtures/` (`demos/Exhaustive.dhall` is now - `fixtures/Exhaustive.dhall`), matching the `fixtures/` naming already used - by the other generators. `build.bash`'s `regenerate_demo_output` is now - `regenerate_fixture_output`. No behavior change. +- Moved PostgreSQL conversion to psycopg's class-aware adapters. Generated enums + are pure `StrEnum` classes, composites and statement rows are frozen slotted + dataclasses, and `args_row` constructs each canonical Row positionally through + `BaseRowFactory`. `EnumInfo`/`register_enum` and + `CompositeInfo`/`register_composite` register generated classes in + dependency-first order. Statement SQL remains one `LiteralString`; generated + output has no query decoders, model codecs, casts, or bytes SQL. + +- Completed custom-type coverage for scalar enums, enum arrays through rank 2, + scalar composite load and dump, rank-1 composite arrays, nested scalar + composites, nullable members, and sanitized identifiers. Unsupported enum + ranks, composite ranks, custom-array fields inside composites, and missing or + unsupported custom types fail loudly. + +- Retained `buildLookup` as the sound project-wide custom-kind resolver. Its + `Text/equal` use is an explicit pgn-fork constraint; a stable custom kind or + identifier in the upstream contract is the planned exit. Natural project + indexes now provide deterministic custom-import deduplication and ordering. + Query, parameter, field, and private statement names are collision-safe while + SQL names remain unchanged. + +- Kept `onUnsupported: Fail | Skip`. `Fail` aborts generation with the nested + report. `Skip` preserves warnings and computes a bounded fixed point that + removes an unsupported custom type, its dependent custom types and statements, + and all affected type, registration, facade, Row, and statement entries. The + surviving package remains strict-importable. + +- Established the generated tree as a greenfield layout with no compatibility + layer or promise for internal generated paths. Every generated Python file now + begins with the exact marker + `# @generated by python.gen (pGenie); regeneration overwrites manual changes.` + followed by REUSE copyright and MIT-0 SPDX lines. Takeover contract tests pin + the marker and greenfield layout. Consumers must disable regeneration and + replace the marker before treating the files as owned Python. + +- Added Ruff check and format gates, authored and SQL line-length gates, raw + output checks with no postformat step, public identity checks, import-boundary + checks, adapter AST checks, and async/sync PostgreSQL round trips. Final + evidence: H1 CONFIRM with psycopg 3.3.4, consumer `psycopg>=3.3,<4`, + class-aware adapters, pure models, canonical `args_row` Rows, and no query + decoders, model codecs, casts, or bytes SQL. H2 CONFIRM: 25 Python files / + 1467 lines / 11 statement files / 801 statement lines / 11 SQL. H3 CONFIRM: + Ruff 0/0, authored long0, SQL long0, raw output/no postformat. Tests: 49 + passed, 0 skipped; pgn 0.9.1; strict basedpyright 0/0. + +- Migrated the generator to gen-contract v4.0.1 and gen-sdk v2.0.0 using + `Sdk.Sigs`. The implementation moved from `gen/` into `src/`, the working-tree + entry point is `src/package.dhall`, and the contract fixture is + `fixtures/Exhaustive.dhall`. Remote imports in `src/Deps/` are ordinary + sha256-pinned imports. + +- Preserved the pgn subprocess RSS watchdog. Each process runs in its own process + group, is polled every 2 seconds, and is killed on a + `PGN_MAX_RSS_GB` breach, with a 40 GB default. Heavy fixture generation remains + serial. + +- Fixed one-field composite binding. Generated dumpers construct field-ordered + tuples with `dataclasses.fields` and `getattr`, preserving one-field arity. + The fixture exercises the type as both a parameter and a result. + +- Kept `PyIdent.dhall` independent of fork-only text equality by using its + bounded `Text/replace` marker construction. Keyword fields and parameters gain + a trailing underscore only in Python, while raw database names remain stable. + +- Preserved the release and license flow. The release job resolves + `src/package.dhall` into the `resolved.dhall` release asset, then byte-checks + and bundles that same asset into the `pgenie-python-gen` wheel. Repository + source remains MIT, emitted Python is MIT-0, and the combined resolved artifact + and wheel are GPL-3.0-or-later because they inline gen-sdk. The wheel includes + the sha256-pinned GPL text and `wheel/NOTICE`; PyPI publication remains + disabled behind its explicit gate. diff --git a/DESIGN.md b/DESIGN.md index 7212d92..9ce6322 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,752 +1,384 @@ -# python.gen DESIGN +# 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 `src/`, the fixture project -and golden output under `tests/`. When this doc and the tree disagree, the -tree wins. +Status: current. The Dhall under `src/` and the live PostgreSQL harness under +`tests/` are the source of truth. This document describes the verified +greenfield client, not an intermediate migration design. -The generator turns a pgn project (queries and custom types, analyzed against -a live PostgreSQL) into a typed Python database client. It emits exactly one -surface per generate — async (`psycopg.AsyncConnection`) by default, or sync -(`psycopg.Connection`) when `config.sync` is `True` — at the same unified -output paths either way. +The generator consumes pgn's analyzed project and emits one typed Python +package. Async is always the root API. `emitSync: true` adds a sync facade and +sync I/O functions without duplicating SQL or model definitions. -Constraints that bound every decision below: +## 1. Ownership and package layout -- Generated code depends ONLY on `psycopg>=3.3,<4` and the stdlib. No pydantic, - no third-party codec library. -- Generated code passes `basedpyright` strict with zero errors/warnings and - `ruff` clean. -- No em dashes, comments only for non-obvious WHY, English everywhere. -- psycopg is the target driver. Driver portability is out of scope (see - "Deliberate driver scope" below). +pgn receives a `List { path, content }` from the generator and writes it below +the artifact root. `packageName` is converted to an import name by replacing +`-` with `_`. ---- +For a project with custom types and `emitSync: true`, generated ownership is: -## 1. Generated package layout - -pgn writes the `compile` output (a `List { path, content }`) under the -artifact's own root. Every `path` is relative to that root and starts with -`src//`, where `` is `packageName` with `-` replaced -by `_` (`my-db-client` -> `my_db_client`). +```text +src//__init__.py +src//sync/__init__.py +src//_generated/__init__.py +src//_generated/_core.py +src//_generated/_runtime.py +src//_generated/_register.py +src//_generated/sync/_runtime.py +src//_generated/statements/__init__.py +src//_generated/statements/.py +src//_generated/types/__init__.py +src//_generated/types/.py +``` -The generator emits the entire `src//_generated/` subtree plus -the package-root facade module (`__init__.py`). Everything else, a -`pyproject.toml` and a `py.typed` marker, is the consumer's own hand-written -shell; the generator never touches it. +The sync facade and runtime are omitted unless `emitSync` is true. Type and +registration files are omitted when the project has no custom types. A +consumer-owned shell, such as `pyproject.toml` and `py.typed`, sits around this +output and is not generated. -Every emitted `.py` file starts with the exact first line -`# @generated by python.gen (pGenie). DO NOT EDIT.` followed by a blank line. -The header is prepended once in `Interpreters/Project.dhall` (`withHeader`) -so it applies to all modules. +There is one package, one generated type namespace, and one canonical statement +module per query. Internal generated paths are greenfield implementation detail; +the project makes no compatibility promise for them. -### What the generator emits +`Interpreters/Project.dhall` prepends one header to every Python file. Its exact +first line is: -``` -src//__init__.py # generated facade (re-exports everything) -src//_generated/__init__.py -src//_generated/_core.py # surface-agnostic: JsonValue, NoRowError, require_array -src//_generated/_runtime.py # I/O helpers for whichever surface config.sync picked -src//_generated/_register.py # only if composites or enums -src//_generated/_rows.py # shared Row dataclasses + decode functions -src//_generated/statements/__init__.py -src//_generated/statements/.py # one thin wrapper per query, def or async def -src//_generated/types/__init__.py # only if custom types exist -src//_generated/types/.py +```text +# @generated by python.gen (pGenie); regeneration overwrites manual changes. ``` -Exactly one surface is emitted per generate, picked by `config.sync` -(`False`, the default, emits async; `True` emits sync) — the tree shape and -every import path are identical either way; only the statement modules' -`def`/`async def`, `Connection`/`AsyncConnection`, and `_runtime.py`'s body -change. A project that needs both surfaces generates two artifacts against -this same `gen:` with different `packageName`s, one with `sync: true` and -one without. - -### Naming (all from the pgn `Name` object, never recomputed) - -- module file: `name.inSnakeCase + ".py"`. -- query function: `query.name.inSnakeCase` (e.g. `get_specimen`). -- Row dataclass: `query.name.inPascalCase + "Row"` (e.g. `GetSpecimenRow`). -- decode function: `"decode_" + query.name.inSnakeCase` (e.g. - `decode_get_specimen`). -- enum / composite class: `customType.name.inPascalCase`. - -One exception to "never recomputed": a name that becomes a Python identifier -(a param, a result-row field, or a composite field) and collides with a -Python keyword is sanitized to `name_` via `Structures/PyIdent.dhall`. The -SQL placeholder, the params-dict key, and the `row["..."]` lookup keep the -raw name, so only the emitted identifier changes. Section 13 covers how that -sanitizing actually works and why it can't just use `Text/equal`. - ---- - -## 2. Shared type layer: `_rows.py` and `types/` - -Decode is CENTRALIZED. For each query that returns rows, `_rows.py` holds one -`@dataclass(frozen=True, slots=True)` Row plus a module-level -`decode_(row: Mapping[str, object]) -> ` function. A statement -module imports both from the shared module and does no decoding inline: - -```python -# statements/get_specimen.py (async) -from .._rows import GetSpecimenRow, decode_get_specimen -from .._runtime import fetch_single -... -return await fetch_single(conn, _SQL, params, decode_get_specimen) -``` +The two SPDX lines immediately following it grant MIT-0 for emitted code. The +marker is intentionally operational: manual edits are safe only after all +regeneration paths have been disabled and the marker has been replaced. -Because `_rows.py` is shared by both surfaces, async and sync return the SAME -Row type for a given query (cross-surface identity, not two equal-but-distinct -classes). `Templates/RowsModule.dhall` renders it; `Interpreters/Project.dhall` -concatenates every query's `rowDef` into the single file and unions their -imports. - -`types/.py` holds the enum / composite class. Custom-type imports -inside `_rows.py` use the `.types.` prefix (one level above `types/`); inside -a statement module they use `..types.` (async) or `...types.` (sync). - -### Decode per column - -psycopg3 default adapters already return the right Python object for `bool`, -`int`, `float`, `Decimal`, `str`, `UUID`, `date`, `datetime`, `time`, -`timedelta`, `bytes`, parsed JSON objects, and Python `list` for arrays. For -those the decode is a `cast(, row[""])` to satisfy strict -(the dict value type is `object`). Real conversion happens only for: - -- enum column: `Mood(cast(str, row["feeling"]))`; nullable guards `None`. -- enum array column: element-wise rebuild, - `[Mood.pg_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. 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}.pg_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. - ---- - -## 3. `_runtime.py` API - -Self-contained, emitted once per surface from `Templates/RuntimeModule.dhall` -(`content` for async, `runSync` for sync). Five helpers, one per cardinality: - -| cardinality | helper | return | -| ---------------------- | ----------------------- | ------------- | -| `Rows` `Optional` | `fetch_optional` | `Row \| None` | -| `Rows` `Single` | `fetch_single` | `Row` | -| `Rows` `Multiple` | `fetch_many` | `list[Row]` | -| `RowsAffected` | `execute_rows_affected` | `int` | -| `Void` | `execute_void` | `None` | - -The async runtime defines `JsonValue` and `NoRowError`; the sync runtime -re-exports both from `_core` (`from ._core import JsonValue, NoRowError`) -so there is one canonical identity. The missing-single-row case -raises `NoRowError(RuntimeError)`. - -Result helpers open `conn.cursor(row_factory=dict_row)` and pass each row -dict to the `decode` closure; void / rows-affected use a plain cursor. The -helpers take `sql: bytes`; each statement module encodes its SQL once at -import (`_SQL = SQL.encode()`, see section 8) and passes the cached bytes, -which also sidesteps psycopg's `LiteralString` requirement under strict -(the SQL string carries `%(name)s` placeholders, so it is a runtime -template, not a `LiteralString`). Each `cursor.execute(...)` result is bound -to `_` so `reportUnusedCallResult` stays quiet. - -`JsonValue` is a recursive alias: - -```python -type JsonValue = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] -``` +## 2. Canonical statement modules and rows + +`Interpreters/Query.dhall` compiles a query into a single statement module. The +module contains, in order: + +- imports needed by parameters, results, and selected I/O surfaces; +- one `SQL` string; +- one `@dataclass(frozen=True, slots=True)` Row when the query returns rows; +- the async function; +- an adjacent function with the `_sync` suffix when `emitSync` is true. + +The public facades alias those canonical objects. The root facade exports the +async function. `.sync` exports the adjacent sync function under the same +public name. Both facades export the exact same Row classes, custom types, +`JsonValue`, and `NoRowError` identities. -`json`/`jsonb` columns and params type as `JsonValue`. The client stays -faithful to the SQL type; a consumer that wants `dict[str, JsonValue]` -narrows at its own boundary. +For a row-returning query the statement passes `args_row(Row)` to the runtime. +psycopg's `BaseRowFactory` constructs the dataclass positionally in result-column +order. There is no mapping row, generated query decoder, model codec, +`typing.cast`, or second Row definition. This makes the statement's Row +the canonical runtime result type for both surfaces. ---- +Imports for custom annotations use the generated `types` namespace. The +`ImportSet` project index is both a deduplication key and stable sort key, so a +statement or composite imports each referenced type once in project order. -## 4. Surface mechanism (async / sync) +Python identifiers are sanitized only where Python syntax requires it. +Parameters, result fields, composite fields, and query function names use the +safe identifier. SQL placeholders and PostgreSQL names retain their original +spelling. For example, a PostgreSQL column named `class` becomes the Python +field `class_`, while its positional value still occupies the analyzed column +position. + +## 3. Adapter-owned custom types and registration + +Generated models are pure declarations: + +- enums are `StrEnum` subclasses containing the exact PostgreSQL labels; +- composites are frozen, slotted dataclasses containing their typed fields; +- statement rows are frozen, slotted dataclasses. + +They contain no database conversion methods. psycopg owns loading and dumping. +`Templates/RegisterModule.dhall` emits class-aware registration into one shared +module: + +- `EnumInfo.fetch` and `register_enum` receive the generated enum class and an + explicit member-to-label mapping; +- `CompositeInfo.fetch` and `register_composite` receive the generated + dataclass plus validated object and sequence callbacks. + +The callback factory compares sanitized PostgreSQL field names with dataclass +field names before constructing or flattening an object. This catches schema and +model drift at the adapter boundary. + +`register_types` is async. When sync output is enabled, the same module also +contains `register_types_sync`, which the sync facade exports as +`register_types`. Registration is a per-new-connection precondition and must run +after the database migrations and custom types exist. Missing catalog entries +raise `LookupError` rather than falling back to text. + +Registration order is dependency-first. Enums are ready immediately; +composites are emitted only when all referenced custom types are already ready. +A bounded pass either resolves the full order or reports unresolved or cyclic +dependencies. This ordering lets psycopg recursively adapt nested scalar +composites and arrays of the supported ranks. + +Verified custom shapes are scalar enums, enum arrays through rank 2, scalar +composite load and dump, rank-1 composite arrays, nested scalar composites, +nullable fields, and sanitized fields. Async and sync return the same exact +model identities. + +## 4. Runtime and surfaces + +`Structures/Surface.dhall` is a small render-token record for adjacent functions: ```dhall -{ defKeyword : Text -- "async def" | "def" -, connType : Text -- "AsyncConnection" | "Connection" -, awaitKw : Text -- "await " | "" -, rowsImport : Text -- ".._rows" (same depth for both surfaces) -, corePrefix : Text -- ".._core" (same depth for both surfaces) -, typesPrefix : Text -- "..types" (same depth for both surfaces) +{ defKeyword : Text +, connType : Text +, awaitKw : Text +, functionSuffix : Text +, runtimePrefix : Text +, helperSuffix : Text +, corePrefix : Text } ``` -`Surface.async` and `Surface.sync` are the two values. `Interpreters/ -Project.dhall` picks one — `if config.sync then Surface.sync else -Surface.async` — and threads it through a single render pass: -`Interpreters/Query.dhall` calls the statement-module template once per -query (not twice), and `Interpreters/Project.dhall` picks the matching -`_runtime.py` body and, when custom types exist, the matching `_register.py` -body. `_rows.py` and `types/` are surface-agnostic and always render exactly -once, so switching `config.sync` costs only the thin I/O wrappers. - ---- - -## 5. Type mapping and the loud-fail policy - -`Interpreters/Primitive.dhall` switches on the pgn `Primitive` union tag (the -Dhall constructor, NOT the sig YAML string) and maps the supported set (see -the README's Supported types table for the full mapping). Every other -primitive (`money`, network types, ranges/multiranges, geometry, `bit`, -`xml`, `tsvector`, `timetz`, ...) maps to -`Lude.Compiled.report Output [pgTypeName] "Unsupported type"`. There is no -silent fallback. The report propagates through `Compiled.nest` at each level -(query `srcPath` -> `"result"`/`"sql"`/`"params"` -> column/param `pgName` -> -pg type), so the error names the precise query and column. Generation fails -non-zero (or the query is skipped, see section 11); pgn prints the report; -no partial output for the affected query under `Fail`. Add a mapping to -`Primitive.dhall` to support a new type. - -The param side (`Interpreters/ParamsMember.dhall`) carries the same -loud-fail contract for bind shapes psycopg cannot adapt faithfully. It -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 `.pg_encode()` for a scalar custom-type param and -building `[x.pg_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 - -`Interpreters/Value.dhall` reads `arraySettings = { dimensionality, -elementIsNullable }` and wraps the scalar `pyType` in one `list[...]` per -dimension, applying `elementIsNullable` to the inner element. -`Interpreters/Member.dhall` then applies the member-level `isNullable` on -the outside. So a nullable array of nullable UUIDs renders -`list[UUID | None] | None`. - -### Enums -> StrEnum - -`Templates/EnumModule.dhall` renders an enum custom type to a `StrEnum` -subclass. Member name = `variant.name.inScreamingSnakeCase`; value = -`variant.pgName` (the exact PG label): - -```python -class Mood(StrEnum): - HAPPY = "happy" - SAD = "sad" - MEH = "meh" -``` +The async value renders `async def`, `AsyncConnection`, and `await`. The sync +value renders `def`, `Connection`, and `_sync` implementation names. It does not +select between mutually exclusive package shapes. + +`_core.py` is emitted once and owns `JsonValue` and `NoRowError`. The async +runtime is always emitted; the sync runtime is additive. Each runtime contains +the same five cardinality helpers: -Encode (param): pass the StrEnum value; psycopg sends text and PostgreSQL -coerces the unknown-typed param to the enum in assignment / `::enum` -contexts; enum-array params carry an explicit `::[]` cast in the SQL. -Decode (scalar): the `StrEnum(str)` constructor validates the label. - -### Composite -> frozen dataclass - -`Templates/CompositeModule.dhall` renders a composite to a -`@dataclass(frozen=True, slots=True)` with one field per member -(`member.name.inSnakeCase`, type from the Value mapping + nullability). -Encode and decode go through psycopg's registered `CompositeInfo` -(section 6). - -A composite param is bound as its field tuple, e.g. `(x.first, x.second)` -for two fields. A single-field composite is the edge case: `Text.concatMapSep` -never inserts a separator for one element, so the naive tuple expression -renders `(x.field)`, parentheses around a bare expression, not a Python -tuple. `ParamsMember.dhall`'s `compositeBind` forces the trailing comma for -exactly the one-field case (`(x.field,)`); the fixture's `tag_value` -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 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}.pg_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 `pg_decode`/`pg_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 `pg_decode`/`pg_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 -`pg_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. - ---- - -## 6. Type registration (`_register.py`) - -`_register.py` is emitted only when the project has composites or enums, -once per surface (`Templates/RegisterModule.dhall`). It exposes -`register_types(conn)`: - -- composites: `CompositeInfo.fetch` + `register_composite`, so psycopg - decodes the composite to a namedtuple the generated decode can splat. -- enums: `TypeInfo.fetch` + `enum_info.register`. - -Scalar enums do NOT need registration: a scalar enum encodes as text -(PostgreSQL coerces it in assignment / `::enum` contexts) and decodes via -the `StrEnum(str)` constructor in `_rows.py`. A project can therefore ignore -`register_types` entirely as long as it never selects an enum-array or -composite result column; calling it costs a `TypeInfo`/`CompositeInfo` -fetch per type per connection and raises `LookupError` on a database where -the type doesn't exist yet (a pre-migration or partial database), so it's -opt-in rather than wired in automatically. Enum-array params sidestep the -whole question by carrying an explicit `%(p)s::[]` cast in the SQL, so -they bind without registration either way. - ---- - -## 7. Cardinality fidelity - -The generator is faithful to pgn's cardinality and never overrides it. -`Interpreters/Result.dhall` maps `Result` straight through: - -- `Rows` `Optional` -> `Row | None` via `fetch_optional`. -- `Rows` `Single` -> `Row` via `fetch_single`. -- `Rows` `Multiple` -> `list[Row]` via `fetch_many`. -- `RowsAffected` -> `int` via `execute_rows_affected` (no Row, no decode). -- `Void` -> `None` via `execute_void` (no Row, no decode). - -pgn defaults DML-with-RETURNING to `Multiple` and cannot prove single-row -from a predicate on its own, so a predicate-targeted `DELETE ... RETURNING` -returns `list[Row]` even when the caller treats it as a single row or a -boolean. Reconciling `list[Row] -> bool` (or a single row) is the consumer's -job; the generator does not narrow cardinality, because that would drift -from pgn's own analysis. - ---- - -## 8. SQL rendering (`Interpreters/QueryFragments.dhall`) - -A query's `fragments` (`List < Sql : Text | Var : { name, rawName, -paramIndex } >`) render to one Python `str` literal `SQL` per statement -module in psycopg named style: - -- `Var` -> `%()s`, using the snake_case SQL param name. - Repeated occurrences of the same param collapse to one dict key (psycopg - named style). -- `Sql` text -> emitted verbatim after escaping for the Python literal and - after doubling any literal `%` to `%%` (psycopg's placeholder sigil). The - Var branch is not subject to `%` doubling. - -The SQL is emitted as a triple-double-quoted string with a leading backslash -so the first line stays flush, and a trailing newline before the closing -quotes (a harmless newline for psycopg). The params dict is rendered -multi-line with a magic trailing comma so ruff keeps it format-stable at any -width. - -Explicit casts (`::jsonb`, `::uuid[]`, `::[]`) already live in the SQL -text where pgn needs them; the generator does not append per-param cast -suffixes. - ---- - -## 9. The generated facade (`__init__.py`) - -`Templates/FacadeModule.dhall` renders the flat public surface that -consumers import. It re-exports, with PEP 484 `X as X` markers and a -matching `__all__`: - -- every custom type from `_generated/types/`, -- every Row dataclass from `_generated/_rows`, -- every statement function from `_generated/statements/`. - -The facade lives at `/__init__.py` (prefix `._generated`, statements -under `statements`) regardless of which surface `config.sync` selected. - -This file is GENERATED: it carries the `@generated` header and is -overwritten on every run. Do not hand-edit it. +| helper | result | +| --- | --- | +| `fetch_optional` | `Row | None` | +| `fetch_single` | `Row`, or `NoRowError` | +| `fetch_many` | `list[Row]` | +| `execute_rows_affected` | `int` | +| `execute_void` | `None` | ---- +Row helpers accept `BaseRowFactory[T]` and return psycopg's constructed object +directly. Statement SQL is passed as `LiteralString`. No runtime accepts encoded +SQL bytes. The generated package has no separately installed support library; +its only non-stdlib consumer dependency is `psycopg>=3.3,<4`. -## 10. Generator decomposition +## 5. Configuration, mapping, and unsupported shapes -`src/package.dhall` is the entry point handed to gen-sdk: +The public Dhall config is: ```dhall -let Sdk = ./Deps/Sdk.dhall +{ packageName : Optional Text +, emitSync : Optional Bool +, onUnsupported : Optional < Fail | Skip > +} +``` + +`src/package.dhall` passes that type and an all-`None` default to +`Sdk.Sigs.generator`. `Project.run` resolves each field independently: + +- `packageName`: project name in kebab case; +- `emitSync`: `False`; +- `onUnsupported`: `Fail`. + +An omitted config block, an omitted field, and a `null` field therefore use the +same fallback. Async output is present for every value of `emitSync`; only true +adds sync output. + +`Interpreters/Primitive.dhall` maps pgn union constructors, not signature-file +strings. Supported scalars are Boolean, integer and OID, floating point, +numeric, textual, UUID, date/time/timestamp/interval, bytea, and JSON. Arrays +wrap the scalar type once per analyzed dimension, preserving element and outer +nullability independently. JSON parameters use psycopg's `Json` or `Jsonb` +wrapper only for a scalar value. + +`CustomKind.Lookup` classifies each custom reference as enum, composite, or +absent, and supplies project order plus composite fields. That classification +drives type imports, supported array ranks, nested dependencies, and adapter +registration. The generator reports instead of guessing when a primitive or +custom type cannot be mapped. + +The explicit custom limits are: + +- enum dimensions above 2 are unsupported; +- composite dimensions above 1 are unsupported; +- a custom-array field inside a composite is unsupported; +- an absent or unsupported custom type is unsupported. + +Domains are unsupported. JSON array parameters are also rejected because a +single JSON wrapper cannot adapt elements faithfully. Every failure carries the +query or type path and member name through `Compiled.nest`. + +## 6. Cardinality fidelity + +`Interpreters/Result.dhall` preserves pgn's cardinality verbatim: + +- `Rows Optional` maps to `Row | None` and `fetch_optional`; +- `Rows Single` maps to `Row` and `fetch_single`; +- `Rows Multiple` maps to `list[Row]` and `fetch_many`; +- `RowsAffected` maps to `int` and `execute_rows_affected`; +- `Void` maps to `None` and `execute_void`. -let OnUnsupported = ./Structures/OnUnsupported.dhall +The generator does not infer a narrower return from SQL predicates. If pgn +classifies a returning statement as multiple, its API remains `list[Row]`. -let ProjectInterpreter = ./Interpreters/Project.dhall +## 7. SQL rendering -let Config = { packageName : Optional Text, sync : Optional Bool, onUnsupported : Optional OnUnsupported.Mode } +`Interpreters/QueryFragments.dhall` converts pgn fragments into one Python +triple-quoted `SQL` string in each statement module: -let Config/default = { packageName = None Text, sync = None Bool, onUnsupported = None OnUnsupported.Mode } +- a variable becomes `%()s`; +- raw SQL remains verbatim after Python-literal escaping; +- literal percent signs are doubled for psycopg named style; +- the variable branch is not percent-doubled. -in Sdk.Sigs.generator Config Config/default ProjectInterpreter.run +The literal begins with a backslash so the first SQL line is flush and retains +a trailing newline. Parameter dictionaries use raw PostgreSQL names as keys and +sanitized Python expressions as values. Source SQL owns explicit database casts; +the generator does not append adaptation casts. The same `SQL` object is passed +to async and sync helpers as a string whose static type satisfies +`LiteralString`. Output is the raw Dhall render; no Python formatter or SQL +postprocessor rewrites generated files. + +## 8. Project pipeline and `onUnsupported` + +`Interpreters/Project.dhall` is the project-level coordinator: + +```text +optional config + -> resolved config + -> buildLookup(custom types) + -> fixed-point custom-type filtering in Skip mode + -> compile custom types and query checks + -> dependency-first registration order + -> render facades, core, runtimes, types, registration, and statements + -> prepend the generated header ``` -`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. +`onUnsupported: Fail` traverses the original project and propagates the first +compiled failure, so generation aborts without a partial client. + +`onUnsupported: Skip` repeatedly rebuilds `buildLookup` from the surviving +custom types and removes any type that no longer compiles. It then compiles +queries against the final lookup. A removed type therefore also removes its +dependent composites and statements. Facade exports, type initializers, +registration entries, statement files, and Row exports are assembled only from +survivors. Reports for dropped units are preserved as warnings. The bounded +fixed point can only remove candidates, so it terminates after at most the +original custom-type count. + +Query compilation is deliberately consolidated in `QueryCheck`: keep/drop state +and warning data come from one literal `QueryGen.run` call site before the final +render call. Dhall normalization substitutes call sites, and multiplying those +sites caused a measured minutes-scale regression. Custom-type checks use their +separately measured fast shape. + +## 9. Generator decomposition + +The source follows the gen-sdk interpreter/template split: ```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 + package.dhall + Deps/ # sha256-pinned remote packages Structures/ - Surface.dhall # async/sync token table, both surfaces at the same import depth (section 4) - ImportSet.dhall # per-module import flags + combine - PyIdent.dhall # sanitize names that become Python identifiers (keyword -> name_) - OnUnsupported.dhall # < Fail | Skip > (section 11) + CustomKind.dhall + ImportSet.dhall + OnUnsupported.dhall + PyIdent.dhall + Surface.dhall Interpreters/ - Primitive.dhall # pgn Primitive -> Python type / unsupported report - Scalar.dhall # Primitive | Custom -> pyType + decode hint - Value.dhall # arrays + element nullability - Member.dhall # a Member -> field name/type/nullable + decodeExpr (Text -> Text) - ParamsMember.dhall # a param Member -> arg + bind expr (jsonb wrap) + imports - CustomType.dhall # Enum/Composite -> module ; Domain -> unsupported report - QueryFragments.dhall # fragments -> escaped, %%-doubled, named-style SQL literal - ResultColumns.dhall # Rows columns -> Row field lines + decode body - Result.dhall # Void|RowsAffected|Rows -> return type + helper + rowClass - Query.dhall # assemble one query: shared rowDef + one statement module for whichever surface config.sync picked - Project.dhall # traverse queries+customTypes, assemble all files + facade + header, - # apply the Skip filter (section 11) + Primitive.dhall + Scalar.dhall + Value.dhall + Member.dhall + ParamsMember.dhall + CustomType.dhall + QueryFragments.dhall + ResultColumns.dhall + Result.dhall + Query.dhall + Project.dhall Templates/ - CoreModule.dhall # 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 - RegisterModule.dhall # _register.py (per surface) - FacadeModule.dhall # the flat package-root facade - EnumModule.dhall / CompositeModule.dhall / TypesInit.dhall / InitModule.dhall -``` - -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 `pg_decode`/`pg_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 - -`package.dhall`'s `Config` is the user-facing type `{ packageName : Optional -Text, sync : 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, sync, onUnsupported }`, with -`packageName` falling back to the project name in kebab case, `sync` 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. - ---- - -## 11. `onUnsupported`: Fail vs Skip - -`Fail` (the default) is the code path this generator has always had: any -unsupported primitive, unsupported param shape, or domain custom type -aborts the whole `Project.run` non-zero via `Lude.Compiled.report`. - -`Skip` asks a different question of the same failures: rather than aborting, -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 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 -`Alternative.optional` layered over `Compiled.or`, with the failure report -simply discarded). This generator's deliberate difference is twofold: Skip -is opt-in behind an explicit config knob rather than the only behavior, and -the report for each dropped unit is preserved in `Compiled.warnings` instead -of thrown away, even though pgn 0.6.5 doesn't currently surface that -warnings list anywhere on a successful run (see the README's Unsupported -types section). If a future pgn version starts printing `Compiled.warnings` -on success, or another generator-runner consumes it directly, this -generator is already ready for that; it isn't waiting on it. - ---- - -## 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 -`pg_decode`/`pg_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}.pg_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}.pg_encode()` broke enum-array param encode (calling `.pg_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}.pg_decode(v)` / `x.pg_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 -`.pg_decode`/`.pg_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}.pg_decode(v)`/`x.pg_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 `pg_decode`/`pg_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 `pg_decode`/`pg_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: `fixtures/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. - ---- - -## 13. Keyword escaping without `Text/equal` - -`PyIdent.dhall`'s `sanitizeAgainst` needs to answer "does this name equal -one of the Python keywords" and, if so, append an underscore, all in plain -`Text`, without the fork's `Text/equal`. The obvious trick borrowed from -java.gen's equivalent (`escapeJavaKeyword`) is a delimiter wrap: replace -`name` with some sentinel only where it matches a candidate keyword exactly, -by wrapping both in a marker the surrounding text can't produce by -accident. That trick relies on `Text/replace` matching a needle that spans -a concatenation boundary, and pgn's embedded `Text/replace` was found not to -do that (verified against the pinned pgn binary): a needle straddling where -two `Text` values were joined at Dhall-normalization time simply doesn't -match. - -The working replacement threads two bare `Text/replace` calls per candidate -keyword instead of one wrapped comparison: - -```dhall -let markTrue = "0000000000000000000000000001" - -let signal = Text/replace name markTrue candidate -let finalNeedle = Text/replace signal name markTrue -in Text/replace finalNeedle (name ++ "_") acc + CoreModule.dhall + RuntimeModule.dhall + StatementModule.dhall + RegisterModule.dhall + FacadeModule.dhall + EnumModule.dhall + CompositeModule.dhall + TypesInit.dhall + InitModule.dhall ``` -`Text/replace name markTrue candidate` yields exactly `markTrue` only when -`name` equals `candidate` verbatim; any mismatch leaves at least one -alphabetic character of the keyword in place (or overshoots the -digits-only marker), so it can never coincidentally read as `markTrue` -afterward. The second replace maps an exact match to `name` and every -mismatch to `markTrue`; the third rewrites the accumulator only on that -match. Folded over the keyword list, this reproduces "append `_` iff `name` -is a reserved word" without ever comparing two `Text` values for equality. - -Two sharp edges, both called out in `PyIdent.dhall` itself: a `name` that -happens to contain the literal marker string gets corrupted by the mismatch -branch (the marker itself becomes a needle replaced in the accumulator), -and the accumulator must be read exactly once per fold step, or the -expression re-embeds itself at every reserved word and blows up -exponentially. Neither has come up in practice; both are worth knowing if -this ever needs extending to a second reserved-word list. - ---- - -## 14. Perf note: call-site count and Dhall normalization cost - -Dhall normalizes by substitution, so evaluating the same interpreter -function (`QueryGen.run` or `CustomTypeGen.run`) from more than one literal -call site in the source multiplies the normalization work per extra site, -not just per extra invocation. This was found by bisection while wiring up -Skip mode: an earlier draft of `Project.dhall`'s `run` called `QueryGen.run` -once to decide keep/drop, once more to render, and once more to build a -warning, three literal call sites, and `pgn generate`'s wall time on the -fixture project regressed from a few seconds to minutes. - -The fix folds the keep/drop decision, the warning, and the query itself -into one `QueryCheck` record computed from a single `QueryGen.run` call per -query (see `queryChecks` in `Project.dhall`), so the query side is back down -to two literal call sites: one to build `queryChecks`, one for the final -`queriesForCombine` render. That is the side worth holding the line on, -since it scales with the whole query set. The custom-type side took the -opposite shape empirically (`typeSucceeds` and `typeWarning` as two small -separate functions, plus the render call in `typesForCombine`, three call -sites in total) and stayed fast enough regardless, because a project -typically has far fewer custom types than queries; the asymmetry is -deliberate, not an oversight. If a future change makes the custom-type side -slow too, the same single-record collapsing trick applies there. - ---- - -## 15. Sanctioned escape hatch: hand-written SQL on a raw connection - -The generator covers the analyzable, drift-gated query surface. It is not -the only way to reach the database. A consumer obtains a raw connection -from wherever its own connection-management layer provides one, and passes -it to the generated functions; the SAME connection may be used to run -hand-written SQL directly (`conn.execute(...)`, a cursor) for the rare -statement that pgn cannot analyze, or that has no business being in the -generated set. - -This is sanctioned, not a smell. Hand-written SQL on the raw connection -lives outside whatever regeneration drift check a project wires into its -own CI: it isn't a `.sql` file this generator ever saw, so no diff against -generated output will ever touch it. Use it sparingly and keep it in -application code, not anywhere near `_generated`. - ---- - -## 16. Deliberate driver scope - -psycopg `>=3.3,<4` is the target and the only supported driver. The runtime -helpers, the `dict_row` factory, `Jsonb` wrapping, `CompositeInfo`/`TypeInfo` -registration, and the `AsyncConnection`/`Connection` split are all -psycopg-specific by design. Abstracting over other drivers (asyncpg, a -DB-API shim) is explicitly out of scope: it would add an indirection layer -for a portability nobody asked for and would dilute the strict-typed, -zero-dependency surface. If the driver ever changes, the runtime templates -and the Surface token table are the blast radius; the type layer and the -SQL rendering are largely driver-agnostic. - ---- - -## 17. Development - -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 `fixtures/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 `fixtures/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). +`Scalar` identifies primitive versus custom values. `Value` adds array rank and +element nullability. `Member` and `ParamsMember` apply member nullability, +identifier safety, lookup classification, imports, and parameter wrapping. +`ResultColumns` builds Row field declarations in analyzed order. `Query` +combines SQL, Row, parameters, and surfaces. `Project` owns lookup scope, +fixed-point filtering, registration order, file selection, and facades. + +## 10. The pinned `Text/equal` constraint + +`buildLookup` intentionally remains in `Interpreters/Project.dhall`. It compares +a custom reference's snake-case name with the project custom type name using +`Text/equal`. That builtin belongs to pgn's embedded Dhall fork and is not +available in upstream standard Dhall. + +The dependency is pinned and explicit. The complete fixture needs fork-aware +evaluation solely because it invokes this generator and the local `buildLookup` +uses `Text/equal`. The upstream exit is a stable custom kind or identifier on +the contract's custom scalar reference, which would remove the need for text +equality. Until then, pgn and CI's pinned fork-aware action are the supported +evaluators. + +`PyIdent.dhall` uses its separate `Text/replace` marker construction for keyword +membership. `ImportSet.dhall` uses natural project indexes for equality, +deduplication, and ordering. Neither substitutes for the project lookup. + +## 11. Taking ownership + +The generated marker means regeneration is authoritative. A consumer may eject +only as a coordinated transition: + +1. generate and commit the intended complete client; +2. disable every local and CI regeneration path before edits; +3. replace the marker with a project ownership header; +4. maintain the result as ordinary Python; +5. keep the package structure unless intentionally refactoring all imports; +6. gate the result with basedpyright strict, Ruff, and database tests. + +Once regeneration is disabled, there is no generator behavior involved. Before +that point, manual edits are expected to be overwritten. + +## 12. Verification evidence + +The final fixture was regenerated as raw output and verified against live +PostgreSQL with these verdicts: + +- H1 CONFIRM: psycopg 3.3.4; consumer `psycopg>=3.3,<4`; + class-aware `EnumInfo`/`register_enum` and + `CompositeInfo`/`register_composite`; dependency-first registration; pure + `StrEnum` and frozen slotted dataclasses; `args_row` constructs canonical Row; + no query decoders, model codecs, casts, or bytes SQL. +- H2 CONFIRM: 25 Python files / 1467 lines / 11 statement files / 801 statement + lines / 11 SQL. +- H3 CONFIRM: Ruff 0/0, authored long0, SQL long0, raw output/no postformat. +- Tests: 49 passed, 0 skipped; pgn 0.9.1; strict basedpyright 0/0. + +The H1 round trip covers both connection surfaces and exact cross-facade +identities. It covers scalar enum, enum arrays including rank 2, scalar +composite load and dump, rank-1 composite arrays, nested scalar composites, +nullable fields, and sanitized names. Negative contract tests cover unsupported +ranks, custom-array fields inside composites, and missing or unsupported custom +types. + +## 13. Development and release constraints + +Use `mise` for every repository command. `mise run test` drives real pgn +subprocesses against a live PostgreSQL server; `PGN_TEST_DATABASE_URL` selects a +non-default server. The harness only creates and drops its own scratch database. +Golden regeneration is intentionally serial and memory-heavy; use only +`mise run golden` and review its diff. + +`src/Deps/*.dhall` pins remote imports by sha256 and should be changed one at a +time. `src/package.dhall` is the working-tree generator entry point. +`fixtures/Exhaustive.dhall` is the contract fixture. The release workflow +resolves the entry point into `resolved.dhall`, publishes that release asset, +then byte-checks and bundles it into the wheel. Repository source is MIT, +generated Python is MIT-0, and the combined resolved artifact and wheel are +GPL-3.0-or-later because they inline gen-sdk. diff --git a/README.md b/README.md index d805a45..2019282 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,16 @@ # python.gen -A Dhall-authored code generator for [pgenie](https://github.com/pgenie-io/pgenie) -(`pgn`). - -## What it generates - -pgn reads a project's SQL queries and custom types from a live PostgreSQL, -infers parameter and result types, then hands them to this generator, which -emits a typed Python client: async (`psycopg.AsyncConnection`) by default, -or sync (`psycopg.Connection`) when `config.sync` is `True`, at the same -unified output paths either way. Generated code depends on `psycopg>=3.3,<4` -and the stdlib only, nothing else. - -Every emitted file starts with `# @generated by python.gen (pGenie). DO NOT -EDIT.` and an `SPDX-License-Identifier: MIT-0` header: the generated code is -yours to use under your own project's terms, no attribution required. - -## Key features - -- **Async or sync client over psycopg 3.** `config.sync` (default `False`) - picks exactly one surface — the async client (`psycopg.AsyncConnection`) or - the sync one (`psycopg.Connection`) — at the same output paths either way; - install `psycopg[binary]` for the fast C implementation. -- **Fully typed, end to end.** Parameter and result types are inferred from - your live PostgreSQL by preparing the real queries, and every emitted file - passes `basedpyright` strict with zero errors or warnings. -- **Zero runtime dependencies.** Generated code needs `psycopg>=3.3,<4` and the - stdlib, nothing else: no pydantic, no codec libraries, no framework lock-in. +A Dhall-authored code generator for [pGenie](https://github.com/pgenie-io/pgenie) +(`pgn`). It turns queries and custom types analyzed against PostgreSQL into a +strictly typed Python client for psycopg 3. + +The generated package has no separately installed generator runtime. Consumers +must depend on `psycopg>=3.3,<4`; the verified fixture uses psycopg 3.3.4. The +rest of the generated imports are from the Python standard library. ## Quickstart -Point a pgn artifact's `gen:` key at this generator and give it a package -name: +Point an artifact at the working-tree entry point and choose a package name: ```yaml artifacts: @@ -39,284 +18,236 @@ artifacts: gen: https://raw.githubusercontent.com/slavashvets/python.gen/master/src/package.dhall config: packageName: my-db-client - sync: true + emitSync: true ``` -`master` moves; once a `v0.1.0` tag exists, pin to the tagged raw URL instead -so a later generator change cannot silently reshape your output. - -Then run pgn against a project that has migrations and `.sql` queries under -it, same as any other pgn generator: +Then generate against the PostgreSQL server pgn may use for its scratch +database: ```bash pgn --database-url "$DATABASE_URL" generate ``` -pgn creates its own scratch database on that server, applies your migrations, -prepares each query to infer its types, generates, then drops the scratch -database. - -### Pointing `gen:` at this generator - -`gen:` accepts a few forms; only two of them are actually useful: - -| form | example | works? | -| --------------- | --------------------------------------- | ------------------------------------------ | -| 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 -stores a hash of the resolved Dhall, so the hash is identical across forms -as long as they resolve to the same source. - -## Config reference - -| key | type | default | -| --------------- | ------------------ | ------------------------------ | -| `packageName` | `Text` | the project name, kebab-cased | -| `sync` | `Bool` | `False` | -| `onUnsupported` | `"Fail" \| "Skip"` | `"Fail"` | - -All three keys are optional, and so is the `config:` block itself. Decode -semantics observed against pgn 0.6.5 (undocumented by pgn, pinned by this -generator's own test suite rather than assumed): - -- omitting `config:` entirely behaves exactly like `config: {}`. -- a key set to `null` decodes as absent, i.e. its default. -- an unknown key is ignored, not rejected as a schema error. -- each key falls back to its own default independently, so a partial config - (e.g. only `sync`) works field by field. - -`packageName` becomes the Python import name by replacing `-` with `_` -(`my-db-client` -> `my_db_client`). - -## Supported types - -| PostgreSQL type | Python type | import | -| -------------------------------------------------- | ----------- | ------------ | -| `bool` | `bool` | - | -| `int2` `int4` `int8` `oid` | `int` | - | -| `float4` `float8` | `float` | - | -| `numeric` | `Decimal` | `decimal` | -| `text` `varchar` `bpchar` `char` `citext` `name` | `str` | - | -| `uuid` | `UUID` | `uuid` | -| `date` | `date` | `datetime` | -| `time` | `time` | `datetime` | -| `timestamp` `timestamptz` | `datetime` | `datetime` | -| `interval` | `timedelta` | `datetime` | -| `bytea` | `bytes` | - | -| `json` `jsonb` | `JsonValue` | (runtime) | - -`JsonValue` is a recursive alias emitted once in the runtime module: -`None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue]`. - -Arrays wrap the scalar mapping in one `list[...]` per dimension; nullability -of the element and of the column/param are tracked independently, so a -nullable array of nullable UUIDs types as `list[UUID | None] | None`. Enums -render as `StrEnum` subclasses; composites render as frozen dataclasses. See -Notes below for both. - -## Unsupported types and `onUnsupported` - -Everything not in the table above (`money`, network types, ranges and -multiranges, geometry types, `bit`/`varbit`, `xml`, `tsvector`, `timetz`, -domains, ...) has no Python mapping and is rejected rather than guessed at. -A `jsonb` array parameter and a composite array parameter are also rejected: -psycopg cannot bind either faithfully (`Jsonb` wraps a single scalar, not an -element-wise array; there is no adapter for an array of dataclasses). - -`onUnsupported` controls what happens when generation hits one of these -shapes: - -- **`Fail`** (default): abort generation non-zero, naming the offending query - or type and the exact column/param. No partial output. -- **`Skip`**: drop the smallest self-consistent unit and keep generating the - rest. For a query that unit is the whole statement module, its Row, and its - facade entry. For a custom type it is that type's module plus its - `types/__init__` and `_register` entries. A query that references a skipped - custom type is skipped too (the lookup built from the surviving types - resolves the reference to nothing, and that failure propagates through the - same loud-fail machinery as any other unsupported shape). - -Skip mode still succeeds with an empty return code, but there is a real -limitation worth knowing about: on a successful run, pgn 0.6.5 does not -surface the generator's warnings anywhere, not stdout, not stderr, no -side-channel file. The warnings for every skipped unit are assembled -internally (so a future pgn version or a different consumer of this contract -could print them), but today they are only visible on the `Fail` path, where -the failing report is what aborts the run. Skip is silent-but-safe in -practice: check the emitted file list if you need to know what got dropped. - -## Notes - -**Nullability.** A member's nullability comes straight from pgn's analysis -and is applied on top of the mapped Python type; nothing here infers or -overrides it. - -**Arrays.** See Supported types above. - -**Enums.** An enum custom type renders as a `StrEnum`, one member per PG -label: - -```python -class Mood(StrEnum): - HAPPY = "happy" - SAD = "sad" - MEH = "meh" +The raw `master` URL is valid but moving. For reproducible use, pin an actual +published `resolved.dhall` release asset or vendor the artifact delivered by +the release wheel. Do not guess a release tag that has not been published. +A relative path such as `../python.gen/src/package.dhall` is appropriate while +developing against a local checkout. An absolute path also works but makes the +freeze key machine-specific; pgn project configuration does not accept a +`file://` URL. + +## Configuration + +| key | type | default | +| --- | --- | --- | +| `packageName` | `Text` | project name in kebab case | +| `emitSync` | `Bool` | `False` | +| `onUnsupported` | `"Fail" \| "Skip"` | `"Fail"` | + +The `config` block and every field are optional. An omitted or `null` field uses +its default. Omitting `emitSync`, setting it to `null`, or setting it to `false` +emits the async client only. `emitSync: true` keeps that async root and adds a +`.sync` facade, a sync runtime, and an adjacent sync function in each +canonical statement module. `packageName` becomes an import name by replacing +`-` with `_`. + +`onUnsupported: "Fail"` aborts generation with a path-aware report. +`onUnsupported: "Skip"` preserves warnings and repeatedly removes an +unsupported custom type, its dependent custom types and statements, and every +affected facade, registration, and statement entry until the survivors are a +strict-importable fixed point. + +## Generated package + +The generator owns the package-root facade and the generated subtree. With +`emitSync: true`, the layout is: + +```text +src// + __init__.py + sync/__init__.py + _generated/ + __init__.py + _core.py + _runtime.py + _register.py # when custom types need registration + sync/_runtime.py + statements/__init__.py + statements/.py + types/__init__.py # when custom types exist + types/.py ``` -Encoding passes the string value and lets PostgreSQL coerce it in an -assignment or `::enum` context; decoding constructs the enum from the -returned label. A scalar enum needs no connection registration. An -enum-array *result* column does, because psycopg otherwise hands back the -unregistered array as literal text instead of a list; the generated decode -raises a clear error telling you to call `register_types` in that case. -Enum-array *params* carry an explicit `::[]` cast in the query's SQL -and bind without registration either way. +Each statement has one canonical module. That module owns one `SQL` string, +its frozen and slotted `Row` dataclass when rows are returned, the async +function, and the optional adjacent sync function. The SQL value remains a +string accepted by the runtime as `LiteralString`; it is not encoded to bytes. +For row-returning queries, psycopg's `args_row(Row)` constructs the canonical +dataclass directly through `BaseRowFactory`. The output has no query decoders, +model codecs, `typing.cast` calls, or bytes SQL. -**Composites.** A composite custom type renders as a frozen, slotted -dataclass, one field per member: +The async and sync facades share the same SQL, Row classes, custom types, +`_core`, registration module, and `NoRowError`. The only duplicated I/O layer is +the small sync runtime. Internal generated paths are a greenfield implementation +detail and carry no compatibility promise. -```python -@dataclass(frozen=True, slots=True) -class TagValue: - value: str | None +Every generated Python file begins with this exact first line: + +```text +# @generated by python.gen (pGenie); regeneration overwrites manual changes. ``` -Composite params and results both go through psycopg's `CompositeInfo`, -which does require `register_types(conn)` first. A single-field composite -binds as a one-element Python tuple with the trailing comma Python needs to -tell it apart from a plain parenthesized expression (`(value,)`, not -`(value)`); this was a real gap fixed in this repository's history, and the -fixture now covers it with a dedicated round-trip test. Nested composites -(a composite field whose type is itself a custom type) are not supported; -the generator resolves any such reference to nothing and fails loudly on it -rather than emit a wrong decode. - -**Domains.** A domain custom type has no Python mapping and is rejected with -a message pointing at the workaround: lower the column to its base type in -the analysis-time schema. There is no silent unwrapping. - -**Freeze lifecycle.** pgn caches the resolved generator expression's hash in -a `freeze*.pgn.yaml` file keyed on the artifact's `gen:` value, so repeated -runs skip re-resolving it. That cache does not know when the generator's own -source changed underneath a stable path or URL; if you edit this generator -in place and regenerate against a local checkout, delete the freeze file -first, or pgn will silently reuse the old, cached generator. - -**Query comments and `$params`.** The generator renders each SQL fragment -verbatim, comments included, and turns each bound placeholder into a -`%(name)s` entry keyed by its param name. A `$word` written inside a query -comment is a latent hazard: if pgn's own tokenizer picks it up as a -placeholder, it now needs a matching bound param somewhere in the query, and -if it doesn't, the comment text still ships into the emitted SQL string -unchanged either way. The generator's renderer is deliberately not the place -to fix this (rewriting comment text would make the emitted SQL diverge from -the source `.sql` file); keep it as an authoring rule instead: don't name a -`$param` inside a query comment that the query doesn't also bind. - -## Using the generated code - -Import from the flat facade, not from `_generated` directly: +The next two lines are the REUSE copyright and `SPDX-License-Identifier: MIT-0` +headers. + +## Type support + +Primitive mappings are intentionally conservative: + +| PostgreSQL | Python | +| --- | --- | +| `bool` | `bool` | +| `int2`, `int4`, `int8`, `oid` | `int` | +| `float4`, `float8` | `float` | +| `numeric` | `Decimal` | +| `text`, `varchar`, `bpchar`, `char`, `citext`, `name` | `str` | +| `uuid` | `UUID` | +| `date` | `date` | +| `time` | `time` | +| `timestamp`, `timestamptz` | `datetime` | +| `interval` | `timedelta` | +| `bytea` | `bytes` | +| `json`, `jsonb` | recursive `JsonValue` | + +Primitive arrays preserve dimensionality, element nullability, and outer +nullability. Custom types use psycopg's class-aware adapters and stay ordinary +Python models: + +- PostgreSQL enums become `StrEnum` classes. Scalar enums and enum arrays are + supported, including the rank-2 enum-array contract. +- PostgreSQL composites become `@dataclass(frozen=True, slots=True)` classes. + Scalar load and dump, rank-1 composite arrays, and nested scalar composites + are supported. +- Nullable members and Python-keyword names are preserved safely. SQL names + remain raw while Python fields and parameters gain a trailing underscore when + required. + +Unsupported primitive types, domains, missing custom types, and unsupported +custom kinds fail loudly. Custom-shape limits are enum arrays above rank 2, +composite arrays above rank 1, and any custom-array field nested inside a +composite. A `json` or `jsonb` array parameter is also rejected because wrapping +the whole list is not a faithful element-wise psycopg adaptation. + +## Registration and use + +`register_types` is a precondition for each newly opened connection once the +database types and migrations exist. Async and sync registration are separate +operations because psycopg fetches catalog metadata through the connection. +Registration is dependency-first and class-aware: + +- `EnumInfo.fetch` plus `register_enum` binds the generated `StrEnum` class. +- `CompositeInfo.fetch` plus `register_composite` binds the generated dataclass + with validated object and sequence callbacks. + +Async use: ```python -from my_db_client import get_specimen, GetSpecimenRow, Mood +from my_db_client import get_specimen, register_types + + +async def load(conn, specimen_id: int): + await register_types(conn) + return await get_specimen(conn, id=specimen_id) ``` -The facade re-exports every Row dataclass, every enum/composite, and every -statement function with `X as X` markers and a matching `__all__`. If the -project has composites or enums, call `register_types(conn)` once per -connection before relying on native composite decode or enum-array results; -scalar enums do not need it. +Sync use when `emitSync: true`: + +```python +from my_db_client.sync import get_specimen, register_types + -With `sync: true`, the entire client is generated against -`psycopg.Connection` instead — same import path, same facade shape, only the -function signatures and I/O become synchronous: +def load(conn, specimen_id: int): + register_types(conn) + return get_specimen(conn, id=specimen_id) +``` + +The facades expose the exact same model and error objects: ```python -from my_db_client import get_specimen, GetSpecimenRow, Mood +import my_db_client +from my_db_client import sync -def handler(conn): - row = get_specimen(conn, id=42) +assert my_db_client.GetSpecimenRow is sync.GetSpecimenRow +assert my_db_client.Mood is sync.Mood +assert my_db_client.NoRowError is sync.NoRowError ``` -A project that needs both an async backend and a sync consumer (e.g. a -Dagster pipeline) generates two artifacts pointed at this same `gen:`, one -with `sync: true` and one without, each with its own `packageName` — not one -artifact with both surfaces bundled together. +Cardinality follows pgn without narrowing: optional rows return `Row | None`, +single rows return `Row` or raise `NoRowError`, multiple rows return +`list[Row]`, affected-row statements return `int`, and void statements return +`None`. The generated runtimes implement exactly those five helpers. -## Development +## Taking ownership -This repo's own harness (`tests/`) needs a live PostgreSQL: it runs `pgn -analyse`/`generate` against a real scratch database, diffs the result -against a committed golden tree, type-checks the golden package with -`basedpyright` strict, and round-trips every generated statement. Point it -at a non-default server with `PGN_TEST_DATABASE_URL`; it never touches an -existing database, only the temporary one pgn creates and drops itself. The -full suite takes on the order of ten minutes, most of it pgn's own type -inference against Postgres. The contract test that pins the committed -`.sig1.pgn.yaml` signatures byte-for-byte only runs in CI, gated through a -fork-safe Actions workflow, since it needs database credentials a -fork-triggered run should not receive. +Generated code is ordinary Python and can be taken over deliberately: + +1. Generate the desired client and commit the complete result. +2. Disable every regeneration path first, including local tasks, CI jobs, and + automation that invokes pgn for this artifact. +3. Replace the generated marker with a project-owned header before editing. +4. Maintain the files as ordinary Python. Keep the package structure unless a + deliberate refactor updates imports and consumers together. +5. Run basedpyright strict, Ruff check and format checks, and the project's + database tests before accepting the takeover. + +Do not edit while regeneration is still enabled. The marker is a truthful +overwrite warning, and ownership starts only after that overwrite path is +disabled. + +## Development -Run everything through `mise`; do not invoke `uv`/`pytest` directly. +This repository pins pgn 0.9.1 in `mise.toml`. Run all tools through `mise`: ```bash +mise run install +mise run lint mise run test ``` -## Why psycopg 3 - -Generated clients target psycopg 3 rather than asyncpg. On a real FastAPI -app (Gold Lapel benchmark, March 2026: PostgreSQL 16, a filtered SELECT over -500k rows, a 20-connection pool, 500 concurrent users) psycopg 3 in binary -mode sits within ~23% of asyncpg on throughput, nothing like the "5x" from -synthetic microbenchmarks: - -| metric | asyncpg | psycopg 3 binary | psycopg 3 text | -| ---------------- | ---------- | ---------------- | -------------- | -| p50 latency | 1.2 ms | 1.5 ms | 2.1 ms | -| p99 latency | 8.7 ms | 10.2 ms | 14.3 ms | -| throughput | 12,400 rps | 10,100 rps | 7,800 rps | -| connection setup | 4.2 ms | 2.8 ms | 2.8 ms | - -What psycopg 3 buys in exchange: one API for sync and async (this generator -emits both surfaces over shared types), pipeline mode that asyncpg lacks -(batching independent queries saves 20-100 ms per round trip when the -database is remote), 33% faster connection setup (serverless, autoscaling, -pool churn), and well-defined behavior behind connection poolers. Keep the -scale in mind: the driver accounts for roughly 5-10% of real query latency; -the other 90-95% is indexes, join shapes, and pool sizing. If you chase -speed, use the `psycopg[binary]` extra: text mode is measurably slower. - -## Provenance - -Extracted from a production code base where it generates the data layer for -a 115-query corpus. - -## License - -Three layers, three licenses: - -- **This repository** (templates, interpreters, harness): MIT. - SPDX-License-Identifier: MIT, see [LICENSE](LICENSE). - Copyright (C) 2026 Viacheslav Shvets. -- **Emitted code**: MIT-0. Every emitted file carries an - `SPDX-License-Identifier: MIT-0` header; the template text it reproduces - is granted attribution-free, so the containing project may use, modify, - and distribute it under terms of its own choice with no notice to carry. -- **Release artifacts** (`resolved.dhall` and the `pgenie-python-gen` - wheel): GPL-3.0-or-later as a whole. The release build inlines the - [gen-sdk](https://github.com/pgenie-io/gen-sdk) Dhall contract, which is - GPL-3.0-or-later, so the frozen artifact is distributed under the GPL - (the MIT parts inside it stay MIT). - -The GPL layer does not restrict using the generator or its output. The GPL's -conditions attach to redistribution of the artifact, not to running it: -executing `pgn` with this generator in a build or CI pipeline is the same as -compiling with gcc or generating a parser with bison. None of gen-sdk's code -appears in the emitted Python, so the output carries only the MIT-0 grant -above. +The harness drives real pgn subprocesses against a live PostgreSQL server and +only creates and drops its own scratch databases. Set `PGN_TEST_DATABASE_URL` +for a non-default server. The full verified suite is 49 passed and 0 skipped; +it includes byte-for-byte golden comparison, async and sync round trips, +basedpyright strict, Ruff, generated-quality budgets, unsupported-type modes, +and public identity checks. + +Regenerate the committed fixture only with `mise run golden` and follow +[`tests/golden/README.md`](tests/golden/README.md). The task deletes stale +freeze and artifact state in a temporary fixture before invoking pgn. Never +hand-edit generated fixture files. + +pgn freezes a resolved generator by the literal `gen` value. If source behind a +stable local path or URL changes, delete the fixture's stale freeze file before +regenerating or pgn may reuse the cached generator. + +Remote imports under `src/Deps/` are sha256-pinned. Bump them deliberately, one +at a time, and rerun the harness. The release workflow resolves +`src/package.dhall` into the `resolved.dhall` release asset, then builds the +wheel from those exact bytes. The wheel exposes path, URL, and vendor commands; +PyPI publication remains disabled until its explicit release gate is enabled. + +`fixtures/Exhaustive.dhall` is the contract fixture. It and `buildLookup` rely +on the pgn fork's `Text/equal` builtin, so the standard upstream Dhall evaluator +cannot run the complete contract path. CI uses the pinned fork-aware action. + +## Provenance and license + +The generator was extracted from a production code base where it generates the +data layer for a 115-query corpus. + +- Repository source is MIT, see [LICENSE](LICENSE). +- Emitted Python is MIT-0 under its file headers. +- `resolved.dhall` and the `pgenie-python-gen` wheel are + GPL-3.0-or-later as combined artifacts because they inline gen-sdk. The wheel + carries the pinned GPL text and `wheel/NOTICE`; generated Python contains no + gen-sdk code and remains MIT-0. diff --git a/bench/as-source.sh b/bench/as-source.sh index c16945b..3446e80 100755 --- a/bench/as-source.sh +++ b/bench/as-source.sh @@ -1,15 +1,14 @@ #!/usr/bin/env bash -# Cold-cache benchmark of `pgn generate` on the fixture project, comparing 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. +# Historical cold-cache benchmark of a retired Dhall import-mode experiment. +# Current src/Deps uses ordinary sha256-pinned imports, so results from the +# current checkout do not reproduce the original comparison. # # mise run bench:as-source # # Needs a reachable Postgres; override with PGN_TEST_DATABASE_URL. Each variant # gets a fresh XDG_CACHE_HOME, so every run pays the full cold import cost. # PGN_BEFORE_BIN selects a different pgn for the before variant (e.g. 0.8.0, -# which predates `as Source` and can only run that variant): +# which predates the retired mode and can only run the ordinary-import variant): # # PGN_BEFORE_BIN="$(mise x github:pgenie-io/pgenie@0.8.0 -- sh -c 'command -v pgn')" \ # mise run bench:as-source @@ -30,26 +29,13 @@ prepare() { # $1 = variant root; mirrors the repo layout the fixture expects rm -rf "$1/tests/fixture-project/artifacts" } -# Rewrite Deps to the pre-as-Source form: drop the mode and restore the -# normalized-expression pins (an `as Source` pin hashes the import's source, -# so the two modes need different sha256 values for the same version). +# Legacy rewrite for a historical checkout. Current Deps already use ordinary +# imports and must not be interpreted as the old comparison baseline. strip_as_source() { # $1 = variant root 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. + # The substitution below belongs to the old dependency snapshot. It is a + # no-op for the current ordinary imports and their current hashes. perl -i -pe 's/46b527b071eba96a17e76b4bc5774645714dd5b4355974d221e705aa7c126e77/14c43eec97972ae27afe3386ff937d04db66f84273d5551476361db12d2c4b50/' "$1/src/Deps/Lude.dhall" } diff --git a/bench/generate.sh b/bench/generate.sh index 2769621..e99d038 100755 --- a/bench/generate.sh +++ b/bench/generate.sh @@ -1,8 +1,7 @@ #!/usr/bin/env bash -# 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) +# Historical fixture helper for a retired Dhall import-mode comparison. +# Current src/Deps uses ordinary sha256-pinned imports, so the two labels no +# longer describe distinct current dependency configurations. # Output lands in ./demo-with-as-source or ./demo-without-as-source. # Uses the normal dhall cache; for cold-cache measurements use bench/as-source.sh. set -euo pipefail @@ -23,20 +22,8 @@ rm -rf "$out/tests/fixture-project/artifacts" if [ "$variant" = without ]; then 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. + # These rewrites belong to the old dependency snapshot and are no-ops for + # the current ordinary imports and hashes. perl -i -pe 's/46b527b071eba96a17e76b4bc5774645714dd5b4355974d221e705aa7c126e77/14c43eec97972ae27afe3386ff937d04db66f84273d5551476361db12d2c4b50/' "$out/src/Deps/Lude.dhall" fi diff --git a/docs/upstream-asks.md b/docs/upstream-asks.md index 773c348..453beb6 100644 --- a/docs/upstream-asks.md +++ b/docs/upstream-asks.md @@ -1,136 +1,47 @@ -# Upstream asks: pgn and gen-sdk +# Upstream resolution status: pgn and gen-sdk -This is a negotiation brief, not architecture documentation (that stays in -DESIGN.md). It collects three asks whose implementations live outside this -repository: two against pgn, one against gen-sdk. They travel as one package -because they interlock; the sequencing note in ask 3 explains how. +This brief records the current status of three upstream integration points. +The pgn annotation-metadata and warning-surfacing issues are closed and shipped. +Only the gen-sdk request for a stable custom-type kind or identifier remains +actionable. Architecture details stay in `DESIGN.md`. -## 1. pgn: parse annotation comments into the model +## 1. pgn annotation metadata: resolved -Issue #66: https://github.com/pgenie-io/pgenie/issues/66 +Issue [#66](https://github.com/pgenie-io/pgenie/issues/66) is closed. -### Motivation +The pgn-side move is complete. The generator consumes nullability and +cardinality directly from `Model.Member` and `Model.Result` metadata. The former +SQL comment scanner and annotation bridge have already been removed, so no +generator-side annotation parsing remains. -Nullability and cardinality overrides currently ride in SQL comments that -only this generator parses, after the fact, out of already-parsed query -fragments: one line per subject, `-- @param not null|nullable` (with -a `-- @param [] ...` form for array elements), `-- @column not -null`, and `-- @returns single|optional|scalar|optional scalar`, names -unsigiled. Only pgn, which owns the SQL tokenizer, can carry these into the -model so that consumers do not each re-parse comments on their own. +## 2. pgn warning surfacing: resolved -Observed pgn 0.6.5 behavior, filed separately as a bug (issue #65: -https://github.com/pgenie-io/pgenie/issues/65): pgn tokenizes `$`-tokens -inside SQL comments as if they were live placeholders. A comment-only -`$token` receives a param index in the fragments yet never appears in -`params`, and a comment `$id` merges onto the real `$id` param. This is why -the annotation grammar is unsigiled: a sigiled `-- @param $id ...` line -never reaches a comment scanner as literal text. +Issue [#67](https://github.com/pgenie-io/pgenie/issues/67) is closed. Since pgn +0.7.2, successful generation surfaces reports from `Compiled.warnings`; this +repository pins pgn 0.9.1. -### Proposed change +With `onUnsupported: Skip`, the generator retains a report for every dropped +unit while fixed-point filtering removes unsupported custom types, their +dependents, and affected statements. See `DESIGN.md`, section 8. -pgn parses the per-line, unsigiled comment grammar and reflects it in the -model: +## 3. gen-sdk stable custom kind or identifier: actionable -- Params: `-- @param not null|nullable` (and `-- @param [] ...` - for array elements), reflected as `Member.isNullable` / - `elementIsNullable`. -- Columns: `-- @column not null`, reflected as column nullability. -- Cardinality: `-- @returns single|optional|scalar|optional scalar`, - reflected as `Result` cardinality. +Project-wide custom-type lookup classifies every custom reference as an enum, +composite, or absent. Shipped models are pure declarations with no class +codecs. The lookup classification instead drives support-shape validation, +custom imports and dependencies, and dependency-first psycopg adapter +registration. See `DESIGN.md`, sections 3, 5, and 8. -The names in the comment are bare throughout, never `$`-prefixed. +`Interpreters/Project.dhall` currently implements `buildLookup` by comparing a +custom reference's snake-case name with each project custom type through +`Text/equal`. This local lookup is the generator's sole need for that +pgn-specific builtin. -This is framed as a prototype offer, not a committed ship: we are willing -to build a working prototype of the comment-into-model parsing against pgn -and hand it over, so the grammar can be judged on running code rather than -on a proposal. +The upstream ask is a stable `kind` tag or identifier on `Scalar.Custom`, such +as a `Natural` project index, so the lookup can use an equality-free structural +match. That would remove the local `Text/equal` dependency described in +`DESIGN.md`, section 10. -#### Rejected alternative - -An earlier sketch put the marker on the placeholder itself, an inline -`$name!` (with `$name[]!` for array elements) stripped by pgn before the -statement reached Postgres. Two things sank it. pgn 0.6.5 tokenizes -`$`-tokens inside comments (issue #65), so the sigiled forms are unsafe to -place near live SQL; and a per-use-site marker has a consistency problem, -since the same parameter can appear at several placeholders with nothing -forcing the markers to agree. The per-line comment grammar keeps one -annotation per subject and sidesteps both. - -### What the generator deletes afterwards - -The Dhall comment-scanner (`Pragma.dhall`) and the `-- @param` comment -bridge are deleted outright. The generator then consumes nullability and -cardinality from the model like any other query metadata. - -### Compatibility notes - -User-facing syntax does not change; existing queries keep working -unmodified. The move is purely a shift of parsing responsibility from this -generator into pgn and the model, so no annotation needs rewriting. - -## 2. pgn: print `Compiled.warnings` on successful runs - -Issue #67: https://github.com/pgenie-io/pgenie/issues/67 - -### Motivation - -Skip mode drops the smallest failing unit and keeps the rest of the project -generating, and the generator already collects a report per dropped unit -into `Compiled.warnings` per the contract (DESIGN.md, section 11). pgn does -not surface that list anywhere on a successful run, so Skip currently drops -queries silently. The collection side is done; pgn only needs to print it. - -### Proposed change - -On successful runs, print `Compiled.warnings` to stderr in the same format -as the failure report, keeping exit code 0. Optionally, a -`--fail-on-warnings` flag that raises the exit code when the warnings list -is non-empty, for CI setups that want silent drops to fail the build. - -### What the generator simplifies afterwards - -Nothing to delete; the generator was built ready for this (section 11). -The existing warnings plumbing starts paying off, and the README's caveat -about silent drops in Skip mode goes away. - -### Compatibility notes - -Purely additive: exit codes are unchanged by default, stdout is untouched, -and the new stderr output only appears when warnings are non-empty. -`--fail-on-warnings` is opt-in. - -## 3. gen-sdk: `kind` tag or `Natural` index on `Scalar.Custom` - -### Motivation - -Project-wide custom-type lookup is required for sound Skip behavior. After -an unsupported custom type is removed, every dependent statement must -resolve that reference as absent and be removed too. The same -classification is needed at result and parameter boundaries while the -current class codecs remain, and it is the basis for removing those codecs -in a later stage. - -`Interpreters/Project.dhall` currently matches custom types by snake-case -name through `Text/equal`, a builtin supplied only by pgn's embedded Dhall -runtime. The lookup returns a structural `TypeKind`, so the Text-only -replacement technique used by identifier sanitizing cannot replace it. - -### Proposed change - -As recorded in section 12: a `kind` tag or a `Natural` index carried -directly on `Scalar.Custom`, so the lookup becomes an equality-free -structural match. This is a change to gen-sdk's model types, not something -this generator can do unilaterally. - -### What the generator deletes afterwards - -`buildLookup`'s name-equality matching, which removes the last need for the -forked `Text/equal` builtin in this generator's own code. - -### Compatibility notes - -A new field on `Scalar.Custom` touches every gen-sdk consumer (java.gen -included), so it should be introduced in coordination with them. This -generator pins gen-sdk imports by sha256 and adopts the change on a -deliberate future pin bump. +Changing `Scalar.Custom` affects gen-sdk consumers. This repository pins its +gen-sdk import by sha256, so adopting such a change would require an explicit +pin update and a full harness run. diff --git a/fixtures/Exhaustive.dhall b/fixtures/Exhaustive.dhall index 0b1c0ff..f8cc748 100644 --- a/fixtures/Exhaustive.dhall +++ b/fixtures/Exhaustive.dhall @@ -1,9 +1,8 @@ -- Applies this generator to gen-sdk's shared cross-backend fixture project -- (the same "music_catalogue" project java.gen's own fixtures/Exhaustive.dhall -- exercises), so a Python client compiles from it and passes basedpyright --- strict. Pinned directly at gen-sdk's package.dhall, separately from --- 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. +-- strict. Loading through src/Deps/Sdk.dhall keeps this fixture on the same +-- ordinary sha256-pinned gen-sdk import as the generator. -- -- The fixture project deliberately covers PG types this generator does not -- support (box, inet, money, ranges, ...), so onUnsupported is set to Skip: @@ -26,7 +25,7 @@ let project = Sdk.Fixtures.Exhaustive let config = Some { packageName = None Text - , sync = Some False + , emitSync = Some False , onUnsupported = Some OnUnsupported.Mode.Skip } diff --git a/mise.toml b/mise.toml index 0706e6b..00c0cfb 100644 --- a/mise.toml +++ b/mise.toml @@ -22,22 +22,20 @@ uv run ruff check --config pyproject.toml tests wheel uv run ruff format --check --config pyproject.toml tests wheel ''' -# Cold-cache generate benchmark: current gen (`as Source` Deps) vs the same -# tree with pre-as-Source Deps, same pgn binary, fresh dhall cache per run. -# Needs a reachable Postgres (PGN_TEST_DATABASE_URL). See bench/as-source.sh -# for the PGN_BEFORE_BIN override that runs the before variant on pgn 0.8.0. +# Historical cold-cache import-mode benchmark. Current Deps are ordinary +# sha256-pinned imports, so a current checkout does not reproduce its variants. [tasks."bench:as-source"] -description = "Benchmark cold `pgn generate` with vs without `as Source` Deps" +description = "Run the historical cold import-mode benchmark" run = "bench/as-source.sh" # One-command builds of the fixture client for eyeballing the two variants; # output goes to ./demo-with-as-source / ./demo-without-as-source. [tasks."generate:as-source"] -description = "Build the fixture client with the committed `as Source` Deps" +description = "Run the historical with-mode fixture helper" run = "bench/generate.sh with" [tasks."generate:no-as-source"] -description = "Build the fixture client with the pre-as-Source Deps" +description = "Run the historical ordinary-import fixture helper" run = "bench/generate.sh without" # Regenerates only the combined "python" artifact. A full 7-artifact generate diff --git a/src/Interpreters/Query.dhall b/src/Interpreters/Query.dhall index a69d0e0..257ad81 100644 --- a/src/Interpreters/Query.dhall +++ b/src/Interpreters/Query.dhall @@ -33,12 +33,10 @@ let Compiled = Lude.Compiled let Input = Model.Query --- A query renders to one statement module: its own Row dataclass and decode --- function (when it returns rows), the async function, and optional adjacent --- sync function. rowClassName is still surfaced here (not just --- internal to the rendered content) because Project.dhall's facade needs the --- name to build the re-export line; the Row's full definition does not --- leave this module. +-- A query renders to one canonical statement module: its Row dataclass when it +-- returns rows, the async function, and an optional adjacent sync function. +-- rowClassName is also surfaced because Project.dhall needs the name for facade +-- re-exports; the Row's definition remains in this module. let Output = { functionName : Text , rowClassName : Optional Text @@ -100,10 +98,8 @@ let render = ) result.rowClass - -- The Row's own imports (JsonValue, Decimal, custom types, ...) and - -- the parameters' imports both land in this one file now, so they - -- merge into a single ImportSet instead of flowing to two separate - -- consumers (the statement module and, formerly, _rows.py). + -- Row and parameter imports share the canonical statement file, so they + -- must merge into one ImportSet before rendering. let mergedImports = ImportSet.combine paramImports result.imports let content = diff --git a/tests/fixture-project/queries/list_specimens_keyword_column.sql b/tests/fixture-project/queries/list_specimens_keyword_column.sql index 9adb37f..59ac765 100644 --- a/tests/fixture-project/queries/list_specimens_keyword_column.sql +++ b/tests/fixture-project/queries/list_specimens_keyword_column.sql @@ -1,8 +1,8 @@ --- Keyword result-column coverage: the column aliased to the reserved word class --- must emit a dataclass field and decode kwarg of class_ while the row lookup --- keeps the raw "class". Without sanitization the generated _rows.py has a --- SyntaxError, so this query locks the result-column guard (mirrors --- list_specimens_by_class for params). +-- Keyword result-column coverage: the SQL column aliased to reserved word class +-- stays named "class", while the statement-local Row field is class_. +-- psycopg args_row constructs that Row positionally, so no name remapping +-- can hide a missed sanitizer. Without sanitization the canonical statement +-- is invalid Python; this query locks the result-column guard. SELECT id, title AS "class" diff --git a/tests/golden/README.md b/tests/golden/README.md index 66d8589..b925829 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -1,35 +1,41 @@ -# Golden files +# Golden fixture -A committed full fixture package: the hand-written shell (`pyproject.toml`, -`src/specimen_client/py.typed`) plus the generated subtree under -`src/specimen_client/_generated/` and the generated package-root facade -`src/specimen_client/__init__.py`. The generator emits the `_generated/` subtree and -the root and sync facades; the rest of the shell is hand-written and committed -here as a fixture. +This directory is one committed combined client package. Its hand-written shell +is `pyproject.toml` plus `src/specimen_client/py.typed`. Generator-owned content +is the complete `src/specimen_client/_generated/` subtree, the package-root +`src/specimen_client/__init__.py` facade, and, when `emitSync: true`, the +`src/specimen_client/sync/__init__.py` facade. -`test_generated_matches_golden` regenerates the fixture client into a temp tree -and asserts every generated file under the fresh package, including both facades, -equals its golden twin, both ways (no missing, no extra). -`test_generated_passes_basedpyright_strict` runs basedpyright strict on the full -fresh package (hand-written shell overlaid with both facades and `_generated`) so -the strictness guarantee covers the real consumer layout. This `README.md` and -the hand-written shell stay out of the byte-comparison. +The harness overlays that hand-written shell with a freshly generated package. +It compares every generator-owned file in both directions, checks +basedpyright strict on the full consumer layout, and exercises both connection +surfaces. There is only one golden package because async and sync share the same +canonical SQL, Row classes, custom types, core, and registration module. -## Updating the golden +## Regenerating -Run only when a generator change legitimately alters the output, and review the -resulting diff before committing. Refresh the `_generated/` subtree and the -facade; never overwrite the hand-written `pyproject.toml` or `py.typed`. +Run the repository task only when an intentional generator or source-query +comment change alters output: ```bash -# Default admin URL is localhost:5432; set PGN_TEST_DATABASE_URL to point -# elsewhere (e.g. a local pg0 instance on a non-default port). +# Set PGN_TEST_DATABASE_URL for a PostgreSQL server on a non-default address. mise run golden ``` -The task (see `mise.toml`) copies the fixture project to a temp dir, cuts it -down to the `python` artifact (a full 7-artifact generate peaks at ~31 GB RSS, -a single-artifact one at ~10 GB), regenerates from the working-tree `gen/` -with a fresh resolve (no stale `freeze1.pgn.yaml`), and rsyncs the -`_generated/` subtree plus both facades back into the golden tree. The old -separate sync golden is removed only after all combined copies succeed. +The task performs one serial, memory-heavy generation. It copies +`tests/fixture-project/` to a temporary directory, deletes the copied freeze file +and artifact directory, keeps only the Python artifact, and rewrites its `gen` +reference to the working-tree `src/package.dhall`. After pgn succeeds, it syncs +the generated subtree and copies the root and sync facades into this fixture. +The temporary directory is removed on exit. + +Do not hand-edit generator-owned files. Do not copy over `pyproject.toml` or +`py.typed`. The task does not update analyzed signature YAML, so any signature +change is a separate, explicit review and is unexpected for a SQL-comment-only +refresh. + +After regeneration, review the complete fixture diff before committing: + +```bash +mise exec -- git diff -- tests/golden +``` diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py index cc63ea0..ab5dd48 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py @@ -13,11 +13,11 @@ from ..sync._runtime import fetch_many as _fetch_many_sync SQL = """\ --- Keyword result-column coverage: the column aliased to the reserved word class --- must emit a dataclass field and decode kwarg of class_ while the row lookup --- keeps the raw \"class\". Without sanitization the generated _rows.py has a --- SyntaxError, so this query locks the result-column guard (mirrors --- list_specimens_by_class for params). +-- Keyword result-column coverage: the SQL column aliased to reserved word class +-- stays named \"class\", while the statement-local Row field is class_. +-- psycopg args_row constructs that Row positionally, so no name remapping +-- can hide a missed sanitizer. Without sanitization the canonical statement +-- is invalid Python; this query locks the result-column guard. SELECT id, title AS \"class\" diff --git a/wheel/NOTICE b/wheel/NOTICE index c057ecc..bab453d 100644 --- a/wheel/NOTICE +++ b/wheel/NOTICE @@ -14,4 +14,4 @@ because resolved.dhall inlines the gen-sdk Dhall contract. Components: Code that this generator emits into your project is not covered by the GPL: emitted files carry an SPDX-License-Identifier: MIT-0 header and may be used under the containing project's own terms. See the python.gen README, -section License. +section Provenance and license. diff --git a/wheel/pyproject.toml b/wheel/pyproject.toml index 7f4184d..6caa40e 100644 --- a/wheel/pyproject.toml +++ b/wheel/pyproject.toml @@ -8,7 +8,8 @@ description = "The python.gen frozen Dhall generator, shipped as an installable requires-python = ">=3.12" # The wheel bundles resolved.dhall, which inlines the GPL-3.0-or-later gen-sdk, # so the artifact as a whole ships under the GPL. The python.gen sources are -# MIT and emitted code is MIT-0; see the repository README's License section. +# MIT and emitted code is MIT-0; see the repository README's +# Provenance and license section. license = "GPL-3.0-or-later" dynamic = ["version"] dependencies = [] From df3831d5bc8d1adb4547e5e92e7f9a0fce49b3d0 Mon Sep 17 00:00:00 2001 From: Viacheslav Shvets Date: Mon, 13 Jul 2026 15:34:07 +0300 Subject: [PATCH 33/41] fix(gen): reject generated python name collisions --- CHANGELOG.md | 17 +- DESIGN.md | 45 +- README.md | 46 +- .../0001-generated-python-name-collisions.md | 74 ++ docs/upstream-asks.md | 28 +- src/Interpreters/CustomType.dhall | 132 ++- src/Interpreters/Member.dhall | 41 +- src/Interpreters/ParamsMember.dhall | 42 +- src/Interpreters/Project.dhall | 773 +++++++++++++++++- src/Interpreters/Query.dhall | 35 +- src/Interpreters/Value.dhall | 5 +- src/Structures/CustomKind.dhall | 9 +- src/Structures/PyIdent.dhall | 83 +- src/Structures/PythonNameMapping.dhall | 48 ++ src/Structures/PythonNamespace.dhall | 97 +++ src/package.dhall | 9 +- tests/test_python_name_collisions.py | 687 ++++++++++++++++ tests/test_unsupported_types.py | 14 +- 18 files changed, 2082 insertions(+), 103 deletions(-) create mode 100644 docs/adr/0001-generated-python-name-collisions.md create mode 100644 src/Structures/PythonNameMapping.dhall create mode 100644 src/Structures/PythonNamespace.dhall create mode 100644 tests/test_python_name_collisions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 87564c2..265869d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Upcoming +- Added fail-loud, namespace-wide validation for generated Python names. Query + and custom-type collisions can be resolved with typed, whole-entity mappings; + invalid, reserved, duplicate, and unknown mappings are rejected. Defensive + local-member audits identify both sources when such names reach the generator; + pgn may reject them earlier, and resolution is a SQL or schema rename. The + generator never silently overwrites files or assigns numeric suffixes. + Per-type module audits also reject a mapped class that would shadow an actual + primitive, core, or custom dependency import. + - Finalized one additive package surface. Async functions remain at the package root for every configuration. `emitSync: true` adds `.sync`, a sync runtime, and adjacent sync functions in the same canonical statement modules. @@ -22,8 +31,10 @@ - Retained `buildLookup` as the sound project-wide custom-kind resolver. Its `Text/equal` use is an explicit pgn-fork constraint; a stable custom kind or - identifier in the upstream contract is the planned exit. Natural project - indexes now provide deterministic custom-import deduplication and ordering. + qualified identifier in the upstream contract is the planned exit. pgn 0.9.1 + can collapse same-unqualified-name types across schemas, so that database + shape remains unsupported and cannot be repaired by a Python mapping. Natural + project indexes now provide deterministic custom-import deduplication and ordering. Query, parameter, field, and private statement names are collision-safe while SQL names remain unchanged. @@ -48,7 +59,7 @@ class-aware adapters, pure models, canonical `args_row` Rows, and no query decoders, model codecs, casts, or bytes SQL. H2 CONFIRM: 25 Python files / 1467 lines / 11 statement files / 801 statement lines / 11 SQL. H3 CONFIRM: - Ruff 0/0, authored long0, SQL long0, raw output/no postformat. Tests: 49 + Ruff 0/0, authored long0, SQL long0, raw output/no postformat. Tests: 73 passed, 0 skipped; pgn 0.9.1; strict basedpyright 0/0. - Migrated the generator to gen-contract v4.0.1 and gen-sdk v2.0.0 using diff --git a/DESIGN.md b/DESIGN.md index 9ce6322..cf740ce 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -165,6 +165,14 @@ The public Dhall config is: { packageName : Optional Text , emitSync : Optional Bool , onUnsupported : Optional < Fail | Skip > +, queryNameMappings : Optional + (List { source : Text, target : { snakeCase : Text, pascalCase : Text } }) +, customTypeNameMappings : Optional + ( List + { source : { schema : Text, name : Text } + , target : { snakeCase : Text, pascalCase : Text } + } + ) } ``` @@ -174,11 +182,36 @@ The public Dhall config is: - `packageName`: project name in kebab case; - `emitSync`: `False`; - `onUnsupported`: `Fail`. +- `queryNameMappings`: `[]`; +- `customTypeNameMappings`: `[]`. An omitted config block, an omitted field, and a `null` field therefore use the same fallback. Async output is present for every value of `emitSync`; only true adds sync output. +Source-derived identifiers first receive lexical and reserved-name escaping. +Typed mappings replace a complete query or custom-type Python identity and must +already contain exact valid targets. After resolution, project and local +namespace audits reject duplicate modules, functions, Row classes, custom +types, facade exports, parameters, fields, and enum members before rendering. +Each custom-type module also audits its class against the primitive, core, and +custom dependency symbols it actually imports. +Fixed core exports remain occupied. The generator never silently overwrites a +file or assigns an order-dependent numeric suffix; see +[ADR 0001](docs/adr/0001-generated-python-name-collisions.md). + +Custom-type mapping is exact only for entities preserved in the input contract. +pgn 0.9.1 can collapse same-unqualified-name types across schemas and expose an +unqualified `Scalar.Custom` reference. The generator cannot reconstruct the +discarded schema identity from SQL and rejects duplicate unqualified contract +names if they do reach it. Until upstream preserves all schema-qualified types +and a stable qualified reference, such database shapes are unsupported. + +Local member audits are defensive for inputs that reach the generator. pgn may +reject a conflicting SQL or schema spelling during analysis first. Local +conflicts are resolved by renaming that SQL or schema source; whole-entity query +and custom-type mappings do not apply to members. + `Interpreters/Primitive.dhall` maps pgn union constructors, not signature-file strings. Supported scalars are Boolean, integer and OID, floating point, numeric, textual, UUID, date/time/timestamp/interval, bytea, and JSON. Arrays @@ -321,10 +354,12 @@ available in upstream standard Dhall. The dependency is pinned and explicit. The complete fixture needs fork-aware evaluation solely because it invokes this generator and the local `buildLookup` -uses `Text/equal`. The upstream exit is a stable custom kind or identifier on -the contract's custom scalar reference, which would remove the need for text -equality. Until then, pgn and CI's pinned fork-aware action are the supported -evaluators. +uses `Text/equal`. The upstream exit must preserve every schema-qualified +`customTypes` entry and put a stable qualified identifier, or a project index +with equivalent identity, on each custom scalar reference. Besides removing +text equality, that prevents pgn 0.9.1 from collapsing same-unqualified-name +types across schemas. Until then, pgn and CI's pinned fork-aware action are the +supported evaluators, and cross-schema duplicate type names are unsupported. `PyIdent.dhall` uses its separate `Text/replace` marker construction for keyword membership. `ImportSet.dhall` uses natural project indexes for equality, @@ -358,7 +393,7 @@ PostgreSQL with these verdicts: - H2 CONFIRM: 25 Python files / 1467 lines / 11 statement files / 801 statement lines / 11 SQL. - H3 CONFIRM: Ruff 0/0, authored long0, SQL long0, raw output/no postformat. -- Tests: 49 passed, 0 skipped; pgn 0.9.1; strict basedpyright 0/0. +- Tests: 73 passed, 0 skipped; pgn 0.9.1; strict basedpyright 0/0. The H1 round trip covers both connection surfaces and exact cross-facade identities. It covers scalar enum, enum arrays including rank 2, scalar diff --git a/README.md b/README.md index 2019282..5f9d0d1 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ freeze key machine-specific; pgn project configuration does not accept a | `packageName` | `Text` | project name in kebab case | | `emitSync` | `Bool` | `False` | | `onUnsupported` | `"Fail" \| "Skip"` | `"Fail"` | +| `queryNameMappings` | typed query-name mappings | `[]` | +| `customTypeNameMappings` | typed PostgreSQL type-name mappings | `[]` | The `config` block and every field are optional. An omitted or `null` field uses its default. Omitting `emitSync`, setting it to `null`, or setting it to `false` @@ -57,6 +59,48 @@ unsupported custom type, its dependent custom types and statements, and every affected facade, registration, and statement entry until the survivors are a strict-importable fixed point. +Generated Python namespaces are audited before files are rendered. Collisions +fail loudly instead of overwriting a file or receiving an order-dependent +numeric suffix. An intentional public rename uses one typed mapping for the +entity's snake-case module/function name and Pascal-case Row/type name: + +```yaml +queryNameMappings: + - source: sync_query + target: + snakeCase: synchronize + pascalCase: Synchronize +customTypeNameMappings: + - source: + schema: public + name: json_value + target: + snakeCase: pg_json_value + pascalCase: PgJsonValue +``` + +Mapping sources must identify an existing query or exact PostgreSQL schema/type +pair. A query source is pgn's snake-case query name; a custom-type source is the +exact PostgreSQL schema and type name preserved in the input contract. Targets +are final Python names: invalid or reserved targets are rejected, and +PostgreSQL names remain unchanged. +`PgJsonValue` above is a user-chosen Python alias for `public.json_value`, not a +built-in type or automatic naming rule. + +pgn 0.9.1 does not preserve two custom types that share one unqualified name +across schemas: it can retain only one `customTypes` entry, while +`Scalar.Custom` carries only an unqualified `Name`. A mapping cannot recover a +schema-qualified identity missing from the contract. Avoid that database shape +until upstream preserves every schema-qualified type and a stable qualified +reference; if a future input contains two entries with the same unqualified +contract `Name`, this generator rejects it as ambiguous. + +Local parameter, result-field, composite-field, and enum-member audits are +defensive for names that reach the generator. pgn may reject a conflicting SQL +or schema spelling earlier. Such a conflict is resolved at its SQL or schema +source; the entity mappings above do not apply. See +[ADR 0001](docs/adr/0001-generated-python-name-collisions.md). + ## Generated package The generator owns the package-root facade and the generated subtree. With @@ -216,7 +260,7 @@ mise run test The harness drives real pgn subprocesses against a live PostgreSQL server and only creates and drops its own scratch databases. Set `PGN_TEST_DATABASE_URL` -for a non-default server. The full verified suite is 49 passed and 0 skipped; +for a non-default server. The full verified suite is 73 passed and 0 skipped; it includes byte-for-byte golden comparison, async and sync round trips, basedpyright strict, Ruff, generated-quality budgets, unsupported-type modes, and public identity checks. diff --git a/docs/adr/0001-generated-python-name-collisions.md b/docs/adr/0001-generated-python-name-collisions.md new file mode 100644 index 0000000..0eb74d3 --- /dev/null +++ b/docs/adr/0001-generated-python-name-collisions.md @@ -0,0 +1,74 @@ +# ADR 0001: Fail loudly on generated Python name collisions + +Status: Accepted + +## Context + +Different PostgreSQL names can resolve to the same Python name. For example, +the query names `sync` and `sync_query` both resolved to the module and function +`sync_query`, so one generated file silently replaced the other. Similar +collisions can affect Row classes, custom types, facade exports, and members +after Python keyword escaping. + +Silent overwrite loses code. Order-based suffixes such as `name2` also make a +public API change when an unrelated declaration is added or reordered. + +## Decision + +Lexical handling and uniqueness are separate stages: + +1. Source-derived defaults are escaped for Python keywords and generator-owned + implementation names. +2. Optional typed mappings resolve a declaration's complete Python identity. +3. Explicit targets must already be exact, valid, non-reserved Python names. + Snake-case targets start with a lowercase ASCII letter, and Pascal-case + targets start with an uppercase ASCII letter. Facade and module metadata + dunder names cannot be overwritten. Invalid targets are rejected rather + than silently rewritten. +4. The generator audits each Python namespace selected for emission after name + resolution and before rendering files. A collision stops generation and + identifies both owners and the final name. + +The greenfield configuration uses entity-typed mappings: + +```dhall +let PythonName = { snakeCase : Text, pascalCase : Text } + +let QueryNameMapping = { source : Text, target : PythonName } + +let CustomTypeNameMapping = + { source : { schema : Text, name : Text }, target : PythonName } +``` + +For a query, `snakeCase` owns the statement module and async/root public name; +the sync facade exports that same public name while its adjacent implementation +function adds `_sync`. `pascalCase` owns `${pascalCase}Row`. For a custom type, +`snakeCase` owns the type module and imports, while `pascalCase` owns the class, +facade export, and adapter registration. PostgreSQL and SQL names never change. +Duplicate or unknown mapping sources, invalid targets, and mapped collisions all +fail before files are emitted. Fixed package names such as `JsonValue`, +`NoRowError`, `register_types`, and `sync` cannot be remapped. `PgJsonValue` is +an example explicit user alias, not an automatic naming policy. + +Local parameter, result-field, composite-field, and enum-member audits are +defensive for inputs that reach the generator; pgn may reject the conflicting +source spelling earlier. They are resolved by renaming the SQL placeholder, SQL +alias, or schema member. A future local override, if needed, must use a typed, +parent-scoped mapping instead of a string path grammar. + +The structured custom-type source is exact only when the input contract +preserves the entity. pgn 0.9.1 can collapse types with the same unqualified +name across schemas, and `Scalar.Custom` has no qualified identity. A mapping +cannot recover that lost distinction. Such database shapes remain unsupported +until upstream preserves all qualified custom types and references. + +## Consequences + +- Generation cannot silently overwrite a file or export. +- Adding or reordering a declaration cannot renumber existing public names. +- Intentional renames are deterministic and propagate through all references. +- A conflicting source requires either a source-level rename or an explicit + typed mapping. +- Mapping configuration is more verbose than automatic suffixing, but its shape + is type-checked and represented raw identifiers containing punctuation remain + unambiguous. diff --git a/docs/upstream-asks.md b/docs/upstream-asks.md index 453beb6..430fd50 100644 --- a/docs/upstream-asks.md +++ b/docs/upstream-asks.md @@ -2,8 +2,8 @@ This brief records the current status of three upstream integration points. The pgn annotation-metadata and warning-surfacing issues are closed and shipped. -Only the gen-sdk request for a stable custom-type kind or identifier remains -actionable. Architecture details stay in `DESIGN.md`. +Only the custom-type identity request remains actionable. Architecture details +stay in `DESIGN.md`. ## 1. pgn annotation metadata: resolved @@ -24,7 +24,7 @@ With `onUnsupported: Skip`, the generator retains a report for every dropped unit while fixed-point filtering removes unsupported custom types, their dependents, and affected statements. See `DESIGN.md`, section 8. -## 3. gen-sdk stable custom kind or identifier: actionable +## 3. Preserve qualified custom-type identity: actionable Project-wide custom-type lookup classifies every custom reference as an enum, composite, or absent. Shipped models are pure declarations with no class @@ -35,12 +35,24 @@ registration. See `DESIGN.md`, sections 3, 5, and 8. `Interpreters/Project.dhall` currently implements `buildLookup` by comparing a custom reference's snake-case name with each project custom type through `Text/equal`. This local lookup is the generator's sole need for that -pgn-specific builtin. +pgn-specific builtin, but the unqualified comparison also exposes an upstream +identity gap. -The upstream ask is a stable `kind` tag or identifier on `Scalar.Custom`, such -as a `Natural` project index, so the lookup can use an equality-free structural -match. That would remove the local `Text/equal` dependency described in -`DESIGN.md`, section 10. +With pgn 0.9.1, a project containing `alpha.status` and `beta.status` can arrive +with only one `customTypes` entry, while both uses are represented by the same +unqualified `Scalar.Custom Name`. A Python mapping cannot recover the discarded +schema or safely repair annotations and adapter registration. + +The upstream ask has two inseparable parts: + +1. Preserve every schema-qualified custom type in `Project.customTypes`. +2. Put a stable qualified identifier on `Scalar.Custom`, for example a project + index whose target retains schema and PostgreSQL name. + +That identifier may also carry a stable kind tag, but a kind without identity is +not enough. The qualified reference lets lookup use an equality-free structural +match and removes the local `Text/equal` dependency described in `DESIGN.md`, +section 10. Changing `Scalar.Custom` affects gen-sdk consumers. This repository pins its gen-sdk import by sha256, so adopting such a change would require an explicit diff --git a/src/Interpreters/CustomType.dhall b/src/Interpreters/CustomType.dhall index 1396890..2c33074 100644 --- a/src/Interpreters/CustomType.dhall +++ b/src/Interpreters/CustomType.dhall @@ -10,6 +10,12 @@ let CustomKind = ../Structures/CustomKind.dhall let OnUnsupported = ../Structures/OnUnsupported.dhall +let PythonNameMapping = ../Structures/PythonNameMapping.dhall + +let PythonNamespace = ../Structures/PythonNamespace.dhall + +let PyIdent = ../Structures/PyIdent.dhall + let MemberGen = ./Member.dhall let EnumModule = ../Templates/EnumModule.dhall @@ -21,6 +27,7 @@ let Config = , importName : Text , emitSync : Bool , onUnsupported : OnUnsupported.Mode + , customTypeNameMappings : List PythonNameMapping.CustomType } let Input = Model.CustomType @@ -37,6 +44,7 @@ let Output = , kind : TypeKind , order : Natural , dependencies : List Natural + , moduleBindings : List PythonNamespace.Binding } let renderExtraImports = @@ -75,16 +83,110 @@ let renderExtraImports = ) # customLines +let moduleNamespace = + \(input : Input) -> + "custom type module for schema ${Text/show input.pgSchema}, type ${Text/show input.pgName}" + +let moduleBinding = + \(namespace : Text) -> + \(owner : Text) -> + \(name : Text) -> + { namespace + , owner + , name + , remediation = "Choose a different custom type name mapping target" + } + +let enumNamespaceBindings = + \(input : Input) -> + \(typeName : Text) -> + let namespace = moduleNamespace input + + in [ moduleBinding + namespace + "custom type class for schema ${Text/show input.pgSchema}, type ${Text/show input.pgName}" + typeName + , moduleBinding namespace "enum base import" "StrEnum" + ] + +let compositeNamespaceBindings = + \(input : Input) -> + \(typeName : Text) -> + \(imports : ImportSet.Type) -> + let namespace = moduleNamespace input + + let imported = + ( if imports.uuid + then [ moduleBinding namespace "UUID primitive import" "UUID" ] + else [] : List PythonNamespace.Binding + ) + # ( if imports.datetime + then [ moduleBinding namespace "datetime primitive import" "datetime" ] + else [] : List PythonNamespace.Binding + ) + # ( if imports.date + then [ moduleBinding namespace "date primitive import" "date" ] + else [] : List PythonNamespace.Binding + ) + # ( if imports.time + then [ moduleBinding namespace "time primitive import" "time" ] + else [] : List PythonNamespace.Binding + ) + # ( if imports.timedelta + then [ moduleBinding namespace "timedelta primitive import" "timedelta" ] + else [] : List PythonNamespace.Binding + ) + # ( if imports.decimal + then [ moduleBinding namespace "Decimal primitive import" "Decimal" ] + else [] : List PythonNamespace.Binding + ) + # ( if imports.jsonValue + then [ moduleBinding namespace "generated core import" "JsonValue" ] + else [] : List PythonNamespace.Binding + ) + + let customImports = + Prelude.List.map + ImportSet.CustomImport + PythonNamespace.Binding + ( \(custom : ImportSet.CustomImport) -> + moduleBinding + namespace + "custom dependency import \".${custom.moduleName}.${custom.className}\"" + custom.className + ) + imports.customTypes + + in [ moduleBinding + namespace + "custom type class for schema ${Text/show input.pgSchema}, type ${Text/show input.pgName}" + typeName + , moduleBinding namespace "dataclass decorator import" "dataclass" + ] + # imported + # customImports + let run = \(config : Config) -> \(lookup : CustomKind.Lookup) -> \(input : Input) -> - let typeName = input.name.inPascalCase + let pythonName = + PythonNameMapping.resolveCustomType + config.customTypeNameMappings + { schema = input.pgSchema, name = input.pgName } + { snakeCase = PyIdent.typeModuleSafeName input.name.inSnakeCase + , pascalCase = PyIdent.pySafeName input.name.inPascalCase + } + + let typeName = pythonName.pascalCase - let moduleName = input.name.inSnakeCase + let moduleName = pythonName.snakeCase let modulePath = "types/${moduleName}.py" + let coreConfig = + config.{ packageName, importName, emitSync, onUnsupported } + in merge { Enum = \(variants : List Model.EnumVariant) -> @@ -101,7 +203,7 @@ let run = in merge { Enum = - \(order : Natural) -> + \(identity : CustomKind.Identity) -> Lude.Compiled.ok Output { modulePath @@ -115,13 +217,15 @@ let run = , pgSchema = input.pgSchema , pgName = input.pgName , kind = TypeKind.Enum - , order + , order = identity.order , dependencies = [] : List Natural + , moduleBindings = + enumNamespaceBindings input typeName } , Composite = \ ( _ : { fields : List CustomKind.CompositeField - , order : Natural + , identity : CustomKind.Identity } ) -> Lude.Compiled.report @@ -146,7 +250,7 @@ let run = merge { Primitive = \(_ : Model.Primitive) -> - MemberGen.run config lookup m + MemberGen.run coreConfig lookup m , Custom = \(name : Model.Name) -> Prelude.Optional.fold @@ -159,7 +263,7 @@ let run = [ m.pgName, name.inSnakeCase ] "Custom array fields inside a composite type are not supported" ) - (MemberGen.run config lookup m) + (MemberGen.run coreConfig lookup m) } m.value.scalar ) @@ -201,9 +305,8 @@ let run = in merge { Composite = \ ( composite - : { fields : - List CustomKind.CompositeField - , order : Natural + : { fields : List CustomKind.CompositeField + , identity : CustomKind.Identity } ) -> Lude.Compiled.ok @@ -222,11 +325,16 @@ let run = , pgSchema = input.pgSchema , pgName = input.pgName , kind = TypeKind.Composite - , order = composite.order + , order = composite.identity.order , dependencies + , moduleBindings = + compositeNamespaceBindings + input + typeName + combinedImports } , Enum = - \(_ : Natural) -> + \(_ : CustomKind.Identity) -> Lude.Compiled.report Output [ input.pgName ] diff --git a/src/Interpreters/Member.dhall b/src/Interpreters/Member.dhall index 48e92ec..15b0e04 100644 --- a/src/Interpreters/Member.dhall +++ b/src/Interpreters/Member.dhall @@ -23,7 +23,11 @@ let Config = let Input = Model.Member -let Output = { fieldName : Text, pyType : Text, imports : ImportSet.Type } +let Output = + { fieldName : Text + , pyType : Text + , imports : ImportSet.Type + } let runWithPrefix = \(prefix : Text) -> @@ -40,8 +44,6 @@ let runWithPrefix = \(value : Value.Output) -> let nullableSuffix = if input.isNullable then " | None" else "" - let pyType = Value.qualifyCustom prefix value ++ nullableSuffix - let baseImports = value.imports in merge @@ -49,7 +51,7 @@ let runWithPrefix = Lude.Compiled.ok Output { fieldName - , pyType + , pyType = value.pyType ++ nullableSuffix , imports = baseImports } , Custom = @@ -58,19 +60,16 @@ let runWithPrefix = 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 = + \(identity : CustomKind.Identity) -> \(customImports : ImportSet.Type) -> { fieldName - , pyType + , pyType = + Value.qualifyCustom + prefix + identity.className + value + ++ nullableSuffix , imports = ImportSet.combine baseImports customImports } @@ -83,15 +82,15 @@ let runWithPrefix = in merge { Enum = - \(order : Natural) -> + \(identity : CustomKind.Identity) -> let enumImport = ImportSet.customEnum - (customImport order) + identity in if dimsAtMostTwo then Lude.Compiled.ok Output - (mkOutput enumImport) + (mkOutput identity enumImport) else Lude.Compiled.report Output [ input.pgName @@ -100,17 +99,17 @@ let runWithPrefix = "Array of an enum with dimensionality > 2 is not supported" , Composite = \ ( composite - : { fields : - List CustomKind.CompositeField - , order : Natural + : { fields : List CustomKind.CompositeField + , identity : CustomKind.Identity } ) -> if dimsAtMostOne then Lude.Compiled.ok Output ( mkOutput + composite.identity ( ImportSet.customComposite - (customImport composite.order) + composite.identity ) ) else Lude.Compiled.report diff --git a/src/Interpreters/ParamsMember.dhall b/src/Interpreters/ParamsMember.dhall index ecd4d82..1d9761a 100644 --- a/src/Interpreters/ParamsMember.dhall +++ b/src/Interpreters/ParamsMember.dhall @@ -240,9 +240,8 @@ let run = let buildOutput = \(value : Value.Output) -> - let pyType = - Value.qualifyCustom "_db_types." value - ++ (if input.isNullable then " | None" else "") + let nullableSuffix = + if input.isNullable then " | None" else "" let jsonImport = if needsJsonbImport @@ -251,6 +250,7 @@ let run = let mkOutput = \(typeImports : ImportSet.Type) -> + \(pyType : Text) -> \(bindExpr : Text) -> { fieldName , pgName = input.pgName @@ -264,13 +264,6 @@ let run = value.scalar.customRef (Lude.Compiled.Type Output) ( \(name : Model.Name) -> - let customImport = - \(order : Natural) -> - { className = name.inPascalCase - , moduleName = name.inSnakeCase - , order - } - let dimsAtMostTwo = Natural/isZero (Natural/subtract 2 value.dims) @@ -279,10 +272,10 @@ let run = in merge { Enum = - \(order : Natural) -> + \(identity : CustomKind.Identity) -> let enumImport = ImportSet.customEnum - (customImport order) + identity in if dimsAtMostTwo then Lude.Compiled.ok @@ -292,6 +285,12 @@ let run = value.imports enumImport ) + ( Value.qualifyCustom + "_db_types." + identity.className + value + ++ nullableSuffix + ) fieldName ) else Lude.Compiled.report @@ -302,9 +301,8 @@ let run = "Array of an enum parameter with dimensionality > 2 is not supported" , Composite = \ ( composite - : { fields : - List CustomKind.CompositeField - , order : Natural + : { fields : List CustomKind.CompositeField + , identity : CustomKind.Identity } ) -> if dimsAtMostOne @@ -314,9 +312,15 @@ let run = ( ImportSet.combine value.imports ( ImportSet.customComposite - (customImport composite.order) + composite.identity ) ) + ( Value.qualifyCustom + "_db_types." + composite.identity.className + value + ++ nullableSuffix + ) fieldName ) else Lude.Compiled.report @@ -338,7 +342,11 @@ let run = "json/jsonb array as a parameter is not supported" else Lude.Compiled.ok Output - (mkOutput value.imports defaultBind) + ( mkOutput + value.imports + (value.pyType ++ nullableSuffix) + defaultBind + ) ) let compiledValue diff --git a/src/Interpreters/Project.dhall b/src/Interpreters/Project.dhall index 8c0dce3..5b38f83 100644 --- a/src/Interpreters/Project.dhall +++ b/src/Interpreters/Project.dhall @@ -30,6 +30,10 @@ let FacadeModule = ../Templates/FacadeModule.dhall let OnUnsupported = ../Structures/OnUnsupported.dhall +let PythonNameMapping = ../Structures/PythonNameMapping.dhall + +let PythonNamespace = ../Structures/PythonNamespace.dhall + let Report = { path : List Text, message : Text } -- The generator's public Config: every field is independently Optional, so a @@ -41,21 +45,471 @@ let Config = { packageName : Optional Text , emitSync : Optional Bool , onUnsupported : Optional OnUnsupported.Mode + , queryNameMappings : Optional (List PythonNameMapping.Query) + , customTypeNameMappings : Optional (List PythonNameMapping.CustomType) } --- The fully-resolved shape every downstream interpreter (Query, CustomType, --- and everything below them) actually declares as its own `Config`. +-- The root resolves every public option once. Query and CustomType receive only +-- their own mapping lists; lower interpreters receive the four scalar fields so +-- a non-empty mapping does not multiply through every member normalization. let ResolvedConfig = { packageName : Text , importName : Text , emitSync : Bool , onUnsupported : OnUnsupported.Mode + , queryNameMappings : List PythonNameMapping.Query + , customTypeNameMappings : List PythonNameMapping.CustomType } let Input = Model.Project let Output = Lude.Files.Type +let QueryMappingState = + { seen : List Text, error : Optional Report } + +let CustomTypeMappingState = + { seen : List PythonNameMapping.CustomTypeSource + , error : Optional Report + } + +let CustomTypeIdentity = + { contractName : Text, source : PythonNameMapping.CustomTypeSource } + +let CustomTypeIdentityState = + { seen : List CustomTypeIdentity, error : Optional Report } + +let finishMappingValidation = + \(stateError : Optional Report) -> + Prelude.Optional.fold + Report + stateError + (Lude.Compiled.Type {}) + (\(report : Report) -> (Lude.Compiled.Type {}).Err report) + (Lude.Compiled.ok {} {=}) + +let validateCustomTypeIdentities = + \(customTypes : List Model.CustomType) -> + let step = + \(customType : Model.CustomType) -> + \(state : CustomTypeIdentityState) -> + Prelude.Optional.fold + Report + state.error + CustomTypeIdentityState + (\(_ : Report) -> state) + ( let contractName = customType.name.inSnakeCase + + let previous = + List/fold + CustomTypeIdentity + state.seen + (Optional PythonNameMapping.CustomTypeSource) + ( \(identity : CustomTypeIdentity) -> + \(found : Optional PythonNameMapping.CustomTypeSource) -> + if Text/equal + identity.contractName + contractName + then Some identity.source + else found + ) + (None PythonNameMapping.CustomTypeSource) + + let error = + Prelude.Optional.fold + PythonNameMapping.CustomTypeSource + previous + (Optional Report) + ( \(source : PythonNameMapping.CustomTypeSource) -> + Some + { path = [ "customTypes", contractName ] + , message = + "Ambiguous unqualified custom type identity " + ++ Text/show contractName + ++ ": schema " + ++ Text/show source.schema + ++ ", type " + ++ Text/show source.name + ++ " and schema " + ++ Text/show customType.pgSchema + ++ ", type " + ++ Text/show customType.pgName + ++ " share one contract Name. The upstream contract must preserve a distinct schema-qualified custom identifier; Python mappings cannot recover it" + } + ) + (None Report) + + let identity = + { contractName + , source = + { schema = customType.pgSchema + , name = customType.pgName + } + } + + in { seen = state.seen # [ identity ], error } + ) + + let state = + List/fold + Model.CustomType + customTypes + CustomTypeIdentityState + step + { seen = [] : List CustomTypeIdentity + , error = None Report + } + + in finishMappingValidation state.error + +let validateQueryMappings = + \(mappings : List PythonNameMapping.Query) -> + \(queries : List Model.Query) -> + let step = + \(mapping : PythonNameMapping.Query) -> + \(state : QueryMappingState) -> + Prelude.Optional.fold + Report + state.error + QueryMappingState + (\(_ : Report) -> state) + ( let duplicate = + Prelude.List.any + Text + (\(source : Text) -> Text/equal source mapping.source) + state.seen + + let known = + Prelude.List.any + Model.Query + ( \(query : Model.Query) -> + Text/equal query.name.inSnakeCase mapping.source + ) + queries + + let snakeIsExact = + PyIdent.isSnakeIdentifier mapping.target.snakeCase + && Text/equal + (PyIdent.querySafeName mapping.target.snakeCase) + mapping.target.snakeCase + + let pascalIsExact = + PyIdent.isPascalIdentifier mapping.target.pascalCase + && Text/equal + (PyIdent.pySafeName mapping.target.pascalCase) + mapping.target.pascalCase + + let error = + if duplicate + then Some + { path = + [ "config" + , "queryNameMappings" + , mapping.source + ] + , message = + "Duplicate query name mapping source \"${mapping.source}\"" + } + else if Prelude.Bool.not known + then Some + { path = + [ "config" + , "queryNameMappings" + , mapping.source + ] + , message = + "Unknown query name mapping source \"${mapping.source}\"" + } + else if Prelude.Bool.not snakeIsExact + then Some + { path = + [ "config" + , "queryNameMappings" + , mapping.source + , "target" + , "snakeCase" + ] + , message = + "Mapped query snakeCase must start with a lowercase ASCII letter, then contain only lowercase ASCII letters, digits, or underscores, and must not be reserved" + } + else if Prelude.Bool.not pascalIsExact + then Some + { path = + [ "config" + , "queryNameMappings" + , mapping.source + , "target" + , "pascalCase" + ] + , message = + "Mapped query pascalCase must start with an uppercase ASCII letter, then contain only ASCII letters or digits, and must not be reserved" + } + else None Report + + in { seen = state.seen # [ mapping.source ], error } + ) + + let state = + List/fold + PythonNameMapping.Query + mappings + QueryMappingState + step + { seen = [] : List Text, error = None Report } + + in finishMappingValidation state.error + +let validateCustomTypeMappings = + \(mappings : List PythonNameMapping.CustomType) -> + \(customTypes : List Model.CustomType) -> + let sameSource = + \(left : PythonNameMapping.CustomTypeSource) -> + \(right : PythonNameMapping.CustomTypeSource) -> + Text/equal left.schema right.schema + && Text/equal left.name right.name + + let step = + \(mapping : PythonNameMapping.CustomType) -> + \(state : CustomTypeMappingState) -> + Prelude.Optional.fold + Report + state.error + CustomTypeMappingState + (\(_ : Report) -> state) + ( let duplicate = + Prelude.List.any + PythonNameMapping.CustomTypeSource + (sameSource mapping.source) + state.seen + + let known = + Prelude.List.any + Model.CustomType + ( \(customType : Model.CustomType) -> + Text/equal + customType.pgSchema + mapping.source.schema + && Text/equal + customType.pgName + mapping.source.name + ) + customTypes + + let snakeIsExact = + PyIdent.isSnakeIdentifier mapping.target.snakeCase + && Text/equal + ( PyIdent.typeModuleSafeName + mapping.target.snakeCase + ) + mapping.target.snakeCase + + let pascalIsExact = + PyIdent.isPascalIdentifier mapping.target.pascalCase + && Text/equal + (PyIdent.pySafeName mapping.target.pascalCase) + mapping.target.pascalCase + + let source = + "${mapping.source.schema}.${mapping.source.name}" + + let error = + if duplicate + then Some + { path = + [ "config" + , "customTypeNameMappings" + , source + ] + , message = + "Duplicate custom type name mapping source \"${source}\"" + } + else if Prelude.Bool.not known + then Some + { path = + [ "config" + , "customTypeNameMappings" + , source + ] + , message = + "Unknown custom type name mapping source \"${source}\"" + } + else if Prelude.Bool.not snakeIsExact + then Some + { path = + [ "config" + , "customTypeNameMappings" + , source + , "target" + , "snakeCase" + ] + , message = + "Mapped custom type snakeCase must start with a lowercase ASCII letter, then contain only lowercase ASCII letters, digits, or underscores, and must not be reserved" + } + else if Prelude.Bool.not pascalIsExact + then Some + { path = + [ "config" + , "customTypeNameMappings" + , source + , "target" + , "pascalCase" + ] + , message = + "Mapped custom type pascalCase must start with an uppercase ASCII letter, then contain only ASCII letters or digits, and must not be reserved" + } + else None Report + + in { seen = state.seen # [ mapping.source ], error } + ) + + let state = + List/fold + PythonNameMapping.CustomType + mappings + CustomTypeMappingState + step + { seen = [] : List PythonNameMapping.CustomTypeSource + , error = None Report + } + + in finishMappingValidation state.error + +let validateLocalNamespaces = + \(queries : List Model.Query) -> + \(customTypes : List Model.CustomType) -> + let validateQuery = + \(query : Model.Query) -> + let querySource = Text/show query.name.inSnakeCase + + let parameterBindings = + Prelude.List.map + Model.Member + PythonNamespace.Binding + ( \(parameter : Model.Member) -> + { namespace = + "parameters for query ${querySource}" + , owner = + "query parameter ${Text/show parameter.pgName}" + , name = + PyIdent.parameterSafeName + parameter.name.inSnakeCase + , remediation = "Rename one SQL placeholder" + } + ) + query.params + + let resultColumns = + merge + { Void = [] : List Model.Member + , RowsAffected = [] : List Model.Member + , Rows = + \(rows : Model.ResultRows) -> + Prelude.NonEmpty.toList + Model.Member + rows.columns + } + query.result + + let resultBindings = + Prelude.List.map + Model.Member + PythonNamespace.Binding + ( \(column : Model.Member) -> + { namespace = + "result fields for query ${querySource}" + , owner = + "result column ${Text/show column.pgName}" + , name = + PyIdent.pySafeName column.name.inSnakeCase + , remediation = "Rename one SQL result alias" + } + ) + resultColumns + + in PythonNamespace.validate + (parameterBindings # resultBindings) + + let validateCustomType = + \(customType : Model.CustomType) -> + let customSource = + "schema ${Text/show customType.pgSchema}, type ${Text/show customType.pgName}" + + let bindings = + merge + { Composite = + \(members : List Model.Member) -> + Prelude.List.map + Model.Member + PythonNamespace.Binding + ( \(member : Model.Member) -> + { namespace = + "fields for custom type ${customSource}" + , owner = + "composite field ${Text/show member.pgName}" + , name = + PyIdent.pySafeName + member.name.inSnakeCase + , remediation = + "Rename one PostgreSQL composite field" + } + ) + members + , Enum = + \(variants : List Model.EnumVariant) -> + Prelude.List.map + Model.EnumVariant + PythonNamespace.Binding + ( \(variant : Model.EnumVariant) -> + { namespace = + "enum members for custom type ${customSource}" + , owner = + "enum label ${Text/show variant.pgName}" + , name = + PyIdent.pySafeName + variant.name.inScreamingSnakeCase + , remediation = + "Rename one PostgreSQL enum label" + } + ) + variants + , Domain = + \(_ : Model.Value) -> + [] : List PythonNamespace.Binding + } + customType.definition + + in PythonNamespace.validate bindings + + let queryValidation = + Lude.Compiled.map + (List {}) + {} + (\(_ : List {}) -> {=}) + ( Lude.Compiled.traverseList + Model.Query + {} + validateQuery + queries + ) + + let customTypeValidation = + Lude.Compiled.map + (List {}) + {} + (\(_ : List {}) -> {=}) + ( Lude.Compiled.traverseList + Model.CustomType + {} + validateCustomType + customTypes + ) + + in Lude.Compiled.flatMap + {} + {} + (\(_ : {}) -> customTypeValidation) + queryValidation + -- Header of every emitted .py file. The marker truthfully warns that another -- generation replaces manual changes. The SPDX pair (REUSE convention) -- licenses the emitted file itself as MIT-0. @@ -77,6 +531,8 @@ let lookupConfig , importName = "" , emitSync = False , onUnsupported = OnUnsupported.Mode.Fail + , queryNameMappings = [] : List PythonNameMapping.Query + , customTypeNameMappings = [] : List PythonNameMapping.CustomType } let memberPyType = @@ -88,7 +544,10 @@ let memberPyType = wrapped.value.pyType , Err = \(_ : Report) -> "object" } - (Value.run lookupConfig member.value) + ( Value.run + lookupConfig.{ packageName, importName, emitSync, onUnsupported } + member.value + ) in valuePyType ++ (if member.isNullable then " | None" else "") @@ -107,6 +566,7 @@ let compositeFields = let IndexedCustomType = { index : Natural, value : Model.CustomType } let buildLookup = + \(nameMappings : List PythonNameMapping.CustomType) -> \(customTypes : List Model.CustomType) -> Prelude.List.fold IndexedCustomType @@ -117,6 +577,22 @@ let buildLookup = \(name : Model.Name) -> let customType = entry.value + let pythonName = + PythonNameMapping.resolveCustomType + nameMappings + { schema = customType.pgSchema, name = customType.pgName } + { snakeCase = + PyIdent.typeModuleSafeName + customType.name.inSnakeCase + , pascalCase = PyIdent.pySafeName customType.name.inPascalCase + } + + let identity = + { className = pythonName.pascalCase + , moduleName = pythonName.snakeCase + , order = entry.index + } + -- Text/equal is a pgn embedded-Dhall builtin. The upstream exit is -- a stable custom kind or id on Scalar.Custom. in if Text/equal @@ -127,11 +603,11 @@ let buildLookup = \(members : List Model.Member) -> CustomKind.TypeKind.Composite { fields = compositeFields members - , order = entry.index + , identity } , Enum = \(_ : List Model.EnumVariant) -> - CustomKind.TypeKind.Enum entry.index + CustomKind.TypeKind.Enum identity , Domain = \(_ : Model.Value) -> CustomKind.TypeKind.Absent } @@ -229,6 +705,161 @@ let registrationOrder = [ "register_types" ] "Unresolved or cyclic custom type dependencies: ${unresolved}" +let validateProjectNamespaces = + \(config : ResolvedConfig) -> + \(queries : List QueryGen.Output) -> + \(customTypes : List CustomTypeGen.Output) -> + let mappingRemediation = + "Rename the source or configure a typed name mapping" + + let queryOwner = + \(query : QueryGen.Output) -> + "query \"${query.sourceName}\" (${query.sourcePath})" + + let queryModuleBindings = + Prelude.List.map + QueryGen.Output + PythonNamespace.Binding + ( \(query : QueryGen.Output) -> + { namespace = "generated statement modules" + , owner = queryOwner query + , name = query.functionName + , remediation = mappingRemediation + } + ) + queries + + let typeModuleBindings = + Prelude.List.map + CustomTypeGen.Output + PythonNamespace.Binding + ( \(customType : CustomTypeGen.Output) -> + { namespace = "generated custom type modules" + , owner = + "custom type \"${customType.pgSchema}.${customType.pgName}\"" + , name = customType.moduleName + , remediation = mappingRemediation + } + ) + customTypes + + let coreFacadeBindings = + [ { namespace = "package facade" + , owner = "generated core symbol JsonValue" + , name = "JsonValue" + , remediation = mappingRemediation + } + , { namespace = "package facade" + , owner = "generated core symbol NoRowError" + , name = "NoRowError" + , remediation = mappingRemediation + } + ] : List PythonNamespace.Binding + + let syncFacadeBindings = + if config.emitSync + then [ { namespace = "package facade" + , owner = "generated sync facade" + , name = "sync" + , remediation = mappingRemediation + } + ] + else [] : List PythonNamespace.Binding + + let registrationFacadeBindings = + if Prelude.List.null CustomTypeGen.Output customTypes + then [] : List PythonNamespace.Binding + else [ { namespace = "package facade" + , owner = "generated type registration function" + , name = "register_types" + , remediation = mappingRemediation + } + ] + + let queryFacadeBindings = + Prelude.List.concatMap + QueryGen.Output + PythonNamespace.Binding + ( \(query : QueryGen.Output) -> + let functionBinding = + { namespace = "package facade" + , owner = queryOwner query + , name = query.functionName + , remediation = mappingRemediation + } + + let rowBindings = + Prelude.Optional.fold + Text + query.rowClassName + (List PythonNamespace.Binding) + ( \(rowClassName : Text) -> + [ { namespace = "package facade" + , owner = + "Row class from ${queryOwner query}" + , name = rowClassName + , remediation = mappingRemediation + } + ] + ) + ([] : List PythonNamespace.Binding) + + in [ functionBinding ] # rowBindings + ) + queries + + let typeFacadeBindings = + Prelude.List.map + CustomTypeGen.Output + PythonNamespace.Binding + ( \(customType : CustomTypeGen.Output) -> + { namespace = "package facade" + , owner = + "custom type \"${customType.pgSchema}.${customType.pgName}\"" + , name = customType.typeName + , remediation = mappingRemediation + } + ) + customTypes + + -- validate freezes a right-folded state, so groups are supplied in + -- reverse diagnostic priority. Module/file collisions remain the + -- primary cause when the same pair would also collide in a facade. + let projectValidation = + PythonNamespace.validate + ( typeFacadeBindings + # queryFacadeBindings + # registrationFacadeBindings + # syncFacadeBindings + # coreFacadeBindings + # typeModuleBindings + # queryModuleBindings + ) + + -- A module-name collision is fatal in Fail and Skip alike. These + -- bindings travel through CustomType.Output so Skip's support probe + -- cannot mistake a naming error for an unsupported PostgreSQL shape. + let moduleValidation = + Lude.Compiled.map + (List {}) + {} + (\(_ : List {}) -> {=}) + ( Lude.Compiled.traverseList + CustomTypeGen.Output + {} + ( \(customType : CustomTypeGen.Output) -> + PythonNamespace.validate + customType.moduleBindings + ) + customTypes + ) + + in Lude.Compiled.flatMap + {} + {} + (\(_ : {}) -> projectValidation) + moduleValidation + let combineOutputs = \(config : ResolvedConfig) -> \(input : Input) -> @@ -438,6 +1069,12 @@ let combineOutputs = -- two: one to build `queryChecks`, one for the final render). let QueryCheck = { query : Model.Query, keep : Bool, warning : Optional Report } +let CombinedInputs = + { queries : List QueryGen.Output + , customTypes : List CustomTypeGen.Output + , registrationTypes : List CustomTypeGen.Output + } + let run = \(config : Config) -> \(input : Input) -> @@ -470,11 +1107,39 @@ let run = (\(m : OnUnsupported.Mode) -> m) OnUnsupported.Mode.Fail + let queryNameMappings = + Prelude.Optional.fold + (List PythonNameMapping.Query) + config.queryNameMappings + (List PythonNameMapping.Query) + (\(mappings : List PythonNameMapping.Query) -> mappings) + ([] : List PythonNameMapping.Query) + + let customTypeNameMappings = + Prelude.Optional.fold + (List PythonNameMapping.CustomType) + config.customTypeNameMappings + (List PythonNameMapping.CustomType) + (\(mappings : List PythonNameMapping.CustomType) -> mappings) + ([] : List PythonNameMapping.CustomType) + let importName = Prelude.Text.replace "-" "_" packageName let resolvedConfig : ResolvedConfig - = { packageName, importName, emitSync, onUnsupported } + = { packageName + , importName + , emitSync + , onUnsupported + , queryNameMappings + , customTypeNameMappings + } + + let queryConfig = + resolvedConfig.{ packageName, importName, emitSync, onUnsupported, queryNameMappings } + + let customTypeConfig = + resolvedConfig.{ packageName, importName, emitSync, onUnsupported, customTypeNameMappings } let skip = merge { Fail = False, Skip = True } resolvedConfig.onUnsupported @@ -487,7 +1152,7 @@ let run = True , Err = \(_ : Report) -> False } - (CustomTypeGen.run resolvedConfig candidateLookup ct) + (CustomTypeGen.run customTypeConfig candidateLookup 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 @@ -502,7 +1167,7 @@ let run = , Err = \(err : Report) -> Some { path = [ ct.name.inSnakeCase ] # err.path, message = err.message } } - (CustomTypeGen.run resolvedConfig candidateLookup ct) + (CustomTypeGen.run customTypeConfig candidateLookup ct) -- Nested custom support requires transitive closure: after one type is -- removed, composites depending on it must be reconsidered against the @@ -514,7 +1179,10 @@ let run = (Prelude.List.length Model.CustomType input.customTypes) (List Model.CustomType) ( \(survivors : List Model.CustomType) -> - let candidateLookup = buildLookup survivors + let candidateLookup = + buildLookup + resolvedConfig.customTypeNameMappings + survivors in Prelude.List.filter Model.CustomType @@ -524,7 +1192,10 @@ let run = input.customTypes else input.customTypes - let lookup = buildLookup effectiveCustomTypes + let lookup = + buildLookup + resolvedConfig.customTypeNameMappings + effectiveCustomTypes -- Fail mode: identical to the pre-Skip code (traverseList straight -- over input.customTypes), so its error message/path is unchanged. @@ -534,7 +1205,7 @@ let run = Model.CustomType CustomTypeGen.Output ( \(ct : Model.CustomType) -> - CustomTypeGen.run resolvedConfig lookup ct + CustomTypeGen.run customTypeConfig lookup ct ) effectiveCustomTypes @@ -550,7 +1221,7 @@ let run = { query, keep = True, warning = None Report } , Err = \(err : Report) -> { query, keep = False, warning = Some err } } - (QueryGen.run resolvedConfig lookup query) + (QueryGen.run queryConfig lookup query) ) input.queries @@ -572,7 +1243,7 @@ let run = Model.Query QueryGen.Output ( \(query : Model.Query) -> - QueryGen.run resolvedConfig lookup query + QueryGen.run queryConfig lookup query ) effectiveQueries @@ -600,18 +1271,84 @@ let run = (Prelude.List.map QueryCheck (Optional Report) (\(qc : QueryCheck) -> qc.warning) queryChecks) else [] : List Report - let combined - : Lude.Compiled.Type Output + let compiledInputs + : Lude.Compiled.Type CombinedInputs = Lude.Compiled.map3 (List QueryGen.Output) (List CustomTypeGen.Output) (List CustomTypeGen.Output) - Output - (combineOutputs resolvedConfig input) + CombinedInputs + ( \(queries : List QueryGen.Output) -> + \(customTypes : List CustomTypeGen.Output) -> + \(registrationTypes : List CustomTypeGen.Output) -> + { queries, customTypes, registrationTypes } + ) queriesForCombine typesForCombine registrationTypesForCombine - in Lude.Compiled.appendWarnings Output skipWarnings combined + let combined + : Lude.Compiled.Type Output + = Lude.Compiled.flatMap + CombinedInputs + Output + ( \(compiled : CombinedInputs) -> + Lude.Compiled.map + {} + Output + ( \(_ : {}) -> + combineOutputs + resolvedConfig + input + compiled.queries + compiled.customTypes + compiled.registrationTypes + ) + ( validateProjectNamespaces + resolvedConfig + compiled.queries + compiled.customTypes + ) + ) + compiledInputs + + let mappingsValid = + Lude.Compiled.flatMap + {} + {} + ( \(_ : {}) -> + Lude.Compiled.flatMap + {} + {} + ( \(_ : {}) -> + validateCustomTypeMappings + resolvedConfig.customTypeNameMappings + input.customTypes + ) + ( validateQueryMappings + resolvedConfig.queryNameMappings + input.queries + ) + ) + (validateCustomTypeIdentities input.customTypes) + + let mappingsAndLocalsValid = + Lude.Compiled.flatMap + {} + {} + ( \(_ : {}) -> + validateLocalNamespaces + effectiveQueries + effectiveCustomTypes + ) + mappingsValid + + in Lude.Compiled.flatMap + {} + Output + ( \(_ : {}) -> + Lude.Compiled.appendWarnings Output skipWarnings combined + ) + mappingsAndLocalsValid in Sdk.Sigs.interpreter Config Input Output run diff --git a/src/Interpreters/Query.dhall b/src/Interpreters/Query.dhall index 257ad81..bc47e8d 100644 --- a/src/Interpreters/Query.dhall +++ b/src/Interpreters/Query.dhall @@ -14,6 +14,8 @@ let Surface = ../Structures/Surface.dhall let OnUnsupported = ../Structures/OnUnsupported.dhall +let PythonNameMapping = ../Structures/PythonNameMapping.dhall + let ResultModule = ./Result.dhall let QueryFragmentsModule = ./QueryFragments.dhall @@ -27,6 +29,7 @@ let Config = , importName : Text , emitSync : Bool , onUnsupported : OnUnsupported.Mode + , queryNameMappings : List PythonNameMapping.Query } let Compiled = Lude.Compiled @@ -38,7 +41,9 @@ let Input = Model.Query -- rowClassName is also surfaced because Project.dhall needs the name for facade -- re-exports; the Row's definition remains in this module. let Output = - { functionName : Text + { sourceName : Text + , sourcePath : Text + , functionName : Text , rowClassName : Optional Text , modulePath : Text , content : Text @@ -47,14 +52,13 @@ let Output = let render = \(config : Config) -> \(input : Input) -> + \(functionName : Text) -> \(result : ResultModule.Output) -> \(fragments : QueryFragmentsModule.Output) -> \(params : List ParamsMember.Output) -> -- The function name is also the module filename and facade import name, so -- protect both Python syntax and the private globals in a statement module. -- SQL/dict/row lookups still key off raw names. - let functionName = PyIdent.querySafeName input.name.inSnakeCase - let paramSigLines = Prelude.List.map ParamsMember.Output @@ -117,7 +121,9 @@ let render = , syncSurface = Surface.sync } - in { functionName + in { sourceName = input.name.inSnakeCase + , sourcePath = input.srcPath + , functionName , rowClassName , modulePath = "statements/${functionName}.py" , content @@ -127,7 +133,18 @@ let run = \(config : Config) -> \(lookup : CustomKind.Lookup) -> \(input : Input) -> - let rowClassName = input.name.inPascalCase ++ "Row" + let pythonName = + PythonNameMapping.resolveQuery + config.queryNameMappings + input.name.inSnakeCase + { snakeCase = PyIdent.querySafeName input.name.inSnakeCase + , pascalCase = input.name.inPascalCase + } + + let rowClassName = pythonName.pascalCase ++ "Row" + + let coreConfig = + config.{ packageName, importName, emitSync, onUnsupported } in Compiled.nest Output @@ -137,12 +154,12 @@ let run = QueryFragmentsModule.Output (List ParamsMember.Output) Output - (render config input) + (render config input pythonName.snakeCase) ( Compiled.nest ResultModule.Output "result" ( ResultModule.run - (config /\ { rowClassName }) + (coreConfig /\ { rowClassName }) lookup input.result ) @@ -150,7 +167,7 @@ let run = ( Compiled.nest QueryFragmentsModule.Output "sql" - (QueryFragmentsModule.run config input.fragments) + (QueryFragmentsModule.run coreConfig input.fragments) ) ( Compiled.nest (List ParamsMember.Output) @@ -162,7 +179,7 @@ let run = Compiled.nest ParamsMember.Output member.pgName - (ParamsMember.run config lookup member) + (ParamsMember.run coreConfig lookup member) ) input.params ) diff --git a/src/Interpreters/Value.dhall b/src/Interpreters/Value.dhall index 3d26b3b..141c40a 100644 --- a/src/Interpreters/Value.dhall +++ b/src/Interpreters/Value.dhall @@ -70,8 +70,9 @@ let run = (Scalar.run config input.scalar) let qualifyCustom - : Text -> Output -> Text + : Text -> Text -> Output -> Text = \(prefix : Text) -> + \(className : Text) -> \(value : Output) -> Prelude.Optional.fold Model.Name @@ -80,7 +81,7 @@ let qualifyCustom ( \(name : Model.Name) -> Text/replace name.inPascalCase - (prefix ++ name.inPascalCase) + (prefix ++ className) value.pyType ) value.pyType diff --git a/src/Structures/CustomKind.dhall b/src/Structures/CustomKind.dhall index eb20520..bbaa69c 100644 --- a/src/Structures/CustomKind.dhall +++ b/src/Structures/CustomKind.dhall @@ -2,12 +2,15 @@ let Model = ../Deps/Contract.dhall let CompositeField = { fieldName : Text, pyType : Text } +let Identity = + { className : Text, moduleName : Text, order : Natural } + let TypeKind = - < Enum : Natural - | Composite : { fields : List CompositeField, order : Natural } + < Enum : Identity + | Composite : { fields : List CompositeField, identity : Identity } | Absent > let Lookup = Model.Name -> TypeKind -in { TypeKind, Lookup, CompositeField } +in { TypeKind, Lookup, CompositeField, Identity } diff --git a/src/Structures/PyIdent.dhall b/src/Structures/PyIdent.dhall index a340b30..c427d2d 100644 --- a/src/Structures/PyIdent.dhall +++ b/src/Structures/PyIdent.dhall @@ -1,3 +1,5 @@ +let Prelude = ../Deps/Prelude.dhall + -- pgn passes identifier names through verbatim (the keyword-param fixture proves a -- column or placeholder may be spelled as a Python reserved word), so any name that -- becomes a Python identifier in the emitted code must be sanitized. Shared so the @@ -84,6 +86,20 @@ let pySafeName : Text -> Text = sanitizeAgainst pythonKeywords +let moduleMetadataNames = + [ "__init__" + , "__path__" + , "__package__" + , "__spec__" + , "__name__" + , "__loader__" + , "__file__" + , "__cached__" + , "__builtins__" + , "__annotations__" + , "__all__" + ] + let moduleReservedNames = [ "_db_types" , "_args_row" @@ -103,7 +119,7 @@ let moduleReservedNames = , "datetime" , "time" , "timedelta" - ] + ] # moduleMetadataNames let parameterReservedNames = [ "conn" @@ -132,11 +148,76 @@ let parameterSafeName let querySafeName = \(name : Text) -> suffixAgainst "_query" moduleReservedNames (pySafeName name) +let typeModuleSafeName = + \(name : Text) -> sanitizeAgainst (pythonKeywords # moduleMetadataNames) name + +let asciiLetters = + [ "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m" + , "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z" + , "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M" + , "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z" + ] + +let digits = [ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9" ] + +let containsOnly = + \(allowed : List Text) -> + \(name : Text) -> + let residue = + List/fold + Text + allowed + Text + (\(character : Text) -> \(rest : Text) -> Text/replace character "" rest) + name + + in Text/equal residue "" + +-- The string is already limited to identifier characters before this check, so +-- its Text/show value contains a quote only at each end. Replacing `"` can +-- therefore match only the first character and gives us a safe head predicate +-- without a non-standard regex builtin. +let startsWithOneOf = + \(allowed : List Text) -> + \(name : Text) -> + let shown = Text/show name + + in Prelude.List.any + Text + ( \(character : Text) -> + Prelude.Bool.not + ( Text/equal + (Text/replace ("\"" ++ character) "" shown) + shown + ) + ) + allowed + +let isSnakeIdentifier = + \(name : Text) -> + let letters = Prelude.List.take 26 Text asciiLetters + + in Prelude.Bool.not (Text/equal name "") + && containsOnly (letters # digits # [ "_" ]) name + && startsWithOneOf letters name + +let isPascalIdentifier = + \(name : Text) -> + let uppercaseLetters = Prelude.List.drop 26 Text asciiLetters + + in Prelude.Bool.not (Text/equal name "") + && containsOnly (asciiLetters # digits) name + && startsWithOneOf uppercaseLetters name + in { pythonKeywords + , moduleMetadataNames , sanitizeAgainst , pySafeName , moduleReservedNames , parameterReservedNames , parameterSafeName , querySafeName + , typeModuleSafeName + , isSnakeIdentifier + , isPascalIdentifier } diff --git a/src/Structures/PythonNameMapping.dhall b/src/Structures/PythonNameMapping.dhall new file mode 100644 index 0000000..a955517 --- /dev/null +++ b/src/Structures/PythonNameMapping.dhall @@ -0,0 +1,48 @@ +let Prelude = ../Deps/Prelude.dhall + +let PythonName = { snakeCase : Text, pascalCase : Text } + +let Query = { source : Text, target : PythonName } + +let CustomTypeSource = { schema : Text, name : Text } + +let CustomType = { source : CustomTypeSource, target : PythonName } + +let resolveQuery = + \(mappings : List Query) -> + \(source : Text) -> + \(defaultName : PythonName) -> + List/fold + Query + mappings + PythonName + ( \(mapping : Query) -> + \(resolved : PythonName) -> + if Text/equal mapping.source source then mapping.target else resolved + ) + defaultName + +let resolveCustomType = + \(mappings : List CustomType) -> + \(source : CustomTypeSource) -> + \(defaultName : PythonName) -> + List/fold + CustomType + mappings + PythonName + ( \(mapping : CustomType) -> + \(resolved : PythonName) -> + if Text/equal mapping.source.schema source.schema + && Text/equal mapping.source.name source.name + then mapping.target + else resolved + ) + defaultName + +in { PythonName + , Query + , CustomTypeSource + , CustomType + , resolveQuery + , resolveCustomType + } diff --git a/src/Structures/PythonNamespace.dhall b/src/Structures/PythonNamespace.dhall new file mode 100644 index 0000000..28a9fcc --- /dev/null +++ b/src/Structures/PythonNamespace.dhall @@ -0,0 +1,97 @@ +let Lude = ../Deps/Lude.dhall + +let Prelude = ../Deps/Prelude.dhall + +let Binding = + { namespace : Text + , owner : Text + , name : Text + , remediation : Text + } + +let Collision = + { namespace : Text + , firstOwner : Text + , secondOwner : Text + , name : Text + , remediation : Text + } + +let State = { seen : List Binding, collision : Optional Collision } + +let validate = + \(bindings : List Binding) -> + let step = + \(binding : Binding) -> + \(state : State) -> + Prelude.Optional.fold + Collision + state.collision + State + (\(_ : Collision) -> state) + ( let previousOwner = + List/fold + Binding + state.seen + (Optional Text) + ( \(previous : Binding) -> + \(found : Optional Text) -> + if Text/equal + previous.namespace + binding.namespace + && Text/equal previous.name binding.name + then Some previous.owner + else found + ) + (None Text) + + let collision = + Prelude.Optional.fold + Text + previousOwner + (Optional Collision) + ( \(owner : Text) -> + Some + { namespace = binding.namespace + , firstOwner = owner + , secondOwner = binding.owner + , name = binding.name + , remediation = binding.remediation + } + ) + (None Collision) + + in { seen = state.seen # [ binding ], collision } + ) + + let result = + List/fold + Binding + bindings + State + step + { seen = [] : List Binding, collision = None Collision } + + in Prelude.Optional.fold + Collision + result.collision + (Lude.Compiled.Type {}) + ( \(collision : Collision) -> + Lude.Compiled.report + {} + [ "pythonNames", collision.namespace, collision.name ] + ( "Generated Python name collision in " + ++ collision.namespace + ++ ": " + ++ collision.firstOwner + ++ " and " + ++ collision.secondOwner + ++ " both resolve to \"" + ++ collision.name + ++ "\". " + ++ collision.remediation + ) + ) + (Lude.Compiled.ok {} {=}) + +in { Binding, validate } diff --git a/src/package.dhall b/src/package.dhall index 67ff3c1..a820150 100644 --- a/src/package.dhall +++ b/src/package.dhall @@ -2,6 +2,8 @@ let Sdk = ./Deps/Sdk.dhall let OnUnsupported = ./Structures/OnUnsupported.dhall +let PythonNameMapping = ./Structures/PythonNameMapping.dhall + let ProjectInterpreter = ./Interpreters/Project.dhall -- User-facing config for this generator. Async output is always emitted; @@ -11,11 +13,14 @@ let ProjectInterpreter = ./Interpreters/Project.dhall -- 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. +-- Interpreters/Project.dhall's `run` supplies the defaults. Typed name mappings +-- provide explicit whole-entity renames after collision checks. let Config = { packageName : Optional Text , emitSync : Optional Bool , onUnsupported : Optional OnUnsupported.Mode + , queryNameMappings : Optional (List PythonNameMapping.Query) + , customTypeNameMappings : Optional (List PythonNameMapping.CustomType) } : Type let Config/default @@ -23,6 +28,8 @@ let Config/default = { packageName = None Text , emitSync = None Bool , onUnsupported = None OnUnsupported.Mode + , queryNameMappings = None (List PythonNameMapping.Query) + , customTypeNameMappings = None (List PythonNameMapping.CustomType) } in Sdk.Sigs.generator Config Config/default ProjectInterpreter.run diff --git a/tests/test_python_name_collisions.py b/tests/test_python_name_collisions.py new file mode 100644 index 0000000..59ce39c --- /dev/null +++ b/tests/test_python_name_collisions.py @@ -0,0 +1,687 @@ +"""Real-pgn coverage for generated Python namespace collisions and mappings.""" + +from __future__ import annotations + +import ast +import importlib +import shutil +import subprocess +import sys +from collections.abc import Iterable, Mapping, Sequence +from pathlib import Path + +import pytest + +from tests._harness import SRC_DIR, run_pgn + +QueryMapping = tuple[str, str, str] +CustomTypeMapping = tuple[str, str, str, str] + + +def _project_yaml( + *, + query_mappings: Sequence[QueryMapping] = (), + custom_type_mappings: Sequence[CustomTypeMapping] = (), + on_unsupported: str | None = None, +) -> str: + lines = [ + "space: python-gen", + "name: python-name-collisions", + "version: 0.0.0", + "postgres: 18", + "artifacts:", + " python:", + " gen: ../src/package.dhall", + " config:", + " packageName: mapping-client", + " emitSync: true", + ] + + if on_unsupported is not None: + lines.append(f" onUnsupported: {on_unsupported}") + + if query_mappings: + lines.append(" queryNameMappings:") + for source, snake_case, pascal_case in query_mappings: + lines.extend( + [ + f" - source: {source}", + " target:", + f" snakeCase: {snake_case}", + f" pascalCase: {pascal_case}", + ] + ) + + if custom_type_mappings: + lines.append(" customTypeNameMappings:") + for schema, name, snake_case, pascal_case in custom_type_mappings: + lines.extend( + [ + " - source:", + f" schema: {schema}", + f" name: {name}", + " target:", + f" snakeCase: {snake_case}", + f" pascalCase: {pascal_case}", + ] + ) + + return "\n".join(lines) + "\n" + + +def _fresh_project( + tmp_path: Path, + *, + queries: Mapping[str, str], + migration: str = "SELECT 1;\n", + query_mappings: Sequence[QueryMapping] = (), + custom_type_mappings: Sequence[CustomTypeMapping] = (), + on_unsupported: str | None = None, +) -> Path: + root = tmp_path / "pygen" + _ = shutil.copytree(SRC_DIR, root / "src") + project = root / "project" + migrations = project / "migrations" + query_dir = project / "queries" + migrations.mkdir(parents=True) + query_dir.mkdir() + + _ = (migrations / "1.sql").write_text(migration) + for name, sql in queries.items(): + _ = (query_dir / name).write_text(sql) + _ = (project / "project1.pgn.yaml").write_text( + _project_yaml( + query_mappings=query_mappings, + custom_type_mappings=custom_type_mappings, + on_unsupported=on_unsupported, + ) + ) + return project + + +def _write_structural_scope_probe(project: Path, *, kind: str, duplicate_identity: bool) -> None: + root = project.parent + right_name = "leftName" if duplicate_identity else "rightName" + definitions = { + "composite": ( + "< Enum : List Model.EnumVariant | Composite : List Model.Member | Domain : Model.Value >" + ".Composite [ valueMember ]" + ), + "enum": ( + "< Enum : List Model.EnumVariant | Composite : List Model.Member | Domain : Model.Value >" + ".Enum [ readyVariant ]" + ), + } + definition = definitions[kind] + wrapper = f""" +let Model = ./Deps/Contract.dhall + +let Sdk = ./Deps/Sdk.dhall + +let OnUnsupported = ./Structures/OnUnsupported.dhall + +let PythonNameMapping = ./Structures/PythonNameMapping.dhall + +let Target = ./Interpreters/Project.dhall + +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 + }} + +let valueName + : Model.Name + = {{ inCamelCase = "value" + , inPascalCase = "Value" + , inKebabCase = "value" + , inTrainCase = "Value" + , inScreamingKebabCase = "VALUE" + , inSnakeCase = "value" + , inCamelSnakeCase = "Value" + , inScreamingSnakeCase = "VALUE" + }} + +let leftName + : Model.Name + = valueName // {{ inCamelCase = "leftSource", inPascalCase = "LeftSource", inSnakeCase = "left_source" }} + +let rightName + : Model.Name + = valueName // {{ inCamelCase = "rightSource", inPascalCase = "RightSource", inSnakeCase = "right_source" }} + +let valueMember + : Model.Member + = {{ isNullable = False + , name = valueName + , pgName = "value" + , value = + {{ arraySettings = None Model.ArraySettings + , scalar = Model.Scalar.Primitive Model.Primitive.Int8 + }} + }} + +let readyVariant + : Model.EnumVariant + = {{ name = valueName // {{ inScreamingSnakeCase = "READY" }} + , pgName = "ready" + }} + +let leftType + : Model.CustomType + = {{ definition = {definition} + , name = leftName + , pgName = "c" + , pgSchema = "a.b" + }} + +let rightType + : Model.CustomType + = {{ definition = {definition} + , name = {right_name} + , pgName = "b.c" + , pgSchema = "a" + }} + +let targetConfig = + {{ packageName = Some "mapping-client" + , emitSync = Some False + , onUnsupported = Some OnUnsupported.Mode.Fail + , queryNameMappings = None (List PythonNameMapping.Query) + , customTypeNameMappings = + Some + [ {{ source = {{ schema = "a.b", name = "c" }} + , target = {{ snakeCase = "schema_dot_c", pascalCase = "SchemaDotC" }} + }} + , {{ source = {{ schema = "a", name = "b.c" }} + , target = {{ snakeCase = "type_dot_c", pascalCase = "TypeDotC" }} + }} + ] + }} + +let run = + \\(_ : Config) -> + \\(input : Model.Project) -> + Target.run + targetConfig + ( input + // {{ customTypes = [ leftType, rightType ] + , queries = [] : List Model.Query + }} + ) + +in Sdk.Sigs.generator Config Config/default run +""" + _ = (root / "src" / "structural-scope-probe.dhall").write_text(wrapper) + project_file = project / "project1.pgn.yaml" + _ = project_file.write_text( + project_file.read_text().replace("../src/package.dhall", "../src/structural-scope-probe.dhall") + ) + + +def _analyse(pgn_bin: str, pgn_admin_url: str, project: Path) -> None: + result = run_pgn(pgn_bin, pgn_admin_url, project, "analyse") + assert result.returncode == 0, f"pgn analyse rejected the collision fixture:\n{result.stdout}\n{result.stderr}" + + +def _diagnostic(result: subprocess.CompletedProcess[str]) -> str: + return f"{result.stdout}\n{result.stderr}" + + +def _assert_no_python_files(project: Path) -> None: + artifact = project / "artifacts" / "python" + assert not artifact.exists() or not any(artifact.rglob("*.py")) + + +def _assert_generation_fails( + pgn_bin: str, + pgn_admin_url: str, + project: Path, + expected: Iterable[str], +) -> None: + result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") + diagnostic = _diagnostic(result) + assert result.returncode != 0, f"pgn generate unexpectedly succeeded:\n{diagnostic}" + for fragment in expected: + assert fragment in diagnostic, f"missing {fragment!r} in pgn diagnostic:\n{diagnostic}" + _assert_no_python_files(project) + + +def _package_dir(project: Path) -> Path: + package = project / "artifacts" / "python" / "src" / "mapping_client" + assert package.is_dir() + return package + + +def _clear_package_modules() -> None: + for name in list(sys.modules): + if name == "mapping_client" or name.startswith("mapping_client."): + del sys.modules[name] + + +def test_reserved_query_collision_fails_before_emission( + pgn_bin: str, + pgn_admin_url: str, + tmp_path: Path, +) -> None: + project = _fresh_project( + tmp_path, + queries={ + "sync.sql": "SELECT 1::int8 AS value\n", + "sync_query.sql": "SELECT 2::int8 AS value\n", + }, + ) + _analyse(pgn_bin, pgn_admin_url, project) + _assert_generation_fails( + pgn_bin, + pgn_admin_url, + project, + [ + "Generated Python name collision in generated statement modules", + 'query "sync"', + 'query "sync_query"', + 'both resolve to "sync_query"', + ], + ) + + +def test_json_value_custom_type_collision_fails_before_emission( + pgn_bin: str, + pgn_admin_url: str, + tmp_path: Path, +) -> None: + project = _fresh_project( + tmp_path, + migration="CREATE TYPE \"json_value\" AS ENUM ('ready');\n", + queries={"read_json_value.sql": "SELECT 'ready'::public.\"json_value\" AS value\n"}, + ) + _analyse(pgn_bin, pgn_admin_url, project) + _assert_generation_fails( + pgn_bin, + pgn_admin_url, + project, + [ + "Generated Python name collision in package facade", + "generated core symbol JsonValue", + 'custom type "public.json_value"', + 'both resolve to "JsonValue"', + ], + ) + + +def test_query_row_and_custom_type_collision_fails_before_emission( + pgn_bin: str, + pgn_admin_url: str, + tmp_path: Path, +) -> None: + project = _fresh_project( + tmp_path, + migration="CREATE TYPE foo_row AS ENUM ('ready');\n", + queries={"foo.sql": "SELECT 'ready'::foo_row AS value\n"}, + ) + _analyse(pgn_bin, pgn_admin_url, project) + _assert_generation_fails( + pgn_bin, + pgn_admin_url, + project, + [ + "Generated Python name collision in package facade", + 'Row class from query "foo"', + 'custom type "public.foo_row"', + 'both resolve to "FooRow"', + ], + ) + + +@pytest.mark.parametrize( + ("native_type", "native_value", "mapped_snake", "mapped_pascal", "on_unsupported"), + [ + ( + "uuid", + "'00000000-0000-0000-0000-000000000001'::uuid", + "custom_uuid", + "UUID", + None, + ), + ("numeric", "1.25::numeric", "custom_decimal", "Decimal", None), + ( + "uuid", + "'00000000-0000-0000-0000-000000000001'::uuid", + "custom_uuid", + "UUID", + "Skip", + ), + ], + ids=["uuid-fail", "decimal-fail", "uuid-skip"], +) +def test_mapped_custom_dependency_cannot_shadow_composite_import( + native_type: str, + native_value: str, + mapped_snake: str, + mapped_pascal: str, + on_unsupported: str | None, + pgn_bin: str, + pgn_admin_url: str, + tmp_path: Path, +) -> None: + project = _fresh_project( + tmp_path, + migration=( + "CREATE TYPE import_dependency AS ENUM ('ready');\n" + f"CREATE TYPE import_holder AS (native_value {native_type}, dependency import_dependency);\n" + ), + queries={ + "read_import_holder.sql": ( + f"SELECT ROW({native_value}, 'ready'::import_dependency)::import_holder AS value\n" + ) + }, + custom_type_mappings=[("public", "import_dependency", mapped_snake, mapped_pascal)], + on_unsupported=on_unsupported, + ) + _analyse(pgn_bin, pgn_admin_url, project) + _assert_generation_fails( + pgn_bin, + pgn_admin_url, + project, + [ + 'Generated Python name collision in custom type module for schema "public", type "import_holder"', + f"{mapped_pascal} primitive import", + "custom dependency import", + f'both resolve to "{mapped_pascal}"', + ], + ) + + +@pytest.mark.parametrize("kind", ["composite", "enum"]) +def test_custom_local_namespaces_keep_schema_and_name_structured( + kind: str, + pgn_bin: str, + pgn_admin_url: str, + tmp_path: Path, +) -> None: + project = _fresh_project(tmp_path, queries={"seed.sql": "SELECT 1::int8 AS value\n"}) + _write_structural_scope_probe(project, kind=kind, duplicate_identity=False) + _analyse(pgn_bin, pgn_admin_url, project) + + result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") + diagnostic = _diagnostic(result) + assert result.returncode == 0, f"structured local namespace probe failed:\n{diagnostic}" + + package = _package_dir(project) + left_source = (package / "_generated" / "types" / "schema_dot_c.py").read_text() + right_source = (package / "_generated" / "types" / "type_dot_c.py").read_text() + expected = ( + ("class SchemaDotC:", "class TypeDotC:", "value: int") + if kind == "composite" + else ("class SchemaDotC(StrEnum):", "class TypeDotC(StrEnum):", 'READY = "ready"') + ) + assert expected[0] in left_source + assert expected[1] in right_source + assert expected[2] in left_source and expected[2] in right_source + for source in package.rglob("*.py"): + _ = ast.parse(source.read_text(), filename=str(source)) + + +def test_duplicate_unqualified_custom_contract_identity_fails_loudly( + pgn_bin: str, + pgn_admin_url: str, + tmp_path: Path, +) -> None: + project = _fresh_project(tmp_path, queries={"seed.sql": "SELECT 1::int8 AS value\n"}) + _write_structural_scope_probe(project, kind="composite", duplicate_identity=True) + _analyse(pgn_bin, pgn_admin_url, project) + _assert_generation_fails( + pgn_bin, + pgn_admin_url, + project, + [ + 'Ambiguous unqualified custom type identity "left_source"', + 'schema "a.b", type "c"', + 'schema "a", type "b.c"', + "upstream contract must preserve a distinct schema-qualified custom identifier", + "Python mappings cannot recover it", + ], + ) + + +def test_typed_mappings_propagate_through_generated_package( + pgn_bin: str, + pgn_admin_url: str, + tmp_path: Path, +) -> None: + project = _fresh_project( + tmp_path, + migration=( + "CREATE TYPE \"json_value\" AS ENUM ('ready');\n" + "CREATE TYPE mapped_inner AS (value int8);\n" + "CREATE TYPE mapped_outer AS (child mapped_inner);\n" + ), + queries={ + "sync.sql": "SELECT 1::int8 AS value\n", + "sync_query.sql": "SELECT 2::int8 AS value\n", + "read_json_value.sql": "SELECT 'ready'::public.\"json_value\" AS value\n", + "read_mapped_outer.sql": ("SELECT ROW(ROW(7::int8)::mapped_inner)::mapped_outer AS value\n"), + }, + query_mappings=[("sync_query", "api_v2", "ApiV2")], + custom_type_mappings=[ + ("public", "json_value", "pg_json_value", "PgJsonValue"), + ("public", "mapped_inner", "mapped_inner_v2", "MappedInnerV2"), + ], + ) + _analyse(pgn_bin, pgn_admin_url, project) + result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") + diagnostic = _diagnostic(result) + assert result.returncode == 0, f"mapped pgn generate failed:\n{diagnostic}" + + package = _package_dir(project) + statements = package / "_generated" / "statements" + assert {path.name for path in statements.glob("*.py")} == { + "__init__.py", + "api_v2.py", + "read_json_value.py", + "read_mapped_outer.py", + "sync_query.py", + } + api_source = (statements / "api_v2.py").read_text() + assert "class ApiV2Row:" in api_source + assert "async def api_v2(" in api_source + assert "def api_v2_sync(" in api_source + + type_source = (package / "_generated" / "types" / "pg_json_value.py").read_text() + assert "class PgJsonValue(StrEnum):" in type_source + inner_source = (package / "_generated" / "types" / "mapped_inner_v2.py").read_text() + assert "class MappedInnerV2:" in inner_source + outer_source = (package / "_generated" / "types" / "mapped_outer.py").read_text() + assert "from .mapped_inner_v2 import MappedInnerV2" in outer_source + assert "child: MappedInnerV2" in outer_source + json_statement = (statements / "read_json_value.py").read_text() + assert "value: _db_types.PgJsonValue" in json_statement + register_source = (package / "_generated" / "_register.py").read_text() + assert "_db_types.PgJsonValue" in register_source + assert "_db_types.MappedInnerV2" in register_source + assert register_source.index('"public.mapped_inner"') < register_source.index('"public.mapped_outer"') + assert "public.json_value" in register_source + + for source in package.rglob("*.py"): + _ = ast.parse(source.read_text(), filename=str(source)) + + ruff = subprocess.run( + [ + "ruff", + "check", + "--config", + str(SRC_DIR.parent / "pyproject.toml"), + "--extend-ignore", + "I001,RUF022", + str(package), + ], + capture_output=True, + text=True, + ) + assert ruff.returncode == 0, f"mapped generated package failed Ruff:\n{ruff.stdout}\n{ruff.stderr}" + + generated_src = package.parent + sys.path.insert(0, str(generated_src)) + _clear_package_modules() + try: + client = importlib.import_module("mapping_client") + sync_client = importlib.import_module("mapping_client.sync") + assert client.api_v2.__name__ == "api_v2" + assert sync_client.api_v2.__name__ == "api_v2_sync" + assert client.ApiV2Row is sync_client.ApiV2Row + assert client.PgJsonValue is sync_client.PgJsonValue + assert client.MappedInnerV2 is sync_client.MappedInnerV2 + assert client.MappedOuter is sync_client.MappedOuter + assert client.JsonValue is not client.PgJsonValue + finally: + _clear_package_modules() + sys.path.remove(str(generated_src)) + + +@pytest.mark.parametrize( + ("queries", "mappings", "expected"), + [ + ( + {"alpha.sql": "SELECT 1::int8 AS value\n"}, + [("alpha", "api_v2", "ApiV2"), ("alpha", "other_api", "OtherApi")], + ['Duplicate query name mapping source "alpha"'], + ), + ( + {"alpha.sql": "SELECT 1::int8 AS value\n"}, + [("missing", "api_v2", "ApiV2")], + ['Unknown query name mapping source "missing"'], + ), + ( + {"alpha.sql": "SELECT 1::int8 AS value\n"}, + [("alpha", "2api", "ApiV2")], + ["Mapped query snakeCase must start with a lowercase ASCII letter"], + ), + ( + {"alpha.sql": "SELECT 1::int8 AS value\n"}, + [("alpha", "api_v2", "2Api")], + ["Mapped query pascalCase must start with an uppercase ASCII letter"], + ), + ( + {"alpha.sql": "SELECT 1::int8 AS value\n"}, + [("alpha", "api_v2", "apiV2")], + ["Mapped query pascalCase must start with an uppercase ASCII letter"], + ), + ( + {"alpha.sql": "SELECT 1::int8 AS value\n"}, + [("alpha", "__init__", "Init")], + ["Mapped query snakeCase must start with a lowercase ASCII letter"], + ), + ( + {"alpha.sql": "SELECT 1::int8 AS value\n"}, + [("alpha", "__all__", "All")], + ["Mapped query snakeCase must start with a lowercase ASCII letter"], + ), + ( + { + "alpha.sql": "SELECT 1::int8 AS value\n", + "beta.sql": "SELECT 2::int8 AS value\n", + }, + [("beta", "alpha", "BetaResult")], + [ + "Generated Python name collision in generated statement modules", + 'query "alpha"', + 'query "beta"', + 'both resolve to "alpha"', + ], + ), + ], + ids=[ + "duplicate-source", + "unknown-source", + "leading-digit-snake", + "leading-digit-pascal", + "lowercase-pascal", + "init-module", + "all-facade", + "mapped-final-collision", + ], +) +def test_invalid_query_mappings_fail_before_emission( + queries: Mapping[str, str], + mappings: Sequence[QueryMapping], + expected: Sequence[str], + pgn_bin: str, + pgn_admin_url: str, + tmp_path: Path, +) -> None: + project = _fresh_project(tmp_path, queries=queries, query_mappings=mappings) + _analyse(pgn_bin, pgn_admin_url, project) + _assert_generation_fails(pgn_bin, pgn_admin_url, project, expected) + + +@pytest.mark.parametrize( + ("mappings", "expected"), + [ + ( + [ + ("public", "json_value", "pg_json_value", "PgJsonValue"), + ("public", "json_value", "other_json_value", "OtherJsonValue"), + ], + ['Duplicate custom type name mapping source "public.json_value"'], + ), + ( + [("public", "missing", "pg_missing", "PgMissing")], + ['Unknown custom type name mapping source "public.missing"'], + ), + ( + [("public", "json_value", "pg_json_value", "NoRowError")], + [ + "Generated Python name collision in package facade", + "generated core symbol NoRowError", + 'custom type "public.json_value"', + 'both resolve to "NoRowError"', + ], + ), + ( + [("public", "json_value", "pg_json_value", "JsonValue")], + [ + "Generated Python name collision in package facade", + "generated core symbol JsonValue", + 'custom type "public.json_value"', + 'both resolve to "JsonValue"', + ], + ), + ( + [("public", "json_value", "pg_json_value", "pgJsonValue")], + ["Mapped custom type pascalCase must start with an uppercase ASCII letter"], + ), + ( + [("public", "json_value", "__path__", "PgJsonValue")], + ["Mapped custom type snakeCase must start with a lowercase ASCII letter"], + ), + ], + ids=[ + "duplicate-source", + "unknown-source", + "mapped-final-collision", + "mapped-json-value", + "lowercase-pascal", + "path-module", + ], +) +def test_invalid_custom_type_mappings_fail_before_emission( + mappings: Sequence[CustomTypeMapping], + expected: Sequence[str], + pgn_bin: str, + pgn_admin_url: str, + tmp_path: Path, +) -> None: + project = _fresh_project( + tmp_path, + migration="CREATE TYPE \"json_value\" AS ENUM ('ready');\n", + queries={"read_json_value.sql": "SELECT 'ready'::public.\"json_value\" AS value\n"}, + custom_type_mappings=mappings, + ) + _analyse(pgn_bin, pgn_admin_url, project) + _assert_generation_fails(pgn_bin, pgn_admin_url, project, expected) diff --git a/tests/test_unsupported_types.py b/tests/test_unsupported_types.py index bb24bf9..d4fc537 100644 --- a/tests/test_unsupported_types.py +++ b/tests/test_unsupported_types.py @@ -129,9 +129,16 @@ def _write_contract_probe( ) lookup = { "absent": "CustomKind.TypeKind.Absent", - "enum": "CustomKind.TypeKind.Enum 0", - "composite": ("CustomKind.TypeKind.Composite { fields = [] : List CustomKind.CompositeField, order = 0 }"), + "enum": ('CustomKind.TypeKind.Enum { className = "ProbeValue", moduleName = "probe_value", order = 0 }'), + "composite": ( + "CustomKind.TypeKind.Composite " + "{ fields = [] : List CustomKind.CompositeField, " + 'identity = { className = "ProbeValue", moduleName = "probe_value", order = 0 } }' + ), }[lookup_kind] + custom_type_name_mappings = ( + ", customTypeNameMappings = [] : List PythonNameMapping.CustomType" if interpreter == "CustomType" else "" + ) target_input = "customType" if nested else "member" custom_type = ( """ @@ -161,6 +168,8 @@ def _write_contract_probe( let CustomKind = ./Structures/CustomKind.dhall +let PythonNameMapping = ./Structures/PythonNameMapping.dhall + let Target = ./Interpreters/{interpreter}.dhall let Config = @@ -180,6 +189,7 @@ def _write_contract_probe( , importName = "contract_probe" , emitSync = False , onUnsupported = OnUnsupported.Mode.Fail + {custom_type_name_mappings} }} let run = From ae2869cd320c9eb8257ab39652635150d23dac23 Mon Sep 17 00:00:00 2001 From: Viacheslav Shvets Date: Mon, 13 Jul 2026 16:03:53 +0300 Subject: [PATCH 34/41] docs(gen): align takeover acceptance guidance --- CHANGELOG.md | 14 ++++---- .../0001-generated-python-name-collisions.md | 4 +-- fixtures/Exhaustive.dhall | 8 ++--- tests/conftest.py | 8 ++--- tests/fixture-project/migrations/1.sql | 6 ++-- tests/test_config_variants.py | 5 +-- tests/test_generated.py | 32 +++++++------------ 7 files changed, 34 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 265869d..7fdb011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,12 +29,14 @@ ranks, composite ranks, custom-array fields inside composites, and missing or unsupported custom types fail loudly. -- Retained `buildLookup` as the sound project-wide custom-kind resolver. Its - `Text/equal` use is an explicit pgn-fork constraint; a stable custom kind or - qualified identifier in the upstream contract is the planned exit. pgn 0.9.1 - can collapse same-unqualified-name types across schemas, so that database - shape remains unsupported and cannot be repaired by a Python mapping. Natural - project indexes now provide deterministic custom-import deduplication and ordering. +- Retained `buildLookup` as the project-wide custom-kind resolver for identities + preserved in the contract. Its `Text/equal` use is an explicit pgn-fork + constraint. The planned exit must preserve every schema-qualified custom type + entry and expose a stable qualified identifier on `Scalar.Custom`; kind alone + is insufficient. pgn 0.9.1 can collapse same-unqualified-name types across + schemas, so that database shape remains unsupported and cannot be repaired by + a Python mapping. Natural project indexes now provide deterministic + custom-import deduplication and ordering. Query, parameter, field, and private statement names are collision-safe while SQL names remain unchanged. diff --git a/docs/adr/0001-generated-python-name-collisions.md b/docs/adr/0001-generated-python-name-collisions.md index 0eb74d3..ee8783b 100644 --- a/docs/adr/0001-generated-python-name-collisions.md +++ b/docs/adr/0001-generated-python-name-collisions.md @@ -69,6 +69,6 @@ until upstream preserves all qualified custom types and references. - Intentional renames are deterministic and propagate through all references. - A conflicting source requires either a source-level rename or an explicit typed mapping. -- Mapping configuration is more verbose than automatic suffixing, but its shape - is type-checked and represented raw identifiers containing punctuation remain +- Mapping configuration is more verbose than automatic suffixing, but it is + type-checked, and raw identifiers represented in the contract remain unambiguous. diff --git a/fixtures/Exhaustive.dhall b/fixtures/Exhaustive.dhall index f8cc748..5b5f2e6 100644 --- a/fixtures/Exhaustive.dhall +++ b/fixtures/Exhaustive.dhall @@ -9,11 +9,9 @@ -- those statements/types are dropped with a warning instead of aborting the -- whole compile. -- --- Intended to be executed with: --- --- ```bash --- dhall to-directory-tree --file fixtures/Exhaustive.dhall --output --allow-path-separators --- ``` +-- CI evaluates this fixture with the pinned fork-aware directory-tree action. +-- Standard `dhall to-directory-tree` cannot evaluate the pgn fork's Text/equal +-- builtin used by the generator. let Sdk = ../src/Deps/Sdk.dhall let Gen = ../src/package.dhall diff --git a/tests/conftest.py b/tests/conftest.py index e34a02f..f276560 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,9 +33,9 @@ def pgn_bin() -> str: """Absolute path to the mise-managed pgn binary. - pgn is installed via mise from GitHub releases and is not on PATH outside the - monorepo. Resolving it here lets the harness exec it with cwd set to a temp - project copy that lives outside the repo. + pgn is pinned by this repository's mise config and may not be on the caller's + PATH. Resolving it here lets the harness exec it with cwd set to a temporary + project copy. """ resolved = shutil.which("pgn") if resolved: @@ -63,7 +63,7 @@ def pgn_admin_url() -> str: @pytest.fixture def roundtrip_db(pgn_admin_url: str) -> Iterator[str]: - """A uniquely named throwaway database on pg0, dropped on teardown.""" + """A uniquely named scratch database on the configured server, dropped on teardown.""" name = f"pgn_rt_{uuid.uuid4().hex[:12]}" admin = psycopg.connect(pgn_admin_url, autocommit=True) try: diff --git a/tests/fixture-project/migrations/1.sql b/tests/fixture-project/migrations/1.sql index d4ee87e..2dd9f33 100644 --- a/tests/fixture-project/migrations/1.sql +++ b/tests/fixture-project/migrations/1.sql @@ -36,10 +36,8 @@ create domain revision as integer create domain jsonb_object as jsonb check (jsonb_typeof(value) = 'object'); --- Single-field composite fixture: exercises the composite-bind edge case where --- a one-field tuple literal needs a trailing comma to stay a tuple in Python --- (point2d's two-field tuple never needed one, so its golden never exercised --- this path). +-- Single-field composite fixture: verifies that the registered dataclass dumper +-- returns a field-ordered one-element tuple and preserves composite arity. create type tag_value as ( value text ); diff --git a/tests/test_config_variants.py b/tests/test_config_variants.py index 056e11d..0680a55 100644 --- a/tests/test_config_variants.py +++ b/tests/test_config_variants.py @@ -2,8 +2,9 @@ pgn's decode behavior for record types is undocumented, so each artifact in project1.pgn.yaml drives a different subset of `config` keys through the same -compile.dhall and the assertions below record what the pinned pgn was observed to -do, not a documented contract. These variants pin the additive sync surface. +working-tree `src/package.dhall` entry point. The assertions below record what +the pinned pgn was observed to do, not a documented contract. These variants +pin the additive sync surface. """ from __future__ import annotations diff --git a/tests/test_generated.py b/tests/test_generated.py index 9eb4f97..6eea99d 100644 --- a/tests/test_generated.py +++ b/tests/test_generated.py @@ -6,7 +6,8 @@ End to end: validate the fixture pgn project (`pgn analyse`), generate the Python client (`pgn generate`), diff it against the committed golden tree, typecheck the generated package with basedpyright strict, and round-trip every generated -statement function against a throwaway database on the local pg0 instance. +statement function against a uniquely named scratch database on the configured +PostgreSQL server. """ from __future__ import annotations @@ -282,7 +283,8 @@ def test_roundtrip_type_mappings(client_modules, roundtrip_db: str) -> None: enum column decoding to the generated StrEnum, composite param encode + column decode to the frozen dataclass, array param via ANY, jsonb param and column round-trip, the literal-`%` query, nullable columns as None, and the - rows-affected helper. Runs against a throwaway pg0 database. + rows-affected helper. Runs against a uniquely named scratch database on the + configured PostgreSQL server. """ _apply_migrations(roundtrip_db) facade, _, _ = client_modules @@ -406,18 +408,10 @@ async def scenario() -> None: assert isinstance(feeling_rows[0].origin, Point2D) assert await facade.list_specimens_by_feeling(conn, feeling=Mood.SAD) == [] - # Array param via ANY. This exercises the nullable-element branch - # (list[T | None] | None). NOTE: the generator's non-null-element - # branch (list[T]) is not covered by this fixture because the - # single-table specimen schema produces no query shape under which pgn - # infers element_not_null:true (= ANY and unnest forms against - # specimen all yield false), and test_committed_sig_files_match_fresh_analysis - # pins every fixture sig to fresh analysis, so a true flag cannot be - # committed here. The branch ships in the real client - # (documents_have_unpublished_changes, whose unnest-over-FK-join shape - # does infer it) and is guarded by checks:pgn-generate plus call-site - # type-checking in apps/backend and apps/ingest, which pass list[UUID]. - # The generated client bodies are not strict-typechecked themselves. + # Array param via ANY. This fixture exercises the nullable-element + # branch because pgn reports element_not_null:false for its signatures. + # Synthetic custom-shape probes exercise elementIsNullable=False, and + # the fresh generated package passes basedpyright strict above. id_rows = await facade.list_specimens_by_ids(conn, pub_ids=[inserted.pub_id]) assert [r.pub_id for r in id_rows] == [inserted.pub_id] assert await facade.list_specimens_by_ids(conn, pub_ids=[uuid.uuid4()]) == [] @@ -634,13 +628,11 @@ def test_roundtrip_sync_surface(client_modules, roundtrip_db: str) -> None: def test_roundtrip_single_field_composite(client_modules, roundtrip_db: str) -> None: - """Regression test for compositeBind on a one-field composite. + """Regression for class-aware adaptation of a one-field composite. - concatMapSep joins a single-element field list with no separator, so an - unguarded tuple expression would render "(x.f)": a parenthesized value, not - a tuple, which psycopg would try to adapt as the bare field type instead of - the composite. Exercises the param bind (insert) and the result-column - decode (RETURNING and a plain SELECT). + The registered dataclass sequence callback must return a field-ordered + one-element tuple, not the bare field value. Exercises parameter adaptation + on insert and result adaptation through RETURNING and a plain SELECT. """ _apply_migrations(roundtrip_db) facade, _, _ = client_modules From 55d14db50fab8a2df112daf5fed4984cdf725a9e Mon Sep 17 00:00:00 2001 From: Viacheslav Shvets Date: Mon, 13 Jul 2026 17:30:17 +0300 Subject: [PATCH 35/41] perf(gen): slim dhall normalization state --- DESIGN.md | 9 +- src/Interpreters/CustomType.dhall | 29 +---- src/Interpreters/Member.dhall | 23 +--- src/Interpreters/ParamsMember.dhall | 23 +--- src/Interpreters/Primitive.dhall | 11 +- src/Interpreters/Project.dhall | 170 +++++++++++++------------- src/Interpreters/Query.dhall | 16 +-- src/Interpreters/QueryFragments.dhall | 11 +- src/Interpreters/Result.dhall | 18 +-- src/Interpreters/ResultColumns.dhall | 13 +- src/Interpreters/Scalar.dhall | 11 +- src/Interpreters/Value.dhall | 11 +- src/Structures/CustomKind.dhall | 6 +- tests/test_unsupported_types.py | 101 +++++++++++++-- 14 files changed, 221 insertions(+), 231 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index cf740ce..42aacf2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -220,10 +220,11 @@ nullability independently. JSON parameters use psycopg's `Json` or `Jsonb` wrapper only for a scalar value. `CustomKind.Lookup` classifies each custom reference as enum, composite, or -absent, and supplies project order plus composite fields. That classification -drives type imports, supported array ranks, nested dependencies, and adapter -registration. The generator reports instead of guessing when a primitive or -custom type cannot be mapped. +absent. For enum and composite references it returns only the generated Python +class and module identity plus stable project order; composite fields stay with +the original model. That classification drives type imports, supported array +ranks, nested dependencies, and adapter registration. The generator reports +instead of guessing when a primitive or custom type cannot be mapped. The explicit custom limits are: diff --git a/src/Interpreters/CustomType.dhall b/src/Interpreters/CustomType.dhall index 2c33074..1b59985 100644 --- a/src/Interpreters/CustomType.dhall +++ b/src/Interpreters/CustomType.dhall @@ -8,8 +8,6 @@ let ImportSet = ../Structures/ImportSet.dhall let CustomKind = ../Structures/CustomKind.dhall -let OnUnsupported = ../Structures/OnUnsupported.dhall - let PythonNameMapping = ../Structures/PythonNameMapping.dhall let PythonNamespace = ../Structures/PythonNamespace.dhall @@ -23,11 +21,7 @@ let EnumModule = ../Templates/EnumModule.dhall let CompositeModule = ../Templates/CompositeModule.dhall let Config = - { packageName : Text - , importName : Text - , emitSync : Bool - , onUnsupported : OnUnsupported.Mode - , customTypeNameMappings : List PythonNameMapping.CustomType + { customTypeNameMappings : List PythonNameMapping.CustomType } let Input = Model.CustomType @@ -184,9 +178,6 @@ let run = let modulePath = "types/${moduleName}.py" - let coreConfig = - config.{ packageName, importName, emitSync, onUnsupported } - in merge { Enum = \(variants : List Model.EnumVariant) -> @@ -223,11 +214,7 @@ let run = enumNamespaceBindings input typeName } , Composite = - \ ( _ - : { fields : List CustomKind.CompositeField - , identity : CustomKind.Identity - } - ) -> + \(_ : CustomKind.Identity) -> Lude.Compiled.report Output [ input.pgName ] @@ -250,7 +237,7 @@ let run = merge { Primitive = \(_ : Model.Primitive) -> - MemberGen.run coreConfig lookup m + MemberGen.run {=} lookup m , Custom = \(name : Model.Name) -> Prelude.Optional.fold @@ -263,7 +250,7 @@ let run = [ m.pgName, name.inSnakeCase ] "Custom array fields inside a composite type are not supported" ) - (MemberGen.run coreConfig lookup m) + (MemberGen.run {=} lookup m) } m.value.scalar ) @@ -304,11 +291,7 @@ let run = in merge { Composite = - \ ( composite - : { fields : List CustomKind.CompositeField - , identity : CustomKind.Identity - } - ) -> + \(identity : CustomKind.Identity) -> Lude.Compiled.ok Output { modulePath @@ -325,7 +308,7 @@ let run = , pgSchema = input.pgSchema , pgName = input.pgName , kind = TypeKind.Composite - , order = composite.identity.order + , order = identity.order , dependencies , moduleBindings = compositeNamespaceBindings diff --git a/src/Interpreters/Member.dhall b/src/Interpreters/Member.dhall index 15b0e04..c621e6d 100644 --- a/src/Interpreters/Member.dhall +++ b/src/Interpreters/Member.dhall @@ -10,16 +10,9 @@ let CustomKind = ../Structures/CustomKind.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 Config = {} let Input = Model.Member @@ -31,7 +24,7 @@ let Output = let runWithPrefix = \(prefix : Text) -> - \(config : Config) -> + \(_ : Config) -> \(lookup : CustomKind.Lookup) -> \(input : Input) -> -- Result-column / composite-field name becomes a dataclass field and decode @@ -98,18 +91,14 @@ let runWithPrefix = ] "Array of an enum with dimensionality > 2 is not supported" , Composite = - \ ( composite - : { fields : List CustomKind.CompositeField - , identity : CustomKind.Identity - } - ) -> + \(identity : CustomKind.Identity) -> if dimsAtMostOne then Lude.Compiled.ok Output ( mkOutput - composite.identity + identity ( ImportSet.customComposite - composite.identity + identity ) ) else Lude.Compiled.report @@ -139,7 +128,7 @@ let runWithPrefix = = Lude.Compiled.nest Value.Output input.pgName - (Value.run config input.value) + (Value.run {=} input.value) in Lude.Compiled.flatMap Value.Output Output buildOutput compiledValue diff --git a/src/Interpreters/ParamsMember.dhall b/src/Interpreters/ParamsMember.dhall index 1d9761a..b48c1dd 100644 --- a/src/Interpreters/ParamsMember.dhall +++ b/src/Interpreters/ParamsMember.dhall @@ -8,16 +8,9 @@ let ImportSet = ../Structures/ImportSet.dhall let CustomKind = ../Structures/CustomKind.dhall -let OnUnsupported = ../Structures/OnUnsupported.dhall - let Value = ./Value.dhall -let Config = - { packageName : Text - , importName : Text - , emitSync : Bool - , onUnsupported : OnUnsupported.Mode - } +let Config = {} let Input = Model.Member @@ -216,7 +209,7 @@ let isJsonArray = Prelude.Bool.and [ scalarIsJson value, valueIsArray value ] let run = - \(config : Config) -> + \(_ : Config) -> \(lookup : CustomKind.Lookup) -> \(input : Input) -> let fieldName = PyIdent.parameterSafeName input.name.inSnakeCase @@ -300,11 +293,7 @@ let run = ] "Array of an enum parameter with dimensionality > 2 is not supported" , Composite = - \ ( composite - : { fields : List CustomKind.CompositeField - , identity : CustomKind.Identity - } - ) -> + \(identity : CustomKind.Identity) -> if dimsAtMostOne then Lude.Compiled.ok Output @@ -312,12 +301,12 @@ let run = ( ImportSet.combine value.imports ( ImportSet.customComposite - composite.identity + identity ) ) ( Value.qualifyCustom "_db_types." - composite.identity.className + identity.className value ++ nullableSuffix ) @@ -354,7 +343,7 @@ let run = = Lude.Compiled.nest Value.Output input.pgName - (Value.run config input.value) + (Value.run {=} input.value) in Lude.Compiled.flatMap Value.Output Output buildOutput compiledValue diff --git a/src/Interpreters/Primitive.dhall b/src/Interpreters/Primitive.dhall index 052ef1b..65bc55b 100644 --- a/src/Interpreters/Primitive.dhall +++ b/src/Interpreters/Primitive.dhall @@ -6,14 +6,7 @@ let Sdk = ../Deps/Sdk.dhall let ImportSet = ../Structures/ImportSet.dhall -let OnUnsupported = ../Structures/OnUnsupported.dhall - -let Config = - { packageName : Text - , importName : Text - , emitSync : Bool - , onUnsupported : OnUnsupported.Mode - } +let Config = {} let Input = Model.Primitive @@ -31,7 +24,7 @@ let unsupported = let plain = \(pyType : Text) -> supported pyType ImportSet.empty let run = - \(config : Config) -> + \(_ : Config) -> \(input : Input) -> merge { Bit = unsupported "bit" diff --git a/src/Interpreters/Project.dhall b/src/Interpreters/Project.dhall index 5b38f83..b4bf60d 100644 --- a/src/Interpreters/Project.dhall +++ b/src/Interpreters/Project.dhall @@ -10,8 +10,6 @@ let CustomKind = ../Structures/CustomKind.dhall let PyIdent = ../Structures/PyIdent.dhall -let Value = ./Value.dhall - let QueryGen = ./Query.dhall let CustomTypeGen = ./CustomType.dhall @@ -49,9 +47,9 @@ let Config = , customTypeNameMappings : Optional (List PythonNameMapping.CustomType) } --- The root resolves every public option once. Query and CustomType receive only --- their own mapping lists; lower interpreters receive the four scalar fields so --- a non-empty mapping does not multiply through every member normalization. +-- The root resolves every public option once. Query receives only emitSync and +-- its mappings, while CustomType receives only its mappings. Lower interpreters +-- use empty configs except for Result's caller-supplied rowClassName. let ResolvedConfig = { packageName : Text , importName : Text @@ -525,56 +523,26 @@ let withHeader = \(file : Lude.File.Type) -> { path = file.path, content = generatedHeader ++ file.content } -let lookupConfig - : ResolvedConfig - = { packageName = "" - , importName = "" - , emitSync = False - , onUnsupported = OnUnsupported.Mode.Fail - , queryNameMappings = [] : List PythonNameMapping.Query - , customTypeNameMappings = [] : List PythonNameMapping.CustomType - } - -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.{ packageName, importName, emitSync, onUnsupported } - member.value - ) +let IndexedCustomType = { index : Natural, value : Model.CustomType } - in valuePyType ++ (if member.isNullable then " | None" else "") +let LookupKind = < Composite | Enum | Domain > -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 +let LookupEntry = + { contractName : Text + , kind : LookupKind + , identity : CustomKind.Identity + } -let IndexedCustomType = { index : Natural, value : Model.CustomType } +let ResolvedCustomType = + { value : Model.CustomType, lookupEntry : LookupEntry } -let buildLookup = +let resolveCustomTypes = \(nameMappings : List PythonNameMapping.CustomType) -> \(customTypes : List Model.CustomType) -> - Prelude.List.fold + Prelude.List.map IndexedCustomType - (Prelude.List.indexed Model.CustomType customTypes) - CustomKind.Lookup + ResolvedCustomType ( \(entry : IndexedCustomType) -> - \(rest : CustomKind.Lookup) -> - \(name : Model.Name) -> let customType = entry.value let pythonName = @@ -593,26 +561,52 @@ let buildLookup = , order = entry.index } + let kind = + merge + { Composite = \(_ : List Model.Member) -> LookupKind.Composite + , Enum = \(_ : List Model.EnumVariant) -> LookupKind.Enum + , Domain = \(_ : Model.Value) -> LookupKind.Domain + } + customType.definition + + in { value = customType + , lookupEntry = + { contractName = customType.name.inSnakeCase + , kind + , identity + } + } + ) + (Prelude.List.indexed Model.CustomType customTypes) + +let lookupEntries = + \(customTypes : List ResolvedCustomType) -> + Prelude.List.map + ResolvedCustomType + LookupEntry + (\(customType : ResolvedCustomType) -> customType.lookupEntry) + customTypes + +let buildLookup = + \(entries : List LookupEntry) -> + Prelude.List.fold + LookupEntry + entries + CustomKind.Lookup + ( \(entry : LookupEntry) -> + \(rest : CustomKind.Lookup) -> + \(name : Model.Name) -> -- Text/equal is a pgn embedded-Dhall builtin. The upstream exit is -- a stable custom kind or id on Scalar.Custom. - in if Text/equal - name.inSnakeCase - customType.name.inSnakeCase - then merge - { Composite = - \(members : List Model.Member) -> - CustomKind.TypeKind.Composite - { fields = compositeFields members - , identity - } - , Enum = - \(_ : List Model.EnumVariant) -> - CustomKind.TypeKind.Enum identity - , Domain = - \(_ : Model.Value) -> CustomKind.TypeKind.Absent - } - customType.definition - else rest name + if Text/equal name.inSnakeCase entry.contractName + then merge + { Composite = + CustomKind.TypeKind.Composite entry.identity + , Enum = CustomKind.TypeKind.Enum entry.identity + , Domain = CustomKind.TypeKind.Absent + } + entry.kind + else rest name ) (\(_ : Model.Name) -> CustomKind.TypeKind.Absent) @@ -1060,7 +1054,7 @@ 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 calling QueryGen.run config lookup query from +-- side re-uses `queryChecks` because calling QueryGen.run queryConfig 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 @@ -1136,10 +1130,10 @@ let run = } let queryConfig = - resolvedConfig.{ packageName, importName, emitSync, onUnsupported, queryNameMappings } + resolvedConfig.{ emitSync, queryNameMappings } let customTypeConfig = - resolvedConfig.{ packageName, importName, emitSync, onUnsupported, customTypeNameMappings } + resolvedConfig.{ customTypeNameMappings } let skip = merge { Fail = False, Skip = True } resolvedConfig.onUnsupported @@ -1169,33 +1163,45 @@ let run = } (CustomTypeGen.run customTypeConfig candidateLookup ct) + let resolvedCustomTypes = + resolveCustomTypes + resolvedConfig.customTypeNameMappings + input.customTypes + -- Nested custom support requires transitive closure: after one type is -- removed, composites depending on it must be reconsidered against the -- smaller lookup. Each bounded pass can only remove survivors. - let effectiveCustomTypes - : List Model.CustomType + let effectiveResolvedCustomTypes + : List ResolvedCustomType = if skip then Natural/fold (Prelude.List.length Model.CustomType input.customTypes) - (List Model.CustomType) - ( \(survivors : List Model.CustomType) -> + (List ResolvedCustomType) + ( \(survivors : List ResolvedCustomType) -> let candidateLookup = - buildLookup - resolvedConfig.customTypeNameMappings - survivors + buildLookup (lookupEntries survivors) in Prelude.List.filter - Model.CustomType - (typeSucceedsWith candidateLookup) + ResolvedCustomType + ( \(customType : ResolvedCustomType) -> + typeSucceedsWith + candidateLookup + customType.value + ) survivors ) - input.customTypes - else input.customTypes + resolvedCustomTypes + else resolvedCustomTypes + + let effectiveCustomTypes = + Prelude.List.map + ResolvedCustomType + Model.CustomType + (\(customType : ResolvedCustomType) -> customType.value) + effectiveResolvedCustomTypes let lookup = - buildLookup - resolvedConfig.customTypeNameMappings - effectiveCustomTypes + buildLookup (lookupEntries effectiveResolvedCustomTypes) -- Fail mode: identical to the pre-Skip code (traverseList straight -- over input.customTypes), so its error message/path is unchanged. diff --git a/src/Interpreters/Query.dhall b/src/Interpreters/Query.dhall index bc47e8d..b4eb4a5 100644 --- a/src/Interpreters/Query.dhall +++ b/src/Interpreters/Query.dhall @@ -12,8 +12,6 @@ let PyIdent = ../Structures/PyIdent.dhall let Surface = ../Structures/Surface.dhall -let OnUnsupported = ../Structures/OnUnsupported.dhall - let PythonNameMapping = ../Structures/PythonNameMapping.dhall let ResultModule = ./Result.dhall @@ -25,10 +23,7 @@ let ParamsMember = ./ParamsMember.dhall let StatementModule = ../Templates/StatementModule.dhall let Config = - { packageName : Text - , importName : Text - , emitSync : Bool - , onUnsupported : OnUnsupported.Mode + { emitSync : Bool , queryNameMappings : List PythonNameMapping.Query } @@ -143,9 +138,6 @@ let run = let rowClassName = pythonName.pascalCase ++ "Row" - let coreConfig = - config.{ packageName, importName, emitSync, onUnsupported } - in Compiled.nest Output input.srcPath @@ -159,7 +151,7 @@ let run = ResultModule.Output "result" ( ResultModule.run - (coreConfig /\ { rowClassName }) + { rowClassName } lookup input.result ) @@ -167,7 +159,7 @@ let run = ( Compiled.nest QueryFragmentsModule.Output "sql" - (QueryFragmentsModule.run coreConfig input.fragments) + (QueryFragmentsModule.run {=} input.fragments) ) ( Compiled.nest (List ParamsMember.Output) @@ -179,7 +171,7 @@ let run = Compiled.nest ParamsMember.Output member.pgName - (ParamsMember.run coreConfig lookup member) + (ParamsMember.run {=} lookup member) ) input.params ) diff --git a/src/Interpreters/QueryFragments.dhall b/src/Interpreters/QueryFragments.dhall index 7813900..bc8a3e1 100644 --- a/src/Interpreters/QueryFragments.dhall +++ b/src/Interpreters/QueryFragments.dhall @@ -8,14 +8,7 @@ let Compiled = Lude.Compiled let Model = ../Deps/Contract.dhall -let OnUnsupported = ../Structures/OnUnsupported.dhall - -let Config = - { packageName : Text - , importName : Text - , emitSync : Bool - , onUnsupported : OnUnsupported.Mode - } +let Config = {} let Input = Model.QueryFragments @@ -52,7 +45,7 @@ let renderSql Prelude.Text.concatMap Model.QueryFragment renderFragment fragments let run = - \(config : Config) -> + \(_ : Config) -> \(input : Input) -> Compiled.ok Output { sqlLiteral = renderSql input } diff --git a/src/Interpreters/Result.dhall b/src/Interpreters/Result.dhall index 0da872d..37ae10e 100644 --- a/src/Interpreters/Result.dhall +++ b/src/Interpreters/Result.dhall @@ -8,8 +8,6 @@ let ImportSet = ../Structures/ImportSet.dhall let CustomKind = ../Structures/CustomKind.dhall -let OnUnsupported = ../Structures/OnUnsupported.dhall - let ResultColumns = ./ResultColumns.dhall let Compiled = Lude.Compiled @@ -17,15 +15,9 @@ let Compiled = Lude.Compiled -- 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. +-- Model.Result. ResultColumns and lower interpreters use empty configs. let Config = - { packageName : Text - , importName : Text - , emitSync : Bool - , onUnsupported : OnUnsupported.Mode - , rowClassName : Text - } + { rowClassName : Text } let Input = Model.Result @@ -87,11 +79,7 @@ let rowsOutput = , imports = cols.imports } ) - ( ResultColumns.run - config.{ packageName, importName, emitSync, onUnsupported } - lookup - columns - ) + (ResultColumns.run {=} lookup columns) let run = \(config : Config) -> diff --git a/src/Interpreters/ResultColumns.dhall b/src/Interpreters/ResultColumns.dhall index 0fda1b5..cb0f835 100644 --- a/src/Interpreters/ResultColumns.dhall +++ b/src/Interpreters/ResultColumns.dhall @@ -8,18 +8,11 @@ let ImportSet = ../Structures/ImportSet.dhall let CustomKind = ../Structures/CustomKind.dhall -let OnUnsupported = ../Structures/OnUnsupported.dhall - let Member = ./Member.dhall let Compiled = Lude.Compiled -let Config = - { packageName : Text - , importName : Text - , emitSync : Bool - , onUnsupported : OnUnsupported.Mode - } +let Config = {} let Input = List Model.Member @@ -46,7 +39,7 @@ let assemble } let run = - \(config : Config) -> + \(_ : Config) -> \(lookup : CustomKind.Lookup) -> \(input : Input) -> Compiled.map @@ -60,7 +53,7 @@ let run = Compiled.nest Member.Output member.pgName - (Member.runWithPrefix "_db_types." config lookup member) + (Member.runWithPrefix "_db_types." {=} lookup member) ) input ) diff --git a/src/Interpreters/Scalar.dhall b/src/Interpreters/Scalar.dhall index d735396..ba1e749 100644 --- a/src/Interpreters/Scalar.dhall +++ b/src/Interpreters/Scalar.dhall @@ -6,16 +6,9 @@ let Sdk = ../Deps/Sdk.dhall let ImportSet = ../Structures/ImportSet.dhall -let OnUnsupported = ../Structures/OnUnsupported.dhall - let Primitive = ./Primitive.dhall -let Config = - { packageName : Text - , importName : Text - , emitSync : Bool - , onUnsupported : OnUnsupported.Mode - } +let Config = {} let Input = Model.Scalar @@ -46,7 +39,7 @@ let run = , decode = ScalarDecode.Passthrough } ) - (Primitive.run config primitive) + (Primitive.run {=} primitive) , Custom = \(name : Model.Name) -> Lude.Compiled.ok diff --git a/src/Interpreters/Value.dhall b/src/Interpreters/Value.dhall index 141c40a..f9717de 100644 --- a/src/Interpreters/Value.dhall +++ b/src/Interpreters/Value.dhall @@ -8,16 +8,9 @@ let Sdk = ../Deps/Sdk.dhall let ImportSet = ../Structures/ImportSet.dhall -let OnUnsupported = ../Structures/OnUnsupported.dhall - let Scalar = ./Scalar.dhall -let Config = - { packageName : Text - , importName : Text - , emitSync : Bool - , onUnsupported : OnUnsupported.Mode - } +let Config = {} let Input = Model.Value @@ -67,7 +60,7 @@ let run = , elementIsNullable = False } ) - (Scalar.run config input.scalar) + (Scalar.run {=} input.scalar) let qualifyCustom : Text -> Text -> Output -> Text diff --git a/src/Structures/CustomKind.dhall b/src/Structures/CustomKind.dhall index bbaa69c..1621f13 100644 --- a/src/Structures/CustomKind.dhall +++ b/src/Structures/CustomKind.dhall @@ -1,16 +1,14 @@ let Model = ../Deps/Contract.dhall -let CompositeField = { fieldName : Text, pyType : Text } - let Identity = { className : Text, moduleName : Text, order : Natural } let TypeKind = < Enum : Identity - | Composite : { fields : List CompositeField, identity : Identity } + | Composite : Identity | Absent > let Lookup = Model.Name -> TypeKind -in { TypeKind, Lookup, CompositeField, Identity } +in { TypeKind, Lookup, Identity } diff --git a/tests/test_unsupported_types.py b/tests/test_unsupported_types.py index d4fc537..e9fd292 100644 --- a/tests/test_unsupported_types.py +++ b/tests/test_unsupported_types.py @@ -131,13 +131,11 @@ def _write_contract_probe( "absent": "CustomKind.TypeKind.Absent", "enum": ('CustomKind.TypeKind.Enum { className = "ProbeValue", moduleName = "probe_value", order = 0 }'), "composite": ( - "CustomKind.TypeKind.Composite " - "{ fields = [] : List CustomKind.CompositeField, " - 'identity = { className = "ProbeValue", moduleName = "probe_value", order = 0 } }' + 'CustomKind.TypeKind.Composite { className = "ProbeValue", moduleName = "probe_value", order = 0 }' ), }[lookup_kind] - custom_type_name_mappings = ( - ", customTypeNameMappings = [] : List PythonNameMapping.CustomType" if interpreter == "CustomType" else "" + interpreter_config = ( + "{ customTypeNameMappings = [] : List PythonNameMapping.CustomType }" if interpreter == "CustomType" else "{=}" ) target_input = "customType" if nested else "member" custom_type = ( @@ -185,12 +183,7 @@ def _write_contract_probe( }} let interpreterConfig = - {{ packageName = "contract-probe" - , importName = "contract_probe" - , emitSync = False - , onUnsupported = OnUnsupported.Mode.Fail - {custom_type_name_mappings} - }} + {interpreter_config} let run = \\(_ : Config) -> @@ -389,6 +382,92 @@ def test_custom_shape_contracts_succeed( assert result.returncode == 0, f"expected {case_id} to succeed:\n{_combined_output(result)}" +def test_skip_preserves_mapped_nested_registration_chain( + pgn_bin: str, + pgn_admin_url: str, + tmp_path: Path, +) -> None: + _, project = _fresh_project(tmp_path) + for query_file in (project / "queries").iterdir(): + query_file.unlink() + shutil.rmtree(project / "types") + + _ = (project / "migrations" / "1.sql").write_text( + "CREATE TYPE a_bad AS (amount money);\n" + "CREATE TYPE z_survivor_status AS ENUM ('ready');\n" + "CREATE TYPE m_survivor_leaf AS (status z_survivor_status);\n" + "CREATE TYPE n_survivor_outer AS (leaf m_survivor_leaf);\n" + ) + _ = (project / "queries" / "read_survivor_outer.sql").write_text( + "SELECT ROW(ROW('ready'::z_survivor_status)::m_survivor_leaf)::n_survivor_outer AS value\n" + ) + _ = (project / "queries" / "read_bad.sql").write_text("SELECT ROW(1::money)::a_bad AS value\n") + _write_single_artifact(project, "../../src/package.dhall", "skip-mapping-chain", "Skip") + config = project / "project1.pgn.yaml" + _ = config.write_text( + config.read_text() + + " customTypeNameMappings:\n" + + " - source:\n" + + " schema: public\n" + + " name: z_survivor_status\n" + + " target:\n" + + " snakeCase: mapped_status\n" + + " pascalCase: MappedStatus\n" + ) + + result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") + diagnostic = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", _combined_output(result)) + assert result.returncode == 0, f"mapped Skip generation failed:\n{diagnostic}" + warnings = re.findall(r"Warning: ([^\n]+)\nStage: ([^\n]+)", diagnostic) + assert warnings == [ + ("Unsupported type", "Generating > python > Compiling > money > amount > a_bad"), + ( + "Custom type not found in project customTypes", + "Generating > python > Compiling > a_bad > value > result > ./queries/read_bad.sql", + ), + ] + + package = project / "artifacts" / "python" / "src" / "skip_mapping_chain" + generated = package / "_generated" + types = generated / "types" + assert {path.name for path in types.glob("*.py")} == { + "__init__.py", + "m_survivor_leaf.py", + "mapped_status.py", + "n_survivor_outer.py", + } + + generated_sources = [path.read_text() for path in package.rglob("*.py")] + assert all("a_bad" not in source and "ABad" not in source for source in generated_sources) + assert all("read_bad" not in source for source in generated_sources) + assert not list(package.rglob("*read_bad*")) + + leaf_source = (types / "m_survivor_leaf.py").read_text() + outer_source = (types / "n_survivor_outer.py").read_text() + assert [line for line in leaf_source.splitlines() if line.startswith("from .")] == [ + "from .mapped_status import MappedStatus" + ] + assert "status: MappedStatus" in leaf_source + assert [line for line in outer_source.splitlines() if line.startswith("from .")] == [ + "from .m_survivor_leaf import MSurvivorLeaf" + ] + assert "leaf: MSurvivorLeaf" in outer_source + + register_source = (generated / "_register.py").read_text() + pg_name_constants = [ + line for line in register_source.splitlines() if line.startswith("_") and "_pg_name = " in line + ] + assert pg_name_constants == [ + '_mapped_status_pg_name = "public.z_survivor_status"', + '_m_survivor_leaf_pg_name = "public.m_survivor_leaf"', + '_n_survivor_outer_pg_name = "public.n_survivor_outer"', + ] + + statement = (generated / "statements" / "read_survivor_outer.py").read_text() + assert "from .. import types as _db_types" in statement + assert "value: _db_types.NSurvivorOuter" in statement + + def test_skip_unsupported_drops_offending_units_and_cascades(pgn_bin: str, pgn_admin_url: str, tmp_path: Path) -> None: """onUnsupported: Skip drops only the smallest self-consistent unit. From 4b89b1b51df22678d6ef3cb1cd404b916052a085 Mon Sep 17 00:00:00 2001 From: Viacheslav Shvets Date: Mon, 13 Jul 2026 18:18:00 +0300 Subject: [PATCH 36/41] fix(fixtures): pass name mappings to exhaustive config --- fixtures/Exhaustive.dhall | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fixtures/Exhaustive.dhall b/fixtures/Exhaustive.dhall index 5b5f2e6..6bb6fb9 100644 --- a/fixtures/Exhaustive.dhall +++ b/fixtures/Exhaustive.dhall @@ -18,6 +18,8 @@ let Gen = ../src/package.dhall let OnUnsupported = ../src/Structures/OnUnsupported.dhall +let PythonNameMapping = ../src/Structures/PythonNameMapping.dhall + let project = Sdk.Fixtures.Exhaustive let config = @@ -25,6 +27,8 @@ let config = { packageName = None Text , emitSync = Some False , onUnsupported = Some OnUnsupported.Mode.Skip + , queryNameMappings = None (List PythonNameMapping.Query) + , customTypeNameMappings = None (List PythonNameMapping.CustomType) } in Sdk.Output.toFileMap (Gen.compile config project) From a1ac9f82cdca2d4610c23073d3e042375442d7fb Mon Sep 17 00:00:00 2001 From: Viacheslav Shvets Date: Mon, 13 Jul 2026 19:53:19 +0300 Subject: [PATCH 37/41] fix(generator): preserve project order under Ruff --- src/Templates/FacadeModule.dhall | 41 +++++++++++++++++-- src/Templates/TypesInit.dhall | 7 +++- tests/golden/src/specimen_client/__init__.py | 13 ++++-- .../_generated/types/__init__.py | 2 + .../src/specimen_client/sync/__init__.py | 13 ++++-- tests/test_python_name_collisions.py | 9 +++- 6 files changed, 73 insertions(+), 12 deletions(-) diff --git a/src/Templates/FacadeModule.dhall b/src/Templates/FacadeModule.dhall index 48acb98..fda53d4 100644 --- a/src/Templates/FacadeModule.dhall +++ b/src/Templates/FacadeModule.dhall @@ -39,6 +39,11 @@ let importBlocks = (\(entry : Text) -> importBlock source entry) entries +-- Dhall has no Text ordering, so project-ordered imports need an explicit +-- formatter boundary instead of a comparator that would only handle fixtures. +let preserveImportOrder = + \(block : Text) -> "\n# isort: off\n${block}\n# isort: on" + let rowNames : List StatementExport -> List Text = \(statements : List StatementExport) -> @@ -75,6 +80,8 @@ let run = ) params.types + let typeBlock = preserveImportOrder typeBlock + let rows = rowNames params.statements -- A query's Row class lives in its statement module. Keep the row @@ -103,6 +110,8 @@ let run = ) params.statements + let statementBlock = preserveImportOrder statementBlock + let registrationBlock = merge { None = [] : List Text @@ -145,6 +154,15 @@ let run = names ++ "]" + let renderProjectAllBlock = + \(names : List Text) -> + "__all__ += [ # noqa: RUF022, RUF100\n" + ++ Prelude.Text.concatMap + Text + (\(name : Text) -> " \"${name}\",\n") + names + ++ "]" + let typeNames = Prelude.List.map TypeExport @@ -169,19 +187,36 @@ let run = let syncNames = if params.includeSyncModule then [ "sync" ] else [] : List Text - let extraAllGroups = + let projectAllGroups = Prelude.List.filter (List Text) (\(group : List Text) -> Prelude.Bool.not (Prelude.List.null Text group)) - [ typeNames, rows, functionNames, registrationNames, syncNames ] + [ typeNames, rows, functionNames ] + + let fixedAllGroups = + Prelude.List.filter + (List Text) + (\(group : List Text) -> Prelude.Bool.not (Prelude.List.null Text group)) + [ registrationNames, syncNames ] + + let projectAllBlocks = + if Prelude.List.null (List Text) projectAllGroups + then [] : List Text + else [ "# Project order is intentional for public re-export groups." ] + # Prelude.List.map + (List Text) + Text + renderProjectAllBlock + projectAllGroups let allBlocks = [ renderAllBlock "=" runtimeNames ] + # projectAllBlocks # Prelude.List.map (List Text) Text (renderAllBlock "+=") - extraAllGroups + fixedAllGroups in '' ${importSection} diff --git a/src/Templates/TypesInit.dhall b/src/Templates/TypesInit.dhall index 3182085..adf5a8e 100644 --- a/src/Templates/TypesInit.dhall +++ b/src/Templates/TypesInit.dhall @@ -17,8 +17,13 @@ let run = ) params.exports + let exportSection = + if Prelude.List.null Export params.exports + then "" + else "# isort: off\n${exportLines}\n# isort: on" + in '' - ${exportLines} + ${exportSection} '' in Sdk.Sigs.template Params run /\ { Export } diff --git a/tests/golden/src/specimen_client/__init__.py b/tests/golden/src/specimen_client/__init__.py index 55bb24e..7f11f41 100644 --- a/tests/golden/src/specimen_client/__init__.py +++ b/tests/golden/src/specimen_client/__init__.py @@ -14,6 +14,8 @@ from ._generated._register import ( register_types as register_types, ) + +# isort: off from ._generated.statements.bump_specimen_revision import ( bump_specimen_revision as bump_specimen_revision, ) @@ -77,6 +79,9 @@ from ._generated.statements.search_specimens import ( search_specimens as search_specimens, ) +# isort: on + +# isort: off from ._generated.types.a_codec_wrapper import ( ACodecWrapper as ACodecWrapper, ) @@ -92,19 +97,21 @@ from ._generated.types.z_codec_payload import ( ZCodecPayload as ZCodecPayload, ) +# isort: on __all__ = [ "JsonValue", "NoRowError", ] -__all__ += [ +# Project order is intentional for public re-export groups. +__all__ += [ # noqa: RUF022, RUF100 "ACodecWrapper", "Mood", "Point2D", "TagValue", "ZCodecPayload", ] -__all__ += [ +__all__ += [ # noqa: RUF022, RUF100 "GetSpecimenRow", "GetTaggedItemRow", "InsertSpecimenRow", @@ -116,7 +123,7 @@ "ListSpecimensKeywordColumnRow", "SearchSpecimensRow", ] -__all__ += [ +__all__ += [ # noqa: RUF022, RUF100 "bump_specimen_revision", "get_specimen", "get_tagged_item", diff --git a/tests/golden/src/specimen_client/_generated/types/__init__.py b/tests/golden/src/specimen_client/_generated/types/__init__.py index aa326cf..9a8d597 100644 --- a/tests/golden/src/specimen_client/_generated/types/__init__.py +++ b/tests/golden/src/specimen_client/_generated/types/__init__.py @@ -2,8 +2,10 @@ # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 +# isort: off from .a_codec_wrapper import ACodecWrapper as ACodecWrapper from .mood import Mood as Mood from .point_2_d import Point2D as Point2D from .tag_value import TagValue as TagValue from .z_codec_payload import ZCodecPayload as ZCodecPayload +# isort: on diff --git a/tests/golden/src/specimen_client/sync/__init__.py b/tests/golden/src/specimen_client/sync/__init__.py index 27d3289..4a89f76 100644 --- a/tests/golden/src/specimen_client/sync/__init__.py +++ b/tests/golden/src/specimen_client/sync/__init__.py @@ -11,6 +11,8 @@ from .._generated._register import ( register_types_sync as register_types, ) + +# isort: off from .._generated.statements.bump_specimen_revision import ( bump_specimen_revision_sync as bump_specimen_revision, ) @@ -74,6 +76,9 @@ from .._generated.statements.search_specimens import ( search_specimens_sync as search_specimens, ) +# isort: on + +# isort: off from .._generated.types.a_codec_wrapper import ( ACodecWrapper as ACodecWrapper, ) @@ -89,19 +94,21 @@ from .._generated.types.z_codec_payload import ( ZCodecPayload as ZCodecPayload, ) +# isort: on __all__ = [ "JsonValue", "NoRowError", ] -__all__ += [ +# Project order is intentional for public re-export groups. +__all__ += [ # noqa: RUF022, RUF100 "ACodecWrapper", "Mood", "Point2D", "TagValue", "ZCodecPayload", ] -__all__ += [ +__all__ += [ # noqa: RUF022, RUF100 "GetSpecimenRow", "GetTaggedItemRow", "InsertSpecimenRow", @@ -113,7 +120,7 @@ "ListSpecimensKeywordColumnRow", "SearchSpecimensRow", ] -__all__ += [ +__all__ += [ # noqa: RUF022, RUF100 "bump_specimen_revision", "get_specimen", "get_tagged_item", diff --git a/tests/test_python_name_collisions.py b/tests/test_python_name_collisions.py index 59ce39c..8503264 100644 --- a/tests/test_python_name_collisions.py +++ b/tests/test_python_name_collisions.py @@ -508,6 +508,13 @@ def test_typed_mappings_propagate_through_generated_package( assert register_source.index('"public.mapped_inner"') < register_source.index('"public.mapped_outer"') assert "public.json_value" in register_source + facade_source = (package / "__init__.py").read_text() + assert facade_source.index("statements.sync_query") < facade_source.index("statements.api_v2") + assert facade_source.index("types.pg_json_value") < facade_source.index("types.mapped_inner_v2") + assert facade_source.index('"PgJsonValue"') < facade_source.index('"MappedInnerV2"') + types_init_source = (package / "_generated" / "types" / "__init__.py").read_text() + assert types_init_source.index(".pg_json_value import") < types_init_source.index(".mapped_inner_v2 import") + for source in package.rglob("*.py"): _ = ast.parse(source.read_text(), filename=str(source)) @@ -517,8 +524,6 @@ def test_typed_mappings_propagate_through_generated_package( "check", "--config", str(SRC_DIR.parent / "pyproject.toml"), - "--extend-ignore", - "I001,RUF022", str(package), ], capture_output=True, From cfcd0ae6297a965b0c21b3709314933e448a3af0 Mon Sep 17 00:00:00 2001 From: Viacheslav Shvets Date: Mon, 13 Jul 2026 19:53:24 +0300 Subject: [PATCH 38/41] chore(deps): raise psycopg floor and refresh tooling --- .github/scripts/build-contract-shell.sh | 2 +- .github/workflows/bump.yml | 2 +- .github/workflows/ci.yml | 8 ++++---- .github/workflows/release.yml | 8 ++++---- CHANGELOG.md | 2 +- DESIGN.md | 4 ++-- README.md | 2 +- pyproject.toml | 4 ++-- tests/golden/pyproject.toml | 2 +- uv.lock | 16 ++++++++-------- 10 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/scripts/build-contract-shell.sh b/.github/scripts/build-contract-shell.sh index bb3cd78..037bb43 100755 --- a/.github/scripts/build-contract-shell.sh +++ b/.github/scripts/build-contract-shell.sh @@ -31,7 +31,7 @@ build-backend = "hatchling.build" name = "${pkg_name//_/-}" version = "0.0.0" requires-python = ">=3.12" -dependencies = ["psycopg>=3.3,<4"] +dependencies = ["psycopg>=3.3.4,<4"] [tool.hatch.build.targets.wheel] packages = ["src/$pkg_name"] diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml index 02c9c8a..360aaed 100644 --- a/.github/workflows/bump.yml +++ b/.github/workflows/bump.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d75a2d..8a2322f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.ref || github.sha }} @@ -120,7 +120,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.ref || github.sha }} @@ -161,8 +161,8 @@ jobs: mise exec -- uv venv contract-shell/.venv mise exec -- uv pip install --python contract-shell/.venv/bin/python \ - "psycopg[binary]>=3.3,<4" \ - "basedpyright==1.39.7" \ + "psycopg[binary]>=3.3.4,<4" \ + "basedpyright==1.39.9" \ "ruff==0.15.20" - name: Ruff check the generated package diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 91764bc..5b9362c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: fi - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.ref }} fetch-depth: 0 @@ -77,7 +77,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.ref }} fetch-depth: 0 @@ -124,7 +124,7 @@ jobs: echo "commit-sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Create GitHub Release - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: tag_name: v${{ inputs.version }} name: "v${{ inputs.version }}" @@ -158,7 +158,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.ref }} fetch-depth: 0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fdb011..ea7e441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,7 +57,7 @@ - Added Ruff check and format gates, authored and SQL line-length gates, raw output checks with no postformat step, public identity checks, import-boundary checks, adapter AST checks, and async/sync PostgreSQL round trips. Final - evidence: H1 CONFIRM with psycopg 3.3.4, consumer `psycopg>=3.3,<4`, + evidence: H1 CONFIRM with psycopg 3.3.4, consumer `psycopg>=3.3.4,<4`, class-aware adapters, pure models, canonical `args_row` Rows, and no query decoders, model codecs, casts, or bytes SQL. H2 CONFIRM: 25 Python files / 1467 lines / 11 statement files / 801 statement lines / 11 SQL. H3 CONFIRM: diff --git a/DESIGN.md b/DESIGN.md index 42aacf2..a55e017 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -155,7 +155,7 @@ the same five cardinality helpers: Row helpers accept `BaseRowFactory[T]` and return psycopg's constructed object directly. Statement SQL is passed as `LiteralString`. No runtime accepts encoded SQL bytes. The generated package has no separately installed support library; -its only non-stdlib consumer dependency is `psycopg>=3.3,<4`. +its only non-stdlib consumer dependency is `psycopg>=3.3.4,<4`. ## 5. Configuration, mapping, and unsupported shapes @@ -386,7 +386,7 @@ that point, manual edits are expected to be overwritten. The final fixture was regenerated as raw output and verified against live PostgreSQL with these verdicts: -- H1 CONFIRM: psycopg 3.3.4; consumer `psycopg>=3.3,<4`; +- H1 CONFIRM: psycopg 3.3.4; consumer `psycopg>=3.3.4,<4`; class-aware `EnumInfo`/`register_enum` and `CompositeInfo`/`register_composite`; dependency-first registration; pure `StrEnum` and frozen slotted dataclasses; `args_row` constructs canonical Row; diff --git a/README.md b/README.md index 5f9d0d1..d06900e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A Dhall-authored code generator for [pGenie](https://github.com/pgenie-io/pgenie strictly typed Python client for psycopg 3. The generated package has no separately installed generator runtime. Consumers -must depend on `psycopg>=3.3,<4`; the verified fixture uses psycopg 3.3.4. The +must depend on `psycopg>=3.3.4,<4`; the verified fixture uses psycopg 3.3.4. The rest of the generated imports are from the Python standard library. ## Quickstart diff --git a/pyproject.toml b/pyproject.toml index 79edd51..7e38c7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,8 @@ dependencies = [] [dependency-groups] dev = [ "pytest>=8", - "psycopg[binary]>=3.3,<4", - "basedpyright==1.39.7", + "psycopg[binary]>=3.3.4,<4", + "basedpyright==1.39.9", "ruff==0.15.20", ] diff --git a/tests/golden/pyproject.toml b/tests/golden/pyproject.toml index 3c0372a..f9a51db 100644 --- a/tests/golden/pyproject.toml +++ b/tests/golden/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" name = "specimen-client" version = "0.0.0" requires-python = ">=3.12" -dependencies = ["psycopg>=3.3,<4"] +dependencies = ["psycopg>=3.3.4,<4"] [tool.hatch.build.targets.wheel] packages = ["src/specimen_client"] diff --git a/uv.lock b/uv.lock index 8329095..46c2abd 100644 --- a/uv.lock +++ b/uv.lock @@ -4,14 +4,14 @@ requires-python = ">=3.12" [[package]] name = "basedpyright" -version = "1.39.7" +version = "1.39.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodejs-wheel-binaries" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/e5/0d685b5808436c628ab8b9edad6810b889d11044a962bc42b128543910ea/basedpyright-1.39.7.tar.gz", hash = "sha256:688d913a19c417870c164c630ed9cdd83a8d8b484b30ab8e99f5dec4ae9604a6", size = 25503256, upload-time = "2026-06-07T11:33:27.266Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/4b/c1f4e211e50389304d6af32b9280e026a7133e3ad59bbdf8f7a3250f8bee/basedpyright-1.39.9.tar.gz", hash = "sha256:32cbea5fc8273e89df3db20daea56cb7286e419ccdfdc479c64759d2dc071901", size = 24412216, upload-time = "2026-06-27T02:19:49.834Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/f4/5b1e8ea279ce8f97a6bb1518c84fa25f5794022053ce10eab22ad3f0b51b/basedpyright-1.39.7-py3-none-any.whl", hash = "sha256:81266deb6044c9be98fb4555e4b7b1a521d8aee06b66e80858d183b0e3991140", size = 13182666, upload-time = "2026-06-07T11:33:24.119Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d4/e1fa108710d0498a18c77b1e13897f31eab47c69aa8cfe2d2a4df746541e/basedpyright-1.39.9-py3-none-any.whl", hash = "sha256:6b0837b9eba972c71895167ab9b127e6afdbc17abc92312e3f8d15ca82a5611c", size = 13374276, upload-time = "2026-06-27T02:19:54.431Z" }, ] [[package]] @@ -74,8 +74,8 @@ dev = [ [package.metadata.requires-dev] dev = [ - { name = "basedpyright", specifier = "==1.39.7" }, - { name = "psycopg", extras = ["binary"], specifier = ">=3.3,<4" }, + { name = "basedpyright", specifier = "==1.39.9" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4,<4" }, { name = "pytest", specifier = ">=8" }, { name = "ruff", specifier = "==0.15.20" }, ] @@ -158,7 +158,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -167,9 +167,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] From e92a4985df684758c61baaab2f27b34922d4617f Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Thu, 16 Jul 2026 13:27:49 +0300 Subject: [PATCH 39/41] Migrate to new contract removing deps on Text/equal (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Plan * Prohibit local cross-repo deps * Update the plan * Bump gen-contract to v5.0.0 and gen-sdk to v3.0.0 Scalar.Custom now carries a CustomTypeRef (name, pgSchema, pgName, index) instead of a bare Name, and Value flattens its optional array wrapper into plain dimensionality/elementIsNullable fields. Updated every site touched by these two shape changes (Scalar, Value, Member, ParamsMember, CustomType interpreters) to compile against the new contract while preserving current behavior exactly; the buildLookup/CustomKind.Lookup mechanism itself is untouched (that migration is a separate follow-up task). BLOCKED on verification: pgn v0.9.1 (pinned in mise.toml) hard-rejects the new contract's major version before loading the generator at all ("Incompatible contract major version: 5. Expected 4."), so mise run test and mise run golden cannot currently exercise this change end-to-end. See .superpowers/sdd/task-1-report.md for the full failure output and analysis. * Another plan * python.gen: replace PyIdent's hand-rolled keyword equality with Lude.Text.replaceIfOneOf The prior double-Text/replace + digit-marker trick was justified by a claimed pgn Text/replace bug across concatenation boundaries that never actually reproduced (verified against plain dhall). Text/equal still can't be used (dropped in pgn 0.11.0, absent from the Dhall spec), so this swaps in the shared library's own sentinel-based equality helper instead, already used elsewhere in this codebase. Same public interface; golden output unchanged. * Bump pgn pin to v0.12.0 to unblock gen-contract v5 verification pgn v0.9.1 hard-rejects gen-contract major version 5 ("Incompatible contract major version: 5. Expected 4."), which blocked verifying Task 1's gen-contract v5.0.0 / gen-sdk v3.0.0 bump. pgn v0.12.0 adds gen-contract v5.0.0 support. * Update mise.lock for pgn v0.12.0 Generated by mise install after the mise.toml pin bump. * Discard Text/equal-dependent mapping/namespace validation pgn 0.11.0 (landed in Task 2) dropped the Text/equal builtin from its embedded Dhall evaluator, and there is no way to reconstruct a Bool or other branching decision from comparing two runtime Text values using only Text/replace. Every piece of this generator that made such a decision is removed: - queryNameMappings/customTypeNameMappings (Structures/PythonNameMapping.dhall and its two call sites in Query.dhall/CustomType.dhall) - the rename-mapping config feature, whole-cloth discarded per the plan's decision table. - validateCustomTypeIdentities (Interpreters/Project.dhall) - pushed upstream instead; pgenie-io/pgenie#75 asks pgn to guarantee unique custom-type identities at the source. - Structures/PythonNamespace.dhall's validate and its 4 call sites (per-query, per-custom-type, project-wide facade/module, and per-type module-internal namespace audits) - no longer expressible, but tests/test_generated.py's basedpyright --strict gate (errorCount == 0, warningCount == 0) already catches the same collisions one layer down, just later and less precisely attributed. Also removes PyIdent.dhall's isSnakeIdentifier/isPascalIdentifier helpers (dead code once the mapping-target validation they served is gone), the now-stale ADR 0001, and tests/test_python_name_collisions.py. Updates README/DESIGN/CHANGELOG to describe the new state honestly, and updates test_unsupported_types.py's synthetic contract-probe helper to the gen-contract v5.0.0 shape (CustomTypeRef, flattened dimensionality) so it typechecks again, independent of which task's changes it nominally covers. Repo-wide `grep -rn "Text/equal" src/` now turns up exactly one remaining functional use: Interpreters/Project.dhall's buildLookup (line 142) - that's the next task's scope (replacing it with gen-sdk's Sdk.CustomTypes). mise run test: 4 failed, 18 passed, 28 errors (up from 39 failed, 7 passed, 28 errors before this task) - every remaining failure traces to that same buildLookup Text/equal line, confirmed by inspecting each one individually. mise run golden also still fails at the same line (Exhaustive.dhall has custom types), so tests/golden is untouched - a null result, not a confirmation of no diff, pending the next task. * Replace buildLookup's Text/equal cascade with gen-sdk CustomTypes Retire the hand-rolled buildLookup custom-kind resolver (the last Text/equal user in src/) in favor of gen-sdk v3's Sdk.CustomTypes. References now resolve by gen-contract v5's CustomTypeRef.index via CustomKind.at instead of a Model.Name -> TypeKind closure keyed on Text/equal. - CustomKind.Lookup is now a List TypeKind indexed by CustomTypeRef.index, with an `at` accessor (out-of-range reads as Absent). - Member/ParamsMember/CustomType resolve refs by index; CustomType.run gains an explicit self-index parameter for its self-lookup consistency check. - Project.dhall builds a fixed, never-shrinking kind classification (kindOf) and, in Skip mode, a one-pass survivor cascade via supportedCustomTypesReasoned, then masks unsupported indices to Absent so a reference to a removed type reads exactly as a missing type (Absent on either failure). Fail mode masks nothing, so its behavior is unchanged. - The survivor predicate (ownDefinitionSupported) mirrors CustomTypeGen.run's own-shape failure modes exactly: Domain unsupported, Enum always supported, Composite supported iff every primitive member is a supported primitive (oracle: Primitive.run) and every custom member is a bare scalar. This is stricter than a kind-based array-rank check and is required so no surviving type later fails to render under Skip (exercised by test_skip_preserves_nested_registration_chain and test_skip_unsupported_drops_offending_units_and_cascades). - Update the contract-probe test helper for the List-shaped lookup and the new CustomType self-index argument. - Refresh tests/golden: pgn v0.12.0 now emits Project.customTypes in the gen-contract v5 topological order (dependencies first), reordering only the facade/types-init export lists (a_codec_wrapper after z_codec_payload). No generated type body or statement changes; the generator preserves input order. * Docs: buildLookup replaced by gen-sdk CustomTypes; no Text/equal left in src - README/DESIGN: describe the index-based reference resolution and the single-pass Sdk.CustomTypes survivor cascade; record that no live Text/equal use remains anywhere in src/ (buildLookup was the last one). - CHANGELOG (Upcoming): add the v5.0.0/v3.0.0 pin bump + buildLookup retirement bullet; reconcile the now-superseded "Retained buildLookup" and "bounded fixed point" wording in the same unreleased section. - upstream-asks: mark the qualified-reference ask as delivered by gen-contract v5 and correct the stale buildLookup current-mechanism claim. * Sweep stale doc status strings after final branch review The 4-task migration (gen-contract/gen-sdk bump, pgn v0.12.0 pin, Text/equal-dependent validation removal, buildLookup replacement) left several stale status strings that no single task owned: - README.md and DESIGN.md still cited the old pgn 0.9.1 pin and the old 73-passed test count; the suite is now 50 passed, 0 skipped after Task 3 deleted a 692-line test file for a discarded feature. - CHANGELOG.md's "Upcoming" section had two bullets contradicting each other on the pgn version and test count. - docs/upstream-asks.md repeated the stale pgn 0.9.1 pin statement. - DESIGN.md §9 and docs/upstream-asks.md §2 still described the old repeated-pass "fixed-point filtering" cascade that Task 4 replaced with Sdk.CustomTypes' single-pass left-fold; reworded both to match §8's already-correct description. - README.md's onUnsupported: Skip description also read as the old iterative "repeatedly removes... until fixed point" mechanism; reworded to describe the single pass over topologically-sorted custom types. Doc-only change; confirmed via mise run test (50 passed) and mise run lint. * Drop plans * Fix PR migration checks and collision docs --------- Co-authored-by: Viacheslav Shvets --- .github/workflows/ci.yml | 18 +- AGENTS.md | 6 + CHANGELOG.md | 67 +- DESIGN.md | 145 +-- README.md | 83 +- .../0001-generated-python-name-collisions.md | 84 +- docs/upstream-asks.md | 40 +- fixtures/Exhaustive.dhall | 8 +- mise.lock | 14 +- mise.toml | 2 +- src/Deps/Contract.dhall | 4 +- src/Deps/Sdk.dhall | 4 +- src/Interpreters/CustomType.dhall | 133 +-- src/Interpreters/Member.dhall | 12 +- src/Interpreters/ParamsMember.dhall | 28 +- src/Interpreters/Project.dhall | 1002 ++++------------- src/Interpreters/Query.dhall | 16 +- src/Interpreters/Scalar.dhall | 10 +- src/Interpreters/Value.dhall | 35 +- src/Structures/CustomKind.dhall | 22 +- src/Structures/PyIdent.dhall | 102 +- src/Structures/PythonNameMapping.dhall | 48 - src/Structures/PythonNamespace.dhall | 97 -- src/package.dhall | 9 +- tests/conftest.py | 2 +- tests/golden/src/specimen_client/__init__.py | 8 +- .../_generated/types/__init__.py | 2 +- .../src/specimen_client/sync/__init__.py | 8 +- tests/test_python_name_collisions.py | 692 ------------ tests/test_unsupported_types.py | 62 +- 30 files changed, 546 insertions(+), 2217 deletions(-) delete mode 100644 src/Structures/PythonNameMapping.dhall delete mode 100644 src/Structures/PythonNamespace.dhall delete mode 100644 tests/test_python_name_collisions.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a2322f..b1d03de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,12 +51,9 @@ jobs: with: ref: ${{ inputs.ref || github.sha }} - # A single-artifact pgn generate peaks at ~6.5 GB RSS on this runner - # (memory goes to normalizing the generator closure, near-independent - # of the query count), which flirts with the 7.8 GiB RAM + 3 GiB stock - # swap and got the VM killed by the memory watchdog mid-suite; extra - # swap lets the GC spill instead. /swapfile is taken by the image's - # stock swap, so the extra file goes on the /mnt ephemeral disk. + # pgn generation has exceeded the hosted runner's stock memory on this + # suite. Keep extra swap as evaluator and GC headroom. /swapfile is taken + # by the image's stock swap, so the extra file goes on /mnt. - name: Add swap headroom for pgn generate shell: bash run: | @@ -77,8 +74,8 @@ jobs: set -euo pipefail version="$(mise x -- pgn --version)" - if [[ "$version" != "0.9.1" ]]; then - echo "::error::Expected pgn 0.9.1 (mise.toml pin), got '$version'." + if [[ "$version" != "0.12.0" ]]; then + echo "::error::Expected pgn 0.12.0 (mise.toml pin), got '$version'." exit 1 fi @@ -124,9 +121,8 @@ jobs: with: ref: ${{ inputs.ref || github.sha }} - # A plain, standard-Dhall evaluator cannot run this because the local - # Project.buildLookup uses Text/equal, a builtin from pgn's forked Dhall. - # The pinned action bundles that same fork. + # Use the pinned directory-tree action for the same reproducible contract + # evaluation path used by release validation. - name: Generate output from Dhall uses: nikita-volkov/dhall-directory-tree.github-action@60a18dc647d6daea805263ea0fed7bb8011f3bcd # v2 with: diff --git a/AGENTS.md b/AGENTS.md index 7da3b7b..79f0a1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,3 +36,9 @@ already says. `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. + +Never replace a pin with a local filesystem path (`../gen-contract/...`, +`../gen-sdk/...`) even temporarily for convenience; it only works on a +machine with those sibling repos checked out and breaks CI. Always use a +`https://raw.githubusercontent.com/pgenie-io///...` import with +its `sha256`. diff --git a/CHANGELOG.md b/CHANGELOG.md index ea7e441..2f0001a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,25 @@ # Upcoming -- Added fail-loud, namespace-wide validation for generated Python names. Query - and custom-type collisions can be resolved with typed, whole-entity mappings; - invalid, reserved, duplicate, and unknown mappings are rejected. Defensive - local-member audits identify both sources when such names reach the generator; - pgn may reject them earlier, and resolution is a SQL or schema rename. The - generator never silently overwrites files or assigns numeric suffixes. - Per-type module audits also reject a mapped class that would shadow an actual - primitive, core, or custom dependency import. +- Bumped the pins to gen-contract v5.0.0 and gen-sdk v3.0.0 (requires `pgn` + v0.12.0). Replaced the hand-rolled `buildLookup` custom-kind resolver and its + fixed-point removal cascade with gen-sdk's `CustomTypes` module: references + now resolve by the contract's `CustomTypeRef.index` and `onUnsupported: Skip` + computes survivorship in a single left-fold over the topologically-sorted + `customTypes`. This retires the generator's last `Text/equal` use, so no live + `Text/equal` call remains anywhere in `src/`. + +- Removed the `queryNameMappings`/`customTypeNameMappings` rename-mapping + config and all generation-time Python namespace-collision detection + (project-wide facade/module, per-query, per-custom-type, and per-type + module-internal audits). `pgn` 0.11.0 dropped the `Text/equal` builtin these + relied on to compare two runtime `Text` values, and there is no + `Text/replace`-based way to reconstruct that decision. A colliding schema is + no longer caught at `pgn generate`. Collisions that remain visible in the + generated tree generally fail the package's `basedpyright --strict` gate + (`reportRedeclaration` or `reportInvalidTypeForm`), but a module-path + collision can overwrite an earlier file before the checker sees it. + [pgenie-io/pgenie#75](https://github.com/pgenie-io/pgenie/issues/75) asks pgn + to guarantee unique custom-type identities at the source. - Finalized one additive package surface. Async functions remain at the package root for every configuration. `emitSync: true` adds `.sync`, a sync @@ -29,22 +41,22 @@ ranks, composite ranks, custom-array fields inside composites, and missing or unsupported custom types fail loudly. -- Retained `buildLookup` as the project-wide custom-kind resolver for identities - preserved in the contract. Its `Text/equal` use is an explicit pgn-fork - constraint. The planned exit must preserve every schema-qualified custom type - entry and expose a stable qualified identifier on `Scalar.Custom`; kind alone - is insufficient. pgn 0.9.1 can collapse same-unqualified-name types across - schemas, so that database shape remains unsupported and cannot be repaired by - a Python mapping. Natural project indexes now provide deterministic - custom-import deduplication and ordering. - Query, parameter, field, and private statement names are collision-safe while - SQL names remain unchanged. +- Resolved custom-type references by their contract-supplied + `CustomTypeRef.index` (gen-contract v5) rather than by name comparison, so a + reference carries a stable schema-qualified identity (`pgSchema`/`pgName`) + directly. Same-unqualified-name types across schemas no longer collapse + during reference resolution. Generated class and module names still come + from the unqualified contract name, so their Python names must remain unique. + Natural project indexes provide deterministic custom-import deduplication and + ordering. Reserved helper and keyword conflicts are escaped while SQL names + remain unchanged. - Kept `onUnsupported: Fail | Skip`. `Fail` aborts generation with the nested - report. `Skip` preserves warnings and computes a bounded fixed point that - removes an unsupported custom type, its dependent custom types and statements, - and all affected type, registration, facade, Row, and statement entries. The - surviving package remains strict-importable. + report. `Skip` preserves warnings and computes survivorship in a single + left-fold over the topologically-sorted `customTypes`, removing an unsupported + custom type, its dependent custom types and statements, and all affected type, + registration, facade, Row, and statement entries. The surviving package + remains strict-importable. - Established the generated tree as a greenfield layout with no compatibility layer or promise for internal generated paths. Every generated Python file now @@ -61,8 +73,8 @@ class-aware adapters, pure models, canonical `args_row` Rows, and no query decoders, model codecs, casts, or bytes SQL. H2 CONFIRM: 25 Python files / 1467 lines / 11 statement files / 801 statement lines / 11 SQL. H3 CONFIRM: - Ruff 0/0, authored long0, SQL long0, raw output/no postformat. Tests: 73 - passed, 0 skipped; pgn 0.9.1; strict basedpyright 0/0. + Ruff 0/0, authored long0, SQL long0, raw output/no postformat. Tests: 50 + passed, 0 skipped; pgn v0.12.0; strict basedpyright 0/0. - Migrated the generator to gen-contract v4.0.1 and gen-sdk v2.0.0 using `Sdk.Sigs`. The implementation moved from `gen/` into `src/`, the working-tree @@ -79,9 +91,10 @@ tuples with `dataclasses.fields` and `getattr`, preserving one-field arity. The fixture exercises the type as both a parameter and a result. -- Kept `PyIdent.dhall` independent of fork-only text equality by using its - bounded `Text/replace` marker construction. Keyword fields and parameters gain - a trailing underscore only in Python, while raw database names remain stable. +- Kept `PyIdent.dhall` independent of fork-only text equality by using + `Lude.Text.replaceIfOneOf`'s bounded, sentinel-wrapped `Text/replace` + construction. Keyword fields and parameters gain a trailing underscore only + in Python, while raw database names remain stable. - Preserved the release and license flow. The release job resolves `src/package.dhall` into the `resolved.dhall` release asset, then byte-checks diff --git a/DESIGN.md b/DESIGN.md index a55e017..1496cb6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -157,7 +157,7 @@ directly. Statement SQL is passed as `LiteralString`. No runtime accepts encoded SQL bytes. The generated package has no separately installed support library; its only non-stdlib consumer dependency is `psycopg>=3.3.4,<4`. -## 5. Configuration, mapping, and unsupported shapes +## 5. Configuration and unsupported shapes The public Dhall config is: @@ -165,14 +165,6 @@ The public Dhall config is: { packageName : Optional Text , emitSync : Optional Bool , onUnsupported : Optional < Fail | Skip > -, queryNameMappings : Optional - (List { source : Text, target : { snakeCase : Text, pascalCase : Text } }) -, customTypeNameMappings : Optional - ( List - { source : { schema : Text, name : Text } - , target : { snakeCase : Text, pascalCase : Text } - } - ) } ``` @@ -182,35 +174,27 @@ The public Dhall config is: - `packageName`: project name in kebab case; - `emitSync`: `False`; - `onUnsupported`: `Fail`. -- `queryNameMappings`: `[]`; -- `customTypeNameMappings`: `[]`. An omitted config block, an omitted field, and a `null` field therefore use the same fallback. Async output is present for every value of `emitSync`; only true adds sync output. -Source-derived identifiers first receive lexical and reserved-name escaping. -Typed mappings replace a complete query or custom-type Python identity and must -already contain exact valid targets. After resolution, project and local -namespace audits reject duplicate modules, functions, Row classes, custom -types, facade exports, parameters, fields, and enum members before rendering. -Each custom-type module also audits its class against the primitive, core, and -custom dependency symbols it actually imports. -Fixed core exports remain occupied. The generator never silently overwrites a -file or assigns an order-dependent numeric suffix; see -[ADR 0001](docs/adr/0001-generated-python-name-collisions.md). - -Custom-type mapping is exact only for entities preserved in the input contract. -pgn 0.9.1 can collapse same-unqualified-name types across schemas and expose an -unqualified `Scalar.Custom` reference. The generator cannot reconstruct the -discarded schema identity from SQL and rejects duplicate unqualified contract -names if they do reach it. Until upstream preserves all schema-qualified types -and a stable qualified reference, such database shapes are unsupported. - -Local member audits are defensive for inputs that reach the generator. pgn may -reject a conflicting SQL or schema spelling during analysis first. Local -conflicts are resolved by renaming that SQL or schema source; whole-entity query -and custom-type mappings do not apply to members. +Source-derived identifiers receive lexical and reserved-name escaping only +(`Structures/PyIdent.dhall`); there is no rename-mapping config and no +generation-time namespace-collision detection. `pgn`'s embedded Dhall evaluator +dropped `Text/equal` (pgn 0.11.0), which removed the only mechanism that could +compare two runtime `Text` values to decide a collision or resolve a mapping's +`source` against a query/type name; that decision is not reconstructible from +`Text/replace` alone (see section 10). Instead, the generated package is held +to `basedpyright --strict` returning zero errors and zero warnings. A duplicate +dataclass field name or a name that shadows a needed type surfaces there +(`reportRedeclaration`/`reportInvalidTypeForm`) rather than at `pgn generate` +time, and less precisely attributed (generated Python, not the originating +SQL/schema). A module-path collision can overwrite an earlier generated file +before the type checker runs, so input schemas must still guarantee unique +generated Python names. +[pgenie-io/pgenie#75](https://github.com/pgenie-io/pgenie/issues/75) asks pgn +to guarantee unique custom-type identities at the source. `Interpreters/Primitive.dhall` maps pgn union constructors, not signature-file strings. Supported scalars are Boolean, integer and OID, floating point, @@ -275,25 +259,31 @@ postprocessor rewrites generated files. ```text optional config -> resolved config - -> buildLookup(custom types) - -> fixed-point custom-type filtering in Skip mode - -> compile custom types and query checks + -> index-aligned kind classification (kindOf) of every custom type + -> one-pass survivor cascade (Sdk.CustomTypes.supportedCustomTypesReasoned) + masking unsupported indices to Absent in Skip mode + -> compile custom types and query checks against the masked lookup -> dependency-first registration order -> render facades, core, runtimes, types, registration, and statements -> prepend the generated header ``` `onUnsupported: Fail` traverses the original project and propagates the first -compiled failure, so generation aborts without a partial client. - -`onUnsupported: Skip` repeatedly rebuilds `buildLookup` from the surviving -custom types and removes any type that no longer compiles. It then compiles -queries against the final lookup. A removed type therefore also removes its -dependent composites and statements. Facade exports, type initializers, -registration entries, statement files, and Row exports are assembled only from -survivors. Reports for dropped units are preserved as warnings. The bounded -fixed point can only remove candidates, so it terminates after at most the -original custom-type count. +compiled failure, so generation aborts without a partial client. In this mode +nothing is masked: the lookup is the full, unmasked kind classification. + +`onUnsupported: Skip` computes survivorship in a single left-fold via +`Sdk.CustomTypes.supportedCustomTypesReasoned`. Because `Project.customTypes` +is topologically sorted (every referenced index precedes the referencing type), +one pass suffices: the fold marks a type unsupported if its own definition +cannot compile (`ownDefinitionSupported`) or if any custom type it references +was already marked unsupported. Unsupported indices are then masked to `Absent` +in the kind lookup, so a reference to a removed type reads exactly like a +missing type. Queries compile against that same masked lookup, so a removed type +also removes its dependent composites and statements. Facade exports, type +initializers, registration entries, statement files, and Row exports are +assembled only from survivors, and reports for dropped units are preserved as +warnings. Query compilation is deliberately consolidated in `QueryCheck`: keep/drop state and warning data come from one literal `QueryGen.run` call site before the final @@ -344,27 +334,52 @@ element nullability. `Member` and `ParamsMember` apply member nullability, identifier safety, lookup classification, imports, and parameter wrapping. `ResultColumns` builds Row field declarations in analyzed order. `Query` combines SQL, Row, parameters, and surfaces. `Project` owns lookup scope, -fixed-point filtering, registration order, file selection, and facades. +single-pass survivor filtering, registration order, file selection, and +facades. ## 10. The pinned `Text/equal` constraint -`buildLookup` intentionally remains in `Interpreters/Project.dhall`. It compares -a custom reference's snake-case name with the project custom type name using -`Text/equal`. That builtin belongs to pgn's embedded Dhall fork and is not -available in upstream standard Dhall. - -The dependency is pinned and explicit. The complete fixture needs fork-aware -evaluation solely because it invokes this generator and the local `buildLookup` -uses `Text/equal`. The upstream exit must preserve every schema-qualified -`customTypes` entry and put a stable qualified identifier, or a project index -with equivalent identity, on each custom scalar reference. Besides removing -text equality, that prevents pgn 0.9.1 from collapsing same-unqualified-name -types across schemas. Until then, pgn and CI's pinned fork-aware action are the -supported evaluators, and cross-schema duplicate type names are unsupported. - -`PyIdent.dhall` uses its separate `Text/replace` marker construction for keyword -membership. `ImportSet.dhall` uses natural project indexes for equality, -deduplication, and ordering. Neither substitutes for the project lookup. +`pgn` 0.11.0 removed `Text/equal` (along with `Text/length` and `Bool/equal`) +from its embedded Dhall evaluator, to stay in line with the official Dhall +spec. There is no way to reconstruct a `Bool` or a differently-typed decision +from comparing two arbitrary runtime `Text` values using only `Text/replace`; +`Text/replace`-based tricks (this repository's own former keyword-marker +trick, and gen-sdk's `Lude.Text.replaceIfEqual`/`replaceIfOneOf`) only ever +transform text, they cannot branch into a different type. Every piece of this +generator that made a decision this way is gone: the `queryNameMappings`/ +`customTypeNameMappings` rename-mapping config +(`Structures/PythonNameMapping.dhall`, matched a mapping's `source` against a +runtime name), `validateCustomTypeIdentities` (rejected two custom types +collapsing to the same unqualified contract `Name`), and +`Structures/PythonNamespace.dhall`'s `validate` (4 call sites in +`Interpreters/Project.dhall`: per-query and per-custom-type local audits, the +project-wide facade/module audit, and per-type module-internal bindings). +[pgenie-io/pgenie#75](https://github.com/pgenie-io/pgenie/issues/75) asks pgn +to guarantee unique custom-type identities at the source instead. The +generated package's `basedpyright --strict` gate (see section 12) catches +collisions that remain visible in the output tree. It cannot detect an earlier +file that was overwritten at the same generated module path. + +`buildLookup`, the last `Text/equal` user, compared a reference's snake-case +name against each project custom type's name and has been removed. +gen-contract v5's `CustomTypeRef` carries an `index` into `Project.customTypes`, +and gen-sdk v3's `CustomTypes` module folds over that topologically-sorted list +to compute survivorship without any text comparison. References now resolve by +index (`Structures/CustomKind.dhall`'s `at`), so a repo-wide `grep -rn +"Text/equal" src/` finds only these explanatory comments. No live use remains. + +The generator no longer needs fork-only text equality anywhere. It does still +rely on gen-contract v5's contract guarantees: every `CustomTypeRef.index` must +address the intended `customTypes` entry, and `customTypes` must be +topologically sorted (every referenced index precedes the referencing type), +which is what makes the single-pass survivor cascade sound. `pgn` remains the +supported evaluator and generation driver; upholding those index/ordering +invariants is the producer's responsibility. + +`PyIdent.dhall` uses `Lude.Text.replaceIfOneOf`'s bounded `Text/replace` +construction for keyword membership. `ImportSet.dhall` uses natural project +indexes for equality, deduplication, and ordering. Neither substitutes for the +project lookup. ## 11. Taking ownership @@ -394,7 +409,7 @@ PostgreSQL with these verdicts: - H2 CONFIRM: 25 Python files / 1467 lines / 11 statement files / 801 statement lines / 11 SQL. - H3 CONFIRM: Ruff 0/0, authored long0, SQL long0, raw output/no postformat. -- Tests: 73 passed, 0 skipped; pgn 0.9.1; strict basedpyright 0/0. +- Tests: 50 passed, 0 skipped; pgn v0.12.0; strict basedpyright 0/0. The H1 round trip covers both connection surfaces and exact cross-facade identities. It covers scalar enum, enum arrays including rank 2, scalar diff --git a/README.md b/README.md index d06900e..258ac26 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,6 @@ freeze key machine-specific; pgn project configuration does not accept a | `packageName` | `Text` | project name in kebab case | | `emitSync` | `Bool` | `False` | | `onUnsupported` | `"Fail" \| "Skip"` | `"Fail"` | -| `queryNameMappings` | typed query-name mappings | `[]` | -| `customTypeNameMappings` | typed PostgreSQL type-name mappings | `[]` | The `config` block and every field are optional. An omitted or `null` field uses its default. Omitting `emitSync`, setting it to `null`, or setting it to `false` @@ -54,52 +52,26 @@ canonical statement module. `packageName` becomes an import name by replacing `-` with `_`. `onUnsupported: "Fail"` aborts generation with a path-aware report. -`onUnsupported: "Skip"` preserves warnings and repeatedly removes an -unsupported custom type, its dependent custom types and statements, and every -affected facade, registration, and statement entry until the survivors are a -strict-importable fixed point. - -Generated Python namespaces are audited before files are rendered. Collisions -fail loudly instead of overwriting a file or receiving an order-dependent -numeric suffix. An intentional public rename uses one typed mapping for the -entity's snake-case module/function name and Pascal-case Row/type name: - -```yaml -queryNameMappings: - - source: sync_query - target: - snakeCase: synchronize - pascalCase: Synchronize -customTypeNameMappings: - - source: - schema: public - name: json_value - target: - snakeCase: pg_json_value - pascalCase: PgJsonValue -``` - -Mapping sources must identify an existing query or exact PostgreSQL schema/type -pair. A query source is pgn's snake-case query name; a custom-type source is the -exact PostgreSQL schema and type name preserved in the input contract. Targets -are final Python names: invalid or reserved targets are rejected, and -PostgreSQL names remain unchanged. -`PgJsonValue` above is a user-chosen Python alias for `public.json_value`, not a -built-in type or automatic naming rule. - -pgn 0.9.1 does not preserve two custom types that share one unqualified name -across schemas: it can retain only one `customTypes` entry, while -`Scalar.Custom` carries only an unqualified `Name`. A mapping cannot recover a -schema-qualified identity missing from the contract. Avoid that database shape -until upstream preserves every schema-qualified type and a stable qualified -reference; if a future input contains two entries with the same unqualified -contract `Name`, this generator rejects it as ambiguous. - -Local parameter, result-field, composite-field, and enum-member audits are -defensive for names that reach the generator. pgn may reject a conflicting SQL -or schema spelling earlier. Such a conflict is resolved at its SQL or schema -source; the entity mappings above do not apply. See -[ADR 0001](docs/adr/0001-generated-python-name-collisions.md). +`onUnsupported: "Skip"` preserves warnings and, in a single pass over the +topologically-sorted custom types, removes an unsupported custom type, its +dependent custom types and statements, and every affected facade, +registration, and statement entry, leaving a strict-importable survivor set. + +Generated Python names are not audited for collisions at generation time. +Query and custom-type identifiers are derived directly from their PostgreSQL +source names (sanitized for Python syntax and reserved words only); a schema +that maps two different SQL entities onto the same Python identifier will not +be caught by `pgn generate`. Instead, the generated package is held to +`basedpyright --strict` with zero errors and zero warnings. Collisions that +remain visible in the generated tree typically surface there as +`reportRedeclaration` or `reportInvalidTypeForm`, pointing at the generated +Python rather than the original SQL/schema source. That gate cannot detect a +module-path collision after one generated file has overwritten another, so +Python-name uniqueness remains an input-schema requirement. Renaming the +conflicting SQL or schema entity is the fix. +[pgenie-io/pgenie#75](https://github.com/pgenie-io/pgenie/issues/75) asks pgn +to guarantee unique custom-type identities at the source, which would let a +future version of this generator catch such collisions earlier again. ## Generated package @@ -250,7 +222,7 @@ disabled. ## Development -This repository pins pgn 0.9.1 in `mise.toml`. Run all tools through `mise`: +This repository pins pgn v0.12.0 in `mise.toml`. Run all tools through `mise`: ```bash mise run install @@ -260,7 +232,7 @@ mise run test The harness drives real pgn subprocesses against a live PostgreSQL server and only creates and drops its own scratch databases. Set `PGN_TEST_DATABASE_URL` -for a non-default server. The full verified suite is 73 passed and 0 skipped; +for a non-default server. The full verified suite is 50 passed and 0 skipped; it includes byte-for-byte golden comparison, async and sync round trips, basedpyright strict, Ruff, generated-quality budgets, unsupported-type modes, and public identity checks. @@ -280,9 +252,14 @@ at a time, and rerun the harness. The release workflow resolves wheel from those exact bytes. The wheel exposes path, URL, and vendor commands; PyPI publication remains disabled until its explicit release gate is enabled. -`fixtures/Exhaustive.dhall` is the contract fixture. It and `buildLookup` rely -on the pgn fork's `Text/equal` builtin, so the standard upstream Dhall evaluator -cannot run the complete contract path. CI uses the pinned fork-aware action. +`fixtures/Exhaustive.dhall` is the contract fixture. The generator no longer +depends on the pgn fork's `Text/equal` builtin at all: the custom-type removal +cascade that was the last user (`buildLookup`) has been replaced by gen-sdk's +`CustomTypes` module, which resolves references by their contract-supplied +`CustomTypeRef.index` instead of by name comparison. `pgn` itself remains the +supported evaluator and generation driver (CI invokes `pgn generate`), and it +requires the contract's topological `customTypes` ordering that gen-contract +v5 guarantees. ## Provenance and license diff --git a/docs/adr/0001-generated-python-name-collisions.md b/docs/adr/0001-generated-python-name-collisions.md index ee8783b..d3024ee 100644 --- a/docs/adr/0001-generated-python-name-collisions.md +++ b/docs/adr/0001-generated-python-name-collisions.md @@ -1,74 +1,34 @@ -# ADR 0001: Fail loudly on generated Python name collisions +# ADR 0001: Generated Python name collisions -Status: Accepted +Status: Superseded ## Context -Different PostgreSQL names can resolve to the same Python name. For example, -the query names `sync` and `sync_query` both resolved to the module and function -`sync_query`, so one generated file silently replaced the other. Similar -collisions can affect Row classes, custom types, facade exports, and members -after Python keyword escaping. +Different PostgreSQL names can resolve to the same Python name. The original +implementation collected every generated binding and compared it with a growing +list of prior bindings. It did this for project exports and modules, query +members, custom-type members, and imports inside each custom-type module. -Silent overwrite loses code. Order-based suffixes such as `name2` also make a -public API change when an unrelated declaration is added or reordered. +Those scans were quadratic and expensive under Dhall normalization. They ran on +every generation, including projects that did not configure any name mappings. -## Decision +## Superseding decision -Lexical handling and uniqueness are separate stages: +The namespace-wide and local audits are removed. The typed query and custom-type +rename-mapping API and its runtime identity validators are removed with them. -1. Source-derived defaults are escaped for Python keywords and generator-owned - implementation names. -2. Optional typed mappings resolve a declaration's complete Python identity. -3. Explicit targets must already be exact, valid, non-reserved Python names. - Snake-case targets start with a lowercase ASCII letter, and Pascal-case - targets start with an uppercase ASCII letter. Facade and module metadata - dunder names cannot be overwritten. Invalid targets are rejected rather - than silently rewritten. -4. The generator audits each Python namespace selected for emission after name - resolution and before rendering files. A collision stops generation and - identifies both owners and the final name. +Lexical handling remains. Source-derived defaults are escaped for Python +keywords and generator-owned implementation names. -The greenfield configuration uses entity-typed mappings: - -```dhall -let PythonName = { snakeCase : Text, pascalCase : Text } - -let QueryNameMapping = { source : Text, target : PythonName } - -let CustomTypeNameMapping = - { source : { schema : Text, name : Text }, target : PythonName } -``` - -For a query, `snakeCase` owns the statement module and async/root public name; -the sync facade exports that same public name while its adjacent implementation -function adds `_sync`. `pascalCase` owns `${pascalCase}Row`. For a custom type, -`snakeCase` owns the type module and imports, while `pascalCase` owns the class, -facade export, and adapter registration. PostgreSQL and SQL names never change. -Duplicate or unknown mapping sources, invalid targets, and mapped collisions all -fail before files are emitted. Fixed package names such as `JsonValue`, -`NoRowError`, `register_types`, and `sync` cannot be remapped. `PgJsonValue` is -an example explicit user alias, not an automatic naming policy. - -Local parameter, result-field, composite-field, and enum-member audits are -defensive for inputs that reach the generator; pgn may reject the conflicting -source spelling earlier. They are resolved by renaming the SQL placeholder, SQL -alias, or schema member. A future local override, if needed, must use a typed, -parent-scoped mapping instead of a string path grammar. - -The structured custom-type source is exact only when the input contract -preserves the entity. pgn 0.9.1 can collapse types with the same unqualified -name across schemas, and `Scalar.Custom` has no qualified identity. A mapping -cannot recover that lost distinction. Such database shapes remain unsupported -until upstream preserves all qualified custom types and references. +Final uniqueness is now the project's responsibility. Two distinct entities can +resolve to the same module, class, function, field, or export. Static analysis +can catch many collisions in the surviving generated Python, but a module-path +collision can overwrite an earlier file before static analysis sees it. ## Consequences -- Generation cannot silently overwrite a file or export. -- Adding or reordering a declaration cannot renumber existing public names. -- Intentional renames are deterministic and propagate through all references. -- A conflicting source requires either a source-level rename or an explicit - typed mapping. -- Mapping configuration is more verbose than automatic suffixing, but it is - type-checked, and raw identifiers represented in the contract remain - unambiguous. +- Normal generation uses less time and peak memory. +- Projects must keep final generated names unique. +- Resolve a conflict by renaming its SQL or schema source. +- A future collision check should avoid growing-list normalization and should + preferably be enforced by pgn before generator evaluation. diff --git a/docs/upstream-asks.md b/docs/upstream-asks.md index 430fd50..1bac627 100644 --- a/docs/upstream-asks.md +++ b/docs/upstream-asks.md @@ -1,9 +1,10 @@ # Upstream resolution status: pgn and gen-sdk This brief records the current status of three upstream integration points. -The pgn annotation-metadata and warning-surfacing issues are closed and shipped. -Only the custom-type identity request remains actionable. Architecture details -stay in `DESIGN.md`. +The first two issues are closed and shipped. The qualified custom-type identity +gap is addressed by gen-contract v5, while the broader normalized-name +guarantee remains open in pgenie issue 75. Architecture details stay in +`DESIGN.md`. ## 1. pgn annotation metadata: resolved @@ -18,13 +19,13 @@ generator-side annotation parsing remains. Issue [#67](https://github.com/pgenie-io/pgenie/issues/67) is closed. Since pgn 0.7.2, successful generation surfaces reports from `Compiled.warnings`; this -repository pins pgn 0.9.1. +repository pins pgn v0.12.0. With `onUnsupported: Skip`, the generator retains a report for every dropped -unit while fixed-point filtering removes unsupported custom types, their +unit while a single-pass survivor fold removes unsupported custom types, their dependents, and affected statements. See `DESIGN.md`, section 8. -## 3. Preserve qualified custom-type identity: actionable +## 3. Preserve qualified custom-type identity: contract support shipped Project-wide custom-type lookup classifies every custom reference as an enum, composite, or absent. Shipped models are pure declarations with no class @@ -32,16 +33,19 @@ codecs. The lookup classification instead drives support-shape validation, custom imports and dependencies, and dependency-first psycopg adapter registration. See `DESIGN.md`, sections 3, 5, and 8. -`Interpreters/Project.dhall` currently implements `buildLookup` by comparing a -custom reference's snake-case name with each project custom type through -`Text/equal`. This local lookup is the generator's sole need for that -pgn-specific builtin, but the unqualified comparison also exposes an upstream -identity gap. +This ask has since been addressed by gen-contract v5.0.0 (see below). For +history: `Interpreters/Project.dhall` formerly implemented `buildLookup` by +comparing a custom reference's snake-case name with each project custom type +through `Text/equal`, which was the generator's sole need for that pgn-specific +builtin; the unqualified comparison also exposed an upstream identity gap. That +lookup has been removed, references now resolve by `CustomTypeRef.index`. +Generated Python class and module names still use the unqualified contract +name, so gen-contract v5 does not itself prevent normalized-name collisions. -With pgn 0.9.1, a project containing `alpha.status` and `beta.status` can arrive -with only one `customTypes` entry, while both uses are represented by the same -unqualified `Scalar.Custom Name`. A Python mapping cannot recover the discarded -schema or safely repair annotations and adapter registration. +Before gen-contract v5, a project containing `alpha.status` and `beta.status` +could arrive with only one `customTypes` entry, while both uses were represented +by the same unqualified `Scalar.Custom Name`. A Python mapping cannot recover a +discarded schema or safely repair annotations and adapter registration. The upstream ask has two inseparable parts: @@ -54,6 +58,6 @@ not enough. The qualified reference lets lookup use an equality-free structural match and removes the local `Text/equal` dependency described in `DESIGN.md`, section 10. -Changing `Scalar.Custom` affects gen-sdk consumers. This repository pins its -gen-sdk import by sha256, so adopting such a change would require an explicit -pin update and a full harness run. +Changing `Scalar.Custom` affects gen-sdk consumers. This repository adopted the +new shape through explicit sha256 pin updates to gen-contract v5 and gen-sdk v3 +and verified it with the full harness. diff --git a/fixtures/Exhaustive.dhall b/fixtures/Exhaustive.dhall index 6bb6fb9..fd1030b 100644 --- a/fixtures/Exhaustive.dhall +++ b/fixtures/Exhaustive.dhall @@ -9,17 +9,13 @@ -- those statements/types are dropped with a warning instead of aborting the -- whole compile. -- --- CI evaluates this fixture with the pinned fork-aware directory-tree action. --- Standard `dhall to-directory-tree` cannot evaluate the pgn fork's Text/equal --- builtin used by the generator. +-- CI evaluates this fixture with its pinned directory-tree action. let Sdk = ../src/Deps/Sdk.dhall let Gen = ../src/package.dhall let OnUnsupported = ../src/Structures/OnUnsupported.dhall -let PythonNameMapping = ../src/Structures/PythonNameMapping.dhall - let project = Sdk.Fixtures.Exhaustive let config = @@ -27,8 +23,6 @@ let config = { packageName = None Text , emitSync = Some False , onUnsupported = Some OnUnsupported.Mode.Skip - , queryNameMappings = None (List PythonNameMapping.Query) - , customTypeNameMappings = None (List PythonNameMapping.CustomType) } in Sdk.Output.toFileMap (Gen.compile config project) diff --git a/mise.lock b/mise.lock index 2f2ab47..78f3772 100644 --- a/mise.lock +++ b/mise.lock @@ -1,18 +1,18 @@ # @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html [[tools."github:pgenie-io/pgenie"]] -version = "0.9.1" +version = "0.12.0" backend = "github:pgenie-io/pgenie" [tools."github:pgenie-io/pgenie"."platforms.linux-x64"] -checksum = "sha256:6fd7d9852b43083e29e6b81311cad526773ee58fb34e3e2916c599a477828ae3" -url = "https://github.com/pgenie-io/pgenie/releases/download/v0.9.1/pgn-linux-x64.tar.gz" -url_api = "https://api.github.com/repos/pgenie-io/pgenie/releases/assets/472372948" +checksum = "sha256:27aab711ba18b76ce3daeb3e1f204ce404d0ad97c7952ad08b8f1c13c41703e3" +url = "https://github.com/pgenie-io/pgenie/releases/download/v0.12.0/pgn-linux-x64.tar.gz" +url_api = "https://api.github.com/repos/pgenie-io/pgenie/releases/assets/476896947" [tools."github:pgenie-io/pgenie"."platforms.macos-arm64"] -checksum = "sha256:17469d67bbb540af197df9b1abc30b097c4f222cb59bad53f8a3458929a0e5bd" -url = "https://github.com/pgenie-io/pgenie/releases/download/v0.9.1/pgn-macos-arm64.tar.gz" -url_api = "https://api.github.com/repos/pgenie-io/pgenie/releases/assets/472372949" +checksum = "sha256:9f95485d1607e3c1c3eebec9b4cde99163981c517d31b836b456228af0164206" +url = "https://github.com/pgenie-io/pgenie/releases/download/v0.12.0/pgn-macos-arm64.tar.gz" +url_api = "https://api.github.com/repos/pgenie-io/pgenie/releases/assets/476896949" [[tools.python]] version = "3.12.12" diff --git a/mise.toml b/mise.toml index 00c0cfb..b185c3b 100644 --- a/mise.toml +++ b/mise.toml @@ -1,7 +1,7 @@ # This is a standalone repo (unlike the monorepo copy this was extracted from, # which inherits pgn from its repo-root mise.toml), so pgn is pinned here. [tools] -"github:pgenie-io/pgenie" = { version = "v0.9.1", exe = "pgn" } +"github:pgenie-io/pgenie" = { version = "v0.12.0", exe = "pgn" } uv = "latest" python = "3.12" diff --git a/src/Deps/Contract.dhall b/src/Deps/Contract.dhall index ea9e805..6fdb9e8 100644 --- a/src/Deps/Contract.dhall +++ b/src/Deps/Contract.dhall @@ -1,2 +1,2 @@ -https://raw.githubusercontent.com/pgenie-io/gen-contract/v4.0.1/src/package.dhall - sha256:4a130ba7fbaa152a776babbb1bf2994a4833931ca76bde9bf6930d354225651e +https://raw.githubusercontent.com/pgenie-io/gen-contract/v5.0.0/src/package.dhall + sha256:a1b48fe025c5536b13907bcd2db307cd438f3ae0f67a59222b3aa39e4bdac9ef diff --git a/src/Deps/Sdk.dhall b/src/Deps/Sdk.dhall index 18f9fce..95de509 100644 --- a/src/Deps/Sdk.dhall +++ b/src/Deps/Sdk.dhall @@ -1,2 +1,2 @@ -https://raw.githubusercontent.com/pgenie-io/gen-sdk/v2.0.0/src/package.dhall - sha256:b9def6ab1179bc4aaae7fc6e91977f094f75934cd5755175c294a9e97ca71b15 +https://raw.githubusercontent.com/pgenie-io/gen-sdk/v3.0.0/src/package.dhall + sha256:368e4ee1f7557e8a1713a0ac53db6bbfb476027b322d06a660842e5e22e18662 diff --git a/src/Interpreters/CustomType.dhall b/src/Interpreters/CustomType.dhall index 1b59985..b158fce 100644 --- a/src/Interpreters/CustomType.dhall +++ b/src/Interpreters/CustomType.dhall @@ -8,10 +8,6 @@ let ImportSet = ../Structures/ImportSet.dhall let CustomKind = ../Structures/CustomKind.dhall -let PythonNameMapping = ../Structures/PythonNameMapping.dhall - -let PythonNamespace = ../Structures/PythonNamespace.dhall - let PyIdent = ../Structures/PyIdent.dhall let MemberGen = ./Member.dhall @@ -20,9 +16,7 @@ let EnumModule = ../Templates/EnumModule.dhall let CompositeModule = ../Templates/CompositeModule.dhall -let Config = - { customTypeNameMappings : List PythonNameMapping.CustomType - } +let Config = {} let Input = Model.CustomType @@ -38,7 +32,6 @@ let Output = , kind : TypeKind , order : Natural , dependencies : List Natural - , moduleBindings : List PythonNamespace.Binding } let renderExtraImports = @@ -77,100 +70,19 @@ let renderExtraImports = ) # customLines -let moduleNamespace = - \(input : Input) -> - "custom type module for schema ${Text/show input.pgSchema}, type ${Text/show input.pgName}" - -let moduleBinding = - \(namespace : Text) -> - \(owner : Text) -> - \(name : Text) -> - { namespace - , owner - , name - , remediation = "Choose a different custom type name mapping target" - } - -let enumNamespaceBindings = - \(input : Input) -> - \(typeName : Text) -> - let namespace = moduleNamespace input - - in [ moduleBinding - namespace - "custom type class for schema ${Text/show input.pgSchema}, type ${Text/show input.pgName}" - typeName - , moduleBinding namespace "enum base import" "StrEnum" - ] - -let compositeNamespaceBindings = - \(input : Input) -> - \(typeName : Text) -> - \(imports : ImportSet.Type) -> - let namespace = moduleNamespace input - - let imported = - ( if imports.uuid - then [ moduleBinding namespace "UUID primitive import" "UUID" ] - else [] : List PythonNamespace.Binding - ) - # ( if imports.datetime - then [ moduleBinding namespace "datetime primitive import" "datetime" ] - else [] : List PythonNamespace.Binding - ) - # ( if imports.date - then [ moduleBinding namespace "date primitive import" "date" ] - else [] : List PythonNamespace.Binding - ) - # ( if imports.time - then [ moduleBinding namespace "time primitive import" "time" ] - else [] : List PythonNamespace.Binding - ) - # ( if imports.timedelta - then [ moduleBinding namespace "timedelta primitive import" "timedelta" ] - else [] : List PythonNamespace.Binding - ) - # ( if imports.decimal - then [ moduleBinding namespace "Decimal primitive import" "Decimal" ] - else [] : List PythonNamespace.Binding - ) - # ( if imports.jsonValue - then [ moduleBinding namespace "generated core import" "JsonValue" ] - else [] : List PythonNamespace.Binding - ) - - let customImports = - Prelude.List.map - ImportSet.CustomImport - PythonNamespace.Binding - ( \(custom : ImportSet.CustomImport) -> - moduleBinding - namespace - "custom dependency import \".${custom.moduleName}.${custom.className}\"" - custom.className - ) - imports.customTypes - - in [ moduleBinding - namespace - "custom type class for schema ${Text/show input.pgSchema}, type ${Text/show input.pgName}" - typeName - , moduleBinding namespace "dataclass decorator import" "dataclass" - ] - # imported - # customImports - let run = \(config : Config) -> \(lookup : CustomKind.Lookup) -> + -- The custom type's own position in the project's `customTypes` list. + -- Unlike a `CustomTypeRef`, a `Model.CustomType` does not carry its own + -- index, so the caller (Interpreters/Project.dhall) threads it in for the + -- self-lookup kind-consistency check in each merge arm below. + \(index : Natural) -> \(input : Input) -> let pythonName = - PythonNameMapping.resolveCustomType - config.customTypeNameMappings - { schema = input.pgSchema, name = input.pgName } - { snakeCase = PyIdent.typeModuleSafeName input.name.inSnakeCase - , pascalCase = PyIdent.pySafeName input.name.inPascalCase - } + { snakeCase = PyIdent.typeModuleSafeName input.name.inSnakeCase + , pascalCase = PyIdent.pySafeName input.name.inPascalCase + } let typeName = pythonName.pascalCase @@ -210,8 +122,6 @@ let run = , kind = TypeKind.Enum , order = identity.order , dependencies = [] : List Natural - , moduleBindings = - enumNamespaceBindings input typeName } , Composite = \(_ : CustomKind.Identity) -> @@ -225,7 +135,7 @@ let run = [ input.pgName ] "Custom type not found in project customTypes" } - (lookup input.name) + (CustomKind.at lookup index) , Composite = \(members : List Model.Member) -> let compiledMembers @@ -239,18 +149,14 @@ let run = \(_ : Model.Primitive) -> MemberGen.run {=} lookup m , Custom = - \(name : Model.Name) -> - Prelude.Optional.fold - Model.ArraySettings - m.value.arraySettings - (Lude.Compiled.Type MemberGen.Output) - ( \(_ : Model.ArraySettings) -> - Lude.Compiled.report + \(ref : Model.CustomTypeRef) -> + if Prelude.Bool.not + (Natural/isZero m.value.dimensionality) + then Lude.Compiled.report MemberGen.Output - [ m.pgName, name.inSnakeCase ] + [ m.pgName, ref.name.inSnakeCase ] "Custom array fields inside a composite type are not supported" - ) - (MemberGen.run {=} lookup m) + else MemberGen.run {=} lookup m } m.value.scalar ) @@ -310,11 +216,6 @@ let run = , kind = TypeKind.Composite , order = identity.order , dependencies - , moduleBindings = - compositeNamespaceBindings - input - typeName - combinedImports } , Enum = \(_ : CustomKind.Identity) -> @@ -328,7 +229,7 @@ let run = [ input.pgName ] "Custom type not found in project customTypes" } - (lookup input.name) + (CustomKind.at lookup index) in Lude.Compiled.flatMap (List MemberGen.Output) diff --git a/src/Interpreters/Member.dhall b/src/Interpreters/Member.dhall index c621e6d..bffb7bb 100644 --- a/src/Interpreters/Member.dhall +++ b/src/Interpreters/Member.dhall @@ -49,10 +49,10 @@ let runWithPrefix = } , Custom = Prelude.Optional.fold - Model.Name + Model.CustomTypeRef value.scalar.customRef (Lude.Compiled.Type Output) - ( \(name : Model.Name) -> + ( \(ref : Model.CustomTypeRef) -> let mkOutput = \(identity : CustomKind.Identity) -> \(customImports : ImportSet.Type) -> @@ -87,7 +87,7 @@ let runWithPrefix = else Lude.Compiled.report Output [ input.pgName - , name.inSnakeCase + , ref.name.inSnakeCase ] "Array of an enum with dimensionality > 2 is not supported" , Composite = @@ -104,16 +104,16 @@ let runWithPrefix = else Lude.Compiled.report Output [ input.pgName - , name.inSnakeCase + , ref.name.inSnakeCase ] "Array of a composite type with dimensionality > 1 is not supported" , Absent = Lude.Compiled.report Output - [ name.inSnakeCase ] + [ ref.name.inSnakeCase ] "Custom type not found in project customTypes" } - (lookup name) + (CustomKind.at lookup ref.index) ) ( Lude.Compiled.report Output diff --git a/src/Interpreters/ParamsMember.dhall b/src/Interpreters/ParamsMember.dhall index b48c1dd..d2fb6d3 100644 --- a/src/Interpreters/ParamsMember.dhall +++ b/src/Interpreters/ParamsMember.dhall @@ -166,23 +166,21 @@ let primitiveIsJsonb = let scalarIsJson = \(value : Model.Value) -> merge - { Primitive = primitiveIsJson, Custom = \(_ : Model.Name) -> False } + { Primitive = primitiveIsJson + , Custom = \(_ : Model.CustomTypeRef) -> False + } value.scalar let scalarIsJsonb = \(value : Model.Value) -> merge - { Primitive = primitiveIsJsonb, Custom = \(_ : Model.Name) -> False } + { Primitive = primitiveIsJsonb + , Custom = \(_ : Model.CustomTypeRef) -> False + } value.scalar let valueIsArray = - \(value : Model.Value) -> - Prelude.Optional.fold - Model.ArraySettings - value.arraySettings - Bool - (\(_ : Model.ArraySettings) -> True) - False + \(value : Model.Value) -> Prelude.Bool.not (Natural/isZero value.dimensionality) -- A bare (non-array) jsonb scalar binds via psycopg Jsonb(); a bare json scalar -- binds via Json(). json preserves the document verbatim, jsonb normalizes it, so @@ -253,10 +251,10 @@ let run = } in Prelude.Optional.fold - Model.Name + Model.CustomTypeRef value.scalar.customRef (Lude.Compiled.Type Output) - ( \(name : Model.Name) -> + ( \(ref : Model.CustomTypeRef) -> let dimsAtMostTwo = Natural/isZero (Natural/subtract 2 value.dims) @@ -289,7 +287,7 @@ let run = else Lude.Compiled.report Output [ input.pgName - , name.inSnakeCase + , ref.name.inSnakeCase ] "Array of an enum parameter with dimensionality > 2 is not supported" , Composite = @@ -314,15 +312,15 @@ let run = ) else Lude.Compiled.report Output - [ input.pgName, name.inSnakeCase ] + [ input.pgName, ref.name.inSnakeCase ] "Array of a composite type parameter with dimensionality > 1 is not supported" , Absent = Lude.Compiled.report Output - [ name.inSnakeCase ] + [ ref.name.inSnakeCase ] "Custom type not found in project customTypes" } - (lookup name) + (CustomKind.at lookup ref.index) ) ( if isJsonArrayParam then Lude.Compiled.report diff --git a/src/Interpreters/Project.dhall b/src/Interpreters/Project.dhall index b4bf60d..68b62f4 100644 --- a/src/Interpreters/Project.dhall +++ b/src/Interpreters/Project.dhall @@ -14,6 +14,8 @@ let QueryGen = ./Query.dhall let CustomTypeGen = ./CustomType.dhall +let PrimitiveGen = ./Primitive.dhall + let CoreModule = ../Templates/CoreModule.dhall let RuntimeModule = ../Templates/RuntimeModule.dhall @@ -28,10 +30,6 @@ let FacadeModule = ../Templates/FacadeModule.dhall let OnUnsupported = ../Structures/OnUnsupported.dhall -let PythonNameMapping = ../Structures/PythonNameMapping.dhall - -let PythonNamespace = ../Structures/PythonNamespace.dhall - let Report = { path : List Text, message : Text } -- The generator's public Config: every field is independently Optional, so a @@ -43,471 +41,21 @@ let Config = { packageName : Optional Text , emitSync : Optional Bool , onUnsupported : Optional OnUnsupported.Mode - , queryNameMappings : Optional (List PythonNameMapping.Query) - , customTypeNameMappings : Optional (List PythonNameMapping.CustomType) } --- The root resolves every public option once. Query receives only emitSync and --- its mappings, while CustomType receives only its mappings. Lower interpreters --- use empty configs except for Result's caller-supplied rowClassName. +-- The root resolves every public option once. Lower interpreters use empty +-- configs except for Query's emitSync and Result's caller-supplied rowClassName. let ResolvedConfig = { packageName : Text , importName : Text , emitSync : Bool , onUnsupported : OnUnsupported.Mode - , queryNameMappings : List PythonNameMapping.Query - , customTypeNameMappings : List PythonNameMapping.CustomType } let Input = Model.Project let Output = Lude.Files.Type -let QueryMappingState = - { seen : List Text, error : Optional Report } - -let CustomTypeMappingState = - { seen : List PythonNameMapping.CustomTypeSource - , error : Optional Report - } - -let CustomTypeIdentity = - { contractName : Text, source : PythonNameMapping.CustomTypeSource } - -let CustomTypeIdentityState = - { seen : List CustomTypeIdentity, error : Optional Report } - -let finishMappingValidation = - \(stateError : Optional Report) -> - Prelude.Optional.fold - Report - stateError - (Lude.Compiled.Type {}) - (\(report : Report) -> (Lude.Compiled.Type {}).Err report) - (Lude.Compiled.ok {} {=}) - -let validateCustomTypeIdentities = - \(customTypes : List Model.CustomType) -> - let step = - \(customType : Model.CustomType) -> - \(state : CustomTypeIdentityState) -> - Prelude.Optional.fold - Report - state.error - CustomTypeIdentityState - (\(_ : Report) -> state) - ( let contractName = customType.name.inSnakeCase - - let previous = - List/fold - CustomTypeIdentity - state.seen - (Optional PythonNameMapping.CustomTypeSource) - ( \(identity : CustomTypeIdentity) -> - \(found : Optional PythonNameMapping.CustomTypeSource) -> - if Text/equal - identity.contractName - contractName - then Some identity.source - else found - ) - (None PythonNameMapping.CustomTypeSource) - - let error = - Prelude.Optional.fold - PythonNameMapping.CustomTypeSource - previous - (Optional Report) - ( \(source : PythonNameMapping.CustomTypeSource) -> - Some - { path = [ "customTypes", contractName ] - , message = - "Ambiguous unqualified custom type identity " - ++ Text/show contractName - ++ ": schema " - ++ Text/show source.schema - ++ ", type " - ++ Text/show source.name - ++ " and schema " - ++ Text/show customType.pgSchema - ++ ", type " - ++ Text/show customType.pgName - ++ " share one contract Name. The upstream contract must preserve a distinct schema-qualified custom identifier; Python mappings cannot recover it" - } - ) - (None Report) - - let identity = - { contractName - , source = - { schema = customType.pgSchema - , name = customType.pgName - } - } - - in { seen = state.seen # [ identity ], error } - ) - - let state = - List/fold - Model.CustomType - customTypes - CustomTypeIdentityState - step - { seen = [] : List CustomTypeIdentity - , error = None Report - } - - in finishMappingValidation state.error - -let validateQueryMappings = - \(mappings : List PythonNameMapping.Query) -> - \(queries : List Model.Query) -> - let step = - \(mapping : PythonNameMapping.Query) -> - \(state : QueryMappingState) -> - Prelude.Optional.fold - Report - state.error - QueryMappingState - (\(_ : Report) -> state) - ( let duplicate = - Prelude.List.any - Text - (\(source : Text) -> Text/equal source mapping.source) - state.seen - - let known = - Prelude.List.any - Model.Query - ( \(query : Model.Query) -> - Text/equal query.name.inSnakeCase mapping.source - ) - queries - - let snakeIsExact = - PyIdent.isSnakeIdentifier mapping.target.snakeCase - && Text/equal - (PyIdent.querySafeName mapping.target.snakeCase) - mapping.target.snakeCase - - let pascalIsExact = - PyIdent.isPascalIdentifier mapping.target.pascalCase - && Text/equal - (PyIdent.pySafeName mapping.target.pascalCase) - mapping.target.pascalCase - - let error = - if duplicate - then Some - { path = - [ "config" - , "queryNameMappings" - , mapping.source - ] - , message = - "Duplicate query name mapping source \"${mapping.source}\"" - } - else if Prelude.Bool.not known - then Some - { path = - [ "config" - , "queryNameMappings" - , mapping.source - ] - , message = - "Unknown query name mapping source \"${mapping.source}\"" - } - else if Prelude.Bool.not snakeIsExact - then Some - { path = - [ "config" - , "queryNameMappings" - , mapping.source - , "target" - , "snakeCase" - ] - , message = - "Mapped query snakeCase must start with a lowercase ASCII letter, then contain only lowercase ASCII letters, digits, or underscores, and must not be reserved" - } - else if Prelude.Bool.not pascalIsExact - then Some - { path = - [ "config" - , "queryNameMappings" - , mapping.source - , "target" - , "pascalCase" - ] - , message = - "Mapped query pascalCase must start with an uppercase ASCII letter, then contain only ASCII letters or digits, and must not be reserved" - } - else None Report - - in { seen = state.seen # [ mapping.source ], error } - ) - - let state = - List/fold - PythonNameMapping.Query - mappings - QueryMappingState - step - { seen = [] : List Text, error = None Report } - - in finishMappingValidation state.error - -let validateCustomTypeMappings = - \(mappings : List PythonNameMapping.CustomType) -> - \(customTypes : List Model.CustomType) -> - let sameSource = - \(left : PythonNameMapping.CustomTypeSource) -> - \(right : PythonNameMapping.CustomTypeSource) -> - Text/equal left.schema right.schema - && Text/equal left.name right.name - - let step = - \(mapping : PythonNameMapping.CustomType) -> - \(state : CustomTypeMappingState) -> - Prelude.Optional.fold - Report - state.error - CustomTypeMappingState - (\(_ : Report) -> state) - ( let duplicate = - Prelude.List.any - PythonNameMapping.CustomTypeSource - (sameSource mapping.source) - state.seen - - let known = - Prelude.List.any - Model.CustomType - ( \(customType : Model.CustomType) -> - Text/equal - customType.pgSchema - mapping.source.schema - && Text/equal - customType.pgName - mapping.source.name - ) - customTypes - - let snakeIsExact = - PyIdent.isSnakeIdentifier mapping.target.snakeCase - && Text/equal - ( PyIdent.typeModuleSafeName - mapping.target.snakeCase - ) - mapping.target.snakeCase - - let pascalIsExact = - PyIdent.isPascalIdentifier mapping.target.pascalCase - && Text/equal - (PyIdent.pySafeName mapping.target.pascalCase) - mapping.target.pascalCase - - let source = - "${mapping.source.schema}.${mapping.source.name}" - - let error = - if duplicate - then Some - { path = - [ "config" - , "customTypeNameMappings" - , source - ] - , message = - "Duplicate custom type name mapping source \"${source}\"" - } - else if Prelude.Bool.not known - then Some - { path = - [ "config" - , "customTypeNameMappings" - , source - ] - , message = - "Unknown custom type name mapping source \"${source}\"" - } - else if Prelude.Bool.not snakeIsExact - then Some - { path = - [ "config" - , "customTypeNameMappings" - , source - , "target" - , "snakeCase" - ] - , message = - "Mapped custom type snakeCase must start with a lowercase ASCII letter, then contain only lowercase ASCII letters, digits, or underscores, and must not be reserved" - } - else if Prelude.Bool.not pascalIsExact - then Some - { path = - [ "config" - , "customTypeNameMappings" - , source - , "target" - , "pascalCase" - ] - , message = - "Mapped custom type pascalCase must start with an uppercase ASCII letter, then contain only ASCII letters or digits, and must not be reserved" - } - else None Report - - in { seen = state.seen # [ mapping.source ], error } - ) - - let state = - List/fold - PythonNameMapping.CustomType - mappings - CustomTypeMappingState - step - { seen = [] : List PythonNameMapping.CustomTypeSource - , error = None Report - } - - in finishMappingValidation state.error - -let validateLocalNamespaces = - \(queries : List Model.Query) -> - \(customTypes : List Model.CustomType) -> - let validateQuery = - \(query : Model.Query) -> - let querySource = Text/show query.name.inSnakeCase - - let parameterBindings = - Prelude.List.map - Model.Member - PythonNamespace.Binding - ( \(parameter : Model.Member) -> - { namespace = - "parameters for query ${querySource}" - , owner = - "query parameter ${Text/show parameter.pgName}" - , name = - PyIdent.parameterSafeName - parameter.name.inSnakeCase - , remediation = "Rename one SQL placeholder" - } - ) - query.params - - let resultColumns = - merge - { Void = [] : List Model.Member - , RowsAffected = [] : List Model.Member - , Rows = - \(rows : Model.ResultRows) -> - Prelude.NonEmpty.toList - Model.Member - rows.columns - } - query.result - - let resultBindings = - Prelude.List.map - Model.Member - PythonNamespace.Binding - ( \(column : Model.Member) -> - { namespace = - "result fields for query ${querySource}" - , owner = - "result column ${Text/show column.pgName}" - , name = - PyIdent.pySafeName column.name.inSnakeCase - , remediation = "Rename one SQL result alias" - } - ) - resultColumns - - in PythonNamespace.validate - (parameterBindings # resultBindings) - - let validateCustomType = - \(customType : Model.CustomType) -> - let customSource = - "schema ${Text/show customType.pgSchema}, type ${Text/show customType.pgName}" - - let bindings = - merge - { Composite = - \(members : List Model.Member) -> - Prelude.List.map - Model.Member - PythonNamespace.Binding - ( \(member : Model.Member) -> - { namespace = - "fields for custom type ${customSource}" - , owner = - "composite field ${Text/show member.pgName}" - , name = - PyIdent.pySafeName - member.name.inSnakeCase - , remediation = - "Rename one PostgreSQL composite field" - } - ) - members - , Enum = - \(variants : List Model.EnumVariant) -> - Prelude.List.map - Model.EnumVariant - PythonNamespace.Binding - ( \(variant : Model.EnumVariant) -> - { namespace = - "enum members for custom type ${customSource}" - , owner = - "enum label ${Text/show variant.pgName}" - , name = - PyIdent.pySafeName - variant.name.inScreamingSnakeCase - , remediation = - "Rename one PostgreSQL enum label" - } - ) - variants - , Domain = - \(_ : Model.Value) -> - [] : List PythonNamespace.Binding - } - customType.definition - - in PythonNamespace.validate bindings - - let queryValidation = - Lude.Compiled.map - (List {}) - {} - (\(_ : List {}) -> {=}) - ( Lude.Compiled.traverseList - Model.Query - {} - validateQuery - queries - ) - - let customTypeValidation = - Lude.Compiled.map - (List {}) - {} - (\(_ : List {}) -> {=}) - ( Lude.Compiled.traverseList - Model.CustomType - {} - validateCustomType - customTypes - ) - - in Lude.Compiled.flatMap - {} - {} - (\(_ : {}) -> customTypeValidation) - queryValidation - -- Header of every emitted .py file. The marker truthfully warns that another -- generation replaces manual changes. The SPDX pair (REUSE convention) -- licenses the emitted file itself as MIT-0. @@ -528,16 +76,12 @@ let IndexedCustomType = { index : Natural, value : Model.CustomType } let LookupKind = < Composite | Enum | Domain > let LookupEntry = - { contractName : Text - , kind : LookupKind - , identity : CustomKind.Identity - } + { kind : LookupKind, identity : CustomKind.Identity } let ResolvedCustomType = { value : Model.CustomType, lookupEntry : LookupEntry } let resolveCustomTypes = - \(nameMappings : List PythonNameMapping.CustomType) -> \(customTypes : List Model.CustomType) -> Prelude.List.map IndexedCustomType @@ -546,14 +90,10 @@ let resolveCustomTypes = let customType = entry.value let pythonName = - PythonNameMapping.resolveCustomType - nameMappings - { schema = customType.pgSchema, name = customType.pgName } - { snakeCase = - PyIdent.typeModuleSafeName - customType.name.inSnakeCase - , pascalCase = PyIdent.pySafeName customType.name.inPascalCase - } + { snakeCase = + PyIdent.typeModuleSafeName customType.name.inSnakeCase + , pascalCase = PyIdent.pySafeName customType.name.inPascalCase + } let identity = { className = pythonName.pascalCase @@ -570,45 +110,97 @@ let resolveCustomTypes = customType.definition in { value = customType - , lookupEntry = - { contractName = customType.name.inSnakeCase - , kind - , identity - } + , lookupEntry = { kind, identity } } ) (Prelude.List.indexed Model.CustomType customTypes) -let lookupEntries = - \(customTypes : List ResolvedCustomType) -> +-- The fixed, never-shrinking kind classification of every custom type, in +-- `input.customTypes` order (so position i == a `CustomTypeRef.index` of i). +-- A type's kind (Enum/Composite/Domain) is a property of its own definition +-- and never changes across Skip removal passes; Domain has no Python identity, +-- so it classifies as `Absent`. Survivorship is layered on separately (see +-- `supported`/`lookup` in `run`), keeping the two concerns that the former +-- `buildLookup` conflated cleanly apart. +let kindOf + : List ResolvedCustomType -> CustomKind.Lookup + = \(entries : List ResolvedCustomType) -> Prelude.List.map ResolvedCustomType - LookupEntry - (\(customType : ResolvedCustomType) -> customType.lookupEntry) - customTypes - -let buildLookup = - \(entries : List LookupEntry) -> - Prelude.List.fold - LookupEntry + CustomKind.TypeKind + ( \(rt : ResolvedCustomType) -> + merge + { Composite = + CustomKind.TypeKind.Composite rt.lookupEntry.identity + , Enum = CustomKind.TypeKind.Enum rt.lookupEntry.identity + , Domain = CustomKind.TypeKind.Absent + } + rt.lookupEntry.kind + ) entries - CustomKind.Lookup - ( \(entry : LookupEntry) -> - \(rest : CustomKind.Lookup) -> - \(name : Model.Name) -> - -- Text/equal is a pgn embedded-Dhall builtin. The upstream exit is - -- a stable custom kind or id on Scalar.Custom. - if Text/equal name.inSnakeCase entry.contractName - then merge - { Composite = - CustomKind.TypeKind.Composite entry.identity - , Enum = CustomKind.TypeKind.Enum entry.identity - , Domain = CustomKind.TypeKind.Absent + +-- True when the primitive has a faithful Python mapping (`money`, `bit`, geo +-- types, etc. do not). Reuses the Primitive interpreter as the oracle rather +-- than duplicating its 60-way supported/unsupported table, so this predicate +-- can never drift from what `CustomTypeGen.run` actually emits. +let primitiveSupported = + \(primitive : Model.Primitive) -> + merge + { Ok = + \(_ : { warnings : List Report, value : PrimitiveGen.Output }) -> + True + , Err = \(_ : Report) -> False + } + (PrimitiveGen.run {=} primitive) + +-- Whether a custom type's OWN definition compiles, assuming every custom type +-- it references is present (the transitive "is a referenced type still a +-- survivor" half is handled by `Sdk.CustomTypes.supportedCustomTypesReasoned`, +-- which folds this predicate over the topologically-sorted `customTypes`). +-- This exactly mirrors the own-shape failure modes of `CustomTypeGen.run`: +-- * Domain -> never supported (no Python lowering). +-- * Enum -> always compiles. +-- * Composite -> every member must compile: a primitive member needs a +-- supported primitive; a custom member must be a bare +-- scalar (CustomType.dhall rejects any custom array +-- field, regardless of the referenced kind). +-- Using this as the survivorship predicate (instead of the former +-- `buildLookup`-based render probe) keeps Skip mode dropping exactly the set of +-- types that could not be emitted, so no surviving type later fails to render. +let ownDefinitionSupported + : Model.CustomTypeDefinition -> Bool + = \(definition : Model.CustomTypeDefinition) -> + merge + { Composite = + \(members : List Model.Member) -> + Prelude.List.all + Model.Member + ( \(member : Model.Member) -> + merge + { Primitive = + \(primitive : Model.Primitive) -> + primitiveSupported primitive + , Custom = + \(_ : Model.CustomTypeRef) -> + Natural/isZero member.value.dimensionality } - entry.kind - else rest name - ) - (\(_ : Model.Name) -> CustomKind.TypeKind.Absent) + member.value.scalar + ) + members + , Enum = \(_ : List Model.EnumVariant) -> True + , Domain = \(_ : Model.Value) -> False + } + definition + +let boolAt = + \(bools : List Bool) -> + \(index : Natural) -> + Prelude.Optional.fold + Bool + (Prelude.List.index index Bool bools) + Bool + (\(b : Bool) -> b) + False let sameOrder = \(left : Natural) -> @@ -699,161 +291,6 @@ let registrationOrder = [ "register_types" ] "Unresolved or cyclic custom type dependencies: ${unresolved}" -let validateProjectNamespaces = - \(config : ResolvedConfig) -> - \(queries : List QueryGen.Output) -> - \(customTypes : List CustomTypeGen.Output) -> - let mappingRemediation = - "Rename the source or configure a typed name mapping" - - let queryOwner = - \(query : QueryGen.Output) -> - "query \"${query.sourceName}\" (${query.sourcePath})" - - let queryModuleBindings = - Prelude.List.map - QueryGen.Output - PythonNamespace.Binding - ( \(query : QueryGen.Output) -> - { namespace = "generated statement modules" - , owner = queryOwner query - , name = query.functionName - , remediation = mappingRemediation - } - ) - queries - - let typeModuleBindings = - Prelude.List.map - CustomTypeGen.Output - PythonNamespace.Binding - ( \(customType : CustomTypeGen.Output) -> - { namespace = "generated custom type modules" - , owner = - "custom type \"${customType.pgSchema}.${customType.pgName}\"" - , name = customType.moduleName - , remediation = mappingRemediation - } - ) - customTypes - - let coreFacadeBindings = - [ { namespace = "package facade" - , owner = "generated core symbol JsonValue" - , name = "JsonValue" - , remediation = mappingRemediation - } - , { namespace = "package facade" - , owner = "generated core symbol NoRowError" - , name = "NoRowError" - , remediation = mappingRemediation - } - ] : List PythonNamespace.Binding - - let syncFacadeBindings = - if config.emitSync - then [ { namespace = "package facade" - , owner = "generated sync facade" - , name = "sync" - , remediation = mappingRemediation - } - ] - else [] : List PythonNamespace.Binding - - let registrationFacadeBindings = - if Prelude.List.null CustomTypeGen.Output customTypes - then [] : List PythonNamespace.Binding - else [ { namespace = "package facade" - , owner = "generated type registration function" - , name = "register_types" - , remediation = mappingRemediation - } - ] - - let queryFacadeBindings = - Prelude.List.concatMap - QueryGen.Output - PythonNamespace.Binding - ( \(query : QueryGen.Output) -> - let functionBinding = - { namespace = "package facade" - , owner = queryOwner query - , name = query.functionName - , remediation = mappingRemediation - } - - let rowBindings = - Prelude.Optional.fold - Text - query.rowClassName - (List PythonNamespace.Binding) - ( \(rowClassName : Text) -> - [ { namespace = "package facade" - , owner = - "Row class from ${queryOwner query}" - , name = rowClassName - , remediation = mappingRemediation - } - ] - ) - ([] : List PythonNamespace.Binding) - - in [ functionBinding ] # rowBindings - ) - queries - - let typeFacadeBindings = - Prelude.List.map - CustomTypeGen.Output - PythonNamespace.Binding - ( \(customType : CustomTypeGen.Output) -> - { namespace = "package facade" - , owner = - "custom type \"${customType.pgSchema}.${customType.pgName}\"" - , name = customType.typeName - , remediation = mappingRemediation - } - ) - customTypes - - -- validate freezes a right-folded state, so groups are supplied in - -- reverse diagnostic priority. Module/file collisions remain the - -- primary cause when the same pair would also collide in a facade. - let projectValidation = - PythonNamespace.validate - ( typeFacadeBindings - # queryFacadeBindings - # registrationFacadeBindings - # syncFacadeBindings - # coreFacadeBindings - # typeModuleBindings - # queryModuleBindings - ) - - -- A module-name collision is fatal in Fail and Skip alike. These - -- bindings travel through CustomType.Output so Skip's support probe - -- cannot mistake a naming error for an unsupported PostgreSQL shape. - let moduleValidation = - Lude.Compiled.map - (List {}) - {} - (\(_ : List {}) -> {=}) - ( Lude.Compiled.traverseList - CustomTypeGen.Output - {} - ( \(customType : CustomTypeGen.Output) -> - PythonNamespace.validate - customType.moduleBindings - ) - customTypes - ) - - in Lude.Compiled.flatMap - {} - {} - (\(_ : {}) -> projectValidation) - moduleValidation - let combineOutputs = \(config : ResolvedConfig) -> \(input : Input) -> @@ -1101,117 +538,102 @@ let run = (\(m : OnUnsupported.Mode) -> m) OnUnsupported.Mode.Fail - let queryNameMappings = - Prelude.Optional.fold - (List PythonNameMapping.Query) - config.queryNameMappings - (List PythonNameMapping.Query) - (\(mappings : List PythonNameMapping.Query) -> mappings) - ([] : List PythonNameMapping.Query) - - let customTypeNameMappings = - Prelude.Optional.fold - (List PythonNameMapping.CustomType) - config.customTypeNameMappings - (List PythonNameMapping.CustomType) - (\(mappings : List PythonNameMapping.CustomType) -> mappings) - ([] : List PythonNameMapping.CustomType) - let importName = Prelude.Text.replace "-" "_" packageName let resolvedConfig : ResolvedConfig - = { packageName - , importName - , emitSync - , onUnsupported - , queryNameMappings - , customTypeNameMappings - } + = { packageName, importName, emitSync, onUnsupported } - let queryConfig = - resolvedConfig.{ emitSync, queryNameMappings } + let queryConfig = resolvedConfig.{ emitSync } - let customTypeConfig = - resolvedConfig.{ customTypeNameMappings } + let customTypeConfig = {=} let skip = merge { Fail = False, Skip = True } resolvedConfig.onUnsupported - let typeSucceedsWith = - \(candidateLookup : CustomKind.Lookup) -> - \(ct : Model.CustomType) -> - merge - { Ok = - \(_ : { value : CustomTypeGen.Output, warnings : List Report }) -> - True - , Err = \(_ : Report) -> False - } - (CustomTypeGen.run customTypeConfig candidateLookup ct) + let resolvedCustomTypes = resolveCustomTypes input.customTypes - -- Nested under the type's own name so the warning names the type - -- that failed, not just the inner member/column that triggered it - -- (CustomType.run itself does not). - let typeWarningWith = - \(candidateLookup : CustomKind.Lookup) -> - \(ct : Model.CustomType) -> - merge - { Ok = - \(_ : { value : CustomTypeGen.Output, warnings : List Report }) -> - None Report - , Err = - \(err : Report) -> Some { path = [ ct.name.inSnakeCase ] # err.path, message = err.message } - } - (CustomTypeGen.run customTypeConfig candidateLookup ct) + -- The unmasked, index-aligned kind classification of every custom type. + let fixedKindOf = kindOf resolvedCustomTypes + + -- Per-index survivorship. In Skip mode this is the one-pass transitive + -- cascade from gen-sdk: `supportedCustomTypesReasoned` folds + -- `ownDefinitionSupported` over the topologically-sorted `customTypes`, + -- returning `None` (supported) or `Some ` (unsupported) + -- per index; we keep only the Bool. In Fail mode nothing is ever + -- dropped, so every index is supported. + let supported + : List Bool + = if skip + then Prelude.List.map + (Optional Model.CustomTypeRef) + Bool + (Prelude.Optional.null Model.CustomTypeRef) + ( Sdk.CustomTypes.supportedCustomTypesReasoned + ownDefinitionSupported + input.customTypes + ) + else Prelude.List.map + ResolvedCustomType + Bool + (\(_ : ResolvedCustomType) -> True) + resolvedCustomTypes + + -- The kind lookup handed to every interpreter. In Skip mode an + -- unsupported index is masked to `Absent` regardless of its real kind, + -- so a reference to a removed type reads exactly as a missing type + -- (the "Absent on either failure" invariant the former `buildLookup` + -- provided by shrinking its entry list). In Fail mode it is the + -- unmasked classification, so behaviour is identical to before Skip. + let lookup + : CustomKind.Lookup + = if skip + then Prelude.List.map + { index : Natural, value : CustomKind.TypeKind } + CustomKind.TypeKind + ( \(e : { index : Natural, value : CustomKind.TypeKind }) -> + if boolAt supported e.index + then e.value + else CustomKind.TypeKind.Absent + ) + (Prelude.List.indexed CustomKind.TypeKind fixedKindOf) + else fixedKindOf - let resolvedCustomTypes = - resolveCustomTypes - resolvedConfig.customTypeNameMappings - input.customTypes + let indexedResolvedCustomTypes + : List { index : Natural, value : ResolvedCustomType } + = Prelude.List.indexed ResolvedCustomType resolvedCustomTypes - -- Nested custom support requires transitive closure: after one type is - -- removed, composites depending on it must be reconsidered against the - -- smaller lookup. Each bounded pass can only remove survivors. let effectiveResolvedCustomTypes - : List ResolvedCustomType + : List { index : Natural, value : ResolvedCustomType } = if skip - then Natural/fold - (Prelude.List.length Model.CustomType input.customTypes) - (List ResolvedCustomType) - ( \(survivors : List ResolvedCustomType) -> - let candidateLookup = - buildLookup (lookupEntries survivors) - - in Prelude.List.filter - ResolvedCustomType - ( \(customType : ResolvedCustomType) -> - typeSucceedsWith - candidateLookup - customType.value - ) - survivors + then Prelude.List.filter + { index : Natural, value : ResolvedCustomType } + ( \(e : { index : Natural, value : ResolvedCustomType }) -> + boolAt supported e.index ) - resolvedCustomTypes - else resolvedCustomTypes + indexedResolvedCustomTypes + else indexedResolvedCustomTypes - let effectiveCustomTypes = - Prelude.List.map - ResolvedCustomType - Model.CustomType - (\(customType : ResolvedCustomType) -> customType.value) + let effectiveCustomTypes + : List { index : Natural, value : Model.CustomType } + = Prelude.List.map + { index : Natural, value : ResolvedCustomType } + { index : Natural, value : Model.CustomType } + ( \(e : { index : Natural, value : ResolvedCustomType }) -> + { index = e.index, value = e.value.value } + ) effectiveResolvedCustomTypes - let lookup = - buildLookup (lookupEntries effectiveResolvedCustomTypes) - - -- Fail mode: identical to the pre-Skip code (traverseList straight - -- over input.customTypes), so its error message/path is unchanged. + -- Fail mode: `effectiveCustomTypes` is every input type (indexed), so + -- this is the pre-Skip traversal with each type's own index threaded to + -- CustomTypeGen.run for its self-lookup; its error message/path is + -- unchanged. Skip mode traverses only the survivors. let typesForCombine : Lude.Compiled.Type (List CustomTypeGen.Output) = Lude.Compiled.traverseList - Model.CustomType + { index : Natural, value : Model.CustomType } CustomTypeGen.Output - ( \(ct : Model.CustomType) -> - CustomTypeGen.run customTypeConfig lookup ct + ( \(e : { index : Natural, value : Model.CustomType }) -> + CustomTypeGen.run customTypeConfig lookup e.index e.value ) effectiveCustomTypes @@ -1261,16 +683,39 @@ let run = registrationOrder typesForCombine + -- Nested under the type's own name so the warning names the type that + -- failed, not just the inner member/column that triggered it + -- (CustomType.run itself does not). Uses the single final `lookup` + -- (which already masks removed types to Absent) and each type's own + -- real index, so every input type is diagnosed at its true position. + let typeWarning = + \(ct : Model.CustomType) -> + \(index : Natural) -> + merge + { Ok = + \(_ : { value : CustomTypeGen.Output, warnings : List Report }) -> + None Report + , Err = + \(err : Report) -> + Some + { path = [ ct.name.inSnakeCase ] # err.path + , message = err.message + } + } + (CustomTypeGen.run customTypeConfig lookup index ct) + let skipWarnings : List Report = if skip then Prelude.List.unpackOptionals Report ( Prelude.List.map - Model.CustomType + { index : Natural, value : ResolvedCustomType } (Optional Report) - (typeWarningWith lookup) - input.customTypes + ( \(e : { index : Natural, value : ResolvedCustomType }) -> + typeWarning e.value.value e.index + ) + indexedResolvedCustomTypes ) # Prelude.List.unpackOptionals Report @@ -1295,66 +740,19 @@ let run = let combined : Lude.Compiled.Type Output - = Lude.Compiled.flatMap + = Lude.Compiled.map CombinedInputs Output ( \(compiled : CombinedInputs) -> - Lude.Compiled.map - {} - Output - ( \(_ : {}) -> - combineOutputs - resolvedConfig - input - compiled.queries - compiled.customTypes - compiled.registrationTypes - ) - ( validateProjectNamespaces - resolvedConfig - compiled.queries - compiled.customTypes - ) + combineOutputs + resolvedConfig + input + compiled.queries + compiled.customTypes + compiled.registrationTypes ) compiledInputs - let mappingsValid = - Lude.Compiled.flatMap - {} - {} - ( \(_ : {}) -> - Lude.Compiled.flatMap - {} - {} - ( \(_ : {}) -> - validateCustomTypeMappings - resolvedConfig.customTypeNameMappings - input.customTypes - ) - ( validateQueryMappings - resolvedConfig.queryNameMappings - input.queries - ) - ) - (validateCustomTypeIdentities input.customTypes) - - let mappingsAndLocalsValid = - Lude.Compiled.flatMap - {} - {} - ( \(_ : {}) -> - validateLocalNamespaces - effectiveQueries - effectiveCustomTypes - ) - mappingsValid - - in Lude.Compiled.flatMap - {} - Output - ( \(_ : {}) -> - Lude.Compiled.appendWarnings Output skipWarnings combined - ) - mappingsAndLocalsValid + in Lude.Compiled.appendWarnings Output skipWarnings combined in Sdk.Sigs.interpreter Config Input Output run diff --git a/src/Interpreters/Query.dhall b/src/Interpreters/Query.dhall index b4eb4a5..30d80b6 100644 --- a/src/Interpreters/Query.dhall +++ b/src/Interpreters/Query.dhall @@ -12,8 +12,6 @@ let PyIdent = ../Structures/PyIdent.dhall let Surface = ../Structures/Surface.dhall -let PythonNameMapping = ../Structures/PythonNameMapping.dhall - let ResultModule = ./Result.dhall let QueryFragmentsModule = ./QueryFragments.dhall @@ -22,10 +20,7 @@ let ParamsMember = ./ParamsMember.dhall let StatementModule = ../Templates/StatementModule.dhall -let Config = - { emitSync : Bool - , queryNameMappings : List PythonNameMapping.Query - } +let Config = { emitSync : Bool } let Compiled = Lude.Compiled @@ -129,12 +124,9 @@ let run = \(lookup : CustomKind.Lookup) -> \(input : Input) -> let pythonName = - PythonNameMapping.resolveQuery - config.queryNameMappings - input.name.inSnakeCase - { snakeCase = PyIdent.querySafeName input.name.inSnakeCase - , pascalCase = input.name.inPascalCase - } + { snakeCase = PyIdent.querySafeName input.name.inSnakeCase + , pascalCase = input.name.inPascalCase + } let rowClassName = pythonName.pascalCase ++ "Row" diff --git a/src/Interpreters/Scalar.dhall b/src/Interpreters/Scalar.dhall index ba1e749..9fa4c3d 100644 --- a/src/Interpreters/Scalar.dhall +++ b/src/Interpreters/Scalar.dhall @@ -19,7 +19,7 @@ let ScalarDecode = < Passthrough | Custom > let Output = { pyType : Text , imports : ImportSet.Type - , customRef : Optional Model.Name + , customRef : Optional Model.CustomTypeRef , decode : ScalarDecode } @@ -35,18 +35,18 @@ let run = ( \(p : Primitive.Output) -> { pyType = p.pyType , imports = p.imports - , customRef = None Model.Name + , customRef = None Model.CustomTypeRef , decode = ScalarDecode.Passthrough } ) (Primitive.run {=} primitive) , Custom = - \(name : Model.Name) -> + \(ref : Model.CustomTypeRef) -> Lude.Compiled.ok Output - { pyType = name.inPascalCase + { pyType = ref.name.inPascalCase , imports = ImportSet.empty - , customRef = Some name + , customRef = Some ref , decode = ScalarDecode.Custom } } diff --git a/src/Interpreters/Value.dhall b/src/Interpreters/Value.dhall index f9717de..5ba1381 100644 --- a/src/Interpreters/Value.dhall +++ b/src/Interpreters/Value.dhall @@ -29,19 +29,21 @@ let run = Scalar.Output Output ( \(scalar : Scalar.Output) -> - Prelude.Optional.fold - Model.ArraySettings - input.arraySettings - Output - ( \(arraySettings : Model.ArraySettings) -> - let elementType = - if arraySettings.elementIsNullable + if Natural/isZero input.dimensionality + then { pyType = scalar.pyType + , imports = scalar.imports + , scalar + , dims = 0 + , elementIsNullable = False + } + else let elementType = + if input.elementIsNullable then "${scalar.pyType} | None" else scalar.pyType let arrayType = Natural/fold - arraySettings.dimensionality + input.dimensionality Text (\(inner : Text) -> "list[${inner}]") elementType @@ -49,16 +51,9 @@ let run = in { pyType = arrayType , imports = scalar.imports , scalar - , dims = arraySettings.dimensionality - , elementIsNullable = arraySettings.elementIsNullable + , dims = input.dimensionality + , elementIsNullable = input.elementIsNullable } - ) - { pyType = scalar.pyType - , imports = scalar.imports - , scalar - , dims = 0 - , elementIsNullable = False - } ) (Scalar.run {=} input.scalar) @@ -68,12 +63,12 @@ let qualifyCustom \(className : Text) -> \(value : Output) -> Prelude.Optional.fold - Model.Name + Model.CustomTypeRef value.scalar.customRef Text - ( \(name : Model.Name) -> + ( \(ref : Model.CustomTypeRef) -> Text/replace - name.inPascalCase + ref.name.inPascalCase (prefix ++ className) value.pyType ) diff --git a/src/Structures/CustomKind.dhall b/src/Structures/CustomKind.dhall index 1621f13..4748022 100644 --- a/src/Structures/CustomKind.dhall +++ b/src/Structures/CustomKind.dhall @@ -1,4 +1,4 @@ -let Model = ../Deps/Contract.dhall +let Prelude = ../Deps/Prelude.dhall let Identity = { className : Text, moduleName : Text, order : Natural } @@ -9,6 +9,22 @@ let TypeKind = | Absent > -let Lookup = Model.Name -> TypeKind +-- Index-aligned with a project's `customTypes` list: `Lookup` at position `i` +-- is the kind classification of `customTypes[i]`. Replaces the former +-- `Model.Name -> TypeKind` closure (which relied on `Text/equal`), so a +-- reference resolves by its `CustomTypeRef.index` instead of by name. +let Lookup = List TypeKind -in { TypeKind, Lookup, Identity } +-- Resolve a reference's kind by index; an out-of-range index reads as `Absent` +-- (mirrors gen-sdk's own `optionalIndex` helper in supportedCustomTypes.dhall). +let at = + \(lookup : Lookup) -> + \(index : Natural) -> + Prelude.Optional.fold + TypeKind + (Prelude.List.index index TypeKind lookup) + TypeKind + (\(kind : TypeKind) -> kind) + TypeKind.Absent + +in { TypeKind, Lookup, Identity, at } diff --git a/src/Structures/PyIdent.dhall b/src/Structures/PyIdent.dhall index c427d2d..822f44d 100644 --- a/src/Structures/PyIdent.dhall +++ b/src/Structures/PyIdent.dhall @@ -1,5 +1,3 @@ -let Prelude = ../Deps/Prelude.dhall - -- pgn passes identifier names through verbatim (the keyword-param fixture proves a -- column or placeholder may be spelled as a Python reserved word), so any name that -- becomes a Python identifier in the emitted code must be sanitized. Shared so the @@ -43,42 +41,20 @@ let pythonKeywords = , "yield" ] +let Lude = ../Deps/Lude.dhall + -- Add the caller's suffix when `name` collides with one of `reserved`. Callers -- keep the raw name for the SQL placeholder / dict key / row[...] lookup; only -- the Python identifier is sanitized. --- --- Equality without Text/equal: java.gen's escapeJavaKeyword delimiter trick does --- not apply here because pgn's embedded Text/replace misses needles spanning a --- text concatenation boundary (verified against the pinned pgn), so a "|"-wrapped --- name never matches its wrapped keyword. Bare replaces do work: --- `Text/replace name markTrue candidate` yields exactly `markTrue` only when --- `name` equals `candidate`; any mismatch residue keeps a letter of the --- alphabetic reserved word or grows past the digits-only marker, so it can never --- match inside `markTrue`, the second replace maps match to `name` and mismatch --- to `markTrue`, and the final replace rewrites `acc` on a match only. --- Limitations: a name containing the literal marker string is corrupted by the --- mismatch branch (the marker becomes the needle replaced in `acc`), and `acc` --- must be read exactly once per fold step or the expression re-embeds itself at --- every reserved word and blows up exponentially. -let markTrue = "0000000000000000000000000001" - -let suffixAgainst = - \(suffix : Text) -> +-- Dhall (and pgn's evaluator, as of pgn 0.11.0) has no Text/equal builtin, so +-- Lude.Text.replaceIfOneOf gets exact-match replacement via a sentinel-wrapped +-- Text/replace instead. +let suffixAgainst + : Text -> List Text -> Text -> Text + = \(suffix : Text) -> \(reserved : List Text) -> \(name : Text) -> - List/fold - Text - reserved - Text - ( \(candidate : Text) -> - \(acc : Text) -> - let signal = Text/replace name markTrue candidate - - let finalNeedle = Text/replace signal name markTrue - - in Text/replace finalNeedle (name ++ suffix) acc - ) - name + Lude.Text.replaceIfOneOf reserved (name ++ suffix) name let sanitizeAgainst = suffixAgainst "_" @@ -151,64 +127,6 @@ let querySafeName = let typeModuleSafeName = \(name : Text) -> sanitizeAgainst (pythonKeywords # moduleMetadataNames) name -let asciiLetters = - [ "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m" - , "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z" - , "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M" - , "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z" - ] - -let digits = [ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9" ] - -let containsOnly = - \(allowed : List Text) -> - \(name : Text) -> - let residue = - List/fold - Text - allowed - Text - (\(character : Text) -> \(rest : Text) -> Text/replace character "" rest) - name - - in Text/equal residue "" - --- The string is already limited to identifier characters before this check, so --- its Text/show value contains a quote only at each end. Replacing `"` can --- therefore match only the first character and gives us a safe head predicate --- without a non-standard regex builtin. -let startsWithOneOf = - \(allowed : List Text) -> - \(name : Text) -> - let shown = Text/show name - - in Prelude.List.any - Text - ( \(character : Text) -> - Prelude.Bool.not - ( Text/equal - (Text/replace ("\"" ++ character) "" shown) - shown - ) - ) - allowed - -let isSnakeIdentifier = - \(name : Text) -> - let letters = Prelude.List.take 26 Text asciiLetters - - in Prelude.Bool.not (Text/equal name "") - && containsOnly (letters # digits # [ "_" ]) name - && startsWithOneOf letters name - -let isPascalIdentifier = - \(name : Text) -> - let uppercaseLetters = Prelude.List.drop 26 Text asciiLetters - - in Prelude.Bool.not (Text/equal name "") - && containsOnly (asciiLetters # digits) name - && startsWithOneOf uppercaseLetters name - in { pythonKeywords , moduleMetadataNames , sanitizeAgainst @@ -218,6 +136,4 @@ in { pythonKeywords , parameterSafeName , querySafeName , typeModuleSafeName - , isSnakeIdentifier - , isPascalIdentifier } diff --git a/src/Structures/PythonNameMapping.dhall b/src/Structures/PythonNameMapping.dhall deleted file mode 100644 index a955517..0000000 --- a/src/Structures/PythonNameMapping.dhall +++ /dev/null @@ -1,48 +0,0 @@ -let Prelude = ../Deps/Prelude.dhall - -let PythonName = { snakeCase : Text, pascalCase : Text } - -let Query = { source : Text, target : PythonName } - -let CustomTypeSource = { schema : Text, name : Text } - -let CustomType = { source : CustomTypeSource, target : PythonName } - -let resolveQuery = - \(mappings : List Query) -> - \(source : Text) -> - \(defaultName : PythonName) -> - List/fold - Query - mappings - PythonName - ( \(mapping : Query) -> - \(resolved : PythonName) -> - if Text/equal mapping.source source then mapping.target else resolved - ) - defaultName - -let resolveCustomType = - \(mappings : List CustomType) -> - \(source : CustomTypeSource) -> - \(defaultName : PythonName) -> - List/fold - CustomType - mappings - PythonName - ( \(mapping : CustomType) -> - \(resolved : PythonName) -> - if Text/equal mapping.source.schema source.schema - && Text/equal mapping.source.name source.name - then mapping.target - else resolved - ) - defaultName - -in { PythonName - , Query - , CustomTypeSource - , CustomType - , resolveQuery - , resolveCustomType - } diff --git a/src/Structures/PythonNamespace.dhall b/src/Structures/PythonNamespace.dhall deleted file mode 100644 index 28a9fcc..0000000 --- a/src/Structures/PythonNamespace.dhall +++ /dev/null @@ -1,97 +0,0 @@ -let Lude = ../Deps/Lude.dhall - -let Prelude = ../Deps/Prelude.dhall - -let Binding = - { namespace : Text - , owner : Text - , name : Text - , remediation : Text - } - -let Collision = - { namespace : Text - , firstOwner : Text - , secondOwner : Text - , name : Text - , remediation : Text - } - -let State = { seen : List Binding, collision : Optional Collision } - -let validate = - \(bindings : List Binding) -> - let step = - \(binding : Binding) -> - \(state : State) -> - Prelude.Optional.fold - Collision - state.collision - State - (\(_ : Collision) -> state) - ( let previousOwner = - List/fold - Binding - state.seen - (Optional Text) - ( \(previous : Binding) -> - \(found : Optional Text) -> - if Text/equal - previous.namespace - binding.namespace - && Text/equal previous.name binding.name - then Some previous.owner - else found - ) - (None Text) - - let collision = - Prelude.Optional.fold - Text - previousOwner - (Optional Collision) - ( \(owner : Text) -> - Some - { namespace = binding.namespace - , firstOwner = owner - , secondOwner = binding.owner - , name = binding.name - , remediation = binding.remediation - } - ) - (None Collision) - - in { seen = state.seen # [ binding ], collision } - ) - - let result = - List/fold - Binding - bindings - State - step - { seen = [] : List Binding, collision = None Collision } - - in Prelude.Optional.fold - Collision - result.collision - (Lude.Compiled.Type {}) - ( \(collision : Collision) -> - Lude.Compiled.report - {} - [ "pythonNames", collision.namespace, collision.name ] - ( "Generated Python name collision in " - ++ collision.namespace - ++ ": " - ++ collision.firstOwner - ++ " and " - ++ collision.secondOwner - ++ " both resolve to \"" - ++ collision.name - ++ "\". " - ++ collision.remediation - ) - ) - (Lude.Compiled.ok {} {=}) - -in { Binding, validate } diff --git a/src/package.dhall b/src/package.dhall index a820150..67ff3c1 100644 --- a/src/package.dhall +++ b/src/package.dhall @@ -2,8 +2,6 @@ let Sdk = ./Deps/Sdk.dhall let OnUnsupported = ./Structures/OnUnsupported.dhall -let PythonNameMapping = ./Structures/PythonNameMapping.dhall - let ProjectInterpreter = ./Interpreters/Project.dhall -- User-facing config for this generator. Async output is always emitted; @@ -13,14 +11,11 @@ let ProjectInterpreter = ./Interpreters/Project.dhall -- 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. Typed name mappings --- provide explicit whole-entity renames after collision checks. +-- Interpreters/Project.dhall's `run` supplies the defaults. let Config = { packageName : Optional Text , emitSync : Optional Bool , onUnsupported : Optional OnUnsupported.Mode - , queryNameMappings : Optional (List PythonNameMapping.Query) - , customTypeNameMappings : Optional (List PythonNameMapping.CustomType) } : Type let Config/default @@ -28,8 +23,6 @@ let Config/default = { packageName = None Text , emitSync = None Bool , onUnsupported = None OnUnsupported.Mode - , queryNameMappings = None (List PythonNameMapping.Query) - , customTypeNameMappings = None (List PythonNameMapping.CustomType) } in Sdk.Sigs.generator Config Config/default ProjectInterpreter.run diff --git a/tests/conftest.py b/tests/conftest.py index f276560..78c3bfd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,7 +43,7 @@ def pgn_bin() -> str: out = subprocess.run(["mise", "which", "pgn"], cwd=HERE, capture_output=True, text=True) path = out.stdout.strip() if out.returncode != 0 or not path: - pytest.fail("pgn 0.9.1 is required but is not resolvable via PATH or mise") + pytest.fail("pgn 0.12.0 is required but is not resolvable via PATH or mise") return path diff --git a/tests/golden/src/specimen_client/__init__.py b/tests/golden/src/specimen_client/__init__.py index 7f11f41..a99ea57 100644 --- a/tests/golden/src/specimen_client/__init__.py +++ b/tests/golden/src/specimen_client/__init__.py @@ -82,9 +82,6 @@ # isort: on # isort: off -from ._generated.types.a_codec_wrapper import ( - ACodecWrapper as ACodecWrapper, -) from ._generated.types.mood import ( Mood as Mood, ) @@ -97,6 +94,9 @@ from ._generated.types.z_codec_payload import ( ZCodecPayload as ZCodecPayload, ) +from ._generated.types.a_codec_wrapper import ( + ACodecWrapper as ACodecWrapper, +) # isort: on __all__ = [ @@ -105,11 +105,11 @@ ] # Project order is intentional for public re-export groups. __all__ += [ # noqa: RUF022, RUF100 - "ACodecWrapper", "Mood", "Point2D", "TagValue", "ZCodecPayload", + "ACodecWrapper", ] __all__ += [ # noqa: RUF022, RUF100 "GetSpecimenRow", diff --git a/tests/golden/src/specimen_client/_generated/types/__init__.py b/tests/golden/src/specimen_client/_generated/types/__init__.py index 9a8d597..3b56de9 100644 --- a/tests/golden/src/specimen_client/_generated/types/__init__.py +++ b/tests/golden/src/specimen_client/_generated/types/__init__.py @@ -3,9 +3,9 @@ # SPDX-License-Identifier: MIT-0 # isort: off -from .a_codec_wrapper import ACodecWrapper as ACodecWrapper from .mood import Mood as Mood from .point_2_d import Point2D as Point2D from .tag_value import TagValue as TagValue from .z_codec_payload import ZCodecPayload as ZCodecPayload +from .a_codec_wrapper import ACodecWrapper as ACodecWrapper # isort: on diff --git a/tests/golden/src/specimen_client/sync/__init__.py b/tests/golden/src/specimen_client/sync/__init__.py index 4a89f76..21d00e8 100644 --- a/tests/golden/src/specimen_client/sync/__init__.py +++ b/tests/golden/src/specimen_client/sync/__init__.py @@ -79,9 +79,6 @@ # isort: on # isort: off -from .._generated.types.a_codec_wrapper import ( - ACodecWrapper as ACodecWrapper, -) from .._generated.types.mood import ( Mood as Mood, ) @@ -94,6 +91,9 @@ from .._generated.types.z_codec_payload import ( ZCodecPayload as ZCodecPayload, ) +from .._generated.types.a_codec_wrapper import ( + ACodecWrapper as ACodecWrapper, +) # isort: on __all__ = [ @@ -102,11 +102,11 @@ ] # Project order is intentional for public re-export groups. __all__ += [ # noqa: RUF022, RUF100 - "ACodecWrapper", "Mood", "Point2D", "TagValue", "ZCodecPayload", + "ACodecWrapper", ] __all__ += [ # noqa: RUF022, RUF100 "GetSpecimenRow", diff --git a/tests/test_python_name_collisions.py b/tests/test_python_name_collisions.py deleted file mode 100644 index 8503264..0000000 --- a/tests/test_python_name_collisions.py +++ /dev/null @@ -1,692 +0,0 @@ -"""Real-pgn coverage for generated Python namespace collisions and mappings.""" - -from __future__ import annotations - -import ast -import importlib -import shutil -import subprocess -import sys -from collections.abc import Iterable, Mapping, Sequence -from pathlib import Path - -import pytest - -from tests._harness import SRC_DIR, run_pgn - -QueryMapping = tuple[str, str, str] -CustomTypeMapping = tuple[str, str, str, str] - - -def _project_yaml( - *, - query_mappings: Sequence[QueryMapping] = (), - custom_type_mappings: Sequence[CustomTypeMapping] = (), - on_unsupported: str | None = None, -) -> str: - lines = [ - "space: python-gen", - "name: python-name-collisions", - "version: 0.0.0", - "postgres: 18", - "artifacts:", - " python:", - " gen: ../src/package.dhall", - " config:", - " packageName: mapping-client", - " emitSync: true", - ] - - if on_unsupported is not None: - lines.append(f" onUnsupported: {on_unsupported}") - - if query_mappings: - lines.append(" queryNameMappings:") - for source, snake_case, pascal_case in query_mappings: - lines.extend( - [ - f" - source: {source}", - " target:", - f" snakeCase: {snake_case}", - f" pascalCase: {pascal_case}", - ] - ) - - if custom_type_mappings: - lines.append(" customTypeNameMappings:") - for schema, name, snake_case, pascal_case in custom_type_mappings: - lines.extend( - [ - " - source:", - f" schema: {schema}", - f" name: {name}", - " target:", - f" snakeCase: {snake_case}", - f" pascalCase: {pascal_case}", - ] - ) - - return "\n".join(lines) + "\n" - - -def _fresh_project( - tmp_path: Path, - *, - queries: Mapping[str, str], - migration: str = "SELECT 1;\n", - query_mappings: Sequence[QueryMapping] = (), - custom_type_mappings: Sequence[CustomTypeMapping] = (), - on_unsupported: str | None = None, -) -> Path: - root = tmp_path / "pygen" - _ = shutil.copytree(SRC_DIR, root / "src") - project = root / "project" - migrations = project / "migrations" - query_dir = project / "queries" - migrations.mkdir(parents=True) - query_dir.mkdir() - - _ = (migrations / "1.sql").write_text(migration) - for name, sql in queries.items(): - _ = (query_dir / name).write_text(sql) - _ = (project / "project1.pgn.yaml").write_text( - _project_yaml( - query_mappings=query_mappings, - custom_type_mappings=custom_type_mappings, - on_unsupported=on_unsupported, - ) - ) - return project - - -def _write_structural_scope_probe(project: Path, *, kind: str, duplicate_identity: bool) -> None: - root = project.parent - right_name = "leftName" if duplicate_identity else "rightName" - definitions = { - "composite": ( - "< Enum : List Model.EnumVariant | Composite : List Model.Member | Domain : Model.Value >" - ".Composite [ valueMember ]" - ), - "enum": ( - "< Enum : List Model.EnumVariant | Composite : List Model.Member | Domain : Model.Value >" - ".Enum [ readyVariant ]" - ), - } - definition = definitions[kind] - wrapper = f""" -let Model = ./Deps/Contract.dhall - -let Sdk = ./Deps/Sdk.dhall - -let OnUnsupported = ./Structures/OnUnsupported.dhall - -let PythonNameMapping = ./Structures/PythonNameMapping.dhall - -let Target = ./Interpreters/Project.dhall - -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 - }} - -let valueName - : Model.Name - = {{ inCamelCase = "value" - , inPascalCase = "Value" - , inKebabCase = "value" - , inTrainCase = "Value" - , inScreamingKebabCase = "VALUE" - , inSnakeCase = "value" - , inCamelSnakeCase = "Value" - , inScreamingSnakeCase = "VALUE" - }} - -let leftName - : Model.Name - = valueName // {{ inCamelCase = "leftSource", inPascalCase = "LeftSource", inSnakeCase = "left_source" }} - -let rightName - : Model.Name - = valueName // {{ inCamelCase = "rightSource", inPascalCase = "RightSource", inSnakeCase = "right_source" }} - -let valueMember - : Model.Member - = {{ isNullable = False - , name = valueName - , pgName = "value" - , value = - {{ arraySettings = None Model.ArraySettings - , scalar = Model.Scalar.Primitive Model.Primitive.Int8 - }} - }} - -let readyVariant - : Model.EnumVariant - = {{ name = valueName // {{ inScreamingSnakeCase = "READY" }} - , pgName = "ready" - }} - -let leftType - : Model.CustomType - = {{ definition = {definition} - , name = leftName - , pgName = "c" - , pgSchema = "a.b" - }} - -let rightType - : Model.CustomType - = {{ definition = {definition} - , name = {right_name} - , pgName = "b.c" - , pgSchema = "a" - }} - -let targetConfig = - {{ packageName = Some "mapping-client" - , emitSync = Some False - , onUnsupported = Some OnUnsupported.Mode.Fail - , queryNameMappings = None (List PythonNameMapping.Query) - , customTypeNameMappings = - Some - [ {{ source = {{ schema = "a.b", name = "c" }} - , target = {{ snakeCase = "schema_dot_c", pascalCase = "SchemaDotC" }} - }} - , {{ source = {{ schema = "a", name = "b.c" }} - , target = {{ snakeCase = "type_dot_c", pascalCase = "TypeDotC" }} - }} - ] - }} - -let run = - \\(_ : Config) -> - \\(input : Model.Project) -> - Target.run - targetConfig - ( input - // {{ customTypes = [ leftType, rightType ] - , queries = [] : List Model.Query - }} - ) - -in Sdk.Sigs.generator Config Config/default run -""" - _ = (root / "src" / "structural-scope-probe.dhall").write_text(wrapper) - project_file = project / "project1.pgn.yaml" - _ = project_file.write_text( - project_file.read_text().replace("../src/package.dhall", "../src/structural-scope-probe.dhall") - ) - - -def _analyse(pgn_bin: str, pgn_admin_url: str, project: Path) -> None: - result = run_pgn(pgn_bin, pgn_admin_url, project, "analyse") - assert result.returncode == 0, f"pgn analyse rejected the collision fixture:\n{result.stdout}\n{result.stderr}" - - -def _diagnostic(result: subprocess.CompletedProcess[str]) -> str: - return f"{result.stdout}\n{result.stderr}" - - -def _assert_no_python_files(project: Path) -> None: - artifact = project / "artifacts" / "python" - assert not artifact.exists() or not any(artifact.rglob("*.py")) - - -def _assert_generation_fails( - pgn_bin: str, - pgn_admin_url: str, - project: Path, - expected: Iterable[str], -) -> None: - result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") - diagnostic = _diagnostic(result) - assert result.returncode != 0, f"pgn generate unexpectedly succeeded:\n{diagnostic}" - for fragment in expected: - assert fragment in diagnostic, f"missing {fragment!r} in pgn diagnostic:\n{diagnostic}" - _assert_no_python_files(project) - - -def _package_dir(project: Path) -> Path: - package = project / "artifacts" / "python" / "src" / "mapping_client" - assert package.is_dir() - return package - - -def _clear_package_modules() -> None: - for name in list(sys.modules): - if name == "mapping_client" or name.startswith("mapping_client."): - del sys.modules[name] - - -def test_reserved_query_collision_fails_before_emission( - pgn_bin: str, - pgn_admin_url: str, - tmp_path: Path, -) -> None: - project = _fresh_project( - tmp_path, - queries={ - "sync.sql": "SELECT 1::int8 AS value\n", - "sync_query.sql": "SELECT 2::int8 AS value\n", - }, - ) - _analyse(pgn_bin, pgn_admin_url, project) - _assert_generation_fails( - pgn_bin, - pgn_admin_url, - project, - [ - "Generated Python name collision in generated statement modules", - 'query "sync"', - 'query "sync_query"', - 'both resolve to "sync_query"', - ], - ) - - -def test_json_value_custom_type_collision_fails_before_emission( - pgn_bin: str, - pgn_admin_url: str, - tmp_path: Path, -) -> None: - project = _fresh_project( - tmp_path, - migration="CREATE TYPE \"json_value\" AS ENUM ('ready');\n", - queries={"read_json_value.sql": "SELECT 'ready'::public.\"json_value\" AS value\n"}, - ) - _analyse(pgn_bin, pgn_admin_url, project) - _assert_generation_fails( - pgn_bin, - pgn_admin_url, - project, - [ - "Generated Python name collision in package facade", - "generated core symbol JsonValue", - 'custom type "public.json_value"', - 'both resolve to "JsonValue"', - ], - ) - - -def test_query_row_and_custom_type_collision_fails_before_emission( - pgn_bin: str, - pgn_admin_url: str, - tmp_path: Path, -) -> None: - project = _fresh_project( - tmp_path, - migration="CREATE TYPE foo_row AS ENUM ('ready');\n", - queries={"foo.sql": "SELECT 'ready'::foo_row AS value\n"}, - ) - _analyse(pgn_bin, pgn_admin_url, project) - _assert_generation_fails( - pgn_bin, - pgn_admin_url, - project, - [ - "Generated Python name collision in package facade", - 'Row class from query "foo"', - 'custom type "public.foo_row"', - 'both resolve to "FooRow"', - ], - ) - - -@pytest.mark.parametrize( - ("native_type", "native_value", "mapped_snake", "mapped_pascal", "on_unsupported"), - [ - ( - "uuid", - "'00000000-0000-0000-0000-000000000001'::uuid", - "custom_uuid", - "UUID", - None, - ), - ("numeric", "1.25::numeric", "custom_decimal", "Decimal", None), - ( - "uuid", - "'00000000-0000-0000-0000-000000000001'::uuid", - "custom_uuid", - "UUID", - "Skip", - ), - ], - ids=["uuid-fail", "decimal-fail", "uuid-skip"], -) -def test_mapped_custom_dependency_cannot_shadow_composite_import( - native_type: str, - native_value: str, - mapped_snake: str, - mapped_pascal: str, - on_unsupported: str | None, - pgn_bin: str, - pgn_admin_url: str, - tmp_path: Path, -) -> None: - project = _fresh_project( - tmp_path, - migration=( - "CREATE TYPE import_dependency AS ENUM ('ready');\n" - f"CREATE TYPE import_holder AS (native_value {native_type}, dependency import_dependency);\n" - ), - queries={ - "read_import_holder.sql": ( - f"SELECT ROW({native_value}, 'ready'::import_dependency)::import_holder AS value\n" - ) - }, - custom_type_mappings=[("public", "import_dependency", mapped_snake, mapped_pascal)], - on_unsupported=on_unsupported, - ) - _analyse(pgn_bin, pgn_admin_url, project) - _assert_generation_fails( - pgn_bin, - pgn_admin_url, - project, - [ - 'Generated Python name collision in custom type module for schema "public", type "import_holder"', - f"{mapped_pascal} primitive import", - "custom dependency import", - f'both resolve to "{mapped_pascal}"', - ], - ) - - -@pytest.mark.parametrize("kind", ["composite", "enum"]) -def test_custom_local_namespaces_keep_schema_and_name_structured( - kind: str, - pgn_bin: str, - pgn_admin_url: str, - tmp_path: Path, -) -> None: - project = _fresh_project(tmp_path, queries={"seed.sql": "SELECT 1::int8 AS value\n"}) - _write_structural_scope_probe(project, kind=kind, duplicate_identity=False) - _analyse(pgn_bin, pgn_admin_url, project) - - result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") - diagnostic = _diagnostic(result) - assert result.returncode == 0, f"structured local namespace probe failed:\n{diagnostic}" - - package = _package_dir(project) - left_source = (package / "_generated" / "types" / "schema_dot_c.py").read_text() - right_source = (package / "_generated" / "types" / "type_dot_c.py").read_text() - expected = ( - ("class SchemaDotC:", "class TypeDotC:", "value: int") - if kind == "composite" - else ("class SchemaDotC(StrEnum):", "class TypeDotC(StrEnum):", 'READY = "ready"') - ) - assert expected[0] in left_source - assert expected[1] in right_source - assert expected[2] in left_source and expected[2] in right_source - for source in package.rglob("*.py"): - _ = ast.parse(source.read_text(), filename=str(source)) - - -def test_duplicate_unqualified_custom_contract_identity_fails_loudly( - pgn_bin: str, - pgn_admin_url: str, - tmp_path: Path, -) -> None: - project = _fresh_project(tmp_path, queries={"seed.sql": "SELECT 1::int8 AS value\n"}) - _write_structural_scope_probe(project, kind="composite", duplicate_identity=True) - _analyse(pgn_bin, pgn_admin_url, project) - _assert_generation_fails( - pgn_bin, - pgn_admin_url, - project, - [ - 'Ambiguous unqualified custom type identity "left_source"', - 'schema "a.b", type "c"', - 'schema "a", type "b.c"', - "upstream contract must preserve a distinct schema-qualified custom identifier", - "Python mappings cannot recover it", - ], - ) - - -def test_typed_mappings_propagate_through_generated_package( - pgn_bin: str, - pgn_admin_url: str, - tmp_path: Path, -) -> None: - project = _fresh_project( - tmp_path, - migration=( - "CREATE TYPE \"json_value\" AS ENUM ('ready');\n" - "CREATE TYPE mapped_inner AS (value int8);\n" - "CREATE TYPE mapped_outer AS (child mapped_inner);\n" - ), - queries={ - "sync.sql": "SELECT 1::int8 AS value\n", - "sync_query.sql": "SELECT 2::int8 AS value\n", - "read_json_value.sql": "SELECT 'ready'::public.\"json_value\" AS value\n", - "read_mapped_outer.sql": ("SELECT ROW(ROW(7::int8)::mapped_inner)::mapped_outer AS value\n"), - }, - query_mappings=[("sync_query", "api_v2", "ApiV2")], - custom_type_mappings=[ - ("public", "json_value", "pg_json_value", "PgJsonValue"), - ("public", "mapped_inner", "mapped_inner_v2", "MappedInnerV2"), - ], - ) - _analyse(pgn_bin, pgn_admin_url, project) - result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") - diagnostic = _diagnostic(result) - assert result.returncode == 0, f"mapped pgn generate failed:\n{diagnostic}" - - package = _package_dir(project) - statements = package / "_generated" / "statements" - assert {path.name for path in statements.glob("*.py")} == { - "__init__.py", - "api_v2.py", - "read_json_value.py", - "read_mapped_outer.py", - "sync_query.py", - } - api_source = (statements / "api_v2.py").read_text() - assert "class ApiV2Row:" in api_source - assert "async def api_v2(" in api_source - assert "def api_v2_sync(" in api_source - - type_source = (package / "_generated" / "types" / "pg_json_value.py").read_text() - assert "class PgJsonValue(StrEnum):" in type_source - inner_source = (package / "_generated" / "types" / "mapped_inner_v2.py").read_text() - assert "class MappedInnerV2:" in inner_source - outer_source = (package / "_generated" / "types" / "mapped_outer.py").read_text() - assert "from .mapped_inner_v2 import MappedInnerV2" in outer_source - assert "child: MappedInnerV2" in outer_source - json_statement = (statements / "read_json_value.py").read_text() - assert "value: _db_types.PgJsonValue" in json_statement - register_source = (package / "_generated" / "_register.py").read_text() - assert "_db_types.PgJsonValue" in register_source - assert "_db_types.MappedInnerV2" in register_source - assert register_source.index('"public.mapped_inner"') < register_source.index('"public.mapped_outer"') - assert "public.json_value" in register_source - - facade_source = (package / "__init__.py").read_text() - assert facade_source.index("statements.sync_query") < facade_source.index("statements.api_v2") - assert facade_source.index("types.pg_json_value") < facade_source.index("types.mapped_inner_v2") - assert facade_source.index('"PgJsonValue"') < facade_source.index('"MappedInnerV2"') - types_init_source = (package / "_generated" / "types" / "__init__.py").read_text() - assert types_init_source.index(".pg_json_value import") < types_init_source.index(".mapped_inner_v2 import") - - for source in package.rglob("*.py"): - _ = ast.parse(source.read_text(), filename=str(source)) - - ruff = subprocess.run( - [ - "ruff", - "check", - "--config", - str(SRC_DIR.parent / "pyproject.toml"), - str(package), - ], - capture_output=True, - text=True, - ) - assert ruff.returncode == 0, f"mapped generated package failed Ruff:\n{ruff.stdout}\n{ruff.stderr}" - - generated_src = package.parent - sys.path.insert(0, str(generated_src)) - _clear_package_modules() - try: - client = importlib.import_module("mapping_client") - sync_client = importlib.import_module("mapping_client.sync") - assert client.api_v2.__name__ == "api_v2" - assert sync_client.api_v2.__name__ == "api_v2_sync" - assert client.ApiV2Row is sync_client.ApiV2Row - assert client.PgJsonValue is sync_client.PgJsonValue - assert client.MappedInnerV2 is sync_client.MappedInnerV2 - assert client.MappedOuter is sync_client.MappedOuter - assert client.JsonValue is not client.PgJsonValue - finally: - _clear_package_modules() - sys.path.remove(str(generated_src)) - - -@pytest.mark.parametrize( - ("queries", "mappings", "expected"), - [ - ( - {"alpha.sql": "SELECT 1::int8 AS value\n"}, - [("alpha", "api_v2", "ApiV2"), ("alpha", "other_api", "OtherApi")], - ['Duplicate query name mapping source "alpha"'], - ), - ( - {"alpha.sql": "SELECT 1::int8 AS value\n"}, - [("missing", "api_v2", "ApiV2")], - ['Unknown query name mapping source "missing"'], - ), - ( - {"alpha.sql": "SELECT 1::int8 AS value\n"}, - [("alpha", "2api", "ApiV2")], - ["Mapped query snakeCase must start with a lowercase ASCII letter"], - ), - ( - {"alpha.sql": "SELECT 1::int8 AS value\n"}, - [("alpha", "api_v2", "2Api")], - ["Mapped query pascalCase must start with an uppercase ASCII letter"], - ), - ( - {"alpha.sql": "SELECT 1::int8 AS value\n"}, - [("alpha", "api_v2", "apiV2")], - ["Mapped query pascalCase must start with an uppercase ASCII letter"], - ), - ( - {"alpha.sql": "SELECT 1::int8 AS value\n"}, - [("alpha", "__init__", "Init")], - ["Mapped query snakeCase must start with a lowercase ASCII letter"], - ), - ( - {"alpha.sql": "SELECT 1::int8 AS value\n"}, - [("alpha", "__all__", "All")], - ["Mapped query snakeCase must start with a lowercase ASCII letter"], - ), - ( - { - "alpha.sql": "SELECT 1::int8 AS value\n", - "beta.sql": "SELECT 2::int8 AS value\n", - }, - [("beta", "alpha", "BetaResult")], - [ - "Generated Python name collision in generated statement modules", - 'query "alpha"', - 'query "beta"', - 'both resolve to "alpha"', - ], - ), - ], - ids=[ - "duplicate-source", - "unknown-source", - "leading-digit-snake", - "leading-digit-pascal", - "lowercase-pascal", - "init-module", - "all-facade", - "mapped-final-collision", - ], -) -def test_invalid_query_mappings_fail_before_emission( - queries: Mapping[str, str], - mappings: Sequence[QueryMapping], - expected: Sequence[str], - pgn_bin: str, - pgn_admin_url: str, - tmp_path: Path, -) -> None: - project = _fresh_project(tmp_path, queries=queries, query_mappings=mappings) - _analyse(pgn_bin, pgn_admin_url, project) - _assert_generation_fails(pgn_bin, pgn_admin_url, project, expected) - - -@pytest.mark.parametrize( - ("mappings", "expected"), - [ - ( - [ - ("public", "json_value", "pg_json_value", "PgJsonValue"), - ("public", "json_value", "other_json_value", "OtherJsonValue"), - ], - ['Duplicate custom type name mapping source "public.json_value"'], - ), - ( - [("public", "missing", "pg_missing", "PgMissing")], - ['Unknown custom type name mapping source "public.missing"'], - ), - ( - [("public", "json_value", "pg_json_value", "NoRowError")], - [ - "Generated Python name collision in package facade", - "generated core symbol NoRowError", - 'custom type "public.json_value"', - 'both resolve to "NoRowError"', - ], - ), - ( - [("public", "json_value", "pg_json_value", "JsonValue")], - [ - "Generated Python name collision in package facade", - "generated core symbol JsonValue", - 'custom type "public.json_value"', - 'both resolve to "JsonValue"', - ], - ), - ( - [("public", "json_value", "pg_json_value", "pgJsonValue")], - ["Mapped custom type pascalCase must start with an uppercase ASCII letter"], - ), - ( - [("public", "json_value", "__path__", "PgJsonValue")], - ["Mapped custom type snakeCase must start with a lowercase ASCII letter"], - ), - ], - ids=[ - "duplicate-source", - "unknown-source", - "mapped-final-collision", - "mapped-json-value", - "lowercase-pascal", - "path-module", - ], -) -def test_invalid_custom_type_mappings_fail_before_emission( - mappings: Sequence[CustomTypeMapping], - expected: Sequence[str], - pgn_bin: str, - pgn_admin_url: str, - tmp_path: Path, -) -> None: - project = _fresh_project( - tmp_path, - migration="CREATE TYPE \"json_value\" AS ENUM ('ready');\n", - queries={"read_json_value.sql": "SELECT 'ready'::public.\"json_value\" AS value\n"}, - custom_type_mappings=mappings, - ) - _analyse(pgn_bin, pgn_admin_url, project) - _assert_generation_fails(pgn_bin, pgn_admin_url, project, expected) diff --git a/tests/test_unsupported_types.py b/tests/test_unsupported_types.py index e9fd292..b88cfb4 100644 --- a/tests/test_unsupported_types.py +++ b/tests/test_unsupported_types.py @@ -122,11 +122,6 @@ def _write_contract_probe( dimensionality: int, nested: bool, ) -> None: - array_settings = ( - "None Model.ArraySettings" - if dimensionality == 0 - else f"Some {{ dimensionality = {dimensionality}, elementIsNullable = False }}" - ) lookup = { "absent": "CustomKind.TypeKind.Absent", "enum": ('CustomKind.TypeKind.Enum { className = "ProbeValue", moduleName = "probe_value", order = 0 }'), @@ -134,10 +129,14 @@ def _write_contract_probe( 'CustomKind.TypeKind.Composite { className = "ProbeValue", moduleName = "probe_value", order = 0 }' ), }[lookup_kind] - interpreter_config = ( - "{ customTypeNameMappings = [] : List PythonNameMapping.CustomType }" if interpreter == "CustomType" else "{=}" - ) target_input = "customType" if nested else "member" + # CustomKind.Lookup is now a `List CustomKind.TypeKind` indexed by + # CustomTypeRef.index; these probes reference exactly one custom type + # ("ProbeValue") at index 0, so a one-element list suffices. CustomType.run + # also gained an explicit self-index parameter (0 here), threaded only for + # the CustomType interpreter (Member/ParamsMember resolve by ref.index and + # never needed a self-index). + index_argument = " 0" if interpreter == "CustomType" else "" custom_type = ( """ let customType @@ -166,8 +165,6 @@ def _write_contract_probe( let CustomKind = ./Structures/CustomKind.dhall -let PythonNameMapping = ./Structures/PythonNameMapping.dhall - let Target = ./Interpreters/{interpreter}.dhall let Config = @@ -182,8 +179,7 @@ def _write_contract_probe( , onUnsupported = None OnUnsupported.Mode }} -let interpreterConfig = - {interpreter_config} +let interpreterConfig = {{=}} let run = \\(_ : Config) -> @@ -196,21 +192,28 @@ def _write_contract_probe( , name , pgName = "probe_value" , value = - {{ arraySettings = {array_settings} - , scalar = Model.Scalar.Custom name + {{ dimensionality = {dimensionality} + , elementIsNullable = False + , scalar = + Model.Scalar.Custom + {{ name + , pgSchema = "public" + , pgName = "probe_value" + , index = 0 + }} }} }} {custom_type} let lookup : CustomKind.Lookup - = \\(_ : Model.Name) -> {lookup} + = [ {lookup} ] in Lude.Compiled.map Target.Output Lude.Files.Type (\\(_ : Target.Output) -> [] : Lude.Files.Type) - (Target.run interpreterConfig lookup {target_input}) + (Target.run interpreterConfig lookup{index_argument} {target_input}) in Sdk.Sigs.generator Config Config/default run """ @@ -382,7 +385,7 @@ def test_custom_shape_contracts_succeed( assert result.returncode == 0, f"expected {case_id} to succeed:\n{_combined_output(result)}" -def test_skip_preserves_mapped_nested_registration_chain( +def test_skip_preserves_nested_registration_chain( pgn_bin: str, pgn_admin_url: str, tmp_path: Path, @@ -402,22 +405,11 @@ def test_skip_preserves_mapped_nested_registration_chain( "SELECT ROW(ROW('ready'::z_survivor_status)::m_survivor_leaf)::n_survivor_outer AS value\n" ) _ = (project / "queries" / "read_bad.sql").write_text("SELECT ROW(1::money)::a_bad AS value\n") - _write_single_artifact(project, "../../src/package.dhall", "skip-mapping-chain", "Skip") - config = project / "project1.pgn.yaml" - _ = config.write_text( - config.read_text() - + " customTypeNameMappings:\n" - + " - source:\n" - + " schema: public\n" - + " name: z_survivor_status\n" - + " target:\n" - + " snakeCase: mapped_status\n" - + " pascalCase: MappedStatus\n" - ) + _write_single_artifact(project, "../../src/package.dhall", "skip-nested-chain", "Skip") result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") diagnostic = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", _combined_output(result)) - assert result.returncode == 0, f"mapped Skip generation failed:\n{diagnostic}" + assert result.returncode == 0, f"Skip generation failed:\n{diagnostic}" warnings = re.findall(r"Warning: ([^\n]+)\nStage: ([^\n]+)", diagnostic) assert warnings == [ ("Unsupported type", "Generating > python > Compiling > money > amount > a_bad"), @@ -427,13 +419,13 @@ def test_skip_preserves_mapped_nested_registration_chain( ), ] - package = project / "artifacts" / "python" / "src" / "skip_mapping_chain" + package = project / "artifacts" / "python" / "src" / "skip_nested_chain" generated = package / "_generated" types = generated / "types" assert {path.name for path in types.glob("*.py")} == { "__init__.py", "m_survivor_leaf.py", - "mapped_status.py", + "z_survivor_status.py", "n_survivor_outer.py", } @@ -445,9 +437,9 @@ def test_skip_preserves_mapped_nested_registration_chain( leaf_source = (types / "m_survivor_leaf.py").read_text() outer_source = (types / "n_survivor_outer.py").read_text() assert [line for line in leaf_source.splitlines() if line.startswith("from .")] == [ - "from .mapped_status import MappedStatus" + "from .z_survivor_status import ZSurvivorStatus" ] - assert "status: MappedStatus" in leaf_source + assert "status: ZSurvivorStatus" in leaf_source assert [line for line in outer_source.splitlines() if line.startswith("from .")] == [ "from .m_survivor_leaf import MSurvivorLeaf" ] @@ -458,7 +450,7 @@ def test_skip_preserves_mapped_nested_registration_chain( line for line in register_source.splitlines() if line.startswith("_") and "_pg_name = " in line ] assert pg_name_constants == [ - '_mapped_status_pg_name = "public.z_survivor_status"', + '_z_survivor_status_pg_name = "public.z_survivor_status"', '_m_survivor_leaf_pg_name = "public.m_survivor_leaf"', '_n_survivor_outer_pg_name = "public.n_survivor_outer"', ] From 07634be381a543a078cad6db84906c688ea990f1 Mon Sep 17 00:00:00 2001 From: Viacheslav Shvets Date: Thu, 16 Jul 2026 13:33:52 +0300 Subject: [PATCH 40/41] chore(deps): refresh release workflow actions --- .github/workflows/release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b9362c..a8b4799 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,7 +84,7 @@ jobs: - name: Resolve Dhall id: resolve - uses: nikita-volkov/dhall-resolve.github-action@7caaf1fdb40ac864bc02e37575f577ee084713a8 # v3 + uses: nikita-volkov/dhall-resolve.github-action@b7a346816b48a0f58282855a56e19b2a2c77f35d # v3 with: file: src/package.dhall minify: true @@ -257,7 +257,7 @@ jobs: cmp "$bundled" release-asset/resolved.dhall - name: Upload the built distributions as workflow artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pgenie-python-gen-dist path: wheel/dist/* @@ -279,7 +279,7 @@ jobs: steps: - name: Download the built distributions - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: pgenie-python-gen-dist path: dist From bc459ba4adaf02dcb5904afdebb495e664151d7a Mon Sep 17 00:00:00 2001 From: Viacheslav Shvets Date: Thu, 16 Jul 2026 13:57:02 +0300 Subject: [PATCH 41/41] chore: prepare default branch rename to main --- .github/workflows/ci.yml | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1d03de..cc7e7b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [master] + branches: [main] pull_request: workflow_call: inputs: diff --git a/README.md b/README.md index 258ac26..0f5b25d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Point an artifact at the working-tree entry point and choose a package name: ```yaml artifacts: python: - gen: https://raw.githubusercontent.com/slavashvets/python.gen/master/src/package.dhall + gen: https://raw.githubusercontent.com/slavashvets/python.gen/main/src/package.dhall config: packageName: my-db-client emitSync: true @@ -28,7 +28,7 @@ database: pgn --database-url "$DATABASE_URL" generate ``` -The raw `master` URL is valid but moving. For reproducible use, pin an actual +The raw `main` URL is valid but moving. For reproducible use, pin an actual published `resolved.dhall` release asset or vendor the artifact delivered by the release wheel. Do not guess a release tag that has not been published. A relative path such as `../python.gen/src/package.dhall` is appropriate while