Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/updateSDKQueries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,15 @@ jobs:
# `make snapshots` regenerates tests/snapshots from the queries just
# copied. Without it the sync PR ships updated queries alongside stale
# snapshots, and the `snapshots` job in tests.yml fails on every sync.
# `refresh-snapshot-schema` repins tests/snapshots/schema.graphql to the
# current API and regenerates. Snapshots are pinned so that unrelated PRs
# do not fail when the API changes; this sync is where that drift is meant
# to surface, as a reviewable diff next to the queries it came with.
- name: Generate SDK
working-directory: ./fragment-python
run: |
make build
make snapshots
make refresh-snapshot-schema
make lint

- name: Create Pull Request
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
Releases prior to `1.0.0` were published before this changelog was added and
are not documented here.

## [Unreleased]

### Added

- `AddLedgerEntries` commits a batch of Ledger Entries in one atomic,
strongly-consistent transaction.
- Strongly-typed batch payloads. Codegen now emits a `typed_entries` module with
one model per Ledger Entry type, derived from the per-entry-type
`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.

Copy link
Copy Markdown
Contributor

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:

  • addLedgerEntries enforces 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.py passes 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.

Copy link
Copy Markdown
Contributor Author

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: true header. The batch-wide line cap you mentioned should join them once it lands.

Model names always carry the entry type version, defaulting to `V1`.

## [1.0.0]

### Changed
Expand Down
13 changes: 12 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
.PHONY: lint test snapshots check-snapshots
.PHONY: lint test snapshots check-snapshots refresh-snapshot-schema

# Each tests/snapshots/*/ holds a queries.graphql and the client generated from
# it, checked in. The pair is a regression guard: a change to codegen that alters
# generated output shows up as a reviewable diff instead of silently.
SNAPSHOT_DIRS := $(sort $(dir $(wildcard tests/snapshots/*/queries.graphql)))
SNAPSHOT_PACKAGE := sdk
# Pinned so a snapshot is a function of checked-in inputs alone. Generating
# against the live schema made every PR fail whenever the API changed. Kept
# outside the fixture directories, which codegen scans for operations.
SNAPSHOT_SCHEMA := tests/snapshots/schema.graphql

install:
poetry install --with dev
Expand Down Expand Up @@ -34,6 +38,7 @@ snapshots:
rm -rf "$$dir$(SNAPSHOT_PACKAGE)"; \
poetry run fragment-python-client-codegen \
--input-dir="$$dir" \
--schema-path=$(SNAPSHOT_SCHEMA) \
--target-package-name=$(SNAPSHOT_PACKAGE) \
--output-dir="$$dir" || exit 1; \
done
Expand All @@ -55,6 +60,12 @@ check-snapshots: snapshots
fi
@echo "Snapshots up to date."

# Repin the snapshot schema to the current API. Run deliberately; the diff shows
# what changed upstream.
refresh-snapshot-schema:
poetry run python -c "import httpx; from fragment.codegen.main import GRAPHQL_SCHEMA_API_URL as u; open('$(SNAPSHOT_SCHEMA)','w').write(httpx.get(u).text)"
$(MAKE) snapshots

build: install
poetry run fragment-python-client-codegen --input-dir=queries/ --target-package-name=sdk --output-dir fragment/
poetry run fragment-python-client-codegen --input-dir=queries/ --target-package-name=sync_sdk --output-dir fragment/ --sync
Expand Down
7 changes: 7 additions & 0 deletions fragment/codegen/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 78348c2. Moving the plugin above the rewriter reproduces it:

memo: Union[Optional[str], UnsetType] = None

and 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]"}.

],
),
},
Expand Down
79 changes: 53 additions & 26 deletions fragment/codegen/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import contextlib
import logging
import sys
import tempfile
from typing import Iterator

import click
import httpx
Expand All @@ -15,6 +17,31 @@
GRAPHQL_SCHEMA_API_URL = "https://api.us-west-2.fragment.dev/schema.graphql"


@contextlib.contextmanager
def resolved_schema_path(schema_path: str | None) -> Iterator[str]:
"""Yield a path to the schema to generate against.

A local path is used as-is, which keeps generation reproducible and offline.
Without one the current schema is downloaded to a temporary file, so output
depends on whatever the API looks like at that moment.
"""
if schema_path is not None:
console_log.info(f"Using the GraphQL schema at {schema_path}")
yield schema_path
return

console_log.info(f"Downloading the GraphQL schema from {GRAPHQL_SCHEMA_API_URL}")
try:
response = httpx.get(GRAPHQL_SCHEMA_API_URL)
except httpx.RequestError as error:
console_log.error(f"An error occurred while downloading the schema: {error}")
sys.exit(1)
with tempfile.NamedTemporaryFile(mode="w", suffix=".graphql") as schema_file:
schema_file.write(response.text)
schema_file.flush()
yield schema_file.name


@click.command()
@click.option(
"-i",
Expand All @@ -37,35 +64,35 @@
help="The output directory for the generated SDK. Defaults to CWD.",
required=False,
)
@click.option(
"-s",
"--schema-path",
default=None,
type=click.Path(exists=True, dir_okay=False, readable=True),
help=(
"Path to a local GraphQL schema. Defaults to downloading the current "
"schema. Pass a file to make generation reproducible and offline."
),
required=False,
)
@click.option(
"--sync",
help="Generate a synchronous client. Defaults to async.",
required=False,
is_flag=True,
)
def run(input_dir, target_package_name, sync, output_dir=None):
console_log.info(f"Downloading the GraphQL schema from {GRAPHQL_SCHEMA_API_URL}")
try:
r = httpx.get(GRAPHQL_SCHEMA_API_URL)
with tempfile.NamedTemporaryFile(
mode="w"
) as schema_file, tempfile.NamedTemporaryFile(
dir=input_dir, mode="w", suffix=".graphql"
) as standard_query_file:
# Write and flush the most recent schema
schema_file.write(r.text)
schema_file.flush()
# Write and flush the standard queries to the provided input
standard_query_file.write(get_standard_queries())
standard_query_file.flush()
config_dict = get_codegen_config(
use_sync_client=sync,
schema_path=schema_file.name,
queries_path=input_dir,
target_package_name=target_package_name,
target_package_path=output_dir,
)
generate_graphql_client(config_dict)
except httpx.RequestError as e:
console_log.error(f"An error occurred while downloading the schema: {e}")
sys.exit(1)
def run(input_dir, target_package_name, sync, output_dir=None, schema_path=None):
with resolved_schema_path(schema_path) as resolved, tempfile.NamedTemporaryFile(
dir=input_dir, mode="w", suffix=".graphql"
) as standard_query_file:
# Write and flush the standard queries to the provided input
standard_query_file.write(get_standard_queries())
standard_query_file.flush()
config_dict = get_codegen_config(
use_sync_client=sync,
schema_path=resolved,
queries_path=input_dir,
target_package_name=target_package_name,
target_package_path=output_dir,
)
generate_graphql_client(config_dict)
204 changes: 204 additions & 0 deletions fragment/codegen/plugins/generate_typed_entries.py
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 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 value

So 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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"
)
Loading
Loading