Add support for addLedgerEntries - #45
Conversation
snoble
left a comment
There was a problem hiding this comment.
Review by Claude, running in Steven's session — these are Claude's words and judgements, not Steven's.
Recovering the per-entry-type shapes from the single-entry addLedgerEntry operations is a genuinely good idea: the information really is sitting in the queries directory, and nothing else can close the parameters: JSON gap for a batch. The EntrySpec → render split is clean, the reasoning is documented where it's non-obvious, and reading the base class's attribute names out of its own AST instead of hand-listing them is the right instinct.
Three things I'd want changed before this ships, then nits.
1. Both README batch examples are rejected by the API today
addLedgerEntries currently enforces a homogeneous batch: one entry type, one type version, one ledger, unique iks (assertHomogeneousBatch in the API). The typed-payload example mixes two entry types:
entries=[AuthCaptureV1(...), PlatformFundsAccountV1(...)]That comes back as invalid_input_provided: "entries[1] uses entry type 'platform_funds_account' V1; all entries in a batch must use 'auth_capture' V1." The CHANGELOG line "mixed with raw AddLedgerEntryInput values" is fine — mixing forms works, mixing types doesn't — but the example should show one type per batch, and the constraint is worth stating outright since the typed models make violating it feel natural.
Second, both README examples omit headers={"X-Fragment-Experimental": "true"}, which the endpoint requires (tests/test_add_ledger_entries.py passes it, so the tests pass and a reader following the README doesn't). Keeping the header out of the client is the right call; it just has to be in the docs.
Heads-up while you're here: a batch-wide cap of 30 Ledger Lines between the entries is in flight on the API side (in addition to the existing 30-per-entry limit). Worth a sentence in the README once it lands, since a 16-member batch of 2-line entries will start failing.
2. assign_class_names mutates its input and isn't idempotent
fragment/codegen/typed_entries.py — base = f"{spec.class_name}V{version}" reads class_name, then assigns back into it. Call it twice on the same specs and you get OrderPlacedV1V1. Today that's latent because render_module is called once, but _init_additions() in the plugin reads spec.class_name after render_module mutated it, so the __init__ re-exports are correct only because of hook ordering. Two small things fix it:
- make it pure — return new specs (or a
dict[(type, version), str]name map) instead of assigning tospec.class_name; EntrySpec.class_namecurrently means "unversioned pascal name" before the call and "final model name" after. Two fields (base_name,class_name) or a pure function make that meaning stable.
3. V1 in the name, no typeVersion on the wire
OrderPlacedV1 posts no typeVersion at all when the operation pins none, while OrderPlacedV2 posts 2. The docstring explains it, but a caller reading V1 will reasonably assume 1 is being sent. The API treats a missing typeVersion as 1 (entry.typeVersion ?? 1 in assertHomogeneousBatch), so emitting TYPE_VERSION: ClassVar[Optional[int]] = 1 for the unpinned case would be wire-equivalent and make the name honest. If you'd rather keep "unspecified" distinct from "explicitly 1" — a defensible position — then the class name is the thing to soften, because right now the two disagree.
Pythonic
ast.Name(id="Sequence[Union[AddLedgerEntryInput, TypedLedgerEntry]]")(plugins/generate_typed_entries.py) is a whole expression smuggled into an identifier. It survivesast.unparsebut it isn't a valid AST, so anything that validates or visits the tree (orcompile()) breaks on it.ast.parse("Sequence[Union[...]]", mode="eval").bodygives you the real node for free.module_path.write_text(...)— passencoding="utf-8". On Windows this silently picks up cp1252 and mangles any non-ASCII parameter name or docstring. Considerparents=Trueon the directory too, so the hook doesn't depend on ariadne having created the package dir first.- The package targets
^3.10, so the hand-written codegen modules can usedict[str, str],list[EntrySpec],str | Nonerather thanDict/List/Optional. (The generated module should keep matching ariadne's style — that inconsistency is forced.) - Missing annotations:
_unwrap_type(type_node) -> tuplewants-> tuple[str, bool]and a typed parameter;_get_object_fieldwants-> ValueNode | None;seen: set = set()wantsset[str]. Alsofor f in node.fields→for field_node in ..., sincefieldis already imported fromdataclassesin this module. @lru_cache(maxsize=1)on a zero-argument function is doing the job of a module constant;functools.cachereads better if you want the laziness.
Tests
The end-to-end coverage is the right shape — exercising the snapshotted client is exactly what I'd want, and using a raw input for the unknown-entry-type case is a nice touch. What's missing is anything that runs without live credentials, and it happens to be the trickiest logic in the PR:
PARAMETER_FIELDSkeeping the Schema name when the Python field is escaped (type_,class_,json_) — the README promises this and nothing tests it;- camelCase parameter → snake_case field, with the wire key unchanged;
to_input()/serialize()shape, includingtypeVersionomitted when unpinned;assign_class_nameson two versions of one type, and on the pascal-case collision path (auth_holdvsauthHold).
Those are all pure functions over an EntrySpec or a model instance, so they're fast unit tests, and they'd have caught the idempotency bug above. Also test_add_ledger_entries_rejects_unknown_entry_type stores a second Schema and Ledger it never uses — a session-scoped fixture would halve the setup.
Smaller
TypedLedgerEntryexposesposted,tags,groups,conditionsbut notdescription, whichLedgerEntryInputaccepts. Deliberate?@model_serializermeansmodel_dump()on a typed entry returns theAddLedgerEntryInputshape, soModel.model_validate(instance.model_dump())won't round-trip. Fine for sending, surprising for anyone who logs or caches these — maybe worth a line in the docstring._extract_parametersfalls back to"Any"when a variable's annotation can't be found. Silent is the wrong default for codegen; a warning would tell the user their parameter lost its type._widen_entries_argumentno-ops silently if theentriesargument isn't found (e.g. ariadne renames it), and the typed models then don't typecheck for users. Worth a warning there too.poetry.tomlwithin-project = trueis a local preference being committed for everyone — intentional?
|
Pushed Idempotency (#2). typeVersion (#3). Took your first option. An unpinned operation normalizes to 1 at extraction, so the name matches what goes on the wire, and the caveat is out of the docstring. One consequence: an unpinned operation and one pinning README (#1). I went a different way. The whole batch section came out, since the feature isn't rolled out. That means the homogeneous constraint isn't written down anywhere now, and neither is the header. Both need to land when this ships, along with a note about the batch-wide line cap once it does. The integration test posts a single entry type already. ast.Name. Now Encoding, directory. Both done. Builtin generics. Neither hand-written codegen module imports Annotations. Typing
description. I missed it. Added, and omitted from the payload when unset. Round-trip. Line added to the base class docstring, so it appears in every generated Silent fallbacks. Both warn now. Testing the poetry.toml. Not intentional. Deleted. Tests.
One thing worth passing on from writing them. My first version subclassed the committed Still open: the session-scoped fixture for the integration tests. Happy to do that next if you want it before this comes out of WIP. |
Covers the three cases Steven flagged as untested and credential-free: - PARAMETER_FIELDS keeping the Schema name when the Python field is escaped or snake_cased - camelCase parameters becoming snake_case fields with the wire key unchanged - to_input() / serialisation shape, including omission of unset fields The to_input tests execute BASE_CLASS_SOURCE rather than importing the committed fragment/sdk/typed_entries, so they test what codegen emits now instead of whenever it last ran. Subclassing the generated module first meant removing `description=self.description` from the renderer left all tests green. Claude-Session: https://claude.ai/code/session_01J1SSSkp8AgFGobLLaEAN6K
snoble
left a comment
There was a problem hiding this comment.
🚫 DO NOT MERGE — approving to unblock, but the five inline items below need resolving first.
Second pass by Claude, running in Steven's session — these are Claude's words and judgements, not Steven's.
The idempotency fix is real: resolve_class_names is pure, and executing BASE_CLASS_SOURCE in the fixture instead of importing the committed module is the right way to keep those tests pointed at what codegen emits now rather than at whenever it last ran. Annotations, warnings, encoding, ast.parse — all in.
Five things I'd want resolved before merge, inline below. Three are code, one is a confirmation, one is docs.
Separately, and not something to action here: the test and typecheck holes. Nothing ever executes render_module's output (the per-entry classes are only string-matched), the optional-parameter path has no coverage in any snapshot, and CI runs mypy -p fragment only — so the new [tool.mypy] mypy_path is dead config and the PR's central typing claim is verified by nothing automated. Steven and I are picking those up in a stacked PR.
| arg.annotation = ast.parse( | ||
| "Sequence[Union[AddLedgerEntryInput, TypedLedgerEntry]]", | ||
| mode="eval", | ||
| ).body |
There was a problem hiding this comment.
Blocking: this typechecks but breaks at runtime for any sequence that isn't a list.
ariadne's base client converts variables with an isinstance check on list, not Sequence:
def _convert_value(self, value):
if isinstance(value, BaseModel): return value.model_dump(by_alias=True, exclude_unset=True)
if isinstance(value, list): return [self._convert_value(item) for item in value]
return valueSo a tuple satisfies the widened annotation, skips conversion, and dies in json.dumps. Verified against the snapshotted client:
list -> {"entries": [{"entry": {...}, "ik": "a"}]}
tuple -> TypeError: Object of type OrderPlacedV1 is not JSON serializable
and mypy accepts add_ledger_entries(entries=(t,)) with no error. Before this PR the annotation was list[...], so a tuple was a type error and never reached the wire — the widening is what opens it.
The widening itself is right, and I confirmed it does what it claims: list[OrderPlacedV1] passes via covariance, mixed typed/raw lists pass, list[str] and a wrong parameter type are both rejected. The fix is to also rewrite the method body's variables assignment to {"entries": list(entries)} — this plugin is already doing AST surgery on that method.
There was a problem hiding this comment.
Fixed in 5af71d4. Reproduced it first against the snapshotted client, same result you got.
The plugin now also rewrites the method body, so the generated assignment reads:
variables: dict[str, object] = {"entries": list(entries)}Applied to fragment/sdk, fragment/sync_sdk and the snapshot. Tuple, generator, and a mixed tuple of typed plus raw all serialise after it.
The coercion lives next to the widening rather than somewhere else, since widening the annotation is what makes the failure reachable. There is a warning if that assignment ever stops matching, alongside the two already in this plugin.
test_generated_batch_method_coerces_entries_to_a_list parses the snapshot and asserts the dict literal is exactly {'entries': list(entries)}. Removing the coercion and regenerating makes it fail.
Vignesh and I did weigh dropping Sequence instead, since list[Union[...]] would reject the tuple at type-check time and need no rewrite. Measured what each accepts:
| call | list[Union[...]] |
Sequence[Union[...]] |
|---|---|---|
| inline literal | ok | ok |
pre-built list[OrderPlacedV1] |
rejected | ok |
| tuple | rejected | ok, with the coercion |
The pre-built list is the common shape, a comprehension over orders infers list[OrderPlacedV1], so we kept Sequence.
| ) | ||
| if processed in base_class_attribute_names(): | ||
| processed += "_" | ||
| return processed |
There was a problem hiding this comment.
Blocking: two Schema parameters that snake_case alike collide silently, and the wire payload is wrong.
resolve_class_names guards class-name collisions (auth_hold vs authHold), but nothing guards field names within a class. A Schema declaring both user_id and userId renders:
PARAMETER_FIELDS: ClassVar[Dict[str, str]] = {
"user_id": "user_id",
"userId": "user_id",
}
user_id: str
user_id: strPydantic accepts the duplicate declaration, the last one wins, and both wire keys get the same value. No warning anywhere. That's the same likelihood as the pascal-case collision you already handle, with a worse failure mode — wrong data rather than a missing model.
_safe_field_name can't see its siblings, so the disambiguation needs to happen where the parameter list is assembled (_extract_parameters), the way resolve_class_names does it for class names.
There was a problem hiding this comment.
Fixed in 0d14d2c. Reproduced exactly as described, one value under both keys:
{"user_id": "VALUE", "userId": "VALUE"}
You were right about where it belongs. _safe_field_name cannot see its siblings, so the check is now in _extract_parameters where the list is assembled. Later collisions take a numeric suffix, the same shape resolve_class_names uses one level up, and a warning names the parameter that moved:
WARNING:console:Parameters in operation PostThing map to the same Python field
'user_id'; 'userId' is generated as 'user_id_2' instead. The wire payload is unaffected.
Result:
PARAMETER_FIELDS = {"user_id": "user_id", "userId": "user_id_2"}{"user_id": "SNAKE", "userId": "CAMEL"}
Three tests: the two-way case asserting the exact pairs, a three-way case checking the counter keeps going, and one asserting the rendered PARAMETER_FIELDS keeps both Schema names. Reverting to the plain _safe_field_name call fails all three. The marketing-schema snapshot is unchanged, since none of its parameters collide.
| plugins=[ | ||
| "fragment.codegen.plugins.get_file_comment.GenerateFileComment", | ||
| "fragment.codegen.plugins.generate_client_method.RewriteUnsetTypeMethodArguments", | ||
| "fragment.codegen.plugins.generate_typed_entries.GenerateTypedLedgerEntries", |
There was a problem hiding this comment.
Blocking: this plugin's position in the list is load-bearing and undocumented.
collect_annotations harvests annotations off method_def after RewriteUnsetTypeMethodArguments has rewritten Union[Optional[X], UnsetType] into Optional[X]. That only holds because GenerateTypedLedgerEntries is listed below it. Move it up and typed models emit UnsetType in their annotations, into a module that doesn't import it — NameError when the generated SDK is imported.
Same class of bug as the intra-plugin hook ordering you fixed, one level up. A comment here plus a guard in collect_annotations (warn and skip, or fail, on an annotation mentioning UnsetType) closes it.
There was a problem hiding this comment.
Fixed in 78348c2. Moving the plugin above the rewriter reproduces it:
memo: Union[Optional[str], UnsetType] = Noneand the generated SDK then dies on import with NameError: name 'Union' is not defined.
helpers.py now says the order is load-bearing and why. collect_annotations raises when an annotation still mentions UnsetType, using ariadne's own UNSET_TYPE_NAME:
RuntimeError: Annotation 'Union[Optional[str], UnsetType]' for argument 'memo' still
mentions UnsetType. GenerateTypedLedgerEntries must be listed after
RewriteUnsetTypeMethodArguments in the codegen plugin list; see get_codegen_config
in fragment/codegen/helpers.py.
I took the fail option rather than warn-and-skip. The Any fallback degrades into a working SDK with one untyped field, so a warning suits it. This one writes a package nobody can import, so continuing would hand someone a NameError a long way from the cause.
Two tests, one for the raise and one for the normal path returning {"memo": "Optional[str]"}.
| type_version = DEFAULT_TYPE_VERSION | ||
| version_node = _get_object_field(entry_arg, "typeVersion") | ||
| if isinstance(version_node, IntValueNode): | ||
| type_version = int(version_node.value) |
There was a problem hiding this comment.
Blocking on a confirmation, not on the code.
Normalising unpinned to 1 and always putting it on the wire is the option I'd pick too — but it's only correct if the API resolves a missing typeVersion to 1 rather than to the latest version of the entry type.
Every CLI-generated query in the snapshot pins typeVersion explicitly, so the blast radius is hand-written queries only. But if the server default is "latest", this silently pins those callers to V1 forever, and it's a behaviour change from the previous revision of this PR (which sent nothing). Worth confirming API-side rather than taking my earlier review's word for it.
There was a problem hiding this comment.
Confirmed API-side: a missing typeVersion resolves to 1, never to the latest version. So normalising to 1 and putting it on the wire is equivalent to sending nothing, and the previous revision of this PR was equivalent too. Callers see no behaviour change, only a name that stops disagreeing with the payload.
Your blast-radius read matches the repo. All 9 CLI-generated operations in tests/template-schema/queries.graphql pin typeVersion explicitly, and every model in the snapshot carries one, so hand-written queries are the only place the normalisation does anything.
One consequence worth naming: because it happens at extraction, an unpinned operation and one pinning typeVersion: 1 now share an identity and collapse into a single model. That follows from the resolution rule, and it wasn't true before this revision.
| `addLedgerEntry` operations in the codegen input directory. Because a batch | ||
| mutation takes one list of one input type, GraphQL cannot type each entry's | ||
| `parameters` field individually; these models do. They can be passed to | ||
| `add_ledger_entries` directly, mixed with raw `AddLedgerEntryInput` values. |
There was a problem hiding this comment.
Blocking: this is now the only user-facing documentation of the feature, and it describes a call the API rejects.
Pulling the README section resolved the wrong-examples problem by deletion, but this line survived it. A reader will do exactly what it says and get invalid_input_provided:
addLedgerEntriesenforces a homogeneous batch — one entry type, one type version, one ledger. Mixing forms (typed + raw) works; mixing types doesn't. The sentence isn't wrong, it's incomplete in the direction that fails, and the typed models make violating it feel natural.- The endpoint requires
headers={"X-Fragment-Experimental": "true"}.tests/test_add_ledger_entries.pypasses it, so the tests pass and a reader following this doesn't.
Neither constraint is written down anywhere in the repo now. Either qualify the sentence here or land the docs before this ships.
There was a problem hiding this comment.
Leaving the CHANGELOG as it is. This isn't blocking, because the PR merges once the API is rolled out to all users, so nothing here reaches anyone before the constraints are true and documented alongside the rollout.
The two constraints you named are right and worth capturing then: the homogeneous batch, and the X-Fragment-Experimental: true header. The batch-wide line cap you mentioned should join them once it lands.
Widening the entries argument to Sequence let a tuple through the type
checker, but ariadne's base client only recurses into variables with
isinstance(value, list). A tuple skipped conversion and reached json.dumps
holding model objects:
list -> {"entries": [{"entry": {...}, "ik": "a"}]}
tuple -> TypeError: Object of type OrderPlacedV1 is not JSON serializable
The plugin now also rewrites the method body's variables assignment to
{"entries": list(entries)}, so tuples, generators and mixed sequences all
serialise. Warns if that assignment cannot be found, matching the other
two warnings in this plugin.
resolve_class_names guards class-name collisions, but nothing guarded field
names within a class. A Schema declaring both user_id and userId rendered
the field twice; pydantic kept the last one and both wire keys took the
same value, with no warning:
{"user_id": "VALUE", "userId": "VALUE"}
_safe_field_name cannot see its siblings, so the check belongs in
_extract_parameters where the parameter list is assembled. Later
collisions take a numeric suffix, the same shape resolve_class_names uses
one level up, and a warning names the parameter that moved.
PARAMETER_FIELDS still maps each Schema name to its own field, so the wire
payload is unchanged:
{"user_id": "SNAKE", "userId": "CAMEL"}
collect_annotations copies annotations off the generated client methods, so
it depends on RewriteUnsetTypeMethodArguments having already collapsed
Union[Optional[X], UnsetType] into Optional[X]. That only held because of
where GenerateTypedLedgerEntries sits in the plugin list, with nothing
saying so. Listed earlier it emitted:
memo: Union[Optional[str], UnsetType] = None
into a module that imports neither name, and the generated SDK failed at
import with NameError.
collect_annotations now raises and names the cause. Raising rather than
warning because the output is an unimportable package, not a model with a
weaker type. helpers.py documents why the order matters.
No description provided.