-
Notifications
You must be signed in to change notification settings - Fork 1
Add support for addLedgerEntries #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7f56b96
b97d7e6
15a2a08
f281dc9
cea982c
bef1a9b
4cc6201
5af71d4
0d14d2c
78348c2
27c1aaa
e629f72
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,9 +39,16 @@ def get_codegen_config( | |
| base_client_name=client_name, | ||
| base_client_file_path=get_project_path_relative_to_file(file_path), | ||
| async_client=False if use_sync_client else True, | ||
| # Order matters. GenerateTypedLedgerEntries copies annotations | ||
| # off the generated client methods, so it has to run after | ||
| # RewriteUnsetTypeMethodArguments has turned | ||
| # `Union[Optional[X], UnsetType]` into `Optional[X]`. Listed | ||
| # earlier, it emits `UnsetType` into a module that never imports | ||
| # it. collect_annotations raises if that ever happens. | ||
| plugins=[ | ||
| "fragment.codegen.plugins.get_file_comment.GenerateFileComment", | ||
| "fragment.codegen.plugins.generate_client_method.RewriteUnsetTypeMethodArguments", | ||
| "fragment.codegen.plugins.generate_typed_entries.GenerateTypedLedgerEntries", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking: this plugin's position in the list is load-bearing and undocumented.
Same class of bug as the intra-plugin hook ordering you fixed, one level up. A comment here plus a guard in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in memo: Union[Optional[str], UnsetType] = Noneand the generated SDK then dies on import with
I took the fail option rather than warn-and-skip. The Two tests, one for the raise and one for the normal path returning |
||
| ], | ||
| ), | ||
| }, | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,204 @@ | ||||||||||||||
| import ast | ||||||||||||||
| from pathlib import Path | ||||||||||||||
|
|
||||||||||||||
| from ariadne_codegen.plugins.base import Plugin | ||||||||||||||
| from graphql import OperationDefinitionNode | ||||||||||||||
|
|
||||||||||||||
| from fragment.codegen.typed_entries import ( | ||||||||||||||
| MODULE_NAME, | ||||||||||||||
| EntrySpec, | ||||||||||||||
| collect_annotations, | ||||||||||||||
| extract_entry_spec, | ||||||||||||||
| render_module, | ||||||||||||||
| resolve_class_names, | ||||||||||||||
| ) | ||||||||||||||
| from fragment.logger import console_log | ||||||||||||||
|
|
||||||||||||||
| ADD_LEDGER_ENTRIES_OPERATION = "addLedgerEntries" | ||||||||||||||
| ENTRIES_ARGUMENT = "entries" | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| class GenerateTypedLedgerEntries(Plugin): | ||||||||||||||
| """Emit strongly-typed `addLedgerEntries` payload models. | ||||||||||||||
|
|
||||||||||||||
| `addLedgerEntries` accepts a list of a single input type whose `parameters` | ||||||||||||||
| field is an opaque `JSON` scalar, so GraphQL alone cannot type an individual | ||||||||||||||
| entry in a batch. The per-entry-type `addLedgerEntry` operations already in | ||||||||||||||
| the input queries do carry that information, so this plugin recovers it and | ||||||||||||||
| renders one pydantic model per entry type into a `typed_entries` module. | ||||||||||||||
|
|
||||||||||||||
| Specs are collected in `generate_client_method`, which ariadne calls for | ||||||||||||||
| every operation. The module is written in `generate_init_code`, the last | ||||||||||||||
| hook to run, by which point every operation has been seen. | ||||||||||||||
| """ | ||||||||||||||
|
|
||||||||||||||
| def __init__(self, schema, config_dict: dict) -> None: | ||||||||||||||
| super().__init__(schema, config_dict) | ||||||||||||||
| settings = config_dict.get("tool", {}).get("ariadne-codegen", {}) | ||||||||||||||
| self.package_path = Path( | ||||||||||||||
| settings.get("target_package_path", Path.cwd()) | ||||||||||||||
| ) / settings.get("target_package_name", "graphql_client") | ||||||||||||||
| self.specs: list[EntrySpec] = [] | ||||||||||||||
| # Both names below are owned upstream in fragment-dev/graphql-queries, | ||||||||||||||
| # so track whether each was actually seen rather than assuming. | ||||||||||||||
| self.saw_batch_operation = False | ||||||||||||||
| self.widened_entries_argument = False | ||||||||||||||
|
|
||||||||||||||
| def generate_client_method( | ||||||||||||||
| self, | ||||||||||||||
| method_def: ast.FunctionDef | ast.AsyncFunctionDef, | ||||||||||||||
| operation_definition: OperationDefinitionNode, | ||||||||||||||
| ) -> ast.FunctionDef | ast.AsyncFunctionDef: | ||||||||||||||
| annotations: dict[str, str] = collect_annotations( | ||||||||||||||
| method_def, operation_definition | ||||||||||||||
| ) | ||||||||||||||
| spec = extract_entry_spec(operation_definition, annotations) | ||||||||||||||
| if spec is not None: | ||||||||||||||
| self.specs.append(spec) | ||||||||||||||
| if ( | ||||||||||||||
| operation_definition.name | ||||||||||||||
| and operation_definition.name.value == ADD_LEDGER_ENTRIES_OPERATION | ||||||||||||||
| ): | ||||||||||||||
| self.saw_batch_operation = True | ||||||||||||||
| self._widen_entries_argument(method_def) | ||||||||||||||
| return method_def | ||||||||||||||
|
|
||||||||||||||
| def _widen_entries_argument( | ||||||||||||||
| self, method_def: ast.FunctionDef | ast.AsyncFunctionDef | ||||||||||||||
| ) -> None: | ||||||||||||||
| """Let `add_ledger_entries` take typed entries as well as raw inputs. | ||||||||||||||
|
|
||||||||||||||
| ariadne annotates the argument `list[AddLedgerEntryInput]`, which typed | ||||||||||||||
| entries satisfy at runtime but not under a type checker. Widening to a | ||||||||||||||
| `Sequence` of either keeps raw inputs working while accepting typed | ||||||||||||||
| models directly -- `Sequence` because `list` is invariant, so | ||||||||||||||
| `list[AuthCapture]` would otherwise be rejected. | ||||||||||||||
|
|
||||||||||||||
| Widening the annotation alone would be a runtime trap. The base client | ||||||||||||||
| recurses into variables with `isinstance(value, list)`, so a tuple would | ||||||||||||||
| satisfy the annotation, skip conversion, and reach `json.dumps` as model | ||||||||||||||
| objects. `_coerce_entries_to_list` closes that. | ||||||||||||||
| """ | ||||||||||||||
| for arg in method_def.args.args: | ||||||||||||||
| if arg.arg != ENTRIES_ARGUMENT: | ||||||||||||||
| continue | ||||||||||||||
| # Parsed rather than hand-built: an ast.Name whose id is an entire | ||||||||||||||
| # expression unparses fine but is not a valid tree, so anything that | ||||||||||||||
| # visits or compiles it breaks. | ||||||||||||||
| arg.annotation = ast.parse( | ||||||||||||||
| "Sequence[Union[AddLedgerEntryInput, TypedLedgerEntry]]", | ||||||||||||||
| mode="eval", | ||||||||||||||
| ).body | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking: this typechecks but breaks at runtime for any sequence that isn't a ariadne's base client converts variables with an 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 and mypy accepts The widening itself is right, and I confirmed it does what it claims:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in The plugin now also rewrites the method body, so the generated assignment reads: variables: dict[str, object] = {"entries": list(entries)}Applied to 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.
Vignesh and I did weigh dropping
The pre-built list is the common shape, a comprehension over orders infers |
||||||||||||||
| self._coerce_entries_to_list(method_def) | ||||||||||||||
| self.widened_entries_argument = True | ||||||||||||||
| return | ||||||||||||||
|
|
||||||||||||||
| console_log.warning( | ||||||||||||||
| "Could not find an %r argument on the generated add_ledger_entries " | ||||||||||||||
| "method, so its signature was left as-is. Typed entry payloads will " | ||||||||||||||
| "still serialise correctly but will not typecheck when passed to it.", | ||||||||||||||
| ENTRIES_ARGUMENT, | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| def _coerce_entries_to_list( | ||||||||||||||
| self, method_def: ast.FunctionDef | ast.AsyncFunctionDef | ||||||||||||||
| ) -> None: | ||||||||||||||
| """Rewrite `{"entries": entries}` to `{"entries": list(entries)}`. | ||||||||||||||
|
|
||||||||||||||
| The base client only recurses into `list`, so any other sequence reaches | ||||||||||||||
| the JSON encoder holding model objects. Widening the annotation is what | ||||||||||||||
| makes that reachable, so the coercion belongs with it. | ||||||||||||||
| """ | ||||||||||||||
| for node in ast.walk(method_def): | ||||||||||||||
| if not isinstance(node, ast.Dict): | ||||||||||||||
| continue | ||||||||||||||
| for index, key in enumerate(node.keys): | ||||||||||||||
| if not ( | ||||||||||||||
| isinstance(key, ast.Constant) and key.value == ENTRIES_ARGUMENT | ||||||||||||||
| ): | ||||||||||||||
| continue | ||||||||||||||
| value = node.values[index] | ||||||||||||||
| if isinstance(value, ast.Name) and value.id == ENTRIES_ARGUMENT: | ||||||||||||||
| node.values[index] = ast.Call( | ||||||||||||||
| func=ast.Name(id="list", ctx=ast.Load()), | ||||||||||||||
| args=[value], | ||||||||||||||
| keywords=[], | ||||||||||||||
| ) | ||||||||||||||
| return | ||||||||||||||
|
|
||||||||||||||
| console_log.warning( | ||||||||||||||
| "Could not find the %r variables assignment in add_ledger_entries, so " | ||||||||||||||
| "it was left as-is. Passing a non-list sequence of entries will fail " | ||||||||||||||
| "to serialise.", | ||||||||||||||
| ENTRIES_ARGUMENT, | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| def generate_client_code(self, generated_code: str) -> str: | ||||||||||||||
| if not self.widened_entries_argument: | ||||||||||||||
| return generated_code | ||||||||||||||
| return self._insert_imports( | ||||||||||||||
| generated_code, | ||||||||||||||
| [ | ||||||||||||||
| "from typing import Sequence", | ||||||||||||||
| f"from .{MODULE_NAME} import TypedLedgerEntry", | ||||||||||||||
| ], | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| @staticmethod | ||||||||||||||
| def _insert_imports(code: str, imports: list[str]) -> str: | ||||||||||||||
| """Insert imports after the module's existing top-level import block.""" | ||||||||||||||
| lines = code.splitlines() | ||||||||||||||
| last_import_line = 0 | ||||||||||||||
| for node in ast.parse(code).body: | ||||||||||||||
| if isinstance(node, (ast.Import, ast.ImportFrom)): | ||||||||||||||
| last_import_line = max(last_import_line, node.end_lineno or 0) | ||||||||||||||
| lines[last_import_line:last_import_line] = imports | ||||||||||||||
| return "\n".join(lines) + "\n" | ||||||||||||||
|
|
||||||||||||||
| def generate_init_code(self, generated_code: str) -> str: | ||||||||||||||
| if self.specs and not self.saw_batch_operation: | ||||||||||||||
| # Typed payloads exist but no batch operation was generated to take | ||||||||||||||
| # them. Silence here would leave users with models nothing accepts. | ||||||||||||||
| console_log.warning( | ||||||||||||||
| "Generated %d typed entry payload(s) but found no %r operation, " | ||||||||||||||
| "so no batch method accepts them. Has the operation been renamed " | ||||||||||||||
| "upstream?", | ||||||||||||||
| len(self.specs), | ||||||||||||||
| ADD_LEDGER_ENTRIES_OPERATION, | ||||||||||||||
| ) | ||||||||||||||
| module_path = self.package_path / f"{MODULE_NAME}.py" | ||||||||||||||
| module_path.parent.mkdir(parents=True, exist_ok=True) | ||||||||||||||
| module_path.write_text( | ||||||||||||||
| self._add_comment(render_module(self.specs)), encoding="utf-8" | ||||||||||||||
| ) | ||||||||||||||
| return generated_code + self._init_additions() | ||||||||||||||
|
|
||||||||||||||
| def _add_comment(self, code: str) -> str: | ||||||||||||||
| # This module is written directly rather than through ariadne's module | ||||||||||||||
| # pipeline, so it does not pass through the GenerateFileComment hook. | ||||||||||||||
| queries_path = ( | ||||||||||||||
| self.config_dict.get("tool", {}) | ||||||||||||||
| .get("ariadne-codegen", {}) | ||||||||||||||
| .get("queries_path", "") | ||||||||||||||
| ) | ||||||||||||||
| comment = "# Generated by fragment (with the help of ariadne-codegen)" | ||||||||||||||
| if queries_path: | ||||||||||||||
| comment += f"\n# Source: {queries_path}" | ||||||||||||||
| return f"{comment}\n\n{code}" | ||||||||||||||
|
|
||||||||||||||
| def _init_additions(self) -> str: | ||||||||||||||
| """Re-export the typed models and extend `__all__`. | ||||||||||||||
|
|
||||||||||||||
| Resolves names itself; `resolve_class_names` being pure is what makes this | ||||||||||||||
| agree with the renderer without depending on hook order. | ||||||||||||||
| """ | ||||||||||||||
| names = ["TypedLedgerEntry", "to_entry_inputs"] + [ | ||||||||||||||
| class_name for class_name, _ in resolve_class_names(self.specs) | ||||||||||||||
| ] | ||||||||||||||
| names.sort() | ||||||||||||||
| imported = ",\n ".join(names) | ||||||||||||||
| exported = "\n".join(f' "{name}",' for name in names) | ||||||||||||||
| return ( | ||||||||||||||
| f"\nfrom .{MODULE_NAME} import (\n {imported},\n)\n" | ||||||||||||||
| f"\n__all__ += [\n{exported}\n]\n" | ||||||||||||||
| ) | ||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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: trueheader. The batch-wide line cap you mentioned should join them once it lands.