diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0b4bf8f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 + +updates: + - package-ecosystem: uv + directory: / + schedule: + interval: monthly + groups: + # One pull request for all of them: each pull request costs a CI run and a review. + all: + patterns: ["*"] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..283b72e --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "astral-sh.ty", + "charliermarsh.ruff", + "graphql.vscode-graphql-syntax" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index a70ddf9..53dc977 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,30 @@ { + "[python]": { + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit" + }, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnType": true + }, + "debugpy.debugJustMyCode": false, + "files.insertFinalNewline": true, + "files.readonlyInclude": { + "bookshop/*_graphql.py": true, + "bookshop/graphql/**": true, + "src/graphql_codegen/_cli/_introspection_graphql.py": true + }, + "files.trimFinalNewlines": true, + "files.trimTrailingWhitespace": true, + "python.languageServer": "None", "python.testing.pytestArgs": [ "." ], + "python.testing.pytestEnabled": true, "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true + "search.exclude": { + "bookshop/*_graphql.py": true, + "bookshop/graphql/**": true, + "src/graphql_codegen/_cli/_introspection_graphql.py": true + } } diff --git a/README.md b/README.md index 07a87ef..c2887aa 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen?style=flat-square)](https://github.com/tibdex/graphql-codegen/blob/main/pyproject.toml) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](https://github.com/tibdex/graphql-codegen/blob/main/LICENSE) -This library turns a GraphQL schema and your operations into a typed Python client with: +This library turns a GraphQL schema and your operations into a typed Python package with: -- no imposed transport; -- no validation overhead; -- no runtime dependencies[^typing-extensions]. +- no imposed transport: bytes in, bytes out; +- no runtime dependencies[^typing-extensions]: nothing to install, nothing to upgrade; +- no runtime validation: just dicts, statically checked. ## Installation @@ -23,6 +23,8 @@ Or, with `uv`: uv add --dev graphql-codegen ``` +The generator's only dependency is [graphql-core](https://github.com/graphql-python/graphql-core)[^typing-extensions]. + ## Quick start Every example of this README is a file of [`bookshop`](https://github.com/tibdex/graphql-codegen/tree/main/bookshop), which uses [`schema.graphqls`](https://github.com/tibdex/graphql-codegen/blob/main/bookshop/schema.graphqls): @@ -77,41 +79,38 @@ extensions: pythonCodegen: # Where imports start, relative to this file's directory. moduleRoot: .. - # Dotted name from the module root, so written to `bookshop/client`. - package: bookshop.client + # Dotted name from the module root, so written to `bookshop/graphql`. + package: bookshop.graphql ``` -Generate the client[^yaml]: +Generate the package[^yaml]: ```shell graphql-codegen bookshop/graphql.config.yml ``` -The generated client: +The generated package: - depends on nothing but the standard library[^typing-extensions]; -- holds only the `enum` and `input` types your operations reach, so that it grows with your documents rather than with the schema. - -Each GraphQL document also gets its own Python module holding its operations ([`get_order_graphql.py`](https://github.com/tibdex/graphql-codegen/blob/main/bookshop/get_order_graphql.py) here). +- holds only the `enum` and `input` types your operations reach, so that it grows with your documents rather than with the schema; +- puts each document's operations in a module of its own. Your type checker verifies every variable and every field of the response. -The code block below uses [`assert_never()`](https://docs.python.org/3/library/typing.html#typing.assert_never) and [`assert_type()`](https://docs.python.org/3/library/typing.html#typing.assert_type) so you can see what the type checker can prove. +The example below shows what the type checker can prove with [`assert_type()`](https://docs.python.org/3/library/typing.html#typing.assert_type) and [`assert_never()`](https://docs.python.org/3/library/typing.html#typing.assert_never): ```python from typing import Literal, assert_never, assert_type -from bookshop.client.runtime import Client -from bookshop.client.schema import OrderStatus from bookshop.get_order_graphql import GetOrder -from bookshop.transport import transport +from bookshop.graphql.schema import OrderStatus +from bookshop.transport import send -client = Client(transport) -data = client(GetOrder({"id": "o1"})) +data = send(GetOrder({"id": "o1"})) order = data["order"] -# `Query.order`'s type is nullable, so the type checker requires this test. +# `Query.order`'s type is nullable, so the type checker requires this check. if order is None: print("No such order.") else: @@ -129,16 +128,19 @@ else: print(f"Order {order['id']} is {order['status']}.") ``` -## Typing +> [!NOTE] +> The `send()` function above is the transport you write: see [Sans-IO](#sans-io). + +## Types -### Type checking, not runtime validation +### Type checking over runtime validation GraphQL is strongly typed, and the server: - [validates](https://spec.graphql.org/September2025/#sec-Validation) each operation against its schema before running it; - [responds](https://spec.graphql.org/September2025/#sec-Response) with exactly the operation's shape. -Client-side validation of responses thus mostly adds overhead[^breaking-changes]. +That leaves a client little to validate[^breaking-changes]. What a Python client still lacks is knowing, while you write `order["status"]`, that the key exists and holds an `OrderStatus`. That is a type checker's job, done once, before the code runs. @@ -147,7 +149,7 @@ Only [custom scalars](#custom-scalars) with a codec are converted, and only fiel > [!NOTE] > Most other Python codegen libraries rely on [Pydantic](https://docs.pydantic.dev) models for both type checking and runtime validation. -> The [TypeScript GraphQL Code Generator](https://the-guild.dev/graphql/codegen), which sets the standard for generating code from GraphQL, does otherwise: it could validate each response with [Zod](https://zod.dev) (Pydantic's TypeScript counterpart) but relies on type checking alone. +> The [TypeScript GraphQL Code Generator](https://the-guild.dev/graphql/codegen) (the reference for generating code from GraphQL) could validate each response with [Zod](https://zod.dev) but relies on type checking alone. > This library makes the same call. ### Operation types @@ -159,12 +161,13 @@ Wherever GraphQL lets a value be one of several things, its Python type is a uni - an [`enum`](https://spec.graphql.org/September2025/#sec-Enums) is one of several values, and becomes a [`Literal`](https://docs.python.org/3/library/typing.html#typing.Literal); - a [`@oneOf` `input`](https://spec.graphql.org/September2025/#sec-OneOf-Input-Objects) is one of several fields, and becomes a union of single-key types, so that a value with two keys fails type checking. -These unions are closed for the type checker, but nothing enforces them at runtime, since responses are [not validated](#type-checking-not-runtime-validation). +These unions are closed for the type checker, but nothing enforces them at runtime, since responses are [not validated](#type-checking-over-runtime-validation). -A server may add a `type` to a `union`, an implementation to an `interface`, a member to an `enum`, or a field to a [struct](#structs)'s `input` type without it being considered a breaking change. -A validating client would have raised an error before your code even ran, but this library lets the new value reach your code as sent, so you can choose what to do with it. +A server may add a `type` to a `union`, an implementation to an `interface`, a member to an `enum`, or a field to a [struct](#structs)'s `input` type at any time, since none of these is a breaking change. +A validating client would reject the new value before your code gets a chance to see it. +This library hands it to your code as sent, and leaves the choice to you. -One way is to accept it in a `case _:` arm: +One way is to accept the unknown value in a `case _:` arm: @@ -180,14 +183,14 @@ def status_label(status: OrderStatus, /) -> str: case "CANCELED": return "Canceled" case _: - # A member added after this client was generated. + # A member added after codegen. return status.replace("_", " ").capitalize() ``` The other way is to reject it with `case _ as never: assert_never(never)`, which: - raises an `AssertionError` at runtime; -- makes the type checker point at every missing `case` once the client is regenerated. +- makes your type checker point at every missing `case` next time you run the codegen. For instance, [`app.graphql`](https://github.com/tibdex/graphql-codegen/blob/main/bookshop/app.graphql) selects a publication's length, in pages when it is printed and in minutes when it is an audiobook: @@ -209,15 +212,15 @@ query GetPublication($id: ID!) { ``` `publication` is then a union of one [`TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict) per concrete type: `Audiobook`, and each `type` that `implements Printed`. -Testing for a key narrows it to the `TypedDict`s with that key, and also covers any new `type` that `implements Printed`. +Checking for a key narrows it to the `TypedDict`s with that key, and also covers any new `type` that `implements Printed`. `length()` then narrows it to one `TypedDict` with a `match` on `__typename`, rejecting any other with `assert_never()`: ```python -def length(publication_id: str, /, *, client: Client) -> str: - data = client(GetPublication({"id": publication_id})) +def length(publication_id: str, /) -> str: + data = send(GetPublication({"id": publication_id})) publication = data["publication"] if publication is None: @@ -237,27 +240,22 @@ def length(publication_id: str, /, *, client: Client) -> str: Nothing prevents a schema or a document from using names that clash with Python keywords (`class`, `from`), standard library names the generated code uses (`list`, `Sequence`), or the generator's own helpers. -The names the generator adds, such as `_builtins` or `_GetBookData_book`, avoid every other name in their module, so none can shadow another. +Since `TypedDict` keys are strings, a field named `from` can stay the key `"from"`. +Only the names the generator adds, such as `_builtins` or `_GetBookData_book`, are chosen to avoid every other name in their module, so none can shadow another. -## Client +## Runtime ### Sans-IO -The small [sans-IO](https://sans-io.readthedocs.io) runtime is copied into the generated package, and its [public API](https://github.com/tibdex/graphql-codegen/blob/main/bookshop/client/runtime/__init__.py) is limited to: +The small [sans-IO](https://sans-io.readthedocs.io) runtime is copied into the generated package, and its [public API](https://github.com/tibdex/graphql-codegen/blob/main/bookshop/graphql/runtime/__init__.py) is limited to: - + ```python -from .client import ( - AsyncClient as AsyncClient, - AsyncSubscriptionClient as AsyncSubscriptionClient, - Client as Client, - SubscriptionClient as SubscriptionClient, -) from .error import ( - ClientError as ClientError, Error as Error, ExecutionError as ExecutionError, + GraphqlError as GraphqlError, Location as Location, ProtocolError as ProtocolError, RequestError as RequestError, @@ -265,68 +263,107 @@ from .error import ( UnexpectedNullError as UnexpectedNullError, ) from .injection import OMITTED as OMITTED -from .operation import Operation as Operation, Request as Request +from .merge import merge as merge +from .operation import Operation as Operation +from .request import PreparedRequest as PreparedRequest, Request as Request ``` -A transport is a function from a request body to a response body, so any HTTP client (synchronous or asynchronous) works, and so does anything else that carries `bytes`. -`Client`, `AsyncClient`, `SubscriptionClient`, and `AsyncSubscriptionClient` take the same generated operations, so one generation serves both synchronous and asynchronous code. -Each client forwards every argument but the first (the request) to its transport, type checked against the transport's signature. +The generated package does no IO: + +- `Request.prepare()` returns a `PreparedRequest`; +- `PreparedRequest.__bytes__()` returns the request body to send; +- `PreparedRequest.parse()` returns the response body as typed data. -As an example, the bookshop's asynchronous transport uses [httpx2](https://pydantic.dev/docs/httpx2) and accepts a `timeout` (and nothing else): +You own what happens in between: any HTTP client (synchronous, asynchronous, or callback-based), or anything else that carries `bytes`. + +This library ships no transport on purpose. +IO is a slippery slope: an HTTP client to pick, then headers, authentication, timeouts, and retries, each answered differently by every codebase. +The only winning move is not to play. + +Writing a transport is short, and its few lines do not grow as you add operations. + +For example, the bookshop's asynchronous transport uses [httpx2](https://pydantic.dev/docs/httpx2) and accepts a `timeout` (and nothing else): ```python -http = httpx2.AsyncClient(base_url="https://bookshop.example") -HEADERS = {"Accept": mime_type.GRAPHQL_RESPONSE, "Content-Type": mime_type.JSON} +GRAPHQL_RESPONSE: Final = "application/graphql-response+json" +http: Final = httpx2.AsyncClient( + base_url="https://bookshop.example", + headers={ + "Accept": GRAPHQL_RESPONSE, + "Content-Type": "application/json", + }, +) -async def transport(body: bytes, /, *, timeout: float | None = None) -> bytes: - response = await http.post( - "/graphql", content=body, headers=HEADERS, timeout=timeout - ) +async def send[Data]( + request: Request[Literal["query", "mutation"], Data], + /, + *, + timeout: float | None = None, +) -> Data: + prepared = request.prepare(injectors=INJECTORS) + response = await http.post("/graphql", content=bytes(prepared), timeout=timeout) # GraphQL over HTTP sends a request error as a response with a 4xx status. - if not response.headers.get("Content-Type", "").startswith( - mime_type.GRAPHQL_RESPONSE - ): + if not response.headers.get("Content-Type", "").startswith(GRAPHQL_RESPONSE): response.raise_for_status() - return response.content + return prepared.parse(response.content) ``` -A call through a client over this transport can thus pass a `timeout`: - - +> [!NOTE] +> The `injectors=INJECTORS` above is only needed if you opt into [injectors (explained below)](#injectors). +> By default, `Request.prepare()` takes no argument. -```python -from bookshop.app_graphql import GetBook -from bookshop.async_transport import AsyncClient -from bookshop.scalar import ISBN +`Data` goes from the request to the result, so each call is typed by its operation: + -async def title(isbn: ISBN, /, *, client: AsyncClient) -> str: - data = await client(GetBook({"lookup": {"isbn": isbn}}), timeout=5.0) +```python +async def title(isbn: ISBN, /) -> str: + data = await send(GetBook({"lookup": {"isbn": isbn}}), timeout=5.0) + assert_type(data, GetBookData) return data["book"]["title"] ``` -[`async_transport.py`](https://github.com/tibdex/graphql-codegen/blob/main/bookshop/async_transport.py) also streams a subscription's Server-Sent Events, and [`transport.py`](https://github.com/tibdex/graphql-codegen/blob/main/bookshop/transport.py) does both with the standard library alone. +[`transport.py`](https://github.com/tibdex/graphql-codegen/blob/main/bookshop/transport.py) does the same with the standard library alone. ### Subscriptions -A subscription client works over any transport yielding one body per event, [Server-Sent Events](https://github.com/graphql/graphql-over-http/blob/main/rfcs/GraphQLOverSSE.md), [`graphql-transport-ws`](https://github.com/graphql/graphql-over-http/blob/main/rfcs/GraphQLOverWebSocket.md), or [multipart HTTP](https://github.com/graphql/graphql-over-http/blob/main/rfcs/IncrementalDelivery.md) alike: +A subscription's response is a stream of bodies, one per event, and `PreparedRequest.parse()` reads each as it comes. +The bookshop's `subscribe()` reads [Server-Sent Events](https://github.com/graphql/graphql-over-http/blob/main/rfcs/GraphQLOverSSE.md); [`graphql-transport-ws`](https://github.com/graphql/graphql-over-http/blob/main/rfcs/GraphQLOverWebSocket.md) or [multipart HTTP](https://github.com/graphql/graphql-over-http/blob/main/rfcs/IncrementalDelivery.md) would be read the same way: - + + +```python +async def subscribe[Data]( + request: Request[Literal["subscription"], Data], / +) -> AsyncGenerator[Data, None]: + prepared = request.prepare(injectors=INJECTORS) + + # Closing the generator leaves the block, closes the connection, and ends the subscription. + async with http.sse("/graphql", method="POST", content=bytes(prepared)) as events: + async for event in events: + if event.event == "complete": + return + + if event.event == "next": + yield prepared.parse(event.data.encode()) +``` + +Leaving the `aclosing()` block unsubscribes: + + ```python -def watch(order_id: str, /, *, client: SubscriptionClient) -> list[OrderStatus]: - """Follow the order until it is delivered, and return its statuses.""" +async def watch(order_id: str, /) -> list[OrderStatus]: statuses: list[OrderStatus] = [] - events = client(OnOrderStatusChanged({"orderId": order_id})) + events = subscribe(OnOrderStatusChanged({"orderId": order_id})) - # Closing the stream, however the loop ends, unsubscribes. - with closing(events): - for event in events: + async with aclosing(events): + async for event in events: statuses.append(event["orderStatusChanged"]["status"]) if statuses[-1] == "DELIVERED": @@ -337,45 +374,42 @@ def watch(order_id: str, /, *, client: SubscriptionClient) -> list[OrderStatus]: ### Merging -Sometimes you only know at runtime which operations to send together, or you want to group the same queries in many combinations, but writing each as its own operation in a `.graphql` file is impractical. +Sometimes you only know at runtime which operations go together, and writing every combination as its own operation in a `.graphql` file is impractical. Combining several operations into one request can also let the server answer faster, seeing the whole picture instead of independent requests asking for overlapping data. Some other codegen libraries let you build operations at runtime for this, giving up type safety. This library instead merges operations written ahead of time into one request, so that each result keeps its exact type. -A tuple of queries, or of mutations, runs in one call to the transport, each result typed by its own operation: +`merge()` takes several queries, or several mutations: ```python -def book_and_similar( - isbn: ISBN, text: str, /, *, client: Client -) -> tuple[str, list[str]]: - # Two queries in one call to the transport. - book_data, search_data = client( - ( - GetBook({"lookup": {"isbn": isbn}}), - Search({"text": text}), - ) +def book_and_similar(isbn: ISBN, text: str, /) -> tuple[str, list[str]]: + # Two queries in one request. + book_data, search_data = send( + merge(GetBook({"lookup": {"isbn": isbn}}), Search({"text": text})) ) assert_type(book_data, GetBookData) assert_type(search_data, SearchData) ``` -A list built at runtime also runs in one call to the transport, whatever its length. +A list built at runtime can be merged too, whatever its length. Its results then share one type, the union of its operations' data types: ```python def look_up( - isbns: Sequence[ISBN], publication_ids: Sequence[str], /, *, client: Client + isbns: Sequence[ISBN], publication_ids: Sequence[str], / ) -> tuple[GetBookData | GetPublicationData, ...]: - # Any number of queries in one call to the transport. - return client( # ty: ignore[unsound-return-statement] # Pyright and Pyrefly already infer this. - [ - *(GetBook({"lookup": {"isbn": isbn}}) for isbn in isbns), - *(GetPublication({"id": id_}) for id_ in publication_ids), - ] + # Any number of queries in one request. + return send( # ty: ignore[unsound-return-statement] # Pyright and Pyrefly already infer this. + merge( + [ + *(GetBook({"lookup": {"isbn": isbn}}) for isbn in isbns), + *(GetPublication({"id": id_}) for id_ in publication_ids), + ] + ) ) ``` @@ -383,7 +417,7 @@ def look_up( A response with errors raises a `RequestError` when the request failed before execution, and an `ExecutionError` when it carries partial data: - + ```python class ExecutionError(ResponseError, Generic[_Data_co]): @@ -395,12 +429,12 @@ class ExecutionError(ResponseError, Generic[_Data_co]): data: Final[Mapping[str, object] | None] def parse_data(self) -> _Data_co | None: - """Return the data converted as in a response without errors, in a fresh copy. + """Return a fresh copy of the data, converted as in a response without errors. ``` When several operations are merged, an `ExceptionGroup` holds one for each operation that fails. -You can also have the client return the error instead of raising it, by calling `returning_error()` on the request: +You can also get the error as a value: `returning_error()` gives a request for which `PreparedRequest.parse()` returns its `ExecutionError` instead of raising it: - the result is then typed as either the data or an `ExecutionError`, so you can tell them apart with `isinstance()`; - in a [merge](#merging), you choose for each request whether its error is returned or raised; @@ -409,13 +443,30 @@ You can also have the client return the error instead of raising it, by calling ```python -def cancel(order_ids: list[str], /, *, client: Client) -> list[str]: - """Cancel the orders in one call to the transport, and explain each failure.""" - results = client( - [ - CancelOrder({"input": {"order": order_id}}).returning_error() - for order_id in order_ids - ] +def track(order_id: str, /) -> str: + result = send(GetOrder({"id": order_id}).returning_error()) + + if isinstance(result, ExecutionError): + data = result.parse_data() + assert_type(data, GetOrderData | None) + return f"Partially loaded: {data} ({result!s})." + + tracked = result["order"] + return ( + "No such order." + if tracked is None + else f"Order {tracked['id']} is {tracked['status']}." + ) + + +def cancel(order_ids: list[str], /) -> list[str]: + results = send( + merge( + [ + CancelOrder({"input": {"order": order_id}}).returning_error() + for order_id in order_ids + ] + ) ) # Each error has a note naming its operation and the variables sent. return [ @@ -423,15 +474,6 @@ def cancel(order_ids: list[str], /, *, client: Client) -> list[str]: for error in results if isinstance(error, ExecutionError) ] - - -def track(order_id: str, /, *, client: Client) -> str: - result = client(GetOrder({"id": order_id}).returning_error()) - - if isinstance(result, ExecutionError): - data = result.parse_data() - assert_type(data, GetOrderData | None) - return f"Partially loaded: {data} ({result!s})." ``` ## Config @@ -469,11 +511,11 @@ extensions: Dotted names in the config resolve as follows: -- a path starting with `..` is relative to the directory holding the package; - a bare name, such as `str`, is a builtin; -- an unconfigured custom scalar is typed `object`. +- an absolute dotted name, such as `decimal.Decimal`, is imported as is; +- a path starting with `..`, such as `..scalar.decode_datetime`, is relative to the generated package's directory. -A dotted name must name an attribute of a module, so a method such as `datetime.fromisoformat` needs a function of its own: +A dotted name must be a module's attribute, so a method such as `datetime.fromisoformat` needs to be wrapped in a function: @@ -494,13 +536,13 @@ def encode_datetime(value: datetime, /) -> str: > If your project already relies on a validation library, it can supply the codec. > For instance, with Pydantic, build `adapter = TypeAdapter(YourType)` once, then use `decode = adapter.validate_python` and `encode = partial(adapter.dump_python, mode="json")`. -The client then converts each scalar's values on the way in and out, so your code only ever handles their Python types: +`Request.prepare()` then encodes each scalar's values, and `PreparedRequest.parse()` decodes them, so your code only ever handles their Python types: ```python -def cheaper_than(limit: Decimal, /, *, client: Client) -> list[str]: - data = client(ListBooks({"filter": {"priceBelow": limit}})) +def cheaper_than(limit: Decimal, /) -> list[str]: + data = send(ListBooks({"filter": {"priceBelow": limit}})) labels: list[str] = [] for book in data["books"]: @@ -526,7 +568,7 @@ extensions: nonNullDirectiveName: nonNull ``` -Asserted on `book`, the field's type is then not optional: +With `@nonNull` on `book`, its type loses `| None`: @@ -553,11 +595,13 @@ fragment BookCard on Book { ```python -def describe(isbn: ISBN, /, *, client: Client) -> str: +def describe(isbn: ISBN, /) -> str: variables: GetBookVariables = {"lookup": {"isbn": isbn}} + # Caught only to show what the error carries. + # You would usually let the error propagate since a null here is a bug. try: - data = client(GetBook(variables)) + data = send(GetBook(variables)) except UnexpectedNullError as error: assert error.path == ["book"] assert error.__notes__ == [f"Raised by `GetBook` with variables {variables!r}."] @@ -653,8 +697,8 @@ So the same `BookFilter` goes from one to the other as is: ```python -def run_saved_search(name: str, /, *, client: Client) -> list[str]: - data = client(GetSavedSearch({"name": name})) +def run_saved_search(name: str, /) -> list[str]: + data = send(GetSavedSearch({"name": name})) search = data["savedSearch"] if search is None: @@ -663,13 +707,13 @@ def run_saved_search(name: str, /, *, client: Client) -> list[str]: book_filter = search["value"] assert_type(book_filter, BookFilter | None) # Sent back as is. - books = client(ListBooks({"filter": book_filter})) + books = send(ListBooks({"filter": book_filter})) return [book["title"] for book in books["books"]] ``` ### Injectors -Some input values are `client`'s business rather than each `client()` call's. +Some concerns are better centralized than spread over every call. For instance, an idempotency key makes retrying a mutation safe: @@ -677,13 +721,12 @@ For instance, an idempotency key makes retrying a mutation safe: 2. the transport sends the order again; 3. the key, unique to the order, tells the server it already placed it, so it does not charge the customer twice. -GraphQL has no built-in idempotency, so implementing it usually means adding the key as an argument or an input field of each mutation that needs it. -When many different mutation operations require idempotency, it becomes the concern of all their `client()` calls, each having to get hold of a key. -This applies to other concepts too, such as database transaction IDs. +GraphQL has no built-in idempotency, so the key is an argument or an input field of each mutation needing one, and getting hold of a key becomes every caller's concern. +The same goes for a database transaction ID. -This library handles such a value in one place instead, with an injector given when `client` is constructed. -The client then passes it to every variable or input field with the name given in the config. -No variables' type accepts it, so that no `client()` call can pass one by mistake: +This library handles such a value in one place instead, with an injector given to `Request.prepare()`. +It writes the value to every variable or input field with the name given in the config. +No variables' type accepts it, so that no caller can pass one by mistake: @@ -701,34 +744,19 @@ extensions: ```graphql input PlaceOrderInput { "Makes placing the same order twice harmless: the client sends a new one per order." - idempotencyKey: UUID -``` - -The generated package's [`injection.py`](https://github.com/tibdex/graphql-codegen/blob/main/bookshop/client/injection.py) module types the injectors you must supply: - - - -```python -class InjectorFunctions(_compat.TypedDict, closed=True): - """The functions supplying each injected value, by name. - - Where the value may be null, one returning `OMITTED` leaves it out, and one returning `None` sends `null`.""" - idempotencyKey: _typing.NotRequired[_abc.Callable[[], _scalar.UUID | None | _OMITTED]] - -def injectors(functions: InjectorFunctions, /) -> _injection._Injectors: - """Return what a client calls to supply the injected values, each serialized as its type says.""" - return _injection._Injectors(functions, injector_functions_type=InjectorFunctions) + idempotencyKey: UUID! ``` -You give the client its injectors once when building it: +The generated package's [`injection.py`](https://github.com/tibdex/graphql-codegen/blob/main/bookshop/graphql/injection.py) module types the injectors you must supply, one function per name. +Each returns the value as its scalar's Python type, or `OMITTED` to leave a nullable one out. +`transport.py` builds them once, and gives them to `Request.prepare()` on each call: - + ```python -client = Client( - transport, - injectors=injectors({"idempotencyKey": uuid4}), -) +INJECTORS: Final = injectors({"idempotencyKey": uuid4}) +# … + prepared = request.prepare(injectors=INJECTORS) ``` And no call can pass a key: @@ -736,43 +764,43 @@ And no call can pass a key: ```python -def order(book_id: str, address: Address, /, *, client: Client) -> str: - data = client( +def order(book_id: str, address: Address, /) -> str: + data = send( PlaceOrder( {"input": {"lines": [{"book": book_id}], "shippingAddress": address}} ) ) ``` -The client injects new values on each call, so a retry belongs in your transport, which sends the same body, key included, again: +`Request.prepare()` calls the injectors once, so the key is in `bytes(prepared)`. +`transport.py` resends that body when the connection drops: ```python -def transport(body: bytes, /, *, timeout: float | None = None) -> bytes: - retries = 2 - +ATTEMPTS: Final = 3 +# … + prepared = request.prepare(injectors=INJECTORS) + # Built once, so that a retry sends the same idempotency key. + request_body = bytes(prepared) + attempts_left = ATTEMPTS + # … while True: try: response = _post( - "/graphql", body, accept=mime_type.GRAPHQL_RESPONSE, timeout=timeout + "/graphql", + request_body, + accept="application/graphql-response+json", + timeout=timeout, ) - except HTTPError as error: - # GraphQL over HTTP sends a request error as a response with a 4xx status. - if error.headers.get_content_type() != mime_type.GRAPHQL_RESPONSE: - raise - - response = error + # … except ConnectionError: - # The response was lost. - if not retries: + attempts_left -= 1 + + if not attempts_left: raise - retries -= 1 continue - - with response: - return response.read() ``` ### Colocation @@ -797,8 +825,8 @@ Code calling these operations imports them from there: ```python -from bookshop.client.schema import OrderStatus from bookshop.get_order_graphql import GetOrder +from bookshop.graphql.schema import OrderStatus ``` > [!NOTE] @@ -807,21 +835,6 @@ from bookshop.get_order_graphql import GetOrder ## Python API -[`generate()`](https://github.com/tibdex/graphql-codegen/blob/main/src/graphql_codegen/generate.py) does the same as the command: - - - -```python -class _Params(TypedDict, closed=True): - document: DocumentNode - schema: GraphQLSchema - config: Config - - -def generate(**args: Unpack[_Params]) -> dict[PurePosixPath, bytes]: - """Pure function returning the content of each file of the generated package by its path.""" -``` - The [public API](https://github.com/tibdex/graphql-codegen/blob/main/src/graphql_codegen/__init__.py) is limited to: @@ -836,14 +849,30 @@ from graphql_codegen.package_location import PackageLocation as PackageLocation from graphql_codegen.scalar import Codec as Codec, Scalar as Scalar ``` -[^typing-extensions]: Before Python 3.15, the client also needs [`typing_extensions`](https://typing-extensions.readthedocs.io), for features the standard library's [`typing`](https://docs.python.org/3/library/typing.html) does not have yet, so add it to your project's own dependencies. +[`generate()`](https://github.com/tibdex/graphql-codegen/blob/main/src/graphql_codegen/generate.py) is what the command calls: + + + +```python +class _Params(TypedDict, closed=True): + document: DocumentNode + schema: GraphQLSchema + config: Config + + +def generate(**args: Unpack[_Params]) -> dict[PurePosixPath, bytes]: + """Pure function returning the content of each file of the generated package by its path.""" +``` + +[^typing-extensions]: Before Python 3.15, the generated package needs [`typing-extensions`](https://github.com/python/typing_extensions) to access the latest [`typing`](https://docs.python.org/3/library/typing.html) features. +This library depends on it, but the package copied into your project does not inherit that, so add `typing-extensions` to your own dependencies too. -[^bootstrapping]: This library is partly [bootstrapped](https://en.wikipedia.org/wiki/Bootstrapping_(compilers)): to fetch a schema from a URL, [it uses a client](https://github.com/tibdex/graphql-codegen/blob/main/src/graphql_codegen/_cli/_introspection.py) it generated itself from [`_introspection.graphql`](https://github.com/tibdex/graphql-codegen/blob/main/src/graphql_codegen/_cli/_introspection.graphql). +[^bootstrapping]: This library is partly [bootstrapped](https://en.wikipedia.org/wiki/Bootstrapping_(compilers)): to fetch a schema from a URL, [it uses an operation](https://github.com/tibdex/graphql-codegen/blob/main/src/graphql_codegen/_cli/_introspection.py) it generated itself from [`_introspection.graphql`](https://github.com/tibdex/graphql-codegen/blob/main/src/graphql_codegen/_cli/_introspection.graphql). [^yaml]: A YAML config, like the quick start's, needs the `yaml` extra: install `graphql-codegen[yaml]` instead. JSON and TOML configs need nothing more. [^breaking-changes]: Well-behaved GraphQL APIs [avoid breaking changes](https://graphql.org/learn/schema-design/#versioning), adding fields and [deprecating](https://spec.graphql.org/September2025/#sec--deprecated) old ones instead. -Most breaking changes made after generation never reach the code anyway: removing or renaming a field, changing its arguments, or turning its type from an object into a leaf or the reverse invalidates the operation, so the server rejects it, and the `client()` call raises a [`RequestError`](#errors). +Most breaking changes made after codegen never reach the code anyway: removing or renaming a field, changing its arguments, or turning its type from an object into a leaf or the reverse invalidates the operation, so the server rejects it, and `PreparedRequest.parse()` raises a [`RequestError`](#errors). The few left would fail client-side validation as soon as the response arrives, but otherwise propagate deeper in your code, possibly unnoticed: a field becoming nullable, its leaf type changing, or it switching between a list and a single value. -The more often you regenerate against the deployed schema, the sooner your type checker catches such a change. +The more often you rerun the codegen against the deployed schema, the sooner your type checker catches such a change. diff --git a/bookshop/__main__.py b/bookshop/__main__.py index 5c22608..708990b 100644 --- a/bookshop/__main__.py +++ b/bookshop/__main__.py @@ -1,12 +1,4 @@ -from uuid import uuid4 - from bookshop.app import describe -from bookshop.client.injection import injectors from bookshop.scalar import ISBN -from bookshop.transport import Client, transport -client = Client( - transport, - injectors=injectors({"idempotencyKey": uuid4}), -) -print(describe(ISBN("9780141439518"), client=client)) +print(describe(ISBN("9780141439518"))) diff --git a/bookshop/app.py b/bookshop/app.py index 32fed63..7f0db19 100644 --- a/bookshop/app.py +++ b/bookshop/app.py @@ -18,18 +18,20 @@ Search, SearchData, ) -from bookshop.client.runtime import ExecutionError, UnexpectedNullError -from bookshop.client.schema import Address, BookFilter, OrderStatus from bookshop.get_order_graphql import GetOrder, GetOrderData +from bookshop.graphql.runtime import ExecutionError, UnexpectedNullError, merge +from bookshop.graphql.schema import Address, BookFilter, OrderStatus from bookshop.scalar import ISBN -from bookshop.transport import Client, SubscriptionClient +from bookshop.transport import send, subscribe -def describe(isbn: ISBN, /, *, client: Client) -> str: +def describe(isbn: ISBN, /) -> str: variables: GetBookVariables = {"lookup": {"isbn": isbn}} + # Caught only to show what the error carries. + # You would usually let the error propagate since a null here is a bug. try: - data = client(GetBook(variables)) + data = send(GetBook(variables)) except UnexpectedNullError as error: assert error.path == ["book"] assert error.__notes__ == [f"Raised by `GetBook` with variables {variables!r}."] @@ -46,15 +48,10 @@ def describe(isbn: ISBN, /, *, client: Client) -> str: return f"{book['title']}, {by}, costs {book['price']:.2f}." -def book_and_similar( - isbn: ISBN, text: str, /, *, client: Client -) -> tuple[str, list[str]]: - # Two queries in one call to the transport. - book_data, search_data = client( - ( - GetBook({"lookup": {"isbn": isbn}}), - Search({"text": text}), - ) +def book_and_similar(isbn: ISBN, text: str, /) -> tuple[str, list[str]]: + # Two queries in one request. + book_data, search_data = send( + merge(GetBook({"lookup": {"isbn": isbn}}), Search({"text": text})) ) assert_type(book_data, GetBookData) assert_type(search_data, SearchData) @@ -67,19 +64,21 @@ def book_and_similar( def look_up( - isbns: Sequence[ISBN], publication_ids: Sequence[str], /, *, client: Client + isbns: Sequence[ISBN], publication_ids: Sequence[str], / ) -> tuple[GetBookData | GetPublicationData, ...]: - # Any number of queries in one call to the transport. - return client( # ty: ignore[unsound-return-statement] # Pyright and Pyrefly already infer this. - [ - *(GetBook({"lookup": {"isbn": isbn}}) for isbn in isbns), - *(GetPublication({"id": id_}) for id_ in publication_ids), - ] + # Any number of queries in one request. + return send( # ty: ignore[unsound-return-statement] # Pyright and Pyrefly already infer this. + merge( + [ + *(GetBook({"lookup": {"isbn": isbn}}) for isbn in isbns), + *(GetPublication({"id": id_}) for id_ in publication_ids), + ] + ) ) -def run_saved_search(name: str, /, *, client: Client) -> list[str]: - data = client(GetSavedSearch({"name": name})) +def run_saved_search(name: str, /) -> list[str]: + data = send(GetSavedSearch({"name": name})) search = data["savedSearch"] if search is None: @@ -88,12 +87,12 @@ def run_saved_search(name: str, /, *, client: Client) -> list[str]: book_filter = search["value"] assert_type(book_filter, BookFilter | None) # Sent back as is. - books = client(ListBooks({"filter": book_filter})) + books = send(ListBooks({"filter": book_filter})) return [book["title"] for book in books["books"]] -def length(publication_id: str, /, *, client: Client) -> str: - data = client(GetPublication({"id": publication_id})) +def length(publication_id: str, /) -> str: + data = send(GetPublication({"id": publication_id})) publication = data["publication"] if publication is None: @@ -120,12 +119,12 @@ def status_label(status: OrderStatus, /) -> str: case "CANCELED": return "Canceled" case _: - # A member added after this client was generated. + # A member added after codegen. return status.replace("_", " ").capitalize() -def cheaper_than(limit: Decimal, /, *, client: Client) -> list[str]: - data = client(ListBooks({"filter": {"priceBelow": limit}})) +def cheaper_than(limit: Decimal, /) -> list[str]: + data = send(ListBooks({"filter": {"priceBelow": limit}})) labels: list[str] = [] for book in data["books"]: @@ -136,8 +135,8 @@ def cheaper_than(limit: Decimal, /, *, client: Client) -> list[str]: return labels -def order(book_id: str, address: Address, /, *, client: Client) -> str: - data = client( +def order(book_id: str, address: Address, /) -> str: + data = send( PlaceOrder( {"input": {"lines": [{"book": book_id}], "shippingAddress": address}} ) @@ -148,24 +147,8 @@ def order(book_id: str, address: Address, /, *, client: Client) -> str: return f"Order {order['id']}: {order['total']:.2f} at {order['placedAt']:%H:%M}." -def cancel(order_ids: list[str], /, *, client: Client) -> list[str]: - """Cancel the orders in one call to the transport, and explain each failure.""" - results = client( - [ - CancelOrder({"input": {"order": order_id}}).returning_error() - for order_id in order_ids - ] - ) - # Each error has a note naming its operation and the variables sent. - return [ - f"{error.__notes__[0]} {error!s}" - for error in results - if isinstance(error, ExecutionError) - ] - - -def track(order_id: str, /, *, client: Client) -> str: - result = client(GetOrder({"id": order_id}).returning_error()) +def track(order_id: str, /) -> str: + result = send(GetOrder({"id": order_id}).returning_error()) if isinstance(result, ExecutionError): data = result.parse_data() @@ -180,12 +163,27 @@ def track(order_id: str, /, *, client: Client) -> str: ) -def watch(order_id: str, /, *, client: SubscriptionClient) -> list[OrderStatus]: - """Follow the order until it is delivered, and return its statuses.""" +def cancel(order_ids: list[str], /) -> list[str]: + results = send( + merge( + [ + CancelOrder({"input": {"order": order_id}}).returning_error() + for order_id in order_ids + ] + ) + ) + # Each error has a note naming its operation and the variables sent. + return [ + f"{error.__notes__[0]} {error!s}" + for error in results + if isinstance(error, ExecutionError) + ] + + +def watch(order_id: str, /) -> list[OrderStatus]: statuses: list[OrderStatus] = [] - events = client(OnOrderStatusChanged({"orderId": order_id})) + events = subscribe(OnOrderStatusChanged({"orderId": order_id})) - # Closing the stream, however the loop ends, unsubscribes. with closing(events): for event in events: statuses.append(event["orderStatusChanged"]["status"]) diff --git a/bookshop/app_graphql.py b/bookshop/app_graphql.py index de44862..8af9cec 100644 --- a/bookshop/app_graphql.py +++ b/bookshop/app_graphql.py @@ -2,12 +2,13 @@ import builtins as _builtins import typing as _typing -from bookshop.client.runtime import _compat -from bookshop.client.runtime import _reflection -from bookshop.client import runtime as _runtime -from bookshop.client.schema import enum as _enum -from bookshop.client.schema import input as _input -from bookshop.client import _scalar +from bookshop.graphql.runtime import _compat +from bookshop.graphql.runtime import _operation_type +from bookshop.graphql.runtime import _reflection +from bookshop.graphql import runtime as _runtime +from bookshop.graphql.schema import enum as _enum +from bookshop.graphql.schema import input as _input +from bookshop.graphql import _scalar class _BookCard_author(_compat.TypedDict, closed=True): name: _builtins.str @@ -21,7 +22,7 @@ class BookCard(_compat.TypedDict): """Null for an anthology, which has no single author.""" class GetBookVariables(_compat.TypedDict, closed=True): - lookup: _typing.Required[_input.BookLookup] + lookup: _input.BookLookup """An identifier or an ISBN.""" withReviews: _typing.NotRequired[_builtins.bool] @@ -40,7 +41,7 @@ class GetBookData(_compat.TypedDict, closed=True): book: _typing.Annotated[_GetBookData_book, _reflection.NON_NULL] """The book with this identifier or ISBN.""" -GetBook: _runtime.Operation[_typing.Literal['query'], GetBookVariables, GetBookData] = _runtime.Operation(operation_type='query', name='GetBook', document="""query GetBook($lookup§: BookLookup!, $withReviews§: Boolean! = false) { +GetBook: _typing.Final = _runtime.Operation(operation_type=_operation_type.QUERY, name='GetBook', document="""query GetBook($lookup§: BookLookup!, $withReviews§: Boolean! = false) { book§: book(lookup: $lookup§) { ...BookCard isbn @@ -64,7 +65,7 @@ class GetBookData(_compat.TypedDict, closed=True): """A book, with its reviews when asked for.""" class SearchVariables(_compat.TypedDict, closed=True): - text: _typing.Required[_builtins.str] + text: _builtins.str _SearchData_search_Book_Base = _compat.TypedDict('_SearchData_search_Book_Base', {'__typename': _compat.ReadOnly[_typing.Literal['Book']]}) @@ -95,7 +96,7 @@ class SearchData(_compat.TypedDict, closed=True): search: _builtins.list[_SearchData_search] """Everything whose title or name contains the text.""" -Search: _runtime.Operation[_typing.Literal['query'], SearchVariables, SearchData] = _runtime.Operation(operation_type='query', name='Search', document="""query Search($text§: String!) { +Search: _typing.Final = _runtime.Operation(operation_type=_operation_type.QUERY, name='Search', document="""query Search($text§: String!) { search§: search(text: $text§) { __typename ... on Publication { @@ -119,7 +120,7 @@ class ListBooksData(_compat.TypedDict, closed=True): books: _builtins.list[_ListBooksData_books] """The books matching the filter, at most `first` of them.""" -ListBooks: _runtime.Operation[_typing.Literal['query'], ListBooksVariables, ListBooksData] = _runtime.Operation(operation_type='query', name='ListBooks', document="""query ListBooks($filter§: BookFilter, $first§: Int) { +ListBooks: _typing.Final = _runtime.Operation(operation_type=_operation_type.QUERY, name='ListBooks', document="""query ListBooks($filter§: BookFilter, $first§: Int) { books§: books(filter: $filter§, first: $first§) { ...BookCard genre @@ -136,7 +137,7 @@ class ListBooksData(_compat.TypedDict, closed=True): }""", variables_type=ListBooksVariables, data_type=ListBooksData) class GetPublicationVariables(_compat.TypedDict, closed=True): - id: _typing.Required[_builtins.str] + id: _builtins.str _GetPublicationData_publication_Book_Base = _compat.TypedDict('_GetPublicationData_publication_Book_Base', {'__typename': _compat.ReadOnly[_typing.Literal['Book']]}) @@ -165,7 +166,7 @@ class GetPublicationData(_compat.TypedDict, closed=True): publication: _GetPublicationData_publication | None """The publication with this identifier.""" -GetPublication: _runtime.Operation[_typing.Literal['query'], GetPublicationVariables, GetPublicationData] = _runtime.Operation(operation_type='query', name='GetPublication', document="""query GetPublication($id§: ID!) { +GetPublication: _typing.Final = _runtime.Operation(operation_type=_operation_type.QUERY, name='GetPublication', document="""query GetPublication($id§: ID!) { publication§: publication(id: $id§) { __typename title @@ -182,7 +183,7 @@ class GetPublicationData(_compat.TypedDict, closed=True): }""", variables_type=GetPublicationVariables, data_type=GetPublicationData) class GetSavedSearchVariables(_compat.TypedDict, closed=True): - name: _typing.Required[_builtins.str] + name: _builtins.str class _GetSavedSearchData_savedSearch(_compat.TypedDict, closed=True): value: _input.BookFilter | None @@ -191,14 +192,14 @@ class GetSavedSearchData(_compat.TypedDict, closed=True): savedSearch: _GetSavedSearchData_savedSearch | None """The filter of the saved search with this name, exactly as it was saved.""" -GetSavedSearch: _runtime.Operation[_typing.Literal['query'], GetSavedSearchVariables, GetSavedSearchData] = _runtime.Operation(operation_type='query', name='GetSavedSearch', document="""query GetSavedSearch($name§: String!) { +GetSavedSearch: _typing.Final = _runtime.Operation(operation_type=_operation_type.QUERY, name='GetSavedSearch', document="""query GetSavedSearch($name§: String!) { savedSearch§: savedSearch(name: $name§) { value } }""", variables_type=GetSavedSearchVariables, data_type=GetSavedSearchData) class PlaceOrderVariables(_compat.TypedDict, closed=True): - input: _typing.Required[_input.PlaceOrderInput] + input: _input.PlaceOrderInput class _PlaceOrderData_placeOrder(_compat.TypedDict, closed=True): id: _builtins.str @@ -210,7 +211,7 @@ class PlaceOrderData(_compat.TypedDict, closed=True): placeOrder: _PlaceOrderData_placeOrder """Place an order for the given lines.""" -PlaceOrder: _runtime.Operation[_typing.Literal['mutation'], PlaceOrderVariables, PlaceOrderData] = _runtime.Operation(operation_type='mutation', name='PlaceOrder', document="""mutation PlaceOrder($input§: PlaceOrderInput!) { +PlaceOrder: _typing.Final = _runtime.Operation(operation_type=_operation_type.MUTATION, name='PlaceOrder', document="""mutation PlaceOrder($input§: PlaceOrderInput!) { placeOrder§: placeOrder(input: $input§) { id status @@ -220,7 +221,7 @@ class PlaceOrderData(_compat.TypedDict, closed=True): }""", variables_type=PlaceOrderVariables, injections={'idempotencyKey': _builtins.frozenset({('input',)})}, data_type=PlaceOrderData) class CancelOrderVariables(_compat.TypedDict, closed=True): - input: _typing.Required[_input.CancelOrderInput] + input: _input.CancelOrderInput class _CancelOrderData_cancelOrder(_compat.TypedDict, closed=True): id: _builtins.str @@ -233,7 +234,7 @@ class CancelOrderData(_compat.TypedDict, closed=True): Null, with an error saying why, for an order that has. Nullable so that when several orders are canceled in one request, one failure leaves the others' results intact.""" -CancelOrder: _runtime.Operation[_typing.Literal['mutation'], CancelOrderVariables, CancelOrderData] = _runtime.Operation(operation_type='mutation', name='CancelOrder', document="""mutation CancelOrder($input§: CancelOrderInput!) { +CancelOrder: _typing.Final = _runtime.Operation(operation_type=_operation_type.MUTATION, name='CancelOrder', document="""mutation CancelOrder($input§: CancelOrderInput!) { cancelOrder§: cancelOrder(input: $input§) { id status @@ -241,7 +242,7 @@ class CancelOrderData(_compat.TypedDict, closed=True): }""", variables_type=CancelOrderVariables, injections={'idempotencyKey': _builtins.frozenset({('input',)})}, data_type=CancelOrderData) class OnOrderStatusChangedVariables(_compat.TypedDict, closed=True): - orderId: _typing.Required[_builtins.str] + orderId: _builtins.str class _OnOrderStatusChangedData_orderStatusChanged(_compat.TypedDict, closed=True): id: _builtins.str @@ -251,7 +252,7 @@ class OnOrderStatusChangedData(_compat.TypedDict, closed=True): orderStatusChanged: _OnOrderStatusChangedData_orderStatusChanged """The order, each time its status changes.""" -OnOrderStatusChanged: _runtime.Operation[_typing.Literal['subscription'], OnOrderStatusChangedVariables, OnOrderStatusChangedData] = _runtime.Operation(operation_type='subscription', name='OnOrderStatusChanged', document="""subscription OnOrderStatusChanged($orderId: ID!) { +OnOrderStatusChanged: _typing.Final = _runtime.Operation(operation_type=_operation_type.SUBSCRIPTION, name='OnOrderStatusChanged', document="""subscription OnOrderStatusChanged($orderId: ID!) { orderStatusChanged(orderId: $orderId) { id status diff --git a/bookshop/async_app.py b/bookshop/async_app.py index 18d007f..68f7bb9 100644 --- a/bookshop/async_app.py +++ b/bookshop/async_app.py @@ -1,8 +1,27 @@ -from bookshop.app_graphql import GetBook -from bookshop.async_transport import AsyncClient +from contextlib import aclosing +from typing import assert_type + +from bookshop.app_graphql import GetBook, GetBookData, OnOrderStatusChanged +from bookshop.async_transport import send, subscribe +from bookshop.graphql.schema import OrderStatus from bookshop.scalar import ISBN -async def title(isbn: ISBN, /, *, client: AsyncClient) -> str: - data = await client(GetBook({"lookup": {"isbn": isbn}}), timeout=5.0) +async def title(isbn: ISBN, /) -> str: + data = await send(GetBook({"lookup": {"isbn": isbn}}), timeout=5.0) + assert_type(data, GetBookData) return data["book"]["title"] + + +async def watch(order_id: str, /) -> list[OrderStatus]: + statuses: list[OrderStatus] = [] + events = subscribe(OnOrderStatusChanged({"orderId": order_id})) + + async with aclosing(events): + async for event in events: + statuses.append(event["orderStatusChanged"]["status"]) + + if statuses[-1] == "DELIVERED": + break + + return statuses diff --git a/bookshop/async_transport.py b/bookshop/async_transport.py index bd4e93f..2255bd6 100644 --- a/bookshop/async_transport.py +++ b/bookshop/async_transport.py @@ -1,66 +1,49 @@ +"""The bookshop's asynchronous IO, with httpx2.""" + from collections.abc import AsyncGenerator -from typing import Protocol, TypeAlias +from typing import Final, Literal import httpx2 -from bookshop import mime_type -from bookshop.client.runtime import ( - AsyncClient as _AsyncClient, - AsyncSubscriptionClient as _AsyncSubscriptionClient, -) +from bookshop.graphql.runtime import Request +from bookshop.transport import INJECTORS -http = httpx2.AsyncClient(base_url="https://bookshop.example") -HEADERS = {"Accept": mime_type.GRAPHQL_RESPONSE, "Content-Type": mime_type.JSON} +GRAPHQL_RESPONSE: Final = "application/graphql-response+json" +http: Final = httpx2.AsyncClient( + base_url="https://bookshop.example", + headers={ + "Accept": GRAPHQL_RESPONSE, + "Content-Type": "application/json", + }, +) -async def transport(body: bytes, /, *, timeout: float | None = None) -> bytes: - response = await http.post( - "/graphql", content=body, headers=HEADERS, timeout=timeout - ) +async def send[Data]( + request: Request[Literal["query", "mutation"], Data], + /, + *, + timeout: float | None = None, +) -> Data: + prepared = request.prepare(injectors=INJECTORS) + response = await http.post("/graphql", content=bytes(prepared), timeout=timeout) # GraphQL over HTTP sends a request error as a response with a 4xx status. - if not response.headers.get("Content-Type", "").startswith( - mime_type.GRAPHQL_RESPONSE - ): + if not response.headers.get("Content-Type", "").startswith(GRAPHQL_RESPONSE): response.raise_for_status() - return response.content - - -class Transport(Protocol): - async def __call__( - self, body: bytes, /, *, timeout: float | None = None - ) -> bytes: ... - + return prepared.parse(response.content) -# Keeps the protocol in sync. -_transport: Transport = transport -AsyncClient: TypeAlias = _AsyncClient[Transport] # noqa: UP040 +async def subscribe[Data]( + request: Request[Literal["subscription"], Data], / +) -> AsyncGenerator[Data, None]: + prepared = request.prepare(injectors=INJECTORS) - -async def subscription_transport(body: bytes, /) -> AsyncGenerator[bytes, None]: - """Yield the data of each `next` event, following the GraphQL over Server-Sent Events protocol.""" - # Closing the generator leaves the block, which closes the connection: that is how to unsubscribe. - async with http.sse( - "/graphql", - method="POST", - content=body, - headers={"Content-Type": mime_type.JSON}, - ) as events: + # Closing the generator leaves the block, closes the connection, and ends the subscription. + async with http.sse("/graphql", method="POST", content=bytes(prepared)) as events: async for event in events: if event.event == "complete": return if event.event == "next": - yield event.data.encode() - - -class SubscriptionTransport(Protocol): - def __call__(self, body: bytes, /) -> AsyncGenerator[bytes, None]: ... - - -# Keeps the protocol in sync. -_subscription_transport: SubscriptionTransport = subscription_transport - -AsyncSubscriptionClient: TypeAlias = _AsyncSubscriptionClient[SubscriptionTransport] # noqa: UP040 + yield prepared.parse(event.data.encode()) diff --git a/bookshop/client/runtime/_literal.py b/bookshop/client/runtime/_literal.py deleted file mode 100644 index 3bb199e..0000000 --- a/bookshop/client/runtime/_literal.py +++ /dev/null @@ -1,18 +0,0 @@ -# Code generated by graphql-codegen. DO NOT EDIT. - -from typing import Literal - -type OperationType = Literal["query", "mutation", "subscription"] -"""The spec's `OperationType`. - -- ``"query"`` reads, and a client may run several at once. -- ``"mutation"`` writes, and the server runs the root fields of several in order. -- ``"subscription"`` answers with a stream of responses rather than one, which only a subscription client runs. -""" - -type ExecutionErrorHandling = Literal["raise", "return"] -"""What a client does with the :class:`~.error.ExecutionError` of the response to a request. - -- ``"raise"`` raises it, so that what a call returns is always complete data. -- ``"return"`` returns it in place of the request's data, typed by the request, for a caller who can use partial data. -""" diff --git a/bookshop/client/runtime/_merge.py b/bookshop/client/runtime/_merge.py deleted file mode 100644 index c31f59c..0000000 --- a/bookshop/client/runtime/_merge.py +++ /dev/null @@ -1,153 +0,0 @@ -# Code generated by graphql-codegen. DO NOT EDIT. - -from collections.abc import Mapping, Sequence -from functools import cache -from json import dumps -from typing import Any, Final - -from ._literal import OperationType -from ._sigil import SIGIL -from .error import Error -from .operation import Operation - -MERGED_OPERATION_NAME: Final = "MergedOperation" - -_INDEX_SEPARATOR: Final = "_" - - -@cache -def _template_parts(document: str, /) -> tuple[str, str, Mapping[str, str]]: - """The generator prints a template like any document: the operation first, its header on one line, then its root fields, then each fragment after a blank line. - - Cached: the documents of a generated package are few and fixed. - """ - operation, *fragment_definitions = document.split("\n\n") - header, *root_selections, _ = operation.split("\n") - # An operation holding the sigil has no directives, so its header ends with its variable definitions, if any. - variable_definitions = ( - header[header.index("(") + 1 : header.rindex(")")] if "(" in header else "" - ) - fragments = { - definition.split(" ", 2)[1]: definition for definition in fragment_definitions - } - return variable_definitions, "\n".join(root_selections), fragments - - -def encode_body( - parts: Sequence[tuple[Operation[OperationType, Any, object], Mapping[str, object]]], - /, -) -> bytes: - """Merging lets a server see several operations at once, such as mutations it can apply in one step, and saves round trips. - - Each operation's variables and root fields are suffixed with its index in the merge, so that the operations cannot collide. - The generator writes a sigil wherever the index goes, so suffixing is plain string replacement. - - A lone operation is sent as is, so that the server knows it by its own name. - """ - if not parts: - raise ValueError("Cannot merge no operation.") - - if len(parts) == 1: - ((operation, operation_variables),) = parts - return operation._request_body(operation_variables) - - operation_types = {operation._operation_type for operation, _ in parts} - - if len(operation_types) != 1: - raise ValueError( - f"Cannot merge operations of different types: {', '.join(sorted(operation_types))}.", - ) - - (operation_type,) = operation_types - variable_definitions: list[str] = [] - selections: list[str] = [] - fragments: dict[str, str] = {} - variables: dict[str, object] = {} - - for index, (operation, operation_variables) in enumerate(parts): - if SIGIL not in operation._document: - raise ValueError( - f"Cannot merge `{operation._name}`: its document has no `{SIGIL}` where its index would go." - ) - - definitions, root_selections, operation_fragments = _template_parts( - operation._document - ) - suffix = f"{_INDEX_SEPARATOR}{index}" - - if definitions: - variable_definitions.append(definitions.replace(SIGIL, suffix)) - - selections.append(root_selections.replace(SIGIL, suffix)) - fragments.update(operation_fragments) - variables.update( - {f"{name}{suffix}": value for name, value in operation_variables.items()} - ) - - signature = f"({', '.join(variable_definitions)})" if variable_definitions else "" - document = "\n\n".join( - [ - "\n".join( - [ - f"{operation_type} {MERGED_OPERATION_NAME}{signature} {{", - *selections, - "}", - ] - ), - *fragments.values(), - ], - ) - return dumps( - { - "operationName": MERGED_OPERATION_NAME, - "query": document, - "variables": variables, - }, - ).encode() - - -def _split_response_name(key: str, /) -> tuple[str, int]: - response_name, index = key.rsplit(_INDEX_SEPARATOR, 1) - return response_name, int(index) - - -def split_merged_data( - data: Mapping[str, object], /, *, count: int -) -> Sequence[Mapping[str, object]]: - """Preallocated, so that an operation whose every root field `@skip` left out still gets its own, empty, data rather than shifting the others'.""" - if count == 1: - return [data] - - split: list[dict[str, object]] = [{} for _ in range(count)] - - for key, value in data.items(): - response_name, index = _split_response_name(key) - split[index][response_name] = value - - return split - - -def split_merged_errors( - errors: Sequence[Error], /, *, count: int -) -> Sequence[Sequence[Error]]: - """An error's path starts with the suffixed response name of the root field it concerns, which tells its operation. - - An error without a path concerns them all. - """ - if count == 1: - return [errors] - - split: list[list[Error]] = [[] for _ in range(count)] - - for error in errors: - path = error.get("path") - - if not path or not isinstance(path[0], str): - for operation_errors in split: - operation_errors.append(error) - continue - - response_name, index = _split_response_name(path[0]) - split[index].append({**error, "path": [response_name, *path[1:]]}) - - return split diff --git a/bookshop/client/runtime/_prepare.py b/bookshop/client/runtime/_prepare.py deleted file mode 100644 index d71a834..0000000 --- a/bookshop/client/runtime/_prepare.py +++ /dev/null @@ -1,250 +0,0 @@ -# Code generated by graphql-codegen. DO NOT EDIT. - -from collections.abc import Callable, Mapping, Sequence -from json import JSONDecodeError, loads -from typing import Any, Final, assert_never, cast, final - -from ._literal import OperationType -from ._merge import encode_body, split_merged_data, split_merged_errors -from ._reflection import Convert -from .error import ( - ClientError, - Error, - ExecutionError, - ProtocolError, - RequestError, - UnexpectedNullError, -) -from .injection import _Injectors -from .operation import Operation, Request - - -@final -class PreparedRequest[Data]: - """A call without its IO and awaiting, which are the clients' part.""" - - __slots__ = ("_body", "parse") - - def __init__(self, body: bytes, /, *, parse: Callable[[bytes], Data]) -> None: - self._body: Final = body - self.parse: Final = parse - - def __bytes__(self) -> bytes: - return self._body - - -def resolve_variables( - operation: Operation[OperationType, Any, object], - variables: Mapping[str, object], - /, - *, - injectors: _Injectors | None = None, -) -> Mapping[str, object]: - """The caller's are never mutated: serializing copies them, and injecting copies what it writes into.""" - serialize = operation._variables_serializer - values = ( - variables - if serialize is None - else cast(Mapping[str, object], serialize(variables)) - ) - return ( - values - if injectors is None - else injectors._inject(values, injections=operation._injections) - ) - - -def prepare[Data]( - request: Request[OperationType, Data], /, *, injectors: _Injectors | None = None -) -> PreparedRequest[Data]: - operation = request._operation - variables = resolve_variables(operation, request._variables, injectors=injectors) - - def parse(raw: bytes, /) -> Data: - try: - return parse_data(operation, raw) - except ClientError as error: - error.add_note(_origin_note(operation._name, variables)) - - if isinstance(error, ExecutionError): - match request._on_execution_error: - case "raise": - pass - case "return": - # `Data` is the request's data type or its error: see `Request.returning_error()`. - return cast(Data, error) - case _ as never: - assert_never(never) - - raise - - return PreparedRequest(operation._request_body(variables), parse=parse) - - -def parse_data[Data]( - operation: Operation[OperationType, Any, Data], raw: bytes, / -) -> Data: - """Nothing is validated: the data is trusted to match its static type, from this cast on.""" - data = parse_response(raw, partial_data_parser=operation._partial_data_parser) - - if operation._data_parser is not None: - operation._data_parser(data) - - return cast(Data, data) - - -def _execution_error[Data]( - operation: Operation[OperationType, Any, Data], - errors: Sequence[Error], - /, - *, - data: Mapping[str, object] | None, - extensions: Mapping[str, object] | None, -) -> ExecutionError[Data]: - return ExecutionError[Data]( - errors, - data=data, - extensions=extensions, - partial_data_parser=operation._partial_data_parser, - ) - - -def parse_response( - raw: bytes, /, *, partial_data_parser: Convert | None -) -> Mapping[str, object]: - """A response with errors but no `data` entry is the spec's request error result.""" - try: - payload = loads(raw) - except JSONDecodeError as error: - raise ProtocolError( - f"Expected a JSON response but got: {raw[:200]!r}." - ) from error - - if not isinstance(payload, dict): - raise ProtocolError(f"Expected a JSON object response but got: {raw[:200]!r}.") - - errors = payload.get("errors") - - if errors: - # Trusted to be the spec's errors, as the data is trusted to match its type. - formatted = cast(list[Error], errors) - extensions = payload.get("extensions") - - if "data" not in payload: - raise RequestError(formatted, extensions=extensions) - - raise ExecutionError( - formatted, - data=payload["data"], - extensions=extensions, - partial_data_parser=partial_data_parser, - ) - - data = payload.get("data") - - if not isinstance(data, dict): - raise ProtocolError( - f"Expected the response to carry `data` but got: {raw[:200]!r}." - ) - - # The keys of a JSON object are strings. - return cast(Mapping[str, object], data) - - -def _origin_note(operation_name: str, variables: Mapping[str, object], /) -> str: - """The variables are shown by :func:`repr`, so that a :class:`str` subclass redacting its own keeps a secret out of logs.""" - return f"Raised by `{operation_name}` with variables {variables!r}." - - -def _raises_execution_error(request: Request[OperationType, object], /) -> bool: - match request._on_execution_error: - case "raise": - return True - case "return": - return False - case _ as never: - assert_never(never) - - -def _prepare_merged( - requests: Sequence[Request[OperationType, object]], - /, - *, - injectors: _Injectors | None, -) -> PreparedRequest[tuple[object, ...]]: - operations = [request._operation for request in requests] - # Resolved now rather than when the response comes back, like a lone request's. - variables = [ - resolve_variables(request._operation, request._variables, injectors=injectors) - for request in requests - ] - body = encode_body(list(zip(operations, variables, strict=True))) - count = len(requests) - - def parse(raw: bytes, /) -> tuple[object, ...]: - failures: dict[int, ExecutionError[object]] = {} - - try: - data = parse_response(raw, partial_data_parser=None) - except ExecutionError as error: - # With no data, the merge failed as a whole, which is no one request's error to return. - if error.data is None: - raise - - split = split_merged_data(error.data, count=count) - - for index, (operation, operation_variables, operation_errors) in enumerate( - zip( - operations, - variables, - split_merged_errors(error.errors, count=count), - strict=True, - ) - ): - if operation_errors: - failure = _execution_error( - operation, - operation_errors, - data=split[index], - extensions=error.extensions, - ) - failure.add_note(_origin_note(operation._name, operation_variables)) - failures[index] = failure - - # A call returns or raises: when one failing request raises its error, the group holds every failure, those of requests returning theirs included. - if any(_raises_execution_error(requests[index]) for index in failures): - raise ExceptionGroup( - f"{len(failures)} of the {count} merged operations raised.", - list(failures.values()), - ) from None - else: - split = split_merged_data(data, count=count) - - for index, (operation, operation_variables, operation_data) in enumerate( - zip(operations, variables, split, strict=True) - ): - if index not in failures and operation._data_parser is not None: - try: - operation._data_parser(operation_data) - except UnexpectedNullError as null_error: - null_error.add_note( - _origin_note(operation._name, operation_variables) - ) - raise - - return tuple( - failures.get(index, operation_data) - for index, operation_data in enumerate(split) - ) - - return PreparedRequest(body, parse=parse) - - -def prepare_requests( - requests: object, /, *, injectors: _Injectors | None -) -> PreparedRequest[Any] | None: - if isinstance(requests, Request): - return prepare(requests, injectors=injectors) - - assert isinstance(requests, Sequence) - return _prepare_merged(requests, injectors=injectors) if requests else None diff --git a/bookshop/client/runtime/_sigil.py b/bookshop/client/runtime/_sigil.py deleted file mode 100644 index 7fe4c37..0000000 --- a/bookshop/client/runtime/_sigil.py +++ /dev/null @@ -1,11 +0,0 @@ -# Code generated by graphql-codegen. DO NOT EDIT. - -from typing import Final - -SIGIL: Final = "§" -"""Where an operation's index in a merge goes in its document: after each root field's response name, as its alias, and after each variable's name. - -An operation run alone drops it, leaving each root field aliased as itself, which GraphQL executes as if it were not aliased. -A document without it cannot be merged: a subscription's, or that of an operation with directives of its own. -The generator escapes it in strings, so that every one left is a sigil. -""" diff --git a/bookshop/client/runtime/_transport.py b/bookshop/client/runtime/_transport.py deleted file mode 100644 index 43c436e..0000000 --- a/bookshop/client/runtime/_transport.py +++ /dev/null @@ -1,21 +0,0 @@ -# Code generated by graphql-codegen. DO NOT EDIT. - -from collections.abc import AsyncGenerator, Awaitable, Callable, Generator -from typing import Concatenate - -type Transport[**Params] = Callable[Concatenate[bytes, Params], bytes] -"""The spec leaves the transport mechanism to the implementation, and so does this library. -Bytes in, bytes out keeps it sans-IO: it owns neither the transport nor the connection, so it works under any IO model. - -""" - -type AsyncTransport[**Params] = Callable[Concatenate[bytes, Params], Awaitable[bytes]] - -type SubscriptionTransport[**Params] = Callable[ - Concatenate[bytes, Params], Generator[bytes, None, None] -] -"""A generator rather than any iterator, because closing the response stream closes it: its cleanup is where it unsubscribes.""" - -type AsyncSubscriptionTransport[**Params] = Callable[ - Concatenate[bytes, Params], AsyncGenerator[bytes, None] -] diff --git a/bookshop/client/runtime/client.py b/bookshop/client/runtime/client.py deleted file mode 100644 index 9b259b9..0000000 --- a/bookshop/client/runtime/client.py +++ /dev/null @@ -1,384 +0,0 @@ -# Code generated by graphql-codegen. DO NOT EDIT. - -from collections.abc import AsyncGenerator, Callable, Generator, Sequence -from contextlib import aclosing, closing -from typing import Any, Final, Generic, Literal, final, overload - -from ._compat import TypeVar -from ._prepare import prepare, prepare_requests -from ._transport import ( - AsyncSubscriptionTransport, - AsyncTransport, - SubscriptionTransport, - Transport, -) -from .injection import _Injectors -from .operation import Request - -# A client's transport, whose signature types the arguments each call accepts: none, unless it says otherwise. -# Covariant, so that a client over a transport is a client over any `Protocol` the transport satisfies. -# Legacy `TypeVar`s, since a default for a PEP 695 type parameter needs Python 3.13. -_Transport_co = TypeVar( - "_Transport_co", bound=Transport[...], covariant=True, default=Transport[[]] -) -_AsyncTransport_co = TypeVar( - "_AsyncTransport_co", - bound=AsyncTransport[...], - covariant=True, - default=AsyncTransport[[]], -) -_SubscriptionTransport_co = TypeVar( - "_SubscriptionTransport_co", - bound=SubscriptionTransport[...], - covariant=True, - default=SubscriptionTransport[[]], -) -_AsyncSubscriptionTransport_co = TypeVar( - "_AsyncSubscriptionTransport_co", - bound=AsyncSubscriptionTransport[...], - covariant=True, - default=AsyncSubscriptionTransport[[]], -) - - -def _response_stream[Data]( - bodies: Generator[bytes, None, None], parse: Callable[[bytes], Data], / -) -> Generator[Data, None, None]: - # Closing the response stream, or an error in one of its responses, closes the transport's. - with closing(bodies): - for body in bodies: - yield parse(body) - - -async def _async_response_stream[Data]( - bodies: AsyncGenerator[bytes, None], parse: Callable[[bytes], Data], / -) -> AsyncGenerator[Data, None]: - async with aclosing(bodies): - async for body in bodies: - yield parse(body) - - -@final -class Client(Generic[_Transport_co]): - """Run query and mutation requests over *transport*, each call accepting the transport's parameters. - - Several operations of one type run merged, in one call to the transport: a tuple of up to 6 returns a tuple typed position by position, and any other sequence a tuple of their common data type. - A response with errors raises a :class:`ResponseError`, and a merge an :exc:`ExceptionGroup` of a :class:`ExecutionError` per operation its errors concern, or the unsplit error when its data is null. - A request from :meth:`~Request.returning_error` gets its :class:`ExecutionError` back in place of its data instead, unless the call raises for another. - - The transport's parameters are read off its signature and checked, so that the library never has to know what they are:: - - def transport(body: bytes, /, *, timeout: float | None = None) -> bytes: ... - - client = Client(transport) - client(ListBooks({"first": 10}), timeout=5.0) - - Code taking a client names its type after the transport's signature, written once as a :class:`typing.Protocol`, which the transport a client is built from is checked against:: - - class Transport(Protocol): - def __call__(self, body: bytes, /, *, timeout: float | None = None) -> bytes: ... - - Client = runtime.Client[Transport] - - def describe(isbn: ISBN, /, *, client: Client) -> str: ... - - describe(isbn, client=Client(transport)) - - A plain alias rather than a `type` statement, since only the former can be called to build a client. - A client is generic over its transport rather than over its parameters, because a :class:`typing.ParamSpec`'s value cannot be spelled with keyword parameters, and PEP 692 only unpacks a concrete :class:`typing.TypedDict`, never a type parameter. - """ - - def __init__( - self, - transport: _Transport_co, - /, - *, - injectors: _Injectors | None = None, - ) -> None: - self.transport: Final = transport - self.injectors: Final = injectors - - @overload - def __call__[**Params, Type: Literal["query", "mutation"], Data]( - self: "Client[Transport[Params]]", - request: Request[Type, Data], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> Data: ... - @overload - def __call__[**Params, Type: (Literal["query"], Literal["mutation"]), D1, D2]( - self: "Client[Transport[Params]]", - requests: tuple[Request[Type, D1], Request[Type, D2]], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2]: ... - @overload - def __call__[**Params, Type: (Literal["query"], Literal["mutation"]), D1, D2, D3]( - self: "Client[Transport[Params]]", - requests: tuple[Request[Type, D1], Request[Type, D2], Request[Type, D3]], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2, D3]: ... - @overload - def __call__[ - **Params, - Type: (Literal["query"], Literal["mutation"]), - D1, - D2, - D3, - D4, - ]( - self: "Client[Transport[Params]]", - requests: tuple[ - Request[Type, D1], Request[Type, D2], Request[Type, D3], Request[Type, D4] - ], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2, D3, D4]: ... - @overload - def __call__[ - **Params, - Type: (Literal["query"], Literal["mutation"]), - D1, - D2, - D3, - D4, - D5, - ]( - self: "Client[Transport[Params]]", - requests: tuple[ - Request[Type, D1], - Request[Type, D2], - Request[Type, D3], - Request[Type, D4], - Request[Type, D5], - ], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2, D3, D4, D5]: ... - @overload - def __call__[ - **Params, - Type: (Literal["query"], Literal["mutation"]), - D1, - D2, - D3, - D4, - D5, - D6, - ]( - self: "Client[Transport[Params]]", - requests: tuple[ - Request[Type, D1], - Request[Type, D2], - Request[Type, D3], - Request[Type, D4], - Request[Type, D5], - Request[Type, D6], - ], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2, D3, D4, D5, D6]: ... - @overload - def __call__[**Params, Type: (Literal["query"], Literal["mutation"]), Data]( - self: "Client[Transport[Params]]", - requests: Sequence[Request[Type, Data]], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[Data, ...]: ... - def __call__(self, requests: Any, /, *args: Any, **kwargs: Any) -> Any: - prepared = prepare_requests(requests, injectors=self.injectors) - return ( - () - if prepared is None - else prepared.parse(self.transport(bytes(prepared), *args, **kwargs)) - ) - - -@final -class AsyncClient(Generic[_AsyncTransport_co]): - def __init__( - self, - transport: _AsyncTransport_co, - /, - *, - injectors: _Injectors | None = None, - ) -> None: - self.transport: Final = transport - self.injectors: Final = injectors - - @overload - async def __call__[**Params, Type: Literal["query", "mutation"], Data]( - self: "AsyncClient[AsyncTransport[Params]]", - request: Request[Type, Data], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> Data: ... - @overload - async def __call__[ - **Params, - Type: (Literal["query"], Literal["mutation"]), - D1, - D2, - ]( - self: "AsyncClient[AsyncTransport[Params]]", - requests: tuple[Request[Type, D1], Request[Type, D2]], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2]: ... - @overload - async def __call__[ - **Params, - Type: (Literal["query"], Literal["mutation"]), - D1, - D2, - D3, - ]( - self: "AsyncClient[AsyncTransport[Params]]", - requests: tuple[Request[Type, D1], Request[Type, D2], Request[Type, D3]], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2, D3]: ... - @overload - async def __call__[ - **Params, - Type: (Literal["query"], Literal["mutation"]), - D1, - D2, - D3, - D4, - ]( - self: "AsyncClient[AsyncTransport[Params]]", - requests: tuple[ - Request[Type, D1], Request[Type, D2], Request[Type, D3], Request[Type, D4] - ], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2, D3, D4]: ... - @overload - async def __call__[ - **Params, - Type: (Literal["query"], Literal["mutation"]), - D1, - D2, - D3, - D4, - D5, - ]( - self: "AsyncClient[AsyncTransport[Params]]", - requests: tuple[ - Request[Type, D1], - Request[Type, D2], - Request[Type, D3], - Request[Type, D4], - Request[Type, D5], - ], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2, D3, D4, D5]: ... - @overload - async def __call__[ - **Params, - Type: (Literal["query"], Literal["mutation"]), - D1, - D2, - D3, - D4, - D5, - D6, - ]( - self: "AsyncClient[AsyncTransport[Params]]", - requests: tuple[ - Request[Type, D1], - Request[Type, D2], - Request[Type, D3], - Request[Type, D4], - Request[Type, D5], - Request[Type, D6], - ], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2, D3, D4, D5, D6]: ... - @overload - async def __call__[**Params, Type: (Literal["query"], Literal["mutation"]), Data]( - self: "AsyncClient[AsyncTransport[Params]]", - requests: Sequence[Request[Type, Data]], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[Data, ...]: ... - async def __call__(self, requests: Any, /, *args: Any, **kwargs: Any) -> Any: - prepared = prepare_requests(requests, injectors=self.injectors) - return ( - () - if prepared is None - else prepared.parse(await self.transport(bytes(prepared), *args, **kwargs)) - ) - - -@final -class SubscriptionClient(Generic[_SubscriptionTransport_co]): - """Run subscription requests over *transport*, returning their response stream, which closing unsubscribes. - - A response with errors raises out of the stream. - """ - - def __init__( - self, - transport: _SubscriptionTransport_co, - /, - *, - injectors: _Injectors | None = None, - ) -> None: - self.transport: Final = transport - self.injectors: Final = injectors - - def __call__[**Params, Data]( - self: "SubscriptionClient[SubscriptionTransport[Params]]", - request: Request[Literal["subscription"], Data], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> Generator[Data, None, None]: - prepared = prepare(request, injectors=self.injectors) - return _response_stream( - self.transport(bytes(prepared), *args, **kwargs), prepared.parse - ) - - -@final -class AsyncSubscriptionClient(Generic[_AsyncSubscriptionTransport_co]): - def __init__( - self, - transport: _AsyncSubscriptionTransport_co, - /, - *, - injectors: _Injectors | None = None, - ) -> None: - self.transport: Final = transport - self.injectors: Final = injectors - - def __call__[**Params, Data]( - self: "AsyncSubscriptionClient[AsyncSubscriptionTransport[Params]]", - request: Request[Literal["subscription"], Data], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> AsyncGenerator[Data, None]: - prepared = prepare(request, injectors=self.injectors) - return _async_response_stream( - self.transport(bytes(prepared), *args, **kwargs), prepared.parse - ) diff --git a/bookshop/client/runtime/injection.py b/bookshop/client/runtime/injection.py deleted file mode 100644 index 2106686..0000000 --- a/bookshop/client/runtime/injection.py +++ /dev/null @@ -1,107 +0,0 @@ -# Code generated by graphql-codegen. DO NOT EDIT. - -import sys -from collections.abc import Callable, Mapping -from typing import Final, cast, final - -from ._reflection import Convert, build_injector_serializers - -if sys.version_info >= (3, 15): - OMITTED = sentinel("OMITTED") # noqa: F821 -else: - from typing_extensions import Sentinel - - OMITTED = Sentinel("OMITTED") -"""What an injector returns to leave its value out rather than send ``null``. - -The spec tells the two apart: a nullable input accepts both, a non-null one neither, and a server may treat them differently. -""" - - -type _Injector = Callable[[], object | OMITTED] - - -@final -class _Injectors: - """Built by the ``injectors()`` function of a generated package's ``injection`` module, from a mapping the package's types check. - - Not a mapping itself, so that a client cannot be given injectors that skipped that check: only a client calls it. - """ - - def __init__( - self, functions: Mapping[str, object], /, *, injector_functions_type: object - ) -> None: - self._functions: Final = { - name: cast(_Injector, function) for name, function in functions.items() - } - self._serializers: Final = build_injector_serializers(injector_functions_type) - - def _inject( - self, - variables: Mapping[str, object], - /, - *, - injections: Mapping[str, frozenset[tuple[str, ...]]], - ) -> Mapping[str, object]: - """Calling the injectors is the one impure step, each once, so that every path of a request receives the same value.""" - values = { - name: function() - for name, function in self._functions.items() - if name in injections - } - return _inject_values( - variables, values, serializers=self._serializers, injections=injections - ) - - -def _inject_values( - variables: Mapping[str, object], - values: Mapping[str, object], - /, - *, - serializers: Mapping[str, Convert | None], - injections: Mapping[str, frozenset[tuple[str, ...]]], -) -> Mapping[str, object]: - for name, value in values.items(): - if value is OMITTED: - continue - - serialize = serializers[name] - serialized = value if serialize is None else serialize(value) - - for path in injections[name]: - injected = _inject_into(variables, path, key=name, value=serialized) - - if injected is not None: - variables = injected - - return variables - - -def _inject_into( - container: Mapping[str, object], - path: tuple[str, ...], - /, - *, - key: str, - value: object, -) -> dict[str, object] | None: - """Copied along the path, since the caller may reuse what it passed. - - ``None`` when an object on the path is absent or null, as a nullable input the caller left out is: there is nothing to inject into. - """ - if not path: - return {**container, key: value} - - head, *rest = path - child = container.get(head) - - if child is None: - return None - - # The generator only emits paths through input objects. - assert isinstance(child, Mapping), ( - f"Expected `{head}` to hold an input object to inject `{key}` into." - ) - injected = _inject_into(child, tuple(rest), key=key, value=value) - return None if injected is None else {**container, head: injected} diff --git a/bookshop/client/runtime/operation.py b/bookshop/client/runtime/operation.py deleted file mode 100644 index 95f8da0..0000000 --- a/bookshop/client/runtime/operation.py +++ /dev/null @@ -1,135 +0,0 @@ -# Code generated by graphql-codegen. DO NOT EDIT. - -from collections.abc import Mapping -from functools import cached_property -from json import dumps -from types import MappingProxyType -from typing import Any, Final, Generic, TypeVar, final, override - -from ._literal import ExecutionErrorHandling, OperationType -from ._reflection import Convert, build_parser, build_serializer -from ._sigil import SIGIL -from .error import ExecutionError - -_VARIABLES_KEY: Final = ',"variables":' -_NO_INJECTIONS: Final[Mapping[str, frozenset[tuple[str, ...]]]] = MappingProxyType( - dict[str, frozenset[tuple[str, ...]]]() -) - -# Declared, since PEP 695 infers variance from every method. -# Covariant, so that an operation of one type, or its request, is also one of any type. -_Type_co = TypeVar("_Type_co", bound=OperationType, covariant=True) -# Both held by `variables_type` and taken by `__call__`. -_Variables = TypeVar("_Variables", bound=Mapping[str, object]) -_Data_co = TypeVar("_Data_co", covariant=True) - - -@final -class Operation(Generic[_Type_co, _Variables, _Data_co]): - """A generated operation, parameterized by its type, what it sends, and what it returns, in the order things happen. - - Its type is a parameter, so that clients tell operation types apart statically: a subscription client takes subscriptions only, and a merge takes operations of one type. - """ - - def __init__( - self, - *, - operation_type: _Type_co, - name: str, - document: str, - variables_type: type[_Variables], - injections: Mapping[str, frozenset[tuple[str, ...]]] = _NO_INJECTIONS, - data_type: type[_Data_co], - ) -> None: - """*document* has the merge sigil wherever its index in a merge goes, and *injections* the path of every object receiving each injector's value, by injector name, `()` being the variables themselves. - - The key an injection writes is the injector's name, which the variables' type lacks, so that callers cannot pass it. - What crosses the wire is read off *variables_type* and *data_type* at runtime: they are the only description of it. - """ - self._operation_type: Final = operation_type - self._name: Final = name - self._document: Final = document - self._variables_type: Final = variables_type - self._injections: Final = injections - self._data_type: Final = data_type - - def __call__(self, variables: _Variables, /) -> "Request[_Type_co, _Data_co]": - """Return the request running this operation with *variables*, checked here, before anything runs. - - Their type is already bound here, so every type checker checks them exactly, whereas from a function taking both an operation and its variables, some type checkers would infer a wider type. - """ - return Request(operation=self, variables=variables, on_execution_error="raise") - - @override - def __repr__(self) -> str: - # The operation, rather than its document. - return ( - f"Operation(operation_type={self._operation_type!r}, name={self._name!r})" - ) - - @cached_property - def _data_parser(self) -> Convert | None: - """Only custom scalars with a codec and `@nonNull` fields cost anything at runtime.""" - return build_parser(self._data_type, partial=False) - - @cached_property - def _partial_data_parser(self) -> Convert | None: - return build_parser(self._data_type, partial=True) - - @cached_property - def _variables_serializer(self) -> Convert | None: - return build_serializer(self._variables_type) - - @cached_property - def _request_prefix(self) -> bytes: - """Serialized once, since the document's string escaping costs as much as the document is long.""" - envelope = dumps( - {"operationName": self._name, "query": self._document.replace(SIGIL, "")} - ) - return (envelope[:-1] + _VARIABLES_KEY).encode() - - def _request_body(self, variables: Mapping[str, object], /) -> bytes: - # `json.dumps()`'s default separators are what make the concatenation valid JSON. - return self._request_prefix + dumps(variables).encode() + b"}" - - -@final -class Request(Generic[_Type_co, _Data_co]): - """An operation with the variable values its caller gives, not yet those the client injects, as a :class:`requests.Request` does not yet hold the headers its session adds. - - It runs nothing: `ListBooks({"first": 10})` is a value that a client runs, synchronously or not. - """ - - __slots__ = ("_on_execution_error", "_operation", "_variables") - - def __init__( - self, - *, - # The variables' type has served its purpose once they are checked. - operation: Operation[_Type_co, Any, _Data_co], - variables: Mapping[str, object], - on_execution_error: ExecutionErrorHandling, - ) -> None: - self._operation: Final = operation - self._variables: Final = variables - self._on_execution_error: Final = on_execution_error - - @override - def __repr__(self) -> str: - return f"Request(operation={self._operation!r}, variables={self._variables!r}, on_execution_error={self._on_execution_error!r})" - - def returning_error( - self, - ) -> "Request[_Type_co, _Data_co | ExecutionError[_Data_co]]": - """Return a copy of this request whose :class:`~.error.ExecutionError` a client returns in place of its data instead of raising it. - - The returned error is typed by the request, and so is its :meth:`~.error.ExecutionError.parse_data`, which no `except` clause can express; a caller tells it apart from the data before use. - Every other error still raises, since it means that the request as a whole failed. - - """ - # A request's data type being covariant, one returning its data is one returning its data or its error. - return Request( - operation=self._operation, - variables=self._variables, - on_execution_error="return", - ) diff --git a/bookshop/get_order_graphql.py b/bookshop/get_order_graphql.py index ca8c9fd..57bf698 100644 --- a/bookshop/get_order_graphql.py +++ b/bookshop/get_order_graphql.py @@ -2,12 +2,13 @@ import builtins as _builtins import typing as _typing -from bookshop.client.runtime import _compat -from bookshop.client import runtime as _runtime -from bookshop.client.schema import enum as _enum +from bookshop.graphql.runtime import _compat +from bookshop.graphql.runtime import _operation_type +from bookshop.graphql import runtime as _runtime +from bookshop.graphql.schema import enum as _enum class GetOrderVariables(_compat.TypedDict, closed=True): - id: _typing.Required[_builtins.str] + id: _builtins.str class _GetOrderData_order(_compat.TypedDict, closed=True): id: _builtins.str @@ -17,7 +18,7 @@ class GetOrderData(_compat.TypedDict, closed=True): order: _GetOrderData_order | None """The order with this identifier.""" -GetOrder: _runtime.Operation[_typing.Literal['query'], GetOrderVariables, GetOrderData] = _runtime.Operation(operation_type='query', name='GetOrder', document="""query GetOrder($id§: ID!) { +GetOrder: _typing.Final = _runtime.Operation(operation_type=_operation_type.QUERY, name='GetOrder', document="""query GetOrder($id§: ID!) { order§: order(id: $id§) { id status diff --git a/bookshop/graphql.config.yml b/bookshop/graphql.config.yml index 9d89710..de5ac23 100644 --- a/bookshop/graphql.config.yml +++ b/bookshop/graphql.config.yml @@ -6,11 +6,11 @@ extensions: pythonCodegen: # Where imports start, relative to this file's directory. moduleRoot: .. - # Dotted name from the module root, so written to `bookshop/client`. - package: bookshop.client + # Dotted name from the module root, so written to `bookshop/graphql`. + package: bookshop.graphql # Name pattern of each document's module, written next to the document. documentSiblingModule: "{document}_graphql" - # Values the client injects, which no call can pass. + # Values an injector supplies, which no call can pass. injectorNames: [idempotencyKey] # Client directive asserting a schema-nullable field is not null. nonNullDirectiveName: nonNull diff --git a/bookshop/client/__init__.py b/bookshop/graphql/__init__.py similarity index 100% rename from bookshop/client/__init__.py rename to bookshop/graphql/__init__.py diff --git a/bookshop/client/_scalar.py b/bookshop/graphql/_scalar.py similarity index 100% rename from bookshop/client/_scalar.py rename to bookshop/graphql/_scalar.py diff --git a/bookshop/client/injection.py b/bookshop/graphql/injection.py similarity index 68% rename from bookshop/client/injection.py rename to bookshop/graphql/injection.py index 2ecaec2..d712124 100644 --- a/bookshop/client/injection.py +++ b/bookshop/graphql/injection.py @@ -1,18 +1,16 @@ # Code generated by graphql-codegen. DO NOT EDIT. import collections.abc as _abc -import typing as _typing from .runtime import _compat from .runtime import injection as _injection -from .runtime import OMITTED as _OMITTED from . import _scalar class InjectorFunctions(_compat.TypedDict, closed=True): """The functions supplying each injected value, by name. Where the value may be null, one returning `OMITTED` leaves it out, and one returning `None` sends `null`.""" - idempotencyKey: _typing.NotRequired[_abc.Callable[[], _scalar.UUID | None | _OMITTED]] + idempotencyKey: _abc.Callable[[], _scalar.UUID] def injectors(functions: InjectorFunctions, /) -> _injection._Injectors: - """Return what a client calls to supply the injected values, each serialized as its type says.""" + """Return what `Request.prepare()` calls to supply the injected values, each serialized as its type says.""" return _injection._Injectors(functions, injector_functions_type=InjectorFunctions) diff --git a/bookshop/client/runtime/__init__.py b/bookshop/graphql/runtime/__init__.py similarity index 56% rename from bookshop/client/runtime/__init__.py rename to bookshop/graphql/runtime/__init__.py index 2ef894f..6e22d8b 100644 --- a/bookshop/client/runtime/__init__.py +++ b/bookshop/graphql/runtime/__init__.py @@ -1,15 +1,9 @@ # Code generated by graphql-codegen. DO NOT EDIT. -from .client import ( - AsyncClient as AsyncClient, - AsyncSubscriptionClient as AsyncSubscriptionClient, - Client as Client, - SubscriptionClient as SubscriptionClient, -) from .error import ( - ClientError as ClientError, Error as Error, ExecutionError as ExecutionError, + GraphqlError as GraphqlError, Location as Location, ProtocolError as ProtocolError, RequestError as RequestError, @@ -17,4 +11,6 @@ UnexpectedNullError as UnexpectedNullError, ) from .injection import OMITTED as OMITTED -from .operation import Operation as Operation, Request as Request +from .merge import merge as merge +from .operation import Operation as Operation +from .request import PreparedRequest as PreparedRequest, Request as Request diff --git a/bookshop/client/runtime/_compat.py b/bookshop/graphql/runtime/_compat.py similarity index 87% rename from bookshop/client/runtime/_compat.py rename to bookshop/graphql/runtime/_compat.py index 9849a07..61dc23b 100644 --- a/bookshop/client/runtime/_compat.py +++ b/bookshop/graphql/runtime/_compat.py @@ -2,7 +2,7 @@ import sys -# Closed TypedDicts (PEP 728) and sentinels (PEP 661) are the last to reach the standard library. +# PEP 728 closed TypedDicts and PEP 661 sentinels reach the standard library in Python 3.15. if sys.version_info >= (3, 15): from typing import ( ReadOnly as ReadOnly, diff --git a/bookshop/graphql/runtime/_execution_error_handling.py b/bookshop/graphql/runtime/_execution_error_handling.py new file mode 100644 index 0000000..57b3734 --- /dev/null +++ b/bookshop/graphql/runtime/_execution_error_handling.py @@ -0,0 +1,10 @@ +# Code generated by graphql-codegen. DO NOT EDIT. + +from typing import Literal + +type ExecutionErrorHandling = Literal["raise", "return"] +"""What `PreparedRequest.parse()` does with the `ExecutionError` of a response. + +- `"raise"` raises it, so the data returned is always complete. +- `"return"` returns it, so the data returned may be the error, typed by the request. +""" diff --git a/bookshop/graphql/runtime/_operation_type.py b/bookshop/graphql/runtime/_operation_type.py new file mode 100644 index 0000000..bade8ef --- /dev/null +++ b/bookshop/graphql/runtime/_operation_type.py @@ -0,0 +1,11 @@ +# Code generated by graphql-codegen. DO NOT EDIT. + +from typing import Final, Literal + +type OperationType = Literal["query", "mutation", "subscription"] + +# Generated operations pass these rather than a string so they do not have to repeat their type in an assignment annotation. +# Pyrefly promotes an inferred literal to `str` in a comprehension's element type: a bare `Final` (which ruff wants) infers it, while `Final[Literal[...]]` declares it. +QUERY: Final[Literal["query"]] = "query" # noqa: PYI064 +MUTATION: Final[Literal["mutation"]] = "mutation" # noqa: PYI064 +SUBSCRIPTION: Final[Literal["subscription"]] = "subscription" # noqa: PYI064 diff --git a/bookshop/client/runtime/_reflection.py b/bookshop/graphql/runtime/_reflection.py similarity index 76% rename from bookshop/client/runtime/_reflection.py rename to bookshop/graphql/runtime/_reflection.py index 928dc31..7611ed6 100644 --- a/bookshop/client/runtime/_reflection.py +++ b/bookshop/graphql/runtime/_reflection.py @@ -30,19 +30,19 @@ type Mode = Literal["parse", "partial parse", "serialize"] """What a builder's converters do. -- ``"parse"`` converts the data of a response in place, and raises on the null of a `@nonNull` field. -- ``"partial parse"`` converts the data of a response with errors in place, propagating the null of a `@nonNull` field to its nearest nullable parent, as the server propagates that of a non-null field which raised. -- ``"serialize"`` copies variables, converting what they hold to what goes on the wire. +- `"parse"` converts the data of a response in place, and raises on the null of a `@nonNull` field. +- `"partial parse"` does the same for a response with errors, but moves the null of a `@nonNull` field up to its nearest nullable parent, as the server does for a non-null field. +- `"serialize"` converts a copy of the variables to what goes on the wire. """ type Convert = Callable[[object], object] -"""A parser may convert a mapping in place, a serializer never does.""" +"""A parser converts in place, a serializer copies: a response is ours, the variables are the caller's.""" _QUALIFIERS: Final = frozenset({NotRequired, ReadOnly, Required}) NON_NULL: Final = SentinelType("NON_NULL") -"""Marks a `@nonNull` field, whose null is legal for the schema but not for the query: null there raises :class:`UnexpectedNullError` instead of passing.""" +"""Marks a `@nonNull` field, whose null raises `UnexpectedNullError`.""" @final @@ -53,7 +53,6 @@ class Codec[Value, Wire]: def _unwrap(type_: object, /) -> tuple[object, tuple[object, ...]]: - """Split *type_* from its :data:`typing.Annotated` metadata, which says what a plain type cannot, such as :data:`NON_NULL` or a :class:`Codec`, and which type checkers ignore.""" metadata: list[object] = [] while True: @@ -101,10 +100,9 @@ def _children(unwrapped_type: object, /) -> tuple[object, ...]: @final class _Builder: - """Builds converters for one mode, caching them, so that the work is paid once per type rather than once per call, and tying the knot on recursive types. + """Builds the converters of one mode, once per type. - The build recurses over the grammar of types (:class:`typing.TypedDict`, lists, `X | None`, type aliases, and unions told apart by `__typename` or, for `@oneOf` inputs, by their single key) rather than over the shapes of any particular document. - Nesting, lists of lists, a `@nonNull` under a list, and recursive inputs thus need no special case. + It recurses over the grammar of types, not over the shape of any document, so nesting, lists of lists, and recursive inputs need no special case. """ def __init__(self, mode: Mode, /) -> None: @@ -122,7 +120,7 @@ def __init__(self, mode: Mode, /) -> None: self._partial: Final = partial self._needs: Final[dict[object, bool]] = {} self._converters: Final[dict[object, Convert]] = {} - # Taken by `build()` alone: a recursive type caches a forwarding converter before its real one exists, which another thread must not get, and call, until it resolves. + # A recursive type caches a forwarding converter before its real one exists, which another thread must not call. self._lock: Final = Lock() def _needs_work(self, type_: object, /) -> bool: @@ -132,11 +130,7 @@ def _needs_work(self, type_: object, /) -> bool: return self._needs[type_] def _solve_needs(self, root: object, /) -> None: - """Settle whether each type reachable from *root* needs work, as a least fixed point. - - Types can reach each other in cycles, such as a filter combining filters, so a type cannot be settled from its children alone: it is settled with everything it reaches. - - """ + # A least fixed point, since types reach each other in cycles, such as a filter combining filters. children: dict[object, tuple[object, ...]] = {} needs: dict[object, bool] = {} pending = [root] @@ -170,7 +164,7 @@ def _solve_needs(self, root: object, /) -> None: self._needs.update(needs) def build(self, type_: object, /) -> Convert | None: - """The converter for *type_*, or ``None`` if its values need no work, so that a response needing nothing goes from the transport to the caller untouched.""" + """Return the converter of *type_*, or `None` if its values need no work.""" with self._lock: return self._build(type_) @@ -181,22 +175,21 @@ def _converter(self, type_: object, /) -> Convert: if type_ in self._converters: return self._converters[type_] - # Recursive types reach themselves while being built: they get a forwarding converter, which reads the real one once it exists. + # A recursive type reaches itself while being built, and gets a forwarding converter until then. cell: list[Convert] = [] self._converters[type_] = lambda value: cell[0](value) # noqa: PLW0108 - converter = self._compute_build(type_) + converter = self._new_converter(type_) cell.append(converter) self._converters[type_] = converter return converter - def _compute_build(self, type_: object, /) -> Convert: + def _new_converter(self, type_: object, /) -> Convert: inner, metadata = _unwrap(type_) codecs = [item for item in metadata if isinstance(item, Codec)] convert: Convert | None = None if codecs: (codec,) = codecs - # It annotates this very type, so it converts its values. convert = cast(Convert, codec.decode if self._parses else codec.encode) elif self._needs_work(inner): convert = self._build_structure(inner) @@ -228,11 +221,10 @@ def _build_typed_dict(self, type_: object, /) -> Convert: def convert(value: object, /) -> object: assert isinstance(value, Mapping) - # A response is the client's own, so it is converted in place; variables are the caller's, who may reuse them, so they are copied. result = cast(dict[str, object], value) if in_place else dict(value) for key, convert_value in steps: - # A key may be absent: an optional input, or a field the server skipped. + # Absent for an optional input, or a field the server skipped. if key in result: try: result[key] = convert_value(result[key]) @@ -245,7 +237,7 @@ def convert(value: object, /) -> object: return convert def _build_union(self, type_: object, members: tuple[object, ...], /) -> Convert: - # A sentinel, such as `OMITTED`, is never converted, as `None` is not. + # A sentinel, such as `OMITTED`, is not converted, like `None`. non_null = tuple( member for member in members @@ -275,7 +267,6 @@ def convert_nullable(value: object, /) -> object: return convert_nullable def _build_dispatch(self, type_: object, members: tuple[object, ...], /) -> Convert: - """Tell the members of a union apart, by `__typename` or else by their single key.""" typed_dicts = [_unwrap(member)[0] for member in members] if not all(is_typeddict(member) for member in typed_dicts): @@ -295,7 +286,7 @@ def _build_dispatch(self, type_: object, members: tuple[object, ...], /) -> Conv def convert_by_typename(value: object, /) -> object: assert isinstance(value, Mapping) - # A member added after generation is not converted: nothing is validated. + # A member added after codegen passes as is: nothing is validated. converter = by_typename.get(value["__typename"]) return value if converter is None else converter(value) @@ -316,7 +307,7 @@ def convert_by_typename(value: object, /) -> object: def convert_by_key(value: object, /) -> object: assert isinstance(value, Mapping) (key,) = value - # A struct's payload may hold a member added after generation, which is not converted either. + # A member added after codegen passes as is, like above. converter = by_key.get(key) return value if converter is None else converter(value) @@ -356,7 +347,6 @@ def check(value: object, /) -> object: def build_parser(type_: object, /, *, partial: bool) -> Convert | None: - """A *partial* parser is for the data of a response with errors, where a `@nonNull` field is null when it raised: the null propagates to the field's nearest nullable parent, and a :class:`UnexpectedNullError` only escapes when there is none.""" return (_PARTIAL_PARSE if partial else _PARSE).build(type_) @@ -368,7 +358,7 @@ def build_injector_serializers( injector_functions_type: object, / ) -> dict[str, Convert | None]: return { - # Each hint is `Callable[[], T]`, whose `T` tells the serializer. + # Each hint is `Callable[[], T]`: the serializer is `T`'s. name: build_serializer(get_args(_unwrap(hint)[0])[-1]) for name, hint in get_type_hints( injector_functions_type, include_extras=True diff --git a/bookshop/graphql/runtime/_sigil.py b/bookshop/graphql/runtime/_sigil.py new file mode 100644 index 0000000..6054145 --- /dev/null +++ b/bookshop/graphql/runtime/_sigil.py @@ -0,0 +1,13 @@ +# Code generated by graphql-codegen. DO NOT EDIT. + +from typing import Final + +SIGIL: Final = "§" +"""Marks where a merge writes the operation's index. + +The generator puts it after the alias of each root field, adding the alias when the document has none, and after each variable's name. +A merge replaces it with the index, so that the root fields and variables of two operations cannot collide. +A request sent alone drops it, and with it the aliases the generator added. +A subscription or an operation with directives of its own cannot be merged, so their documents hold none. +It is escaped in strings, so every one in a document is the generator's. +""" diff --git a/bookshop/client/runtime/error.py b/bookshop/graphql/runtime/error.py similarity index 51% rename from bookshop/client/runtime/error.py rename to bookshop/graphql/runtime/error.py index 7b5ee9f..4468cc3 100644 --- a/bookshop/client/runtime/error.py +++ b/bookshop/graphql/runtime/error.py @@ -7,16 +7,46 @@ from ._compat import TypedDict, TypeVar +class Error(Exception): + """The base of every error the generated package raises. + + Your transport's own errors, such as a timeout or an HTTP status, are not among them. + """ + + +@final +class ProtocolError(Error): + """The response was not a GraphQL response, such as an HTML error page from a proxy.""" + + +@final +class UnexpectedNullError(Error): + """A field marked `@nonNull` was null. + + `path` names the exact value, list indices included. + """ + + def __init__(self, path: list[str | int], /) -> None: + self.path: Final = path + super().__init__() + + @override + def __str__(self) -> str: + formatted = "".join( + f"[{segment}]" if isinstance(segment, int) else f".{segment}" + for segment in self.path + ).removeprefix(".") + return f"Expected `{formatted}` to not be null." + + class Location(TypedDict): line: Required[int] column: Required[int] -class Error(TypedDict): - """An error as a server sends it, in the spec's shape, which graphql-core's :class:`~graphql.GraphQLFormattedError` follows too. - - Not graphql-core's :class:`~graphql.GraphQLError`, because four of its eight attributes (:attr:`~graphql.GraphQLError.nodes`, :attr:`~graphql.GraphQLError.source`, :attr:`~graphql.GraphQLError.positions`, and :attr:`~graphql.GraphQLError.original_error`) are server-side execution state a client cannot fill, and importing it would put graphql-core in the runtime for nothing. - """ +# Not graphql-core's `GraphQLError`: half of its attributes are server-side state, and importing it would make the generated package depend on graphql-core. +class GraphqlError(TypedDict): + """An error as the server sends it, in the spec's shape.""" message: Required[str] locations: NotRequired[Sequence[Location]] @@ -24,23 +54,15 @@ class Error(TypedDict): extensions: NotRequired[object] -class ClientError(Exception): - """Base of every error a generated client raises. - - A transport's own errors, such as a timeout or an HTTP status, pass through as they are: the client does not know what carries its requests. - Not :func:`typing.final`: it exists so a caller can catch anything this library raises with one `except`. - """ - - -class ResponseError(ClientError): - """The response carried errors, in either of the two shapes the spec gives such a response. - - A response carrying any error raises, so that the data a client returns never holds a `null` standing for an error. - Not :func:`typing.final`: it exists so a caller can catch both shapes with one `except`. - """ +class ResponseError(Error): + """Raised for any error, even with data beside it: a field that failed is `null` there, which looks like a null field.""" def __init__( - self, errors: Sequence[Error], /, *, extensions: Mapping[str, object] | None + self, + errors: Sequence[GraphqlError], + /, + *, + extensions: Mapping[str, object] | None, ) -> None: assert errors, "Cannot raise without at least one error." self.errors: Final = tuple(errors) @@ -50,13 +72,10 @@ def __init__( @final class RequestError(ResponseError): - """The response is the spec's request error result: the request failed before execution, so it has no data. - - A syntax error, a validation failure, or an invalid variable value, for instance. - """ + """The request failed before execution, so the response has no data.""" -# The type of an execution error's parsed data: the operation's data type when a client returns the error, typed by its request, and a plain mapping when `except` catches it, since that names no operation. +# The default types what `except` catches, which names no operation: `Mapping[str, object]`. # A legacy `TypeVar`, since a default for a PEP 695 type parameter needs Python 3.13. _Data_co = TypeVar("_Data_co", covariant=True, default=Mapping[str, object]) @@ -71,7 +90,7 @@ class ExecutionError(ResponseError, Generic[_Data_co]): data: Final[Mapping[str, object] | None] def parse_data(self) -> _Data_co | None: - """Return the data converted as in a response without errors, in a fresh copy. + """Return a fresh copy of the data, converted as in a response without errors. A `@nonNull` field that raised moves its `null` up to its nearest nullable parent, as the server does for a non-null field. A custom scalar's codec may raise on a value it cannot decode. @@ -87,12 +106,11 @@ def parse_data(self) -> _Data_co | None: except UnexpectedNullError: return None - # Nothing is validated: the data is trusted to match its type, as in a response without errors. return cast(_Data_co, data) def __init__( self, - errors: Sequence[Error], + errors: Sequence[GraphqlError], /, *, data: Mapping[str, object] | None, @@ -102,34 +120,3 @@ def __init__( super().__init__(errors, extensions=extensions) self.data = data self._partial_data_parser: Final = partial_data_parser - - -@final -class ProtocolError(ClientError): - """The response was not a GraphQL response. - - Usually an HTML error page from a proxy. - Without this the caller would get a bare :exc:`json.JSONDecodeError` from somewhere inside the client. - """ - - -@final -class UnexpectedNullError(ClientError): - """A field marked `@nonNull` was null. - - The schema declares the field nullable, so only the client can assert otherwise. - - The path names the exact value, list indices included: it is completed on the way up by each enclosing object and list, which costs nothing unless the error is raised. - """ - - def __init__(self, path: list[str | int], /) -> None: - self.path: Final = path - super().__init__() - - @override - def __str__(self) -> str: - formatted = "".join( - f"[{segment}]" if isinstance(segment, int) else f".{segment}" - for segment in self.path - ).removeprefix(".") - return f"Expected `{formatted}` to not be null." diff --git a/bookshop/graphql/runtime/injection.py b/bookshop/graphql/runtime/injection.py new file mode 100644 index 0000000..33bc8e5 --- /dev/null +++ b/bookshop/graphql/runtime/injection.py @@ -0,0 +1,86 @@ +# Code generated by graphql-codegen. DO NOT EDIT. + +import sys +from collections.abc import Callable, Mapping +from typing import Final, cast, final + +from ._reflection import build_injector_serializers + +if sys.version_info >= (3, 15): + OMITTED = sentinel("OMITTED") # noqa: F821 +else: + from typing_extensions import Sentinel + + OMITTED = Sentinel("OMITTED") +"""What an injector returns to leave its value out, rather than send `null`. + +A nullable input accepts both, and a server may treat them differently. +""" + + +type _Injector = Callable[[], object | OMITTED] + + +@final +class _Injectors: + """Only the generated `injectors()` builds one, so `Request.prepare()` never calls a function the package's types did not check.""" + + def __init__( + self, functions: Mapping[str, object], /, *, injector_functions_type: object + ) -> None: + self._functions: Final = { + name: cast(_Injector, function) for name, function in functions.items() + } + self._serializers: Final = build_injector_serializers(injector_functions_type) + + def _inject( + self, + variables: Mapping[str, object], + /, + *, + injections: Mapping[str, frozenset[tuple[str, ...]]], + ) -> Mapping[str, object]: + for name, function in self._functions.items(): + if name not in injections: + continue + + # Called once per request, so every path gets the same value. + value = function() + + if value is OMITTED: + continue + + serialize = self._serializers[name] + serialized = value if serialize is None else serialize(value) + + for path in injections[name]: + injected = _inject_into(variables, path, key=name, value=serialized) + + if injected is not None: + variables = injected + + return variables + + +def _inject_into( + container: Mapping[str, object], + path: tuple[str, ...], + /, + *, + key: str, + value: object, +) -> dict[str, object] | None: + if not path: + return {**container, key: value} + + head, *rest = path + child = container.get(head) + + if child is None: + return None + + assert isinstance(child, Mapping), ( + f"Expected `{head}` to hold an input object to inject `{key}` into." + ) + injected = _inject_into(child, tuple(rest), key=key, value=value) + return None if injected is None else {**container, head: injected} diff --git a/bookshop/graphql/runtime/merge.py b/bookshop/graphql/runtime/merge.py new file mode 100644 index 0000000..c18fd9e --- /dev/null +++ b/bookshop/graphql/runtime/merge.py @@ -0,0 +1,324 @@ +# Code generated by graphql-codegen. DO NOT EDIT. + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from functools import cache +from json import dumps +from typing import Any, Final, Literal, assert_never, cast, final, overload, override + +from ._operation_type import OperationType +from ._sigil import SIGIL +from .error import ExecutionError, GraphqlError, UnexpectedNullError +from .injection import _Injectors +from .operation import ( + _OperationRequest, + _origin_note, + _parse_response, + _resolve_variables, +) +from .request import PreparedRequest, Request + + +@final +class _MergedRequest[Type: OperationType, Data](Request[Type, Data]): + def __init__(self, requests: Sequence[_OperationRequest[Type, object]], /) -> None: + if not requests: + raise ValueError("Cannot merge no request.") + + super().__init__(operation_type=requests[0]._operation_type) + self._requests: Final = tuple(requests) + + @override + def __repr__(self) -> str: + return f"merge({', '.join(map(repr, self._requests))})" + + @override + def prepare(self, *, injectors: _Injectors | None = None) -> PreparedRequest[Data]: + # Only `merge()`'s overloads know the tuple's type. + return cast( + PreparedRequest[Data], + _prepare_merged(self._operation_type, self._requests, injectors=injectors), + ) + + +# Constrained rather than bound, so every merged request has the same type, and none is a subscription. +# Typed position by position up to 6 requests, as many as typeshed's `asyncio.gather()`: https://github.com/python/typeshed/blob/1ee8b928886a93b060bdafbf1e30dc0bb7c85848/stdlib/asyncio/tasks.pyi#L108-L155 +@overload +def merge[Type: (Literal["query"], Literal["mutation"]), D1, D2]( + first: _OperationRequest[Type, D1], second: _OperationRequest[Type, D2], / +) -> Request[Type, tuple[D1, D2]]: ... +@overload +def merge[Type: (Literal["query"], Literal["mutation"]), D1, D2, D3]( + first: _OperationRequest[Type, D1], + second: _OperationRequest[Type, D2], + third: _OperationRequest[Type, D3], + /, +) -> Request[Type, tuple[D1, D2, D3]]: ... +@overload +def merge[Type: (Literal["query"], Literal["mutation"]), D1, D2, D3, D4]( + first: _OperationRequest[Type, D1], + second: _OperationRequest[Type, D2], + third: _OperationRequest[Type, D3], + fourth: _OperationRequest[Type, D4], + /, +) -> Request[Type, tuple[D1, D2, D3, D4]]: ... +@overload +def merge[Type: (Literal["query"], Literal["mutation"]), D1, D2, D3, D4, D5]( + first: _OperationRequest[Type, D1], + second: _OperationRequest[Type, D2], + third: _OperationRequest[Type, D3], + fourth: _OperationRequest[Type, D4], + fifth: _OperationRequest[Type, D5], + /, +) -> Request[Type, tuple[D1, D2, D3, D4, D5]]: ... +@overload +def merge[Type: (Literal["query"], Literal["mutation"]), D1, D2, D3, D4, D5, D6]( + first: _OperationRequest[Type, D1], + second: _OperationRequest[Type, D2], + third: _OperationRequest[Type, D3], + fourth: _OperationRequest[Type, D4], + fifth: _OperationRequest[Type, D5], + sixth: _OperationRequest[Type, D6], + /, +) -> Request[Type, tuple[D1, D2, D3, D4, D5, D6]]: ... +@overload +def merge[Type: (Literal["query"], Literal["mutation"]), Data]( + requests: Sequence[_OperationRequest[Type, Data]], / +) -> Request[Type, tuple[Data, ...]]: ... +def merge(*args: Any) -> Request[Any, Any]: + """Return one request for several queries, or several mutations, whose data is a tuple of their data. + + Positional requests are typed position by position, a sequence by its common data type. + + The errors of a response are split per request: `PreparedRequest.parse()` raises them as an `ExceptionGroup`, unless every failing request is from `returning_error()`, in which case each gets its error in its place. + A response with no data raises its error unsplit. + """ + # The sequence overload takes one argument, the others take the requests themselves. + requests = args[0] if len(args) == 1 else args + return _MergedRequest(requests) + + +def _prepare_merged( + operation_type: OperationType, + requests: Sequence[_OperationRequest[OperationType, object]], + /, + *, + injectors: _Injectors | None, +) -> PreparedRequest[tuple[object, ...]]: + operations = [request._operation for request in requests] + variables = [ + _resolve_variables(request._operation, request._variables, injectors=injectors) + for request in requests + ] + count = len(requests) + # Sent as is when alone, so the server knows it by its own name. + body = ( + operations[0]._request_body(variables[0]) + if count == 1 + else _encode_body( + operation_type, + [ + _Part( + name=operation._name, + document=operation._document, + variables=operation_variables, + ) + for operation, operation_variables in zip( + operations, variables, strict=True + ) + ], + ) + ) + + def parse(raw: bytes, /) -> tuple[object, ...]: + failures: dict[int, ExecutionError[object]] = {} + + try: + data = _parse_response(raw, partial_data_parser=None) + except ExecutionError as error: + # With no data, the merge failed as a whole: no request's error to return. + if error.data is None: + raise + + split = _split_merged_data(error.data, count=count) + + for index, (operation, operation_variables, operation_errors) in enumerate( + zip( + operations, + variables, + _split_merged_errors(error.errors, count=count), + strict=True, + ) + ): + if operation_errors: + failure = ExecutionError[object]( + operation_errors, + data=split[index], + extensions=error.extensions, + partial_data_parser=operation._partial_data_parser, + ) + failure.add_note(_origin_note(operation._name, operation_variables)) + failures[index] = failure + + # One raising request raises the group of every failure, those of requests returning theirs included. + if any(_raises_execution_error(requests[index]) for index in failures): + raise ExceptionGroup( + f"{len(failures)} of the {count} merged operations raised.", + list(failures.values()), + ) from None + else: + split = _split_merged_data(data, count=count) + + for index, (operation, operation_variables, operation_data) in enumerate( + zip(operations, variables, split, strict=True) + ): + if index not in failures and operation._data_parser is not None: + try: + operation._data_parser(operation_data) + except UnexpectedNullError as null_error: + null_error.add_note( + _origin_note(operation._name, operation_variables) + ) + raise + + return tuple( + failures.get(index, operation_data) + for index, operation_data in enumerate(split) + ) + + return PreparedRequest(body, parse=parse) + + +def _raises_execution_error( + request: _OperationRequest[OperationType, object], / +) -> bool: + match request._on_execution_error: + case "raise": + return True + case "return": + return False + case _ as never: + assert_never(never) + + +_MERGED_OPERATION_NAME: Final = "MergedOperation" + +_INDEX_SEPARATOR: Final = "_" + + +@final +@dataclass(frozen=True, kw_only=True) +class _Part: + name: str + document: str + variables: Mapping[str, object] + + +@cache +def _template_parts(document: str, /) -> tuple[str, str, Mapping[str, str]]: + operation, *fragment_definitions = document.split("\n\n") + header, *root_selections, _ = operation.split("\n") + # No directives on an operation holding the sigil, so the header ends with its variable definitions. + variable_definitions = ( + header[header.index("(") + 1 : header.rindex(")")] if "(" in header else "" + ) + fragments = { + definition.split(" ", 2)[1]: definition for definition in fragment_definitions + } + return variable_definitions, "\n".join(root_selections), fragments + + +def _encode_body(operation_type: OperationType, parts: Sequence[_Part], /) -> bytes: + assert len(parts) > 1 + + variable_definitions: list[str] = [] + selections: list[str] = [] + fragments: dict[str, str] = {} + variables: dict[str, object] = {} + + for index, part in enumerate(parts): + if SIGIL not in part.document: + raise ValueError( + f"Cannot merge `{part.name}`: its document has no `{SIGIL}` where its index would go." + ) + + definitions, root_selections, operation_fragments = _template_parts( + part.document + ) + suffix = f"{_INDEX_SEPARATOR}{index}" + + if definitions: + variable_definitions.append(definitions.replace(SIGIL, suffix)) + + selections.append(root_selections.replace(SIGIL, suffix)) + fragments.update(operation_fragments) + variables.update( + {f"{name}{suffix}": value for name, value in part.variables.items()} + ) + + signature = f"({', '.join(variable_definitions)})" if variable_definitions else "" + document = "\n\n".join( + [ + "\n".join( + [ + f"{operation_type} {_MERGED_OPERATION_NAME}{signature} {{", + *selections, + "}", + ] + ), + *fragments.values(), + ], + ) + return dumps( + { + "operationName": _MERGED_OPERATION_NAME, + "query": document, + "variables": variables, + }, + ).encode() + + +def _split_response_name(key: str, /) -> tuple[str, int]: + response_name, index = key.rsplit(_INDEX_SEPARATOR, 1) + return response_name, int(index) + + +def _split_merged_data( + data: Mapping[str, object], /, *, count: int +) -> Sequence[Mapping[str, object]]: + # Preallocated: an operation whose every root field was skipped still gets its own, empty, data. + if count == 1: + return [data] + + split: list[dict[str, object]] = [{} for _ in range(count)] + + for key, value in data.items(): + response_name, index = _split_response_name(key) + split[index][response_name] = value + + return split + + +def _split_merged_errors( + errors: Sequence[GraphqlError], /, *, count: int +) -> Sequence[Sequence[GraphqlError]]: + # An error's path starts with a suffixed root field, which tells its operation; one without a path concerns them all. + if count == 1: + return [errors] + + split: list[list[GraphqlError]] = [[] for _ in range(count)] + + for error in errors: + path = error.get("path") or () + + if not path or not isinstance(path[0], str): + for operation_errors in split: + operation_errors.append(error) + continue + + response_name, index = _split_response_name(path[0]) + split[index].append({**error, "path": [response_name, *path[1:]]}) + + return split diff --git a/bookshop/graphql/runtime/operation.py b/bookshop/graphql/runtime/operation.py new file mode 100644 index 0000000..391ac10 --- /dev/null +++ b/bookshop/graphql/runtime/operation.py @@ -0,0 +1,235 @@ +# Code generated by graphql-codegen. DO NOT EDIT. + +from __future__ import annotations + +import re +from collections.abc import Mapping +from functools import cached_property +from json import JSONDecodeError, dumps, loads +from types import MappingProxyType +from typing import Any, Final, assert_never, cast, final, override + +from ._execution_error_handling import ExecutionErrorHandling +from ._operation_type import OperationType +from ._reflection import Convert, build_parser, build_serializer +from ._sigil import SIGIL +from .error import Error, ExecutionError, GraphqlError, ProtocolError, RequestError +from .injection import _Injectors +from .request import PreparedRequest, Request + +# A root field the generator aliased as itself, for the suffix a merge adds. +_REDUNDANT_ALIAS: Final = re.compile( + rf"^(?P +)(?P\w+){SIGIL}: (?P=name)\b", re.MULTILINE +) + +_NO_INJECTIONS: Final[Mapping[str, frozenset[tuple[str, ...]]]] = MappingProxyType( + dict[str, frozenset[tuple[str, ...]]]() +) + + +@final +class Operation[Type: OperationType, Variables: Mapping[str, object], Data]: + """A generated operation, typed by its operation type, its variables, and its data. + + The operation type is a type parameter, so a transport can take subscriptions only, and a merge operations of one type. + """ + + def __init__( + self, + *, + operation_type: Type, + name: str, + document: str, + variables_type: type[Variables], + injections: Mapping[str, frozenset[tuple[str, ...]]] = _NO_INJECTIONS, + data_type: type[Data], + ) -> None: + """*injections* maps each injector's name to the paths of the objects receiving its value, `()` being the variables themselves. + + What crosses the wire is read off *variables_type* and *data_type* at runtime. + """ + self._operation_type: Final = operation_type + self._name: Final = name + self._document: Final = document + self._variables_type: Final = variables_type + self._injections: Final = injections + self._data_type: Final = data_type + + def __call__(self, variables: Variables, /) -> _OperationRequest[Type, Data]: + """Return the request of this operation with *variables*. + + A method rather than a function taking both, so every type checker checks the variables against their exact type. + """ + return _OperationRequest( + operation=self, variables=variables, on_execution_error="raise" + ) + + @override + def __repr__(self) -> str: + return ( + f"Operation(operation_type={self._operation_type!r}, name={self._name!r})" + ) + + @cached_property + def _data_parser(self) -> Convert | None: + return build_parser(self._data_type, partial=False) + + @cached_property + def _partial_data_parser(self) -> Convert | None: + return build_parser(self._data_type, partial=True) + + @cached_property + def _variables_serializer(self) -> Convert | None: + return build_serializer(self._variables_type) + + @cached_property + def _request_prefix(self) -> bytes: + # Escaping the document costs as much as it is long, so it is done once. + document = _REDUNDANT_ALIAS.sub(r"\g\g", self._document).replace( + SIGIL, "" + ) + envelope = dumps({"operationName": self._name, "query": document}) + return f'{envelope[:-1]},"variables":'.encode() + + def _request_body(self, variables: Mapping[str, object], /) -> bytes: + # `json.dumps()`'s default separators are what make the concatenation valid JSON. + return self._request_prefix + dumps(variables).encode() + b"}" + + +@final +class _OperationRequest[Type: OperationType, Data](Request[Type, Data]): + def __init__( + self, + *, + # `Any`: `Operation.__call__` already checked the variables. + operation: Operation[Type, Any, Data], + variables: Mapping[str, object], + on_execution_error: ExecutionErrorHandling, + ) -> None: + super().__init__(operation_type=operation._operation_type) + self._operation: Final = operation + self._variables: Final = variables + self._on_execution_error: Final = on_execution_error + + @override + def __repr__(self) -> str: + return f"Request(operation={self._operation!r}, variables={self._variables!r}, on_execution_error={self._on_execution_error!r})" + + def returning_error( + self, + ) -> _OperationRequest[Type, Data | ExecutionError[Data]]: + """Return a copy of this request for which `PreparedRequest.parse()` returns its `ExecutionError` instead of raising it.""" + # Sound since `Data` is covariant: a request returning its data is one returning its data or its error. + return _OperationRequest( + operation=self._operation, + variables=self._variables, + on_execution_error="return", + ) + + @override + def prepare(self, *, injectors: _Injectors | None = None) -> PreparedRequest[Data]: + operation = self._operation + variables = _resolve_variables(operation, self._variables, injectors=injectors) + on_execution_error = self._on_execution_error + + def parse(raw: bytes, /) -> Data: + try: + return _parse_data(operation, raw) + except Error as error: + error.add_note(_origin_note(operation._name, variables)) + + if isinstance(error, ExecutionError): + match on_execution_error: + case "raise": + pass + case "return": + # `Data` includes the error: see `returning_error()`. + return cast(Data, error) + case _ as never: + assert_never(never) + + raise + + return PreparedRequest(operation._request_body(variables), parse=parse) + + +def _resolve_variables( + operation: Operation[OperationType, Any, object], + variables: Mapping[str, object], + /, + *, + injectors: _Injectors | None = None, +) -> Mapping[str, object]: + serialize = operation._variables_serializer + values = ( + variables + if serialize is None + else cast(Mapping[str, object], serialize(variables)) + ) + + if injectors is not None: + return injectors._inject(values, injections=operation._injections) + + # A runtime check, since a request's type does not say what it injects. + if operation._injections: + raise ValueError( + f"Cannot prepare `{operation._name}` without injectors: it injects {', '.join(sorted(operation._injections))}." + ) + + return values + + +def _parse_data[Data]( + operation: Operation[OperationType, Any, Data], raw: bytes, / +) -> Data: + data = _parse_response(raw, partial_data_parser=operation._partial_data_parser) + + if operation._data_parser is not None: + operation._data_parser(data) + + return cast(Data, data) + + +def _parse_response( + raw: bytes, /, *, partial_data_parser: Convert | None +) -> Mapping[str, object]: + try: + payload = loads(raw) + except JSONDecodeError as error: + raise ProtocolError( + f"Expected a JSON response but got: {raw[:200]!r}." + ) from error + + if not isinstance(payload, dict): + raise ProtocolError(f"Expected a JSON object response but got: {raw[:200]!r}.") + + errors = payload.get("errors") + + if errors: + formatted = cast(list[GraphqlError], errors) + extensions = payload.get("extensions") + + # The spec's request error result has no `data` entry at all. + if "data" not in payload: + raise RequestError(formatted, extensions=extensions) + + raise ExecutionError( + formatted, + data=payload["data"], + extensions=extensions, + partial_data_parser=partial_data_parser, + ) + + data = payload.get("data") + + if not isinstance(data, dict): + raise ProtocolError( + f"Expected the response to carry `data` but got: {raw[:200]!r}." + ) + + return cast(Mapping[str, object], data) + + +def _origin_note(operation_name: str, variables: Mapping[str, object], /) -> str: + # `repr()`, so a `str` subclass redacting its own keeps a secret out of logs. + return f"Raised by `{operation_name}` with variables {variables!r}." diff --git a/bookshop/graphql/runtime/request.py b/bookshop/graphql/runtime/request.py new file mode 100644 index 0000000..319e54e --- /dev/null +++ b/bookshop/graphql/runtime/request.py @@ -0,0 +1,34 @@ +# Code generated by graphql-codegen. DO NOT EDIT. + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Callable +from typing import Final, final + +from ._operation_type import OperationType +from .injection import _Injectors + + +class Request[Type: OperationType, Data](ABC): + def __init__(self, *, operation_type: Type) -> None: + """Private.""" + self._operation_type: Final = operation_type + + @abstractmethod + def prepare(self, *, injectors: _Injectors | None = None) -> PreparedRequest[Data]: + """*injectors* are called once, here, so the prepared request always sends the same values.""" + + +@final +class PreparedRequest[Data]: + def __init__(self, body: bytes, /, *, parse: Callable[[bytes], Data]) -> None: + """Private.""" + self._body: Final = body + self._parse: Final = parse + + def __bytes__(self) -> bytes: + return self._body + + def parse(self, raw: bytes, /) -> Data: + return self._parse(raw) diff --git a/bookshop/client/schema/__init__.py b/bookshop/graphql/schema/__init__.py similarity index 100% rename from bookshop/client/schema/__init__.py rename to bookshop/graphql/schema/__init__.py diff --git a/bookshop/client/schema/enum.py b/bookshop/graphql/schema/enum.py similarity index 100% rename from bookshop/client/schema/enum.py rename to bookshop/graphql/schema/enum.py diff --git a/bookshop/client/schema/input.py b/bookshop/graphql/schema/input.py similarity index 62% rename from bookshop/client/schema/input.py rename to bookshop/graphql/schema/input.py index 62d364c..64a8074 100644 --- a/bookshop/client/schema/input.py +++ b/bookshop/graphql/schema/input.py @@ -5,54 +5,54 @@ import collections.abc as _abc import typing as _typing from ..runtime import _compat -from . import enum as _enum +from ..schema import enum as _enum from .. import _scalar type _BookFilter_Ref = BookFilter class _BookLookup_id(_compat.TypedDict, closed=True): - id: _typing.Required[_builtins.str] + id: _builtins.str class _BookLookup_isbn(_compat.TypedDict, closed=True): - isbn: _typing.Required[_scalar.ISBN] + isbn: _scalar.ISBN type BookLookup = _BookLookup_id | _BookLookup_isbn """A book, found by exactly one of its identifiers.""" class _BookFilter_genre(_compat.TypedDict, closed=True): - genre: _typing.Required[_enum.Genre] + genre: _enum.Genre class _BookFilter_author(_compat.TypedDict, closed=True): - author: _typing.Required[_builtins.str] + author: _builtins.str class _BookFilter_priceBelow(_compat.TypedDict, closed=True): - priceBelow: _typing.Required[_scalar.Money] + priceBelow: _scalar.Money -_BookFilter_and = _compat.TypedDict('_BookFilter_and', {'and': _typing.Required[_abc.Sequence[_BookFilter_Ref]]}, closed=True) +_BookFilter_and = _compat.TypedDict('_BookFilter_and', {'and': _abc.Sequence[_BookFilter_Ref]}, closed=True) -_BookFilter_or = _compat.TypedDict('_BookFilter_or', {'or': _typing.Required[_abc.Sequence[_BookFilter_Ref]]}, closed=True) +_BookFilter_or = _compat.TypedDict('_BookFilter_or', {'or': _abc.Sequence[_BookFilter_Ref]}, closed=True) -_BookFilter_not = _compat.TypedDict('_BookFilter_not', {'not': _typing.Required[_BookFilter_Ref]}, closed=True) +_BookFilter_not = _compat.TypedDict('_BookFilter_not', {'not': _BookFilter_Ref}, closed=True) type BookFilter = _BookFilter_genre | _BookFilter_author | _BookFilter_priceBelow | _BookFilter_and | _BookFilter_or | _BookFilter_not """A recursive filter on books.""" class PlaceOrderInput(_compat.TypedDict, closed=True): - lines: _typing.Required[_abc.Sequence[OrderLineInput]] - shippingAddress: _typing.Required[Address] + lines: _abc.Sequence[OrderLineInput] + shippingAddress: Address giftWrap: _typing.NotRequired[_builtins.bool] coupons: _typing.NotRequired[_abc.Sequence[_builtins.str]] note: _typing.NotRequired[_builtins.str | None] class OrderLineInput(_compat.TypedDict, closed=True): - book: _typing.Required[_builtins.str] + book: _builtins.str quantity: _typing.NotRequired[_builtins.int] class Address(_compat.TypedDict, closed=True): - street: _typing.Required[_builtins.str] - city: _typing.Required[_builtins.str] - postalCode: _typing.Required[_builtins.str] - country: _typing.Required[_builtins.str] + street: _builtins.str + city: _builtins.str + postalCode: _builtins.str + country: _builtins.str class CancelOrderInput(_compat.TypedDict, closed=True): - order: _typing.Required[_builtins.str] + order: _builtins.str diff --git a/bookshop/mime_type.py b/bookshop/mime_type.py deleted file mode 100644 index 079269f..0000000 --- a/bookshop/mime_type.py +++ /dev/null @@ -1,5 +0,0 @@ -from mimetypes import types_map - -EVENT_STREAM = "text/event-stream" -GRAPHQL_RESPONSE = "application/graphql-response+json" -JSON = types_map[".json"] diff --git a/bookshop/quickstart.py b/bookshop/quickstart.py index f224208..ce0555e 100644 --- a/bookshop/quickstart.py +++ b/bookshop/quickstart.py @@ -1,15 +1,13 @@ from typing import Literal, assert_never, assert_type -from bookshop.client.runtime import Client -from bookshop.client.schema import OrderStatus from bookshop.get_order_graphql import GetOrder -from bookshop.transport import transport +from bookshop.graphql.schema import OrderStatus +from bookshop.transport import send -client = Client(transport) -data = client(GetOrder({"id": "o1"})) +data = send(GetOrder({"id": "o1"})) order = data["order"] -# `Query.order`'s type is nullable, so the type checker requires this test. +# `Query.order`'s type is nullable, so the type checker requires this check. if order is None: print("No such order.") else: diff --git a/bookshop/schema.graphqls b/bookshop/schema.graphqls index d3064dd..d3e4346 100644 --- a/bookshop/schema.graphqls +++ b/bookshop/schema.graphqls @@ -179,7 +179,7 @@ input BookFilter @oneOf { input PlaceOrderInput { "Makes placing the same order twice harmless: the client sends a new one per order." - idempotencyKey: UUID + idempotencyKey: UUID! lines: [OrderLineInput!]! shippingAddress: Address! giftWrap: Boolean! = false @@ -201,7 +201,7 @@ input Address { input CancelOrderInput { "Makes canceling the same order twice harmless." - idempotencyKey: UUID + idempotencyKey: UUID! order: ID! } diff --git a/bookshop/transport.py b/bookshop/transport.py index a0a177c..500ccc3 100644 --- a/bookshop/transport.py +++ b/bookshop/transport.py @@ -1,27 +1,27 @@ -"""The bookshop's synchronous transports (using the standard library alone).""" +"""The bookshop's synchronous IO, with the standard library alone.""" from collections.abc import Generator from http.client import HTTPResponse -from typing import Protocol, TypeAlias +from typing import Final, Literal from urllib.error import HTTPError -from urllib.request import Request, urlopen +from urllib.request import Request as HTTPRequest, urlopen +from uuid import uuid4 -from bookshop import mime_type -from bookshop.client.runtime import ( - Client as _Client, - SubscriptionClient as _SubscriptionClient, -) +from bookshop.graphql.injection import injectors +from bookshop.graphql.runtime import Request -BASE_URL = "https://bookshop.example" +BASE_URL: Final = "https://bookshop.example" +INJECTORS: Final = injectors({"idempotencyKey": uuid4}) +ATTEMPTS: Final = 3 def _post( path: str, body: bytes, /, *, accept: str, timeout: float | None ) -> HTTPResponse: - request = Request( # noqa: S310 + request = HTTPRequest( # noqa: S310 BASE_URL + path, + headers={"Accept": accept, "Content-Type": "application/json"}, data=body, - headers={"Accept": accept, "Content-Type": mime_type.JSON}, ) response = urlopen(request, timeout=timeout) # noqa: S310 @@ -30,49 +30,55 @@ def _post( return response -def transport(body: bytes, /, *, timeout: float | None = None) -> bytes: - retries = 2 +def send[Data]( + request: Request[Literal["query", "mutation"], Data], + /, + *, + timeout: float | None = None, +) -> Data: + prepared = request.prepare(injectors=INJECTORS) + # Built once, so that a retry sends the same idempotency key. + request_body = bytes(prepared) + attempts_left = ATTEMPTS + # A connection per call keeps the example simple: an HTTP library, such as httpx2 in `async_transport.py`, would pool them. while True: try: response = _post( - "/graphql", body, accept=mime_type.GRAPHQL_RESPONSE, timeout=timeout + "/graphql", + request_body, + accept="application/graphql-response+json", + timeout=timeout, ) except HTTPError as error: # GraphQL over HTTP sends a request error as a response with a 4xx status. - if error.headers.get_content_type() != mime_type.GRAPHQL_RESPONSE: + if error.headers.get_content_type() != "application/graphql-response+json": raise response = error except ConnectionError: - # The response was lost. - if not retries: + attempts_left -= 1 + + if not attempts_left: raise - retries -= 1 continue with response: - return response.read() - - -class Transport(Protocol): - def __call__(self, body: bytes, /, *, timeout: float | None = None) -> bytes: ... - - -# Keeps the protocol in sync. -_transport: Transport = transport + response_body = response.read() + return prepared.parse(response_body) -# Not a `type` statement, which could not be called to build a client. -Client: TypeAlias = _Client[Transport] # noqa: UP040 +def subscribe[Data]( + request: Request[Literal["subscription"], Data], / +) -> Generator[Data, None, None]: + prepared = request.prepare(injectors=INJECTORS) + request_body = bytes(prepared) -def subscription_transport(body: bytes, /) -> Generator[bytes, None, None]: - """Yield the data of each `next` event, following the GraphQL over Server-Sent Events protocol.""" - # Closing the generator leaves the block, which closes the connection: that is how to unsubscribe. + # Closing the generator leaves the block, closes the connection, and ends the subscription. with _post( - "/graphql", body, accept=mime_type.EVENT_STREAM, timeout=None + "/graphql", request_body, accept="text/event-stream", timeout=None ) as response: event = "" data: list[str] = [] @@ -86,7 +92,7 @@ def subscription_transport(body: bytes, /) -> Generator[bytes, None, None]: return if event == "next": - yield "\n".join(data).encode() + yield prepared.parse("\n".join(data).encode()) event = "" data = [] @@ -99,13 +105,3 @@ def subscription_transport(body: bytes, /) -> Generator[bytes, None, None]: event = value elif field == "data": data.append(value) - - -class SubscriptionTransport(Protocol): - def __call__(self, body: bytes, /) -> Generator[bytes, None, None]: ... - - -# Keeps the protocol in sync. -_subscription_transport: SubscriptionTransport = subscription_transport - -SubscriptionClient: TypeAlias = _SubscriptionClient[SubscriptionTransport] # noqa: UP040 diff --git a/prek.toml b/prek.toml index afc28bf..5be1bda 100644 --- a/prek.toml +++ b/prek.toml @@ -1,5 +1,5 @@ -# The generated client is compared byte for byte by the golden tests. -exclude = "^bookshop/client/" +# The generated package is compared byte for byte by the CLI test. +exclude = "^bookshop/graphql/" [[repos]] repo = "local" diff --git a/pyproject.toml b/pyproject.toml index 89313a8..2dabbaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ # Python 3.15 is the first whose standard library has all it provides. "typing-extensions>=4.16.0; python_version < '3.15'", ] -description = "Turn a GraphQL schema and your operations into a typed Python client with no imposed transport, no validation overhead, and no runtime dependencies" +description = "Turn a GraphQL schema and your operations into a typed Python package with no imposed transport, no runtime dependencies, and no runtime validation" keywords = [ "client", "code generation", @@ -35,7 +35,7 @@ license-files = ["LICENSE"] name = "graphql-codegen" readme = "README.md" requires-python = ">=3.12" -version = "0.1.3" +version = "0.2.0" [project.optional-dependencies] # Only reading a YAML graphql-config file needs it. @@ -49,7 +49,6 @@ Documentation = "https://github.com/tibdex/graphql-codegen#readme" Source = "https://github.com/tibdex/graphql-codegen" Issues = "https://github.com/tibdex/graphql-codegen/issues" - [dependency-groups] dev = [ "coverage", @@ -75,12 +74,12 @@ branch = true parallel = true # `python -m graphql_codegen` runs in a subprocess. patch = ["subprocess"] -# The bookshop's copy of the runtime too, since the tests running its client exercise that copy. -source_pkgs = ["bookshop.client.runtime", "graphql_codegen"] +# The bookshop's copy of the runtime too: its tests exercise that copy. +source_pkgs = ["bookshop.graphql.runtime", "graphql_codegen"] [tool.coverage.paths] # Each file of the bookshop's runtime counts as the one it is copied from, which `test_cli.py` checks it is, but for its first line. -runtime = ["src/graphql_codegen/runtime/", "*/bookshop/client/runtime/"] +runtime = ["src/graphql_codegen/runtime/", "*/bookshop/graphql/runtime/"] [tool.coverage.report] exclude_also = [ @@ -102,66 +101,75 @@ testpaths = ["tests"] [tool.ruff] extend-exclude = [ # Generated. - "bookshop/client", + "bookshop/graphql", "bookshop/*_graphql.py", "src/graphql_codegen/_cli/*_graphql.py", - # Its snippets are the generated client and the bookshop's modules verbatim, which `test_readme.py` checks. + # Its code blocks are the generated package's and the bookshop's modules verbatim, which `test_readme.py` checks. "README.md", ] [tool.ruff.lint] +# Preview mode, for naming rules rather than numbering them; its rules stay out of `ALL`. +preview = true +explicit-preview-rules = true ignore = [ - "ASYNC109", # A transport takes the timeout of the HTTP client it wraps. - "C901", # Judged in review rather than by counting branches. - "COM812", # Conflicts with the formatter. - "CPY001", - "D100", - "D101", - "D102", - "D103", - "D104", - "D105", - "D106", - "D107", - "E501", - "EM101", - "EM102", - "ISC001", # Conflicts with the formatter. - "PLC0414", # `X as X` is how PEP 484 marks a re-export, in any module and not only an `__init__.py`. - "PLR0913", # Keyword-only parameters, as every setting is, do not add up. - "S101", # Asserts state what the type checkers cannot prove. - "TC", # `get_type_hints()` resolves annotations at run time, so what they name must be imported then. - "TRY003", - "UP046", # `Generic` declares what PEP 695 cannot: a default before Python 3.13, as a client's transport has, and a variance, as `Operation` has. + "assert", # Asserts state what the type checkers cannot prove. + "f-string-in-exception", # The message is the point of a raise, so it stays on its line rather than in a variable. + "line-too-long", # The formatter already wraps code, and a sentence of prose or a message is one line however long. + "missing-copyright-notice", # LICENSE file is enough. + "missing-trailing-comma", # Conflicts with the formatter. + "raise-vanilla-args", # Each message names its values, so it cannot move into an exception class without one per message. + "raw-string-in-exception", # As `f-string-in-exception`. + "runtime-cast-value", # A `cast()` spells its type like an annotation: unquoted. + "single-line-implicit-string-concatenation", # Conflicts with the formatter. + "too-many-arguments", # A keyword-only parameter costs its caller nothing, since it is named: `too-many-positional-arguments` counts the ones that do. + # Reflection resolves annotations at run time, so no import goes in a `TYPE_CHECKING` block. + "typing-only-first-party-import", + "typing-only-standard-library-import", + "typing-only-third-party-import", + "undocumented-magic-method", + "undocumented-public-class", + "undocumented-public-function", + "undocumented-public-init", + "undocumented-public-method", + "undocumented-public-module", + "undocumented-public-nested-class", + "undocumented-public-package", + "useless-import-alias", # `X as X` is how PEP 484 marks a re-export, in any module and not only an `__init__.py`. ] select = ["ALL"] [tool.ruff.lint.per-file-ignores] -# Examples printing, and asserting on what an error carries to show it: pytest's rules are for tests. -"bookshop/*" = ["PT017", "T201"] -# Asserts combine the conditions one message explains, and the library's modules reach each other's private members: a leading underscore keeps a name from callers, not from the library. -"src/*" = ["PT018", "SLF001"] -# Copied into every generated package, whatever its name. -"src/graphql_codegen/runtime/*" = ["TID252"] -"src/graphql_codegen/_cli/__init__.py" = ["T201"] -# Scripts the tests run, not modules they import. -"tests/__resources__/*" = ["INP001"] -# Fakes take arguments they ignore, names mirror GraphQL's, as generated ones do, and subprocesses run what the tests build. +"bookshop/*" = [ + "async-function-with-timeout", + "print", + "pytest-assert-in-except", +] +"src/*" = ["private-member-access", "pytest-composite-assertion"] +"src/graphql_codegen/runtime/*" = ["relative-imports"] +"src/graphql_codegen/runtime/error.py" = [ + "non-pep695-generic-class", # A legacy `TypeVar`, since a default for a PEP 695 type parameter needs Python 3.13. +] +"src/graphql_codegen/_cli/__init__.py" = ["print"] +"tests/__resources__/*" = ["implicit-namespace-package"] "tests/*" = [ - "ANN401", - "ARG001", - "ARG005", - "N801", - "N815", - "PLR0917", - "PLR2004", - "S603", - "SLF001", + "any-type", + "invalid-class-name", + "magic-value-comparison", + "mixed-case-variable-in-class-scope", + "private-member-access", + "subprocess-without-shell-equals-true", + "too-many-positional-arguments", + "unused-function-argument", + "unused-lambda-argument", ] [tool.ruff.lint.flake8-tidy-imports] ban-relative-imports = "all" +[tool.ruff.lint.mccabe] +max-complexity = 12 + [tool.ruff.lint.isort] combine-as-imports = true diff --git a/src/graphql_codegen/_cli/_graphql_config.py b/src/graphql_codegen/_cli/_graphql_config.py index d14cf11..5e2e53a 100644 --- a/src/graphql_codegen/_cli/_graphql_config.py +++ b/src/graphql_codegen/_cli/_graphql_config.py @@ -28,8 +28,8 @@ ) from graphql_codegen._cli._schema_pointer import SchemaPointer, parse_schema_pointer from graphql_codegen._cli._source import read_schema, read_sources +from graphql_codegen._error_note import error_note from graphql_codegen._metadata import DISTRIBUTION_NAME, METADATA -from graphql_codegen._note import error_note from graphql_codegen.config import Config from graphql_codegen.document_sibling_module import DocumentSiblingModule from graphql_codegen.generate import _Params @@ -46,7 +46,6 @@ def read_graphql_config(path: Path, /) -> dict[Path, _Params]: - """Return the generation arguments for each project of the graphql-config file at *path* by the directory its package goes to.""" load = _get_loader(path) text = path.read_text(encoding="utf-8") config = load(_interpolate(text, environment=os.environ)) @@ -63,14 +62,14 @@ def _interpolate(text: str, /, *, environment: Mapping[str, str]) -> str: def replacement(match: re.Match[str], /) -> str: name = match["name"] - # An empty value falls back to the default too. - if value := environment.get(name): + # As graphql-config does, an empty value falls back to the default. + if value := environment.get(name, ""): return value if isinstance(default := match["default"], str): stripped = default.strip() quoted = _QUOTED.fullmatch(stripped) - # One holding a colon may be quoted, in single or double quotes. + # graphql-config allows quoting a default, for one holding a colon. return ( stripped if quoted is None @@ -131,14 +130,14 @@ class _Project: def _read_project(project: _Project, /, *, directory: Path) -> _Params: with error_note(f"In project `{project.name}`."): - return _parse_project( + return _parse_params( project, schema=read_schema(project.schema, directory=directory), documents=read_sources(project.documents, directory=directory), ) -def _parse_project( +def _parse_params( project: _Project, /, *, @@ -159,7 +158,7 @@ def _parse_project( def _parse_sources(sources: Sequence[Source], /) -> DocumentNode: - """Parse *sources* into one document, since a fragment defined in one file may be spread in another, and a schema's types spread over several files.""" + # One document, since a fragment may be spread from another file. return DocumentNode( definitions=tuple( definition for source in sources for definition in parse(source).definitions @@ -170,7 +169,6 @@ def _parse_sources(sources: Sequence[Source], /) -> DocumentNode: def _parse_graphql_config( config: object, /, *, directory: PurePosixPath ) -> dict[Path, _Project]: - """The projects of *config*, the graphql-config file in *directory*, by the directory each package goes to.""" root = parse_dict(config) entries = ( required(root, "projects", parse_dict) @@ -181,13 +179,11 @@ def _parse_graphql_config( for name, entry in entries.items(): with error_note(f"In project `{name}`."): - project = _project(entry, name=name, directory=directory) + project = _parse_project(entry, name=name, directory=directory) if project is None: continue - # Derived from both, which needs no IO, unlike finding where packages start from a directory. - package_directory = Path(project.package_location._package_directory) if (other := projects.get(package_directory)) is not None: @@ -203,7 +199,7 @@ def _parse_graphql_config( return projects -def _project( +def _parse_project( value: object, /, *, name: str, directory: PurePosixPath ) -> _Project | None: entry = parse_dict(value) @@ -217,7 +213,7 @@ def _project( package_location, config = parse_at( extension, key=f"extensions.{_EXTENSION_NAME}", - parse=lambda value: _extension(value, directory=directory), + parse=lambda value: _parse_extension(value, directory=directory), ) return _Project( name=name, @@ -228,7 +224,7 @@ def _project( ) -def _extension( +def _parse_extension( value: object, /, *, directory: PurePosixPath ) -> tuple[PackageLocation, Config]: unread = parse_dict(value) @@ -252,7 +248,7 @@ def _extension( non_null_directive_name = optional( unread, "nonNullDirectiveName", parse_string, default=None ) - scalars = optional(unread, "scalars", _scalars, default={}) + scalars = optional(unread, "scalars", _parse_scalars, default={}) struct_interface_name = optional( unread, "structInterfaceName", parse_string, default=None ) @@ -267,23 +263,23 @@ def _extension( ) -def _scalars(value: object, /) -> dict[str, Scalar]: +def _parse_scalars(value: object, /) -> dict[str, Scalar]: scalars = parse_dict(value) return { - scalar_name: required(scalars, scalar_name, _scalar) + scalar_name: required(scalars, scalar_name, _parse_scalar) for scalar_name in list(scalars) } -def _scalar(value: object, /) -> Scalar: +def _parse_scalar(value: object, /) -> Scalar: unread = parse_dict(value) type_ = required(unread, "type", parse_string) - codec = optional(unread, "codec", _codec, default=None) + codec = optional(unread, "codec", _parse_codec, default=None) check_all_read(unread) return Scalar(type=type_, codec=codec) -def _codec(value: object, /) -> Codec: +def _parse_codec(value: object, /) -> Codec: unread = parse_dict(value) decode = required(unread, "decode", parse_string) encode = required(unread, "encode", parse_string) diff --git a/src/graphql_codegen/_cli/_introspection.py b/src/graphql_codegen/_cli/_introspection.py index aa56952..3d13dd0 100644 --- a/src/graphql_codegen/_cli/_introspection.py +++ b/src/graphql_codegen/_cli/_introspection.py @@ -1,8 +1,8 @@ -from collections.abc import Callable, Mapping +from collections.abc import Mapping from http.client import HTTPResponse from mimetypes import types_map from typing import Final, Literal, TypeAlias -from urllib.request import Request, urlopen +from urllib.request import Request as HTTPRequest, urlopen from graphql import ( IntrospectionQuery, @@ -17,14 +17,11 @@ IntrospectionProbeData, ) from graphql_codegen._metadata import DISTRIBUTION_NAME, METADATA -from graphql_codegen.runtime import Client, Operation +from graphql_codegen.runtime import Operation, Request _JSON: Final = types_map[".json"] _GRAPHQL_RESPONSE: Final = "application/graphql-response+json" -_TIMEOUT: Final = 30.0 # graphql-config has no option for it. -"""Seconds to wait for each response.""" - def _field_names(type_: FieldNames, /) -> frozenset[str]: return frozenset(field["name"] for field in type_["fields"]) @@ -40,16 +37,17 @@ def _introspection_operation( probe: IntrospectionProbeData, / ) -> _IntrospectionOperation: type_fields = _field_names(probe["type"]) + document = get_introspection_query( + specified_by_url="specifiedByURL" in type_fields, + directive_is_repeatable="isRepeatable" in _field_names(probe["directive"]), + schema_description="description" in _field_names(probe["schema"]), + input_value_deprecation="isDeprecated" in _field_names(probe["inputValue"]), + one_of="isOneOf" in type_fields, + ) return _IntrospectionOperation( operation_type="query", name="IntrospectionQuery", - document=get_introspection_query( - specified_by_url="specifiedByURL" in type_fields, - directive_is_repeatable="isRepeatable" in _field_names(probe["directive"]), - schema_description="description" in _field_names(probe["schema"]), - input_value_deprecation="isDeprecated" in _field_names(probe["inputValue"]), - one_of="isOneOf" in type_fields, - ), + document=document, variables_type=dict[str, object], # graphql-core's `IntrospectionQuery` uses `DirectiveLocation`, which it imports only `if TYPE_CHECKING:`. data_type=dict[str, object], # ty: ignore[invalid-argument-type] @@ -57,31 +55,33 @@ def _introspection_operation( def fetch_sdl(url: str, /, *, headers: Mapping[str, str]) -> str: - client = Client(_transport(url, headers=headers)) - probe = client(IntrospectionProbe({})) - introspection_operation = _introspection_operation(probe) - introspection_data = client(introspection_operation({})) - return print_schema(build_client_schema(introspection=introspection_data)) - - -def _transport(url: str, /, *, headers: Mapping[str, str]) -> Callable[[bytes], bytes]: - def transport(body: bytes, /) -> bytes: - request = Request( # noqa: S310 + def send[Data](request: Request[Literal["query"], Data], /) -> Data: + prepared = request.prepare() + request_body = bytes(prepared) + http_request = HTTPRequest( # noqa: S310 url, - data=body, headers={ "Accept": f"{_GRAPHQL_RESPONSE}, {_JSON}", "Content-Type": _JSON, "User-Agent": f"{DISTRIBUTION_NAME}/{METADATA['Version']}", **headers, }, + data=request_body, ) - response = urlopen(request, timeout=_TIMEOUT) # noqa: S310 + response = urlopen( # noqa: S310 + http_request, + timeout=30.0, # graphql-config has no option for it. + ) # The only response an `http:` or `https:` URL gives. assert isinstance(response, HTTPResponse) with response: - return response.read() + response_body = response.read() + + return prepared.parse(response_body) - return transport + probe = send(IntrospectionProbe({})) + introspection_operation = _introspection_operation(probe) + introspection_data = send(introspection_operation({})) + return print_schema(build_client_schema(introspection=introspection_data)) diff --git a/src/graphql_codegen/_cli/_introspection_graphql.py b/src/graphql_codegen/_cli/_introspection_graphql.py index a8f2485..a9db539 100644 --- a/src/graphql_codegen/_cli/_introspection_graphql.py +++ b/src/graphql_codegen/_cli/_introspection_graphql.py @@ -3,6 +3,7 @@ import builtins as _builtins import typing as _typing from graphql_codegen.runtime import _compat +from graphql_codegen.runtime import _operation_type from graphql_codegen.runtime import _reflection from graphql_codegen import runtime as _runtime @@ -29,7 +30,7 @@ class IntrospectionProbeData(_compat.TypedDict, closed=True): directive: _typing.Annotated[FieldNames, _reflection.NON_NULL] """Request the type information of a single type.""" -IntrospectionProbe: _runtime.Operation[_typing.Literal['query'], IntrospectionProbeVariables, IntrospectionProbeData] = _runtime.Operation(operation_type='query', name='IntrospectionProbe', document="""query IntrospectionProbe { +IntrospectionProbe: _typing.Final = _runtime.Operation(operation_type=_operation_type.QUERY, name='IntrospectionProbe', document="""query IntrospectionProbe { schema§: __type(name: "__Schema") { ...FieldNames } diff --git a/src/graphql_codegen/_cli/_parsing.py b/src/graphql_codegen/_cli/_parsing.py index 0330a01..00bbb95 100644 --- a/src/graphql_codegen/_cli/_parsing.py +++ b/src/graphql_codegen/_cli/_parsing.py @@ -1,9 +1,7 @@ -"""Parse the values of a config file, noting where each error is.""" - from collections.abc import Callable, Mapping from typing import cast -from graphql_codegen._note import error_note +from graphql_codegen._error_note import error_note def check_all_read(unread: dict[str, object], /) -> None: @@ -14,10 +12,10 @@ def check_all_read(unread: dict[str, object], /) -> None: def parse_dict(value: object, /) -> dict[str, object]: - """Return a shallow-copy of *value*.""" if not isinstance(value, Mapping) or not all(isinstance(key, str) for key in value): raise TypeError(f"Expected a mapping with string keys, but got `{value!r}`.") + # A copy, since `optional()` pops the keys it reads. return dict(cast(Mapping[str, object], value)) diff --git a/src/graphql_codegen/_cli/_schema_pointer.py b/src/graphql_codegen/_cli/_schema_pointer.py index 1663e15..129fe0e 100644 --- a/src/graphql_codegen/_cli/_schema_pointer.py +++ b/src/graphql_codegen/_cli/_schema_pointer.py @@ -12,7 +12,7 @@ parse_string, required, ) -from graphql_codegen._note import error_note +from graphql_codegen._error_note import error_note @final @@ -34,8 +34,8 @@ class Endpoint: headers: Mapping[str, str] +# The forms https://the-guild.dev/graphql/config/docs/user/schema lists. type SchemaPointer = Globs | IntrospectionResult | Endpoint -"""Where a project's schema comes from, in one of the forms https://the-guild.dev/graphql/config/docs/user/schema lists.""" def parse_schema_pointer(value: object, /) -> SchemaPointer: @@ -55,7 +55,6 @@ def parse_schema_pointer(value: object, /) -> SchemaPointer: f"Expected a glob of SDL files, since the list holds more than one item, but got `{item}`." ) - # A single item is one glob. (pattern,) = pointer.patterns patterns.append(pattern) diff --git a/src/graphql_codegen/_cli/_source.py b/src/graphql_codegen/_cli/_source.py index 6cf73f1..7eb1128 100644 --- a/src/graphql_codegen/_cli/_source.py +++ b/src/graphql_codegen/_cli/_source.py @@ -1,5 +1,3 @@ -"""Read a project's GraphQL: its documents' files, and its schema, whatever form it comes in.""" - import json from collections.abc import Sequence from pathlib import Path @@ -14,7 +12,7 @@ IntrospectionResult, SchemaPointer, ) -from graphql_codegen._note import error_note +from graphql_codegen._error_note import error_note def read_sources(globs: Sequence[str], /, *, directory: Path) -> list[Source]: @@ -24,7 +22,7 @@ def read_sources(globs: Sequence[str], /, *, directory: Path) -> list[Source]: matched = {path for path in directory.glob(glob) if path.is_file()} if not matched: - # Also catches https://the-guild.dev/graphql/config's braces and negation, which `pathlib` reads literally: a negation would otherwise leave in what it means to leave out. + # Also catches graphql-config's braces and negation, which `pathlib` reads literally. raise ValueError(f"Expected `{glob}` to match at least one file.") paths |= matched @@ -34,7 +32,7 @@ def read_sources(globs: Sequence[str], /, *, directory: Path) -> list[Source]: # Sorted, so that the package does not depend on the file system's order. for path in sorted(paths): text = path.read_text(encoding="utf-8") - # Named by their POSIX paths, in which a module next to a document is placed. + # The name is the path, which places a document's sibling module. sources.append(Source(text, path.as_posix())) return sources diff --git a/src/graphql_codegen/_cli/_write.py b/src/graphql_codegen/_cli/_write.py index c831192..eec8d33 100644 --- a/src/graphql_codegen/_cli/_write.py +++ b/src/graphql_codegen/_cli/_write.py @@ -3,12 +3,9 @@ def write(directory: Path, files: Mapping[PurePosixPath, bytes], /) -> None: - """Write *files*, keyed by their paths relative to *directory*. - - - A file already holding its content is left untouched: file watchers do not see it change. - - Any other file at one of the paths is overwritten: version control can revert it if that was a mistake. - - No other file is touched: orphaned document modules are left behind, which is harmless if the output is gitignored. + """A file already holding its content is left untouched, so that file watchers do not fire. + No other file is touched either: a stale document module stays, which is harmless when the output is gitignored. """ for relative_path, content in files.items(): path = directory / relative_path diff --git a/src/graphql_codegen/_error_note.py b/src/graphql_codegen/_error_note.py new file mode 100644 index 0000000..5d1be7f --- /dev/null +++ b/src/graphql_codegen/_error_note.py @@ -0,0 +1,11 @@ +from collections.abc import Generator +from contextlib import contextmanager + + +@contextmanager +def error_note(note: str, /) -> Generator[None]: + try: + yield + except Exception as error: + error.add_note(note) + raise diff --git a/src/graphql_codegen/_generator/_annotation.py b/src/graphql_codegen/_generator/_annotation.py index 1bdca36..de1f747 100644 --- a/src/graphql_codegen/_generator/_annotation.py +++ b/src/graphql_codegen/_generator/_annotation.py @@ -35,7 +35,7 @@ ) from graphql_codegen._generator.spelling import SettledSpelling -# A project may still configure them like custom ones. +# A config may override them. BUILT_IN_SCALARS: Final[Mapping[str, str]] = { GraphQLBoolean.name: "bool", GraphQLFloat.name: "float", @@ -46,11 +46,7 @@ type Position = Literal["input", "data"] -"""Where a value of the type is. - -- ``"input"``: sent to the server, as a variable or an input object's field, where a list is a :class:`collections.abc.Sequence`, so that a caller may pass a tuple. -- ``"data"``: received from the server, in a response's data, where a list is a :class:`list`, since that is what :func:`json.loads` produces. -""" +"""An input list is a `Sequence`, so that a caller may pass a tuple, and a data list is a `list`, as `json.loads()` produces.""" def builtin(type_name: str, /) -> ast.expr: @@ -77,7 +73,6 @@ def build( object_annotation: ast.expr | None, bare_inputs: bool, ) -> ast.expr: - """*object_annotation* names a composite type, since it depends on the selection rather than on the schema.""" if isinstance(type_, GraphQLNonNull): return self._build_non_null( type_.of_type, @@ -124,7 +119,7 @@ def _build_non_null( case GraphQLScalarType(): return self._scalar_annotation(type_.name) case GraphQLEnumType() if is_introspection_type(type_): - # Not a type of the schema, so not in its package: spelled where it is selected. + # An introspection enum is not in the package, so it is spelled inline. return subscript( qualified(TYPING, "Literal"), *(constant(value_name) for value_name in type_.values), @@ -132,7 +127,6 @@ def _build_non_null( case GraphQLEnumType(): return qualified(ENUM, self._type_spellings[type_.name]) case GraphQLInputObjectType(): - # Unquoted even though input types reference each other in cycles: generated modules postpone the evaluation of annotations. spelling = self._type_spellings[type_.name] return name(spelling) if bare_inputs else qualified(INPUT, spelling) case _: @@ -145,5 +139,4 @@ def _scalar_annotation(self, scalar_name: str, /) -> ast.expr: if scalar_name in self._scalar_spellings: return qualified(SCALAR, self._scalar_spellings[scalar_name]) - # An unconfigured custom scalar stays opaque rather than silently becoming `str`: the wire value is whatever JSON the server sent. return builtin(BUILT_IN_SCALARS.get(scalar_name, "object")) diff --git a/src/graphql_codegen/_generator/_ast_nodes.py b/src/graphql_codegen/_generator/_ast_nodes.py index f163da8..373673d 100644 --- a/src/graphql_codegen/_generator/_ast_nodes.py +++ b/src/graphql_codegen/_generator/_ast_nodes.py @@ -16,10 +16,7 @@ def name(spelling: Spelling, /) -> ast.Name: def qualified(module_alias: Spelling, attribute: str, /) -> ast.Attribute: - """`_alias.attribute`: how a generated module refers to anything it did not declare. - - Qualifying every such reference, builtins included, is what keeps a generated name from ever shadowing one the module uses: an operation called `str` cannot hide `_builtins.str`. - """ + """Every name the module did not declare is qualified, builtins included, so that an operation named `str` cannot hide `_builtins.str`.""" return ast.Attribute(value=name(module_alias), attr=attribute, ctx=ast.Load()) @@ -36,11 +33,9 @@ def constant(value: str | int | EllipsisType | None, /) -> ast.Constant: names=[ast.alias(name="annotations")], level=0, ) -"""Emitted by the input module, whose types reference each other in cycles, such as a filter combining filters, so that annotations need no quoting. +"""Input types reference each other in cycles, so the input module postpones its annotations instead of quoting them. -The cost: under PEP 563, a TypedDict's :attr:`~typing.TypedDict.__required_keys__` is computed from unresolved strings and reports every key as required. -Nothing here reads it, since nothing is validated at runtime, and type checkers, which read the source, are unaffected. -Code introspecting these types at runtime must use :func:`typing.get_type_hints` rather than the class attributes. +Under PEP 563, a TypedDict's `__required_keys__` then reports every key as required: read these types with `typing.get_type_hints()`. """ @@ -66,7 +61,7 @@ def optional(annotation: ast.expr, /) -> ast.expr: def type_alias(target: Spelling, value: ast.expr, /) -> ast.TypeAlias: - """Preferred over `X: TypeAlias = ...`, since a PEP 695 alias evaluates its value lazily, so it may name types declared after it.""" + """A `type` statement evaluates lazily, so it may name a type declared after it.""" return ast.TypeAlias( name=ast.Name(id=ast_str(target), ctx=ast.Store()), type_params=[], @@ -75,12 +70,12 @@ def type_alias(target: Spelling, value: ast.expr, /) -> ast.TypeAlias: def documented(statement: ast.stmt, /, *, text: str) -> list[ast.stmt]: - """A `type` statement and an annotated attribute have no docstring slot in the grammar, but tooling widely understands a bare string literal directly underneath.""" + """A `type` statement and an annotated attribute have no docstring, but tooling reads a bare string right below them.""" return [statement, docstring(text)] if text else [statement] def docstring(text: str, /) -> ast.Expr: - """A docstring holding *text*: :func:`ast.unparse` drops comments, so whatever the generated code tells its reader must be a docstring, which is a real node.""" + """`ast.unparse()` drops comments, so a docstring is the only text that reaches the generated code.""" return ast.Expr(value=constant(text)) @@ -121,10 +116,6 @@ def relative_import_from( level: int, re_export: bool, ) -> ast.ImportFrom: - """When *re_export* is ``True``, each name is aliased to itself, as in `from .X import X as X`, which is how PEP 484 marks an explicit re-export. - - Otherwise, a type checker treats the import as private to the module and rejects importing it from the package. - """ return ast.ImportFrom( module=module, names=[ @@ -151,7 +142,7 @@ def _string_literal(value: str, /, *, quotes: Sequence[str]) -> str: if not usable: return repr(value) - # A quote ending like the value would need that last character escaped. + # Prefer a quote the value does not end with. quote = min(usable, key=lambda quote: escaped.endswith(quote[0])) if escaped.endswith(quote[0]): @@ -161,15 +152,10 @@ def _string_literal(value: str, /, *, quotes: Sequence[str]) -> str: def _triple_quote(source: str, /) -> str: - """Respell with triple quotes the bare strings, which document the statement above them, and the strings spanning lines. - - :func:`ast.unparse` only does this in docstring position; everywhere else a newline becomes a backslash-n inside a single line literal. - GraphQL documents are the only multi-line strings this library emits, and a triple quoted literal keeps them readable. + """`ast.unparse()` only triple quotes docstrings: elsewhere, a GraphQL document would become one line with escaped newlines. - The continuation lines of a bare string are indented to match, as a hand written docstring would be. - That adds leading whitespace to the value, which is what :func:`inspect.cleandoc` exists to remove and what every tool rendering a docstring already does. + A bare string's continuation lines are indented like a handwritten docstring, which `inspect.cleandoc()` undoes. """ - # Split as the tokenizer reads it. line_starts = list(accumulate(map(len, StringIO(source)), initial=0)) pieces: list[str] = [] end = 0 @@ -181,9 +167,8 @@ def _triple_quote(source: str, /) -> str: value = literal_eval(token.string) row, column = token.start - # A docstring too, which `ast.unparse()` quotes but leaves unindented. + # A bare string statement, which `ast.unparse()` leaves unindented. if following.type == NEWLINE and not token.line[:column].strip(): - # Every line but the first, which follows the opening quotes. margin = " " * column literal = _string_literal( indent(value, margin).removeprefix(margin), quotes=['"""'] @@ -201,13 +186,9 @@ def _triple_quote(source: str, /) -> str: def unparse(tree: ast.Module, /) -> str: - """Checked to compile, so that a bad node fails here rather than downstream. - - Generated code is always built as a tree and unparsed, never assembled from strings, which can be syntactically wrong and need forward references quoted by hand. - :func:`ast.unparse` has no formatting options, such as line wrapping or blank lines, and the standard library has no formatter. - Unparsing each top-level statement separately and grouping them still gives imports as a block and one blank line between declarations. - Wrapping long lines is left to whatever formatter the consuming project runs; this library depends on none. + """Generated code is built as a tree, never from strings. + The standard library has no formatter, so long lines are left to the project's own. """ ast.fix_missing_locations(tree) @@ -218,7 +199,7 @@ def unparse(tree: ast.Module, /) -> str: is_import = isinstance(statement, ast.Import | ast.ImportFrom) chunk = ast.unparse(statement) - # A bare string documents the statement above it, so it must stay glued to it. + # A bare string stays glued to the statement it documents. is_docstring = isinstance(statement, ast.Expr) and isinstance( statement.value, ast.Constant, diff --git a/src/graphql_codegen/_generator/_data_type.py b/src/graphql_codegen/_generator/_data_type.py index 3c0d85f..2813631 100644 --- a/src/graphql_codegen/_generator/_data_type.py +++ b/src/graphql_codegen/_generator/_data_type.py @@ -53,7 +53,7 @@ class _DeclaredKey: declaring_fragment: str -def _merge_selection_sets( +def _join_selection_sets( selection_sets: Sequence[SelectionSet], /, *, type_: GraphQLCompositeType ) -> SelectionSet: return SelectionSet( @@ -84,20 +84,19 @@ def _merge_selection_sets( ) -def _merge_fields(fields: Sequence[SelectedField], /) -> tuple[SelectedField, ...]: - """Validation guarantees that fields sharing a response name are the same field with the same arguments, so only their selection sets and client directives need combining.""" +def _collect_fields(fields: Sequence[SelectedField], /) -> tuple[SelectedField, ...]: by_response_name: dict[str, list[SelectedField]] = {} for field in fields: by_response_name.setdefault(field.response_name, []).append(field) return tuple( - group[0] if len(group) == 1 else _merge_field_group(group) + group[0] if len(group) == 1 else _collect_field_group(group) for group in by_response_name.values() ) -def _merge_field_group(group: Sequence[SelectedField], /) -> SelectedField: +def _collect_field_group(group: Sequence[SelectedField], /) -> SelectedField: # Validation guarantees that the fields are all leaves or all select on the same type. selection_sets = [ field.selection_set for field in group if field.selection_set is not None @@ -105,9 +104,7 @@ def _merge_field_group(group: Sequence[SelectedField], /) -> SelectedField: return replace( group[0], directive_names=frozenset().union(*(field.directive_names for field in group)), - selection_set=_merge_selection_sets( - selection_sets, type_=selection_sets[0].type - ) + selection_set=_join_selection_sets(selection_sets, type_=selection_sets[0].type) if selection_sets else None, # Present as soon as one of the fields is. @@ -117,12 +114,10 @@ def _merge_field_group(group: Sequence[SelectedField], /) -> SelectedField: @final class DataTypeEmitter: - """Turn resolved selection sets into TypedDicts, in class syntax, so that a fragment spread can be a base class. + """A fragment spread becomes a base class, so that adding a field next to it changes a class body, never a name. - Inheritance is what keeps a type name stable: adding a field next to a spread changes the class body, never the name. - A fragment's own class is open, since an operation spreading it extends it, and every other type is closed, since a response holds exactly the keys its operation selects. - Types are emitted depth first, children before parents, so a name is always defined before it is used as a base class. - One emitter serves a whole package, so that each fragment is normalized once, whichever modules spread it. + A fragment's class is open, since an operation spreading it extends it, and every other type is closed. + One emitter serves a whole package, so that each fragment is normalized once. """ def __init__( @@ -141,7 +136,6 @@ def __init__( self._resolve_fragment: Final = resolve_fragment self._struct_types: Final = struct_types self._non_null_directive_name: Final = non_null_directive_name - # Cached for the emitter's life: one serves a whole package. self._normalized_fragment: Final = cache(self._normalize_fragment) self._declared_keys: Final = cache(self._find_declared_keys) @@ -154,7 +148,6 @@ def emit( fragment_reference: Callable[[str], ast.expr], inheritable: bool, ) -> list[ast.stmt]: - """The outermost type is public and every nested one private, its spelling pending, so that a path spelled like another gets a suffix rather than clashing.""" return self._emit_node( selection_set, spelling=root_spelling, @@ -173,7 +166,6 @@ def _emit_node( fragment_reference: Callable[[str], ast.expr], inheritable: bool, ) -> list[ast.stmt]: - """A class's keys are required, since GraphQL guarantees that a selected field is present in the response, unless `@skip` or `@include` may leave it out, nullability living in the annotation.""" return self._emit_normalized( self._normalize(selection_set), spelling=spelling, @@ -256,10 +248,7 @@ def _emit_union( path: Sequence[str], fragment_reference: Callable[[str], ast.expr], ) -> list[ast.stmt]: - """Emit *selection_set*, which has conditions, as a union with one member per concrete type, discriminated by `__typename`, which the document selects on every abstract type. - - Each member gathers the fields of every condition its type satisfies, so conditions nested in conditions need no case of their own. - """ + """One member per concrete type, each gathering the fields of every condition its type satisfies, so that nested conditions need no case of their own.""" statements: list[ast.stmt] = [] members: list[PendingSpelling] = [] @@ -299,10 +288,10 @@ def _selection_on( else branch ) - merged = _merge_selection_sets( + joined = _join_selection_sets( [replace(part, conditions=()) for part in gathered], type_=object_type ) - return replace(merged, description=object_type.description or "") + return replace(joined, description=object_type.description or "") def _as_skippable( self, selection_set: SelectionSet, /, *, object_type: GraphQLObjectType @@ -319,10 +308,7 @@ def _as_skippable( return replace(selection_set, fields=tuple(fields), fragment_names=()) def _normalize(self, selection_set: SelectionSet, /) -> SelectionSet: - """Merge fields by key, and expand every spread that cannot soundly be inherited, which is always correct, merely less reused. - - A fragment can soundly be inherited when its own type is a class rather than a union, and every key it shares with the rest of the node is the very same one, declared by the same fragment or as the same leaf. - """ + """A spread is inherited when its fragment is a class and every key it shares with the node is the same one, and expanded otherwise, which is always correct, only less reused.""" fields = list(selection_set.fields) conditions: list[TypeCondition] = list(selection_set.conditions) bases: list[str] = [] @@ -349,14 +335,14 @@ def expand(fragment_name: str, /) -> None: else: bases.append(fragment_name) - merged = _merge_fields(fields) + collected = _collect_fields(fields) conflicting = next( ( base for base in bases - if self._declares_a_key_otherwise( + if self._conflicts( base, - fields=merged, + fields=collected, other_bases=[other for other in bases if other != base], ) ), @@ -366,7 +352,7 @@ def expand(fragment_name: str, /) -> None: if conflicting is None: return replace( selection_set, - fields=merged, + fields=collected, conditions=tuple(conditions), fragment_names=tuple(bases), ) @@ -391,7 +377,7 @@ def _find_declared_keys(self, fragment_name: str, /) -> Mapping[str, _DeclaredKe return declared - def _declares_a_key_otherwise( + def _conflicts( self, base: str, /, @@ -418,7 +404,6 @@ def _declares_a_key_otherwise( return False def _same_leaf(self, field: SelectedField, other: SelectedField, /) -> bool: - """Whether two fields get the very same annotation, which only leaves can.""" return ( field.selection_set is None and other.selection_set is None @@ -453,7 +438,6 @@ def _emit_field( fragment_reference: Callable[[str], ast.expr], ) -> tuple[list[ast.stmt], ast.expr]: non_null = self._is_non_null(field) - # `@nonNull` is the caller asserting that this query cannot return null here, so the annotation loses its `| None`, and `NON_NULL` tells the runtime to enforce it. field_type = as_non_null(field.type) if non_null else field.type statements: list[ast.stmt] = [] struct_input_name = self._struct_input_name(field) @@ -522,7 +506,7 @@ def _possible_types( def _typename_annotation(possible_types: Sequence[GraphQLObjectType], /) -> ast.expr: - # Read-only, which is what lets a subclass narrow the `__typename` of a fragment it inherits: a mutable key cannot change type in a subclass. + # Read-only, so that a subclass may narrow the `__typename` it inherits from a fragment. return subscript( qualified(COMPAT, "ReadOnly"), subscript( diff --git a/src/graphql_codegen/_generator/_document.py b/src/graphql_codegen/_generator/_document.py index 466edbe..345f982 100644 --- a/src/graphql_codegen/_generator/_document.py +++ b/src/graphql_codegen/_generator/_document.py @@ -50,14 +50,13 @@ def parse_operations( def insert_typename( document: DocumentNode, /, *, schema: GraphQLSchema ) -> DocumentNode: - """Return *document* selecting `__typename` on every abstract type, since it tells the values apart, for type checkers to narrow and for the parser to convert.""" type_info = TypeInfo(schema) class _Visitor(Visitor): def enter_selection_set( self, node: SelectionSetNode, _key: object, parent: object, *_args: object ) -> SelectionSetNode | None: - # An inline fragment's selections are gathered into the branches of the selection enclosing it, which that selection's `__typename` tells apart. + # An inline fragment's selections join the enclosing selection's branches, which its `__typename` tells apart. if ( isinstance(parent, InlineFragmentNode) or not is_abstract_type(type_info.get_parent_type()) @@ -65,7 +64,7 @@ def enter_selection_set( isinstance(selection, FieldNode) and selection.name.value == "__typename" and selection.alias is None - and not selection.directives + and not bool(selection.directives) for selection in node.selections ) ): diff --git a/src/graphql_codegen/_generator/_document_module.py b/src/graphql_codegen/_generator/_document_module.py index 41c8d76..06bfe47 100644 --- a/src/graphql_codegen/_generator/_document_module.py +++ b/src/graphql_codegen/_generator/_document_module.py @@ -20,9 +20,9 @@ def document_modules( *, sibling_module: DocumentSiblingModule | None, ) -> dict[PurePosixPath, PurePosixPath]: - """A module's name depends on its document alone, so that adding a document never renames another's module, which code imports types from: two documents whose modules would be one file are an error rather than one of them getting a suffix. + """A module's name depends on its document alone, so that adding a document never renames another's module. - It is relative to the package's directory for a module in the package, and in the documents' own terms for a sibling one. + Two documents mapping to one file are an error rather than one getting a suffix. """ by_file: dict[tuple[PurePosixPath, str], PurePosixPath] = {} modules: dict[PurePosixPath, PurePosixPath] = {} diff --git a/src/graphql_codegen/_generator/_imports.py b/src/graphql_codegen/_generator/_imports.py index dc1eac4..41d9c36 100644 --- a/src/graphql_codegen/_generator/_imports.py +++ b/src/graphql_codegen/_generator/_imports.py @@ -12,6 +12,7 @@ INJECTION, INPUT, OMITTED, + OPERATION_TYPE, REFLECTION, RUNTIME, SCALAR, @@ -40,12 +41,7 @@ class OutsideModule: type GeneratedModule = PackageModule | OutsideModule -"""A generated module, by where it sits, which decides how it imports the package's own modules. - -- :class:`PackageModule`: relatively, so that the package works wherever it is written. -- :class:`OutsideModule`: absolutely, since a relative import cannot leave its top-level package. - -""" +"""A module in the package imports its modules relatively, so that the package works wherever it is written, and a module outside it absolutely.""" def _import_from( @@ -61,7 +57,6 @@ def _from_package( ) -> ast.ImportFrom: match module: case PackageModule(path=path): - # How far up the package is. return _import_from(submodule, name=name, alias=alias, level=len(path) + 1) case OutsideModule(package=package): return _import_from( @@ -97,47 +92,21 @@ def helper_imports( if ast_str(helper) in used: imports.append(import_as(module_name, helper)) - # From the runtime's copy, so that the package needs `typing_extensions` only before Python 3.15. - if ast_str(COMPAT) in used: - imports.append( - _from_package( - RUNTIME_PACKAGE, name="_compat", alias=ast_str(COMPAT), module=module - ) - ) - - if ast_str(INJECTION) in used: - imports.append( - _from_package( - RUNTIME_PACKAGE, - name="injection", - alias=ast_str(INJECTION), - module=module, - ) - ) - - if ast_str(REFLECTION) in used: - imports.append( - _from_package( - RUNTIME_PACKAGE, - name="_reflection", - alias=ast_str(REFLECTION), - module=module, - ) - ) - - if ast_str(RUNTIME) in used: - imports.append( - _from_package( - None, name=RUNTIME_PACKAGE, alias=ast_str(RUNTIME), module=module - ) - ) - - if ast_str(OMITTED) in used: - imports.append( - _from_package( - RUNTIME_PACKAGE, name="OMITTED", alias=ast_str(OMITTED), module=module + for helper, submodule, imported in ( + # The runtime's shim, so that `typing_extensions` is only needed before Python 3.15. + (COMPAT, RUNTIME_PACKAGE, "_compat"), + (INJECTION, RUNTIME_PACKAGE, "injection"), + (OPERATION_TYPE, RUNTIME_PACKAGE, "_operation_type"), + (REFLECTION, RUNTIME_PACKAGE, "_reflection"), + (RUNTIME, None, RUNTIME_PACKAGE), + (OMITTED, RUNTIME_PACKAGE, "OMITTED"), + ): + if ast_str(helper) in used: + imports.append( + _from_package( + submodule, name=imported, alias=ast_str(helper), module=module + ) ) - ) for helper, schema_module in ( (ENUM, schema_spellings.enum_module), @@ -145,10 +114,7 @@ def helper_imports( ): if ast_str(helper) in used: imports.append( - # A module of the `schema` package reaches its siblings directly. - _import_from(None, name=schema_module, alias=ast_str(helper), level=1) - if module == PackageModule(path=(SCHEMA_PACKAGE,)) - else _from_package( + _from_package( SCHEMA_PACKAGE, name=schema_module, alias=ast_str(helper), diff --git a/src/graphql_codegen/_generator/_injector.py b/src/graphql_codegen/_generator/_injector.py index 957dcb9..98a0e42 100644 --- a/src/graphql_codegen/_generator/_injector.py +++ b/src/graphql_codegen/_generator/_injector.py @@ -32,6 +32,8 @@ from graphql_codegen._generator._typed_dict import Key, emit_closed from graphql_codegen._generator.spelling import SettledSpelling +type Injections = Mapping[str, Mapping[tuple[str, ...], GraphQLInputType]] + def get_injections( operation: OperationDefinitionNode, @@ -39,12 +41,8 @@ def get_injections( *, schema: GraphQLSchema, injector_names: AbstractSet[str], -) -> dict[str, dict[tuple[str, ...], GraphQLInputType]]: - """An injected variable goes in the variables themselves, `()`. - - An injected field goes in every input object holding it, at any depth, since a mutation takes the transaction it belongs to inside its input rather than beside it. - A static path can say neither "every element of this list" nor the unbounded depth of a recursive input type, so an injected field reached that way fails generation rather than being silently left out. - """ +) -> Injections: + """An injected variable goes at `()`, and an injected field in every input object holding it, at any depth.""" injections: dict[str, dict[tuple[str, ...], GraphQLInputType]] = {} def holds_injected_field( @@ -66,7 +64,6 @@ def holds_injected_field( ) def visit_input( - input_type: GraphQLInputObjectType, wrapped_type: GraphQLType, /, *, @@ -74,6 +71,11 @@ def visit_input( ancestors: tuple[str, ...], definition: VariableDefinitionNode, ) -> None: + input_type = get_named_type(wrapped_type) + + if not isinstance(input_type, GraphQLInputObjectType): + return + nullable_type = ( wrapped_type.of_type if isinstance(wrapped_type, GraphQLNonNull) @@ -93,16 +95,12 @@ def visit_input( injections.setdefault(field_name, {})[path] = field.type continue - named_type = get_named_type(field.type) - - if isinstance(named_type, GraphQLInputObjectType): - visit_input( - named_type, - field.type, - path=(*path, field_name), - ancestors=(*ancestors, input_type.name), - definition=definition, - ) + visit_input( + field.type, + path=(*path, field_name), + ancestors=(*ancestors, input_type.name), + definition=definition, + ) for definition in operation.variable_definitions or (): variable_name = definition.variable.name.value @@ -118,30 +116,21 @@ def visit_input( "GraphQLInputType", variable_type ) continue - named_type = get_named_type(variable_type) - if isinstance(named_type, GraphQLInputObjectType): - visit_input( - named_type, - variable_type, - path=(variable_name,), - ancestors=(), - definition=definition, - ) + visit_input( + variable_type, path=(variable_name,), ancestors=(), definition=definition + ) return injections def get_injected_types( - injections: Mapping[str, Mapping[str, Mapping[tuple[str, ...], GraphQLInputType]]], + injections: Mapping[str, Injections], /, *, injector_names: AbstractSet[str], ) -> dict[str, GraphQLInputType]: - """An injector supplies one value for every position it is injected into, so they must all have the same type, nullability aside. - - Its type is then the strictest: non-null wherever any position is, since a non-null value suits a nullable position too, while an injector cannot tell which position it is called for. - """ + """An injector has one type wherever it is injected, nullability aside, and the strictest: a non-null value suits a nullable position too.""" positions: dict[str, dict[str, str]] = {} types: dict[str, list[GraphQLInputType]] = {} @@ -166,7 +155,6 @@ def get_injected_types( + "." ) - # An injector injected nowhere, a misspelled one for instance, has no position to take its type from. if nowhere := sorted(injector_names - types.keys()): raise ValueError( f"Expected `{nowhere[0]}` to be injected somewhere, but no operation sends a variable or an input field of that name." @@ -193,15 +181,15 @@ def _strictest(left: GraphQLInputType, right: GraphQLInputType, /) -> GraphQLInp def emit_injector_module( injected_types: Mapping[str, GraphQLInputType], /, *, annotations: AnnotationBuilder ) -> list[ast.stmt]: - """The mapping of functions is a :class:`typing.TypedDict`, rather than keyword arguments, so that a name no keyword can spell is injected too, and a misspelled name fails type checking with every type checker. + """A TypedDict rather than keyword arguments, so that a name no keyword can spell is injected too. - It builds the runtime's private :class:`~graphql_codegen.runtime.injection._Injectors` rather than being given to a client, so that a client only calls injectors that were type checked. + It builds the runtime's private `_Injectors`, so that `Request.prepare()` only calls injectors that were type checked. """ functions_spelling, function_spelling = ( SettledSpelling("InjectorFunctions"), SettledSpelling("injectors"), ) - # Local to the function, so that nothing can clash with it. + # A parameter name, which nothing in the module can clash with. functions = SettledSpelling("functions") keys: list[Key] = [] @@ -220,7 +208,7 @@ def emit_injector_module( annotation=subscript( qualified(ABC, "Callable"), ast.List(elts=[], ctx=ast.Load()), value ), - required=False, + required=True, description="", ), ) @@ -245,7 +233,7 @@ def emit_injector_module( ), body=[ docstring( - "Return what a client calls to supply the injected values, each serialized as its type says." + "Return what `Request.prepare()` calls to supply the injected values, each serialized as its type says." ), ast.Return( value=ast.Call( diff --git a/src/graphql_codegen/_generator/_merge.py b/src/graphql_codegen/_generator/_merge.py index 0d6ed62..9f14d63 100644 --- a/src/graphql_codegen/_generator/_merge.py +++ b/src/graphql_codegen/_generator/_merge.py @@ -23,7 +23,7 @@ from graphql_codegen._generator._document import get_transitively_spread_fragments from graphql_codegen.runtime._sigil import SIGIL -# No printed name can hold it, and a string escapes it, so it only ever stands for a sigil. +# NUL: no printed name holds it, and `print_ast()` escapes it in strings. _PLACEHOLDER: Final = "\x00" _ESCAPED_SIGIL: Final = "\\u" + f"{ord(SIGIL):04x}" @@ -53,16 +53,10 @@ def enter_variable(self, node: VariableNode, *_args: object) -> VariableNode: def print_merge_template(document: DocumentNode, /) -> str: - """Print *document*, holding one operation and the fragments it uses as the server receives them, with the sigil wherever its index in a merge goes, so that merging at runtime is string replacement. + """A subscription selects one root field, and an operation with directives would apply them to the whole merge: neither gets a sigil. - After the response name of each root field, as its alias, and after the name of each variable, in its definition and wherever it is used. - The sigil is written as a character no printed name or string can hold, then swapped in once a literal sigil in a string has been escaped, so that every sigil left is one. - A subscription, which selects a single root field, and an operation with directives of its own, which would apply to the whole merge, cannot be merged: they get no sigil. - - Two things cannot keep their shape in a merge, and are inlined as inline fragments, which the server executes the same: - - - a fragment spread at the root, since the fields it selects are root fields and need aliasing; - - a fragment using variables, since each operation of a merge suffixes its variables differently, while a fragment is defined once for all of them. + A fragment spread at the root is inlined, since its fields are root fields and need aliasing. + So is a fragment using variables, since each operation of a merge suffixes its variables differently. """ (operation,) = ( definition @@ -70,7 +64,7 @@ def print_merge_template(document: DocumentNode, /) -> str: if isinstance(definition, OperationDefinitionNode) ) - if operation.operation is OperationType.SUBSCRIPTION or operation.directives: + if operation.operation is OperationType.SUBSCRIPTION or bool(operation.directives): return print_ast(document).replace(SIGIL, _ESCAPED_SIGIL) fragments = { diff --git a/src/graphql_codegen/_generator/_naming.py b/src/graphql_codegen/_generator/_naming.py index 17f40a8..e01d786 100644 --- a/src/graphql_codegen/_generator/_naming.py +++ b/src/graphql_codegen/_generator/_naming.py @@ -16,17 +16,15 @@ ENUM: Final = PendingSpelling("_enum") INPUT: Final = PendingSpelling("_input") INJECTION: Final = PendingSpelling("_injection") +OPERATION_TYPE: Final = PendingSpelling("_operation_type") REFLECTION: Final = PendingSpelling("_reflection") SCALAR: Final = PendingSpelling("_scalar") OMITTED: Final = PendingSpelling("_OMITTED") -"""The runtime's sentinel, imported by name: pyright only takes a sentinel as a type when it is spelled as a bare name.""" +"""Imported by name: pyright only takes a sentinel as a type when it is spelled bare.""" def nested_type_name(path: Sequence[str], /) -> str: - """Private, since the GraphQL way for code to name a shape is a fragment. - - Prefixed by the root, so that a type checker message naming the type also names its operation or fragment. - """ + """Private, since a fragment is the GraphQL way to name a shape, and prefixed by the root, so that a type checker's message names the operation.""" return f"_{'_'.join(path)}" @@ -41,7 +39,7 @@ class OperationSpellings: def operation_spellings( operations: Sequence[SettledSpelling], /, *, taken: Collection[SettledSpelling] ) -> dict[SettledSpelling, OperationSpellings]: - """The operations keep theirs, so that a type is suffixed rather than an operation: `Get`'s data type is `GetData_1` in a module also holding an operation `GetData`.""" + """An operation keeps its name, so that a clash suffixes its type: `Get`'s data type is `GetData_1` beside an operation `GetData`.""" types = settle_names( [ f"{operation}{suffix}" diff --git a/src/graphql_codegen/_generator/_operation.py b/src/graphql_codegen/_generator/_operation.py index 3f95b36..67b26b8 100644 --- a/src/graphql_codegen/_generator/_operation.py +++ b/src/graphql_codegen/_generator/_operation.py @@ -1,5 +1,5 @@ import ast -from collections.abc import Collection, Mapping, Sequence, Set as AbstractSet +from collections.abc import Sequence, Set as AbstractSet from dataclasses import replace from typing import Final @@ -25,10 +25,11 @@ documented, name, qualified, - subscript, ) +from graphql_codegen._generator._injector import Injections from graphql_codegen._generator._naming import ( BUILTINS, + OPERATION_TYPE, RUNTIME, TYPING, OperationSpellings, @@ -50,12 +51,9 @@ def _undescribed[Node: ExecutableDefinitionNode | VariableDefinitionNode]( class _StripForServer(Visitor): - """Remove what the server does not need: this library's directives, and descriptions. + """Descriptions become docstrings instead of going on the wire, where a server whose parser predates them would reject the request. - The spec says descriptions must not affect execution, so on the wire they would only cost bytes, and a server whose parser predates them would reject the whole request. - They become docstrings instead. - - Block strings are printed as ordinary ones, with the same value, so that no token spans lines, since the runtime slices a merge template at blank lines, and so that a sigil in a string can be escaped, which a block string cannot do. + Block strings are printed as ordinary ones, since the runtime slices a merge template at blank lines and escapes a sigil in a string, which a block string cannot. """ def __init__(self, directive_names: AbstractSet[str], /) -> None: @@ -66,7 +64,7 @@ def enter_directive(self, node: DirectiveNode, *_args: object) -> object: return REMOVE if node.name.value in self._directive_names else None def enter_string_value(self, node: StringValueNode, *_args: object) -> object: - if not node.block: + if node.block is not True: return None return replace(node, block=False) @@ -109,14 +107,13 @@ def emit_variables_type( annotations: AnnotationBuilder, injector_names: AbstractSet[str], ) -> list[ast.stmt]: - """A variable is :data:`typing.Required` only when it is non-null *and* has no default, for the same reason as an input field: a default makes it optional for the client, and the document carries the default so Python never needs to.""" + """A variable with a default is optional, since the document carries the default.""" keys: list[Key] = [] for definition in operation.variable_definitions or (): variable_name = definition.variable.name.value if variable_name in injector_names: - # Injected: the client inserts it, so a caller must not be able to. continue variable_type = type_from_ast(schema, definition.type) @@ -161,20 +158,15 @@ def emit_operation_constant( graphql_name: str, spellings: OperationSpellings, document: str, - injections: Mapping[str, Collection[tuple[str, ...]]], + injections: Injections, ) -> list[ast.stmt]: - """The operation's description is the constant's docstring, since the constant is what callers reach for. - - It is annotated although its type parameters could be inferred from the call: pyrefly widens an inferred `Literal['mutation']` to :class:`str` in a list comprehension passed to the clients' overloads, which then match none. - The document is inlined rather than given a constant of its own: a triple quoted literal reads as GraphQL wherever it sits. - """ - # In the order of the class's fields, which is the order things happen in. keywords = [ - ast.keyword(arg="operation_type", value=constant(operation.operation.value)), - # The GraphQL name, which is what the server knows the operation by. + ast.keyword( + arg="operation_type", + value=qualified(OPERATION_TYPE, operation.operation.value.upper()), + ), ast.keyword(arg="name", value=constant(graphql_name)), ast.keyword(arg="document", value=constant(document)), - # What crosses the wire is read off the variables and data types at runtime: they are the only description of it. ast.keyword(arg="variables_type", value=name(spellings.variables)), ] @@ -188,13 +180,13 @@ def emit_operation_constant( _frozenset( [ ast.Tuple( - elts=[constant(part) for part in target], + elts=[constant(part) for part in path], ctx=ast.Load(), ) - for target in targets + for path in by_path ], ) - for targets in injections.values() + for by_path in injections.values() ], ), ), @@ -202,18 +194,10 @@ def emit_operation_constant( keywords.append(ast.keyword(arg="data_type", value=name(spellings.data))) - # Not annotated `Final`: pyright would then reject the package re-exporting it from the module named like it. return documented( ast.AnnAssign( target=ast.Name(id=spellings.operation, ctx=ast.Store()), - annotation=subscript( - qualified(RUNTIME, "Operation"), - subscript( - qualified(TYPING, "Literal"), constant(operation.operation.value) - ), - name(spellings.variables), - name(spellings.data), - ), + annotation=qualified(TYPING, "Final"), value=ast.Call( func=qualified(RUNTIME, "Operation"), args=[], keywords=keywords ), diff --git a/src/graphql_codegen/_generator/_schema.py b/src/graphql_codegen/_generator/_schema.py index d3b1b6a..48f1915 100644 --- a/src/graphql_codegen/_generator/_schema.py +++ b/src/graphql_codegen/_generator/_schema.py @@ -11,11 +11,10 @@ def prepare_schema( schema: GraphQLSchema, /, *, non_null_directive_name: str | None ) -> GraphQLSchema: - """Declares the client directive, if any, so that documents using it validate.""" if non_null_directive_name is not None: schema = _declare_directive(schema, name=non_null_directive_name) - # The schema operations are validated against, whose result graphql-core's `validate()` then reuses. + # `validate()` later assumes a valid schema, and reuses this result. if errors := validate_schema(schema): raise ExceptionGroup("The schema is invalid", errors) @@ -34,7 +33,6 @@ def _declare_directive(schema: GraphQLSchema, /, *, name: str) -> GraphQLSchema: description="Assert that this field is not null, even though the schema allows it to be.", ) - # A new dictionary on each call, so setting a key of it leaves *schema* alone. kwargs = schema.to_kwargs() kwargs["directives"] = (*schema.directives, directive) return GraphQLSchema(**kwargs) diff --git a/src/graphql_codegen/_generator/_schema_type.py b/src/graphql_codegen/_generator/_schema_type.py index 8ab7475..7075c5c 100644 --- a/src/graphql_codegen/_generator/_schema_type.py +++ b/src/graphql_codegen/_generator/_schema_type.py @@ -43,11 +43,8 @@ def _is_required(field: GraphQLInputField, /) -> bool: - """In GraphQL a non-null input field with a default is optional for the client: the `!` constrains the value, not whether one must be sent. - - Reading `!` alone would force callers to pass values the server would happily supply. - """ - # graphql-core holds a default given as a Python value in `default_value`, and one given as a GraphQL literal, as SDL gives it, in `default`. + """A non-null field with a default is optional, since the server supplies the default.""" + # graphql-core holds a Python default in `default_value`, and an SDL literal one in `default`. return ( is_non_null_type(field.type) and field.default_value is Undefined @@ -73,13 +70,6 @@ def get_reached_type_names( struct_types: StructTypes | None, injector_names: AbstractSet[str], ) -> frozenset[str]: - """Only the enums and input types *document* sends or receives, so that the generated code grows with the documents rather than with the schema. - - An input type is reached through a variable, and reaches the types of its fields in turn, a `@oneOf` input's members included. - An enum is reached like an input type, or when a field of its type is selected. - The input type of a struct payload is reached when the payload is selected, since the payload has its shape. - An injected field reaches nothing, since it is removed from its input type. - """ pending: list[GraphQLNamedType] = [] type_info = TypeInfo(schema) @@ -139,11 +129,6 @@ def enter_field(self, node: FieldNode, *_args: object) -> None: def emit_enums( schema: GraphQLSchema, /, *, schema_spellings: SchemaSpellings ) -> list[ast.stmt]: - """Closed, with no `"%future added value"` sentinel and no per-enum openness config, which costs far less here than in a validating generator. - - A validating client turns a member it has not heard of into a hard failure, so a server adding one breaks every older client. - Nothing is validated here, so an unknown member reaches the caller as the string the server sent, and the `Literal` is guidance: a `match` may keep a catch-all arm, which really runs, or `assert_never`, which a type checker enforces. - """ return [ statement for enum_type in _reached( @@ -171,10 +156,7 @@ def _emit_one_of( spelling: SettledSpelling, bare_names: frozenset[str], ) -> list[ast.stmt]: - """`closed=True` makes a type checker reject a value with two keys, the only enforcement there is, since nothing is validated at runtime. - - `total=` could never express it: it governs whether *declared* keys are required, never whether *undeclared* ones are allowed. - """ + """`closed=True` is what rejects a value with two keys: `total=` only governs the declared keys.""" body: list[ast.stmt] = [] member_spellings: list[PendingSpelling] = [] @@ -187,7 +169,7 @@ def _emit_one_of( [ Key( name=field_name, - # The spec declares every member nullable but rejects a null one, so the member that is set can never be `None`. + # The spec declares every member nullable but rejects null, so the set member is never `None`. annotation=annotations.build( as_non_null(field.type), position="input", @@ -224,10 +206,6 @@ def emit_inputs( schema_spellings: SchemaSpellings, injector_names: AbstractSet[str], ) -> list[ast.stmt]: - """Input types are not a DAG: the schema has reference cycles, all passing through the nullable members of a `@oneOf` input, which the spec permits since only *non-null* cycles are unsatisfiable. - - One module with postponed annotations lets them name each other freely. - """ input_types = _reached( schema, GraphQLInputObjectType, schema_spellings=schema_spellings ) @@ -240,7 +218,6 @@ def emit_inputs( fields = { field_name: field for field_name, field in input_type.fields.items() - # An injected field is set by the client, so a caller must not be able to. if field_name not in injector_names } @@ -284,10 +261,7 @@ def emit_inputs( def _defer_functional_references( body: list[ast.stmt], /, *, input_names: frozenset[SettledSpelling] ) -> list[ast.stmt]: - """Postponed evaluation only covers annotations: the values of a functional TypedDict are an ordinary dict, evaluated when the statement runs, so an input it names that is defined later, or the type itself, would not exist yet. - - Each such name is referenced through a `type` alias instead, whose value is evaluated lazily. - """ + """A functional TypedDict's values are evaluated when the statement runs, unlike annotations, so an input defined later is referenced through a lazy `type` alias.""" aliases: dict[SettledSpelling, PendingSpelling] = {} for statement in body: @@ -311,11 +285,6 @@ def _defer_functional_references( def emit_schema_package( schema: GraphQLSchema, /, *, schema_spellings: SchemaSpellings ) -> ast.Module: - """Re-export every enum and input type of the package, which share GraphQL's single type name space, by a star import of each module. - - It leaves out names starting with an underscore: every helper alias and nested type, and any GraphQL type named that way, which the typing spec keeps private anyway and which its public module still holds. - - """ exports = { schema_spellings.enum_module: _reached( schema, GraphQLEnumType, schema_spellings=schema_spellings diff --git a/src/graphql_codegen/_generator/_selection.py b/src/graphql_codegen/_generator/_selection.py index 66e4d9e..a39e5b9 100644 --- a/src/graphql_codegen/_generator/_selection.py +++ b/src/graphql_codegen/_generator/_selection.py @@ -1,6 +1,6 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass -from typing import Final, cast, final +from typing import Final, Literal, assert_never, cast, final from graphql import ( BooleanValueNode, @@ -21,14 +21,12 @@ type_from_ast, ) +type Inclusion = Literal["always", "never", "runtime"] +"""Whether `@skip` and `@include` keep a selection, or leave it to a variable at runtime, which graphql-core's `should_include_node()` cannot tell without values.""" -def inclusion(directives: Sequence[DirectiveNode], /) -> bool | None: - """Whether `@skip` and `@include` include a selection, or ``None`` when a variable decides. - graphql-core's :func:`~graphql.execution.collect_fields.should_include_node` needs the variables' values, which only exist at runtime. - A directive without its `if` argument, which validation reports, decides nothing either. - """ - included: bool | None = True +def inclusion(directives: Sequence[DirectiveNode], /) -> Inclusion: + result: Inclusion = "always" for directive in directives: name = directive.name.value @@ -46,11 +44,11 @@ def inclusion(directives: Sequence[DirectiveNode], /) -> bool | None: ) if not isinstance(value, BooleanValueNode): - included = None + result = "runtime" elif value.value == (name == GraphQLSkipDirective.name): - return False + return "never" - return included + return result @final @@ -81,12 +79,7 @@ class TypeCondition: @final @dataclass(frozen=True, kw_only=True) class SelectionSet: - """A selection set, resolved, with the selections under a type condition kept apart. - - ``fields`` holds what is selected on every value, ``conditions`` what is selected only on some, and ``fragment_names`` the fragments spread here. - All three are in document order, which keeps emission deterministic. - Spreads are recorded rather than expanded, so that emission can reuse the fragment's own type as a base class. - """ + """`fields` is selected on every value, `conditions` only on some, and `fragment_names` are the spreads, kept for base classes.""" type: GraphQLCompositeType fields: tuple[SelectedField, ...] @@ -97,15 +90,7 @@ class SelectionSet: @final class SelectionResolver: - """Resolve the type of every field an operation or a fragment selects. - - This is the part a generator inferring types itself easily gets wrong, on `interface` chains notably, and graphql-core supplies all of it, so nothing here infers a type: - - - :class:`~graphql.TypeInfo` tracks, during a recursive descent calling its :meth:`~graphql.TypeInfo.enter` and :meth:`~graphql.TypeInfo.leave`, the definition of each field, meta fields included, and the type each selection set selects on; - - :func:`~graphql.is_type_sub_type_of` tells whether a fragment's type condition holds for every value of a type. - - What is left is what graphql-core leaves to its caller: following fragment spreads, and `@skip` and `@include`, whose variables only have values at runtime. - """ + """Nothing here infers a type: graphql-core's `TypeInfo` tracks each field's definition, and `is_type_sub_type_of()` tells whether a type condition holds for every value.""" def __init__( self, @@ -121,7 +106,6 @@ def resolve( self, definition: OperationDefinitionNode | FragmentDefinitionNode, / ) -> SelectionSet: type_info = TypeInfo(self._schema) - # Entering the definition pushes the operation's root type, or the fragment's type condition. type_info.enter(definition) selection_set = self._resolve_selection_set( definition.selection_set, type_info=type_info @@ -132,7 +116,6 @@ def resolve( def _resolve_selection_set( self, node: SelectionSetNode, /, *, type_info: TypeInfo ) -> SelectionSet: - # Entering it makes the type it selects on the parent type of what it selects. type_info.enter(node) parent_type = type_info.get_parent_type() assert parent_type is not None, ( @@ -143,14 +126,15 @@ def _resolve_selection_set( fragment_names: list[str] = [] for selection in node.selections: - # `None` rather than empty when the selection has none. - included = inclusion(selection.directives or ()) - - # Never in the response, so not in the type either. - if included is False: - continue - - skippable = included is None + match inclusion(selection.directives or ()): + case "never": + continue + case "always": + skippable = False + case "runtime": + skippable = True + case _ as never: + assert_never(never) match selection: case FieldNode(): @@ -161,7 +145,6 @@ def _resolve_selection_set( ) case InlineFragmentNode(): type_info.enter(selection) - # Its type condition, or, without one, the enclosing type. selection_set = self._resolve_selection_set( selection.selection_set, type_info=type_info ) @@ -174,7 +157,7 @@ def _resolve_selection_set( ) ) case FragmentSpreadNode(): - # `TypeInfo` does not follow a spread: the fragment is resolved on its own, by name. + # `TypeInfo` does not follow a spread. fragment_name = selection.name.value fragment_type = cast( "GraphQLCompositeType", @@ -187,10 +170,10 @@ def _resolve_selection_set( if not skippable and is_type_sub_type_of( self._schema, parent_type, fragment_type ): - # Its type condition holds for every value selected here, so the fragment's type stands in, as a base class. + # Holds for every value here, so the fragment's type is a base class. fragment_names.append(fragment_name) else: - # One on a narrower type is a branch, as an inline fragment is, and so is a skippable one, since standing in would declare its keys present. + # A narrower or skippable one is a branch, since a base would declare its keys present. conditions.append( TypeCondition( type=fragment_type, diff --git a/src/graphql_codegen/_generator/_structs.py b/src/graphql_codegen/_generator/_structs.py index ce2fd25..b3d796b 100644 --- a/src/graphql_codegen/_generator/_structs.py +++ b/src/graphql_codegen/_generator/_structs.py @@ -20,16 +20,11 @@ class StructTypes: payload_field_name: str input_type_names: Mapping[str, str] - """The name of the input type each struct type's payload is shaped like, by the struct type's name.""" def parse_struct_types( schema: GraphQLSchema, struct_interface_name: str, / ) -> StructTypes: - """The interface has a single field, the payload, of a custom scalar type, since the payload is opaque on the wire. - - Every object type implementing it is named after an input type followed by the interface's name: `BookFilterStruct` has the shape of `input BookFilter`, which lets one definition be both sent and received. - """ interface = schema.type_map.get(struct_interface_name) if not isinstance(interface, GraphQLInterfaceType): @@ -74,13 +69,7 @@ def get_struct_input_name( *, struct_types: StructTypes | None, ) -> str | None: - """The wire type of a struct's payload is an opaque scalar, so without this it would be :class:`object`. - - The payload stays under its field's key rather than replacing the struct. - Moving it up would make every response holding a struct pay for a conversion, even one whose payload otherwise needs none, and the data's type would stop mirroring the document, which selects that field, all for little convenience. - - Once the [Struct RFC](https://github.com/graphql/graphql-wg/blob/main/rfcs/Struct.md) lands, a struct field is selected without a selection set, so the wrapper disappears from the document itself. - """ + """The payload stays under its field's key rather than replacing the struct, so that the data's type mirrors the document and only a response selecting it pays for a conversion.""" if struct_types is None or field_name != struct_types.payload_field_name: return None diff --git a/src/graphql_codegen/_generator/_typed_dict.py b/src/graphql_codegen/_generator/_typed_dict.py index ea5cfe0..a9cbfd9 100644 --- a/src/graphql_codegen/_generator/_typed_dict.py +++ b/src/graphql_codegen/_generator/_typed_dict.py @@ -27,17 +27,17 @@ class Key: def _functional( type_spelling: Spelling, keys: Sequence[Key], /, *, closed: bool ) -> ast.Assign: - """The string passed as the first argument must match the variable it is assigned to, or `ty` reports `mismatched-type-name`.""" + # The variable is named after the name argument. + # Both are the same placeholder, so they settle to the same spelling. return ast.Assign( targets=[ast.Name(id=ast_str(type_spelling), ctx=ast.Store())], value=ast.Call( func=qualified(COMPAT, "TypedDict"), args=[ - # Spelled like the target, placeholder included, so both get the same name. constant(ast_str(type_spelling)), ast.Dict( keys=[constant(key.name) for key in keys], - values=[_annotation(key, closed=closed) for key in keys], + values=[_annotation(key) for key in keys], ), ], keywords=[ast.keyword(arg="closed", value=ast.Constant(value=True))] @@ -47,22 +47,13 @@ def _functional( ) -def _is_class_attribute_safe(key: str, /, *, bare_names: frozenset[str]) -> bool: - """A name Python cannot bind cannot be, a name starting with `__` would be mangled, and a public name the module references unqualified would be shadowed by the key in the class scope. - - No name the generator adds can clash with a key, since :func:`~graphql_codegen._spelling.settle_module` settles none like any name the module holds. - """ +def _can_be_declared_in_a_class_body( + key: str, /, *, bare_names: frozenset[str] +) -> bool: return is_bindable(key) and not key.startswith("__") and key not in bare_names -def _annotation(key: Key, /, *, closed: bool) -> ast.expr: - """The key's annotation, saying whether it must be present: explicitly in a closed TypedDict, where reading what a caller must send matters, and only when it may be absent in an open one.""" - if closed: - return subscript( - qualified(TYPING, "Required" if key.required else "NotRequired"), - key.annotation, - ) - +def _annotation(key: Key, /) -> ast.expr: return ( key.annotation if key.required @@ -70,11 +61,11 @@ def _annotation(key: Key, /, *, closed: bool) -> ast.expr: ) -def _declaration(key: Key, /, *, closed: bool) -> list[ast.stmt]: +def _declaration(key: Key, /) -> list[ast.stmt]: return documented( ast.AnnAssign( target=ast.Name(id=key.name, ctx=ast.Store()), - annotation=_annotation(key, closed=closed), + annotation=_annotation(key), simple=1, ), text=key.description, @@ -89,18 +80,16 @@ def emit_closed( description: str, bare_names: frozenset[str], ) -> list[ast.stmt]: - """Emit a closed TypedDict, as an input or variables type is, in class syntax unless a key cannot be declared in a class body, and then in functional syntax altogether, since a closed TypedDict cannot be extended with keys by a subclass.""" - if all(_is_class_attribute_safe(key.name, bare_names=bare_names) for key in keys): + if all( + _can_be_declared_in_a_class_body(key.name, bare_names=bare_names) + for key in keys + ): return [ class_definition( type_spelling, bases=[qualified(COMPAT, "TypedDict")], keywords={"closed": ast.Constant(value=True)}, - body=[ - statement - for key in keys - for statement in _declaration(key, closed=True) - ], + body=[statement for key in keys for statement in _declaration(key)], docstring_text=description, ), ] @@ -118,23 +107,19 @@ def emit_data( functional_base_spelling: Spelling, description: str, ) -> list[ast.stmt]: - """Emit a data type in class syntax, inheriting the keys a class body cannot declare from a functional base, so that it can still inherit fragments too. - - The functional base is open, since the class extends it. - """ # A data module references no public name unqualified. - safe = [ + body_keys = [ key for key in keys - if _is_class_attribute_safe(key.name, bare_names=frozenset()) + if _can_be_declared_in_a_class_body(key.name, bare_names=frozenset()) ] - unsafe = [key for key in keys if key not in safe] + base_keys = [key for key in keys if key not in body_keys] statements: list[ast.stmt] = [] all_bases = list(bases) - if unsafe: + if base_keys: statements.append( - _functional(functional_base_spelling, unsafe, closed=False), + _functional(functional_base_spelling, base_keys, closed=False), ) all_bases.insert(0, name(functional_base_spelling)) @@ -142,11 +127,7 @@ def emit_data( class_definition( type_spelling, bases=all_bases or [qualified(COMPAT, "TypedDict")], - body=[ - statement - for key in safe - for statement in _declaration(key, closed=False) - ], + body=[statement for key in body_keys for statement in _declaration(key)], docstring_text=description, keywords={"closed": ast.Constant(value=True)} if closed else {}, ), diff --git a/src/graphql_codegen/_generator/dotted_name.py b/src/graphql_codegen/_generator/dotted_name.py index 3f37eb9..eae6aa7 100644 --- a/src/graphql_codegen/_generator/dotted_name.py +++ b/src/graphql_codegen/_generator/dotted_name.py @@ -15,7 +15,7 @@ class Builtin: @dataclass(frozen=True, kw_only=True) class Import: level: int - """Like :attr:`ast.ImportFrom.level`: 0 for an absolute path, and 2 or more for one relative to the directory the package is written to.""" + """As `ast.ImportFrom.level`: 0 for an absolute name, 2 or more for one relative to the package's directory.""" module: str | None name: str @@ -25,21 +25,17 @@ class Import: def parse_dotted_name(dotted_name: str, /) -> Reference: - """A bare name, such as :class:`int`, is a builtin. - - A dotted name is absolute, or starts with `..` to be relative to the directory the package is written to: `..scalars.decode` is `scalars.decode` next to it. - A dotted name starting with one dot would name a module of the generated package, which is not the project's to write in. - """ level = len(dotted_name) - len(dotted_name.lstrip(".")) *modules, last = dotted_name[level:].split(".") + # One dot would point inside the generated package, which holds no user code. if ( level == 1 or not all(is_bindable(part) for part in (*modules, last)) or (level == 0 and not modules and not hasattr(builtins, last)) ): raise ValueError( - f"Expected an absolute dotted name, such as `datetime.datetime`, a dotted name relative to the package's directory, such as `..scalars.decode`, or a builtin, such as `int`, but got `{dotted_name}`." + f"Expected a builtin (`str`), an absolute dotted name (`decimal.Decimal`), or a path relative to the generated package's directory (`..scalar.decode_datetime`), but got `{dotted_name}`." ) if level == 0 and not modules: diff --git a/src/graphql_codegen/_generator/package.py b/src/graphql_codegen/_generator/package.py index 4ee712c..45b1cdb 100644 --- a/src/graphql_codegen/_generator/package.py +++ b/src/graphql_codegen/_generator/package.py @@ -94,7 +94,6 @@ def _generated(source: str, /) -> str: - """*source* under the generated file comment, separated by a blank line unless it is empty.""" return ( f"{_GENERATED_FILE_COMMENT}\n{source}" if source.strip() @@ -108,14 +107,10 @@ def _names_by_document( *, module_root: PurePosixPath | None, ) -> dict[PurePosixPath, list[str]]: - """A document is the path its source is named by. - - When *module_root*, where sibling modules are imported from, is not ``None``, every definition must be located in its terms: that is where its module goes. - """ names: dict[PurePosixPath, list[str]] = {} for definition_name, definition in definitions.items(): - # graphql-core names a string parsed without a source so too. + # graphql-core's name for a string parsed without a source. document = PurePosixPath( "GraphQL request" if definition.loc is None else definition.loc.source.name ) @@ -134,7 +129,7 @@ def _names_by_document( def _import_module(dotted_name: str, /, *, alias: PendingSpelling) -> ast.stmt: - """Import the module *dotted_name* from its package, which a submodule named like the package cannot shadow, as `import a.b.c as alias` lets `a`'s attribute `b` do.""" + """`from a.b import c`, since `import a.b.c as alias` would let a submodule named like the package shadow it.""" package, _, module_name = dotted_name.rpartition(".") return ( ast.ImportFrom( @@ -163,7 +158,7 @@ def enter_fragment_spread( def _dotted_module_name( path: PurePosixPath, /, *, document: PurePosixPath, module_root: PurePosixPath ) -> str: - # Outside the module root, a module's path walks up with `..`, which no dotted name holds. + # Outside the module root, the path holds `..`, which no dotted name can. parts = path.with_suffix("").relative_to(module_root, walk_up=True).parts if all(is_bindable(part) for part in parts): @@ -177,7 +172,7 @@ def _dotted_module_name( _RUNTIME_MARKER: Final = ( b"# Copied into each generated package, where this line says not to edit it.\n" ) -"""The first line of every runtime module, which the copy swaps for the generated file comment.""" +"""The first line of every runtime module, swapped for the generated file comment.""" def _runtime_copy(source: bytes, /) -> bytes: @@ -199,10 +194,7 @@ def _runtime_copy(source: bytes, /) -> bytes: if resource.name.endswith(".py") } ) -"""The runtime, copied so that a generated package depends on nothing but the standard library, and :mod:`typing_extensions` before Python 3.15. - -Read once, at import, so that generating reads no file. -""" +"""Read at import, so that `generate()` reads no file.""" @final @@ -210,7 +202,7 @@ class PackageEmitter: def __init__( self, *, document: DocumentNode, schema: GraphQLSchema, config: Config ) -> None: - """Derive what can fail, so that no emitter holds what it cannot generate from, leaving the rest to first use.""" + """Everything that can fail is derived here, and the rest on first use.""" schema = prepare_schema( schema, non_null_directive_name=config.non_null_directive_name ) @@ -241,7 +233,7 @@ def __init__( modules = document_modules( sorted({*operation_names, *fragment_names}), sibling_module=sibling_module ) - # The `document` package re-exports every operation and fragment beside its modules, which keep their names, then the operations theirs. + # The `document` package re-exports operations beside its modules, which keep their names. module_spellings = ( {SettledSpelling(path.stem) for path in modules.values()} if sibling_module is None @@ -277,30 +269,39 @@ def __init__( self._fragment_documents: Final = fragment_documents self._document_modules: Final = modules self._imported_module_dotted_names: Final = ( - {} - if module_root is None - else { - spread_document: _dotted_module_name( - modules[spread_document], - document=spread_document, - module_root=module_root, - ) - for definitions, names in ( - (operations, operation_names), - (fragments, fragment_names), - ) - for definition_document, definition_names in names.items() - for definition_name in definition_names - for spread_name in _direct_spread_names(definitions[definition_name]) - if (spread_document := fragment_documents[spread_name]) - != definition_document - } + {} if module_root is None else self._spread_module_names(module_root) ) self._injections: Final = injections self._injected_types: Final = get_injected_types( injections, injector_names=injector_names ) + def _spread_module_names( + self, module_root: PurePosixPath, / + ) -> dict[PurePosixPath, str]: + """The dotted name of each module a document spreads a fragment from.""" + dotted_names: dict[PurePosixPath, str] = {} + + for definitions, names_by_document in ( + (self._operations, self._operation_names_by_document), + (self._fragments, self._fragment_names_by_document), + ): + for document, definition_names in names_by_document.items(): + for definition_name in definition_names: + for spread_name in _direct_spread_names( + definitions[definition_name] + ): + spread_document = self._fragment_documents[spread_name] + + if spread_document != document: + dotted_names[spread_document] = _dotted_module_name( + self._document_modules[spread_document], + document=spread_document, + module_root=module_root, + ) + + return dotted_names + @cached_property def _resolver(self) -> SelectionResolver: return SelectionResolver(self._fragments, schema=self._schema) @@ -322,7 +323,6 @@ def _scalar_spellings(self) -> dict[str, SettledSpelling]: @cached_property def _fragment_spellings(self) -> dict[str, SettledSpelling]: - """Settled where their names meet others: in the `document` package, which re-exports them, and in their sibling module otherwise.""" if self._document_sibling_module is None: return settle_names( sorted(self._fragments), @@ -365,7 +365,7 @@ def _module_source( settle_module(tree) for node in ast.walk(tree): - # Settled like the name it imports, a helper's alias would make the import a re-export, as PEP 484 spells one, when a module only exports what it defines. + # A helper settled like the name it imports would spell a re-export. if isinstance(node, ast.alias) and node.asname == node.name: node.asname = None @@ -385,7 +385,7 @@ def _data_types(self) -> DataTypeEmitter: ) def _fragment_order(self, fragment_names: Sequence[str], /) -> list[str]: - """*fragment_names*, each after those of them it spreads, since their types may be its bases.""" + # A fragment comes after the ones it spreads, since it may inherit their types. return list( TopologicalSorter( { @@ -411,7 +411,7 @@ def _fragment_types( definition = self._fragments[fragment_name] selection_set = self._resolver.resolve(definition) return self._data_types.emit( - # The fragment's own description, when it has one, says more than its type's. + # The fragment's description over its type's. replace(selection_set, description=description) if (description := description_text(definition.description)) else selection_set, @@ -434,7 +434,6 @@ def _operation_body( fragments=get_transitively_spread_fragments( operation, definitions=self._fragments ), - # The server knows nothing of the directive, which is the client's assertion. client_directive_names=frozenset() if self._non_null_directive_name is None else frozenset({self._non_null_directive_name}), @@ -448,7 +447,7 @@ def _operation_body( injector_names=self._injector_names, ), *self._data_types.emit( - # `Query` and `Mutation` describe the schema's entry points, which says nothing about this operation, so the root type's description is dropped rather than repeated on every one. + # The root type's description says nothing about this operation. replace(self._resolver.resolve(operation), description=""), root_spelling=spellings.data, fragment_reference=fragment_reference, @@ -513,7 +512,6 @@ def fragment_reference(fragment_name: str, /) -> ast.expr: ] source = self._module_source( [ - # After the helpers' imports, which `_module_source()` puts first. *( self._import_document_module(imported, alias=alias) for imported, alias in sorted(aliases.items()) @@ -550,10 +548,7 @@ def _import_document_module( def _emit_document_reexports( self, modules: Mapping[PurePosixPath, PurePosixPath], / ) -> str: - """The `document` subpackage's `__init__.py`, re-exporting every operation and fragment by name, since their module also holds the types of their operations' variables and data. - - Its modules are listed in `__lazy_modules__`, which Python 3.15 imports on first use (PEP 810) and earlier versions ignore: a star import could not be lazy. - """ + """Names are listed rather than star imported, since `__lazy_modules__` (PEP 810) cannot make a star import lazy.""" stems = sorted((path.stem, document) for document, path in modules.items()) lazy_modules = ast.Assign( targets=[ast.Name(id="__lazy_modules__", ctx=ast.Store())], @@ -635,7 +630,6 @@ def _emit_document_modules(self) -> dict[PurePosixPath, str]: return files def emit(self) -> dict[PurePosixPath, bytes]: - """The generated package, as the content of each file keyed by its path relative to the package's directory, modules next to documents included: UTF-8, with LF line endings, whatever the platform.""" schema_spellings = self._schema_spellings document_files = self._emit_document_modules() files: dict[PurePosixPath, str] = { diff --git a/src/graphql_codegen/_generator/spelling.py b/src/graphql_codegen/_generator/spelling.py index f27ba45..cfdbe98 100644 --- a/src/graphql_codegen/_generator/spelling.py +++ b/src/graphql_codegen/_generator/spelling.py @@ -5,7 +5,7 @@ from typing import Final, Self, assert_never, final _MARK: Final = "\x00" -"""What starts a placeholder, and separates its creation counter from its preferred name: NUL, which no identifier holds and no text starts with, and which Python refuses in source, so that a placeholder left in a name fails to compile rather than go unnoticed.""" +"""NUL: no identifier holds it, and Python refuses it in source, so that a placeholder left in a name fails to compile.""" _counter: Final = count() @@ -16,7 +16,7 @@ def is_bindable(name: str, /) -> bool: return False try: - # Asking Python itself instead of checking against a list of exceptions that could go stale. + # Python itself, rather than a list of keywords that could go stale. compile(f"{name} = None", "", "exec") except SyntaxError: return False @@ -26,14 +26,11 @@ def is_bindable(name: str, /) -> bool: @final class PendingSpelling: - """Nothing prevents a schema or a document from using any name the generator could pick in advance, so none is safe. - - An emitter therefore never writes a name it adds: it makes a pending spelling of the name it would prefer, and uses it wherever the name goes, its placeholder holding the place. - """ + """No name picked in advance is safe, since a schema or a document may use it: an emitter writes a placeholder, settled once its module is complete.""" def __init__(self, preferred: str, /) -> None: assert preferred.isidentifier(), f"Expected `{preferred}` to be an identifier." - # A class body would mangle a name starting with two underscores, unless it also ends with two, as `_GetData`'s nested `__GetData_x` would be when an operation is named `_Get`. + # A class body would mangle a `__` name, such as `__GetData_x` nested in an operation named `_Get`. unmangled = ( f"_{preferred.lstrip('_')}" if preferred.startswith("__") and not preferred.endswith("__") @@ -52,11 +49,6 @@ def __new__(cls, value: str, /) -> Self: type Spelling = PendingSpelling | SettledSpelling -"""How generated code writes a name. - -- :class:`PendingSpelling`: added by the generator, so decided by :func:`settle_module` once its module is complete. -- :class:`SettledSpelling`: known up front, such as a public one, so decided at once by :func:`settle_names`. -""" def ast_str(spelling: Spelling, /) -> str: @@ -108,7 +100,7 @@ def _identifiers(module: ast.Module, /) -> Iterator[str]: def _rewrite(module: ast.Module, spellings: Mapping[str, SettledSpelling], /) -> None: - # Lists of identifiers, as `global`, `nonlocal`, and keyword class patterns hold, are left: generated code writes none. + # `global` and `nonlocal` hold lists of names, which generated code never writes. for node in ast.walk(module): for field, value in ast.iter_fields(node): if isinstance(value, str) and value in spellings: @@ -116,12 +108,9 @@ def _rewrite(module: ast.Module, spellings: Mapping[str, SettledSpelling], /) -> def settle_module(module: ast.Module, /) -> None: - """The other names are fixed, since they come from GraphQL, so a clash is impossible rather than detected. - - Collecting them widely, every name loaded, stored, or imported anywhere in the module, class body keys included, is what makes that hold without reasoning about scopes. - """ + """Every name the module loads, stores, or imports is taken, whatever its scope, so that a placeholder never clashes with a GraphQL name.""" fixed = set(_identifiers(module)) - # In creation order, which the counter after the first mark records. + # In creation order. placeholders = sorted( {value for value in _strings(module) if _is_placeholder(value)}, key=lambda placeholder: int(placeholder.split(_MARK)[1]), @@ -156,7 +145,7 @@ def settle_names( for name in sorted( identifiers, - # Names that can be bound as they are come first, so a renamed one never takes the name of a definition spelled that way. + # Bindable names first, so that a renamed one never takes a name spelled as is. key=lambda name: not is_bindable(name), ): spelling = next( diff --git a/src/graphql_codegen/_metadata.py b/src/graphql_codegen/_metadata.py index c3c2ab8..7270cea 100644 --- a/src/graphql_codegen/_metadata.py +++ b/src/graphql_codegen/_metadata.py @@ -2,10 +2,7 @@ from typing import Final METADATA: Final = metadata(__name__.partition(".")[0]) -"""This library's metadata, which `pyproject.toml` declares.""" - DISTRIBUTION_NAME: Final = METADATA["Name"] -"""This library's name, which only `pyproject.toml` spells.""" -# Inspired by https://github.com/github-linguist/linguist/blob/5fbdfcb8133be2bed88bf3ce62b2335f50474525/lib/linguist/generated.rb#L345. +# GitHub collapses the diff of a file starting with this: https://github.com/github-linguist/linguist/blob/5fbdfcb8133be2bed88bf3ce62b2335f50474525/lib/linguist/generated.rb#L345. GENERATED_FILE_COMMENT_PREFIX: Final = f"# Code generated by {DISTRIBUTION_NAME}" diff --git a/src/graphql_codegen/_note.py b/src/graphql_codegen/_note.py deleted file mode 100644 index 10d5133..0000000 --- a/src/graphql_codegen/_note.py +++ /dev/null @@ -1,12 +0,0 @@ -from collections.abc import Iterator -from contextlib import contextmanager - - -@contextmanager -def error_note(note: str, /) -> Iterator[None]: - """Add *note* to an error raised inside, so that a function need not know where its caller found what it checks.""" - try: - yield - except Exception as error: - error.add_note(note) - raise diff --git a/src/graphql_codegen/config.py b/src/graphql_codegen/config.py index 97a7adc..94b57a1 100644 --- a/src/graphql_codegen/config.py +++ b/src/graphql_codegen/config.py @@ -12,24 +12,24 @@ @dataclass(frozen=True, kw_only=True) class Config: document_sibling_module: DocumentSiblingModule | None = None - """When ``None``, each document's module is named after the document, in the generated package's `document` subpackage, which re-exports every operation and fragment.""" + """When `None`, each document's operations and fragments go to a module named after it in the generated package's `document` subpackage, which re-exports them all.""" injector_names: Collection[str] = frozenset() - """The names of the variables and input fields an injector supplies, left out of the generated types and injected wherever an operation holds them. + """The names of the variables and input fields that injectors supply. - Every variable and input field of a same name must have the same type, nullability aside. + They are left out of the generated types, and must have the same type wherever they appear, nullability aside. """ non_null_directive_name: str | None = None - """The name of the client directive asserting that a nullable field is not null for this operation, stripped before a document goes on the wire.""" + """The name of the client directive asserting that a field is not null, stripped from the document before it is sent.""" scalars: Mapping[str, Scalar] = field(default_factory=dict) - """The Python type of each scalar by GraphQL name.""" + """The Python type, and codec, of each scalar by its GraphQL name.""" struct_interface_name: str | None = None - """The name of the interface every struct implements: an object type whose single field, of a custom scalar type, carries a JSON payload. + """The name of the interface every struct implements: a type whose single field carries a JSON payload. - The payload is shaped like the input type named as the struct without the interface's name, such as ``"BookFilter"`` for ``f"BookFilter{struct_interface_name}"``. + The payload is typed by the input type the struct is named after: `BookFilter` for `BookFilterStruct` when the interface is `Struct`. """ def __post_init__(self) -> None: diff --git a/src/graphql_codegen/document_sibling_module.py b/src/graphql_codegen/document_sibling_module.py index 3887f97..e33f21a 100644 --- a/src/graphql_codegen/document_sibling_module.py +++ b/src/graphql_codegen/document_sibling_module.py @@ -9,25 +9,24 @@ @final @dataclass(frozen=True, kw_only=True) class DocumentSiblingModule: - """The module holding a document's operations and fragments, in the document's directory. + """The module holding a document's operations and fragments, next to the document. - The schema types they reference deliberately stay in the generated package, so that their import paths never depend on which documents use them. - A fragment spread from another document is imported from its own document's module. + The schema types stay in the generated package, so that their import paths do not depend on which documents use them. + A fragment spread from another document is imported from that document's module. """ name: str - """Its name, such as ``_{document}_gql``, in which ``{document}`` is the document's file stem, the whole then settled as a module name.""" + """Its name, with `{document}` standing for the document's file stem, such as `_{document}_gql`.""" package_location: PackageLocation - """Where the package lives, which the module imports it from.""" + """Where the generated package is, for the module to import it.""" def __post_init__(self) -> None: - # Parsed eagerly, so that no sibling module holds a name it cannot use. + # Fail on a bad name now, not at first use. _ = self._affixes @cached_property def _affixes(self) -> tuple[str, str]: - """What the name puts ``(before, after)`` ``{document}``.""" before, placeholder, after = self.name.partition("{document}") if ( @@ -42,10 +41,6 @@ def _affixes(self) -> tuple[str, str]: return before, after def _module_name(self, document: PurePosixPath, /) -> str: - """The name of *document*'s module, before it is settled as a module name. - - An underscore the name puts next to ``{document}`` merges with the stem's own. - """ before, after = self._affixes stem = document.stem diff --git a/src/graphql_codegen/package_location.py b/src/graphql_codegen/package_location.py index 39ef4a4..48f69b8 100644 --- a/src/graphql_codegen/package_location.py +++ b/src/graphql_codegen/package_location.py @@ -13,18 +13,17 @@ class PackageLocation: """Where the generated package lives.""" module_root: PurePosixPath - """Where the package's top-level package starts.""" + """The directory the package's dotted name starts from.""" package: str - """The package's dotted name, which a module outside it imports it by.""" + """The package's dotted name, such as `my_app._graphql`.""" def __post_init__(self) -> None: - # Parsed eagerly, so that no location holds a package it cannot use. + # Fail on a bad name now, not at first use. _ = self._parts @cached_property def _parts(self) -> tuple[str, ...]: - """The names the package's dotted name holds.""" parts = tuple(self.package.split(".")) if not all(is_bindable(part) for part in parts): diff --git a/src/graphql_codegen/runtime/__init__.py b/src/graphql_codegen/runtime/__init__.py index 8522871..4b2d3f6 100644 --- a/src/graphql_codegen/runtime/__init__.py +++ b/src/graphql_codegen/runtime/__init__.py @@ -1,15 +1,9 @@ # Copied into each generated package, where this line says not to edit it. -from .client import ( - AsyncClient as AsyncClient, - AsyncSubscriptionClient as AsyncSubscriptionClient, - Client as Client, - SubscriptionClient as SubscriptionClient, -) from .error import ( - ClientError as ClientError, Error as Error, ExecutionError as ExecutionError, + GraphqlError as GraphqlError, Location as Location, ProtocolError as ProtocolError, RequestError as RequestError, @@ -17,4 +11,6 @@ UnexpectedNullError as UnexpectedNullError, ) from .injection import OMITTED as OMITTED -from .operation import Operation as Operation, Request as Request +from .merge import merge as merge +from .operation import Operation as Operation +from .request import PreparedRequest as PreparedRequest, Request as Request diff --git a/src/graphql_codegen/runtime/_compat.py b/src/graphql_codegen/runtime/_compat.py index 5a3be40..6999bfb 100644 --- a/src/graphql_codegen/runtime/_compat.py +++ b/src/graphql_codegen/runtime/_compat.py @@ -2,7 +2,7 @@ import sys -# Closed TypedDicts (PEP 728) and sentinels (PEP 661) are the last to reach the standard library. +# PEP 728 closed TypedDicts and PEP 661 sentinels reach the standard library in Python 3.15. if sys.version_info >= (3, 15): from typing import ( ReadOnly as ReadOnly, diff --git a/src/graphql_codegen/runtime/_execution_error_handling.py b/src/graphql_codegen/runtime/_execution_error_handling.py new file mode 100644 index 0000000..6241dc1 --- /dev/null +++ b/src/graphql_codegen/runtime/_execution_error_handling.py @@ -0,0 +1,10 @@ +# Copied into each generated package, where this line says not to edit it. + +from typing import Literal + +type ExecutionErrorHandling = Literal["raise", "return"] +"""What `PreparedRequest.parse()` does with the `ExecutionError` of a response. + +- `"raise"` raises it, so the data returned is always complete. +- `"return"` returns it, so the data returned may be the error, typed by the request. +""" diff --git a/src/graphql_codegen/runtime/_literal.py b/src/graphql_codegen/runtime/_literal.py deleted file mode 100644 index 068d356..0000000 --- a/src/graphql_codegen/runtime/_literal.py +++ /dev/null @@ -1,18 +0,0 @@ -# Copied into each generated package, where this line says not to edit it. - -from typing import Literal - -type OperationType = Literal["query", "mutation", "subscription"] -"""The spec's `OperationType`. - -- ``"query"`` reads, and a client may run several at once. -- ``"mutation"`` writes, and the server runs the root fields of several in order. -- ``"subscription"`` answers with a stream of responses rather than one, which only a subscription client runs. -""" - -type ExecutionErrorHandling = Literal["raise", "return"] -"""What a client does with the :class:`~.error.ExecutionError` of the response to a request. - -- ``"raise"`` raises it, so that what a call returns is always complete data. -- ``"return"`` returns it in place of the request's data, typed by the request, for a caller who can use partial data. -""" diff --git a/src/graphql_codegen/runtime/_merge.py b/src/graphql_codegen/runtime/_merge.py deleted file mode 100644 index ef06b99..0000000 --- a/src/graphql_codegen/runtime/_merge.py +++ /dev/null @@ -1,153 +0,0 @@ -# Copied into each generated package, where this line says not to edit it. - -from collections.abc import Mapping, Sequence -from functools import cache -from json import dumps -from typing import Any, Final - -from ._literal import OperationType -from ._sigil import SIGIL -from .error import Error -from .operation import Operation - -MERGED_OPERATION_NAME: Final = "MergedOperation" - -_INDEX_SEPARATOR: Final = "_" - - -@cache -def _template_parts(document: str, /) -> tuple[str, str, Mapping[str, str]]: - """The generator prints a template like any document: the operation first, its header on one line, then its root fields, then each fragment after a blank line. - - Cached: the documents of a generated package are few and fixed. - """ - operation, *fragment_definitions = document.split("\n\n") - header, *root_selections, _ = operation.split("\n") - # An operation holding the sigil has no directives, so its header ends with its variable definitions, if any. - variable_definitions = ( - header[header.index("(") + 1 : header.rindex(")")] if "(" in header else "" - ) - fragments = { - definition.split(" ", 2)[1]: definition for definition in fragment_definitions - } - return variable_definitions, "\n".join(root_selections), fragments - - -def encode_body( - parts: Sequence[tuple[Operation[OperationType, Any, object], Mapping[str, object]]], - /, -) -> bytes: - """Merging lets a server see several operations at once, such as mutations it can apply in one step, and saves round trips. - - Each operation's variables and root fields are suffixed with its index in the merge, so that the operations cannot collide. - The generator writes a sigil wherever the index goes, so suffixing is plain string replacement. - - A lone operation is sent as is, so that the server knows it by its own name. - """ - if not parts: - raise ValueError("Cannot merge no operation.") - - if len(parts) == 1: - ((operation, operation_variables),) = parts - return operation._request_body(operation_variables) - - operation_types = {operation._operation_type for operation, _ in parts} - - if len(operation_types) != 1: - raise ValueError( - f"Cannot merge operations of different types: {', '.join(sorted(operation_types))}.", - ) - - (operation_type,) = operation_types - variable_definitions: list[str] = [] - selections: list[str] = [] - fragments: dict[str, str] = {} - variables: dict[str, object] = {} - - for index, (operation, operation_variables) in enumerate(parts): - if SIGIL not in operation._document: - raise ValueError( - f"Cannot merge `{operation._name}`: its document has no `{SIGIL}` where its index would go." - ) - - definitions, root_selections, operation_fragments = _template_parts( - operation._document - ) - suffix = f"{_INDEX_SEPARATOR}{index}" - - if definitions: - variable_definitions.append(definitions.replace(SIGIL, suffix)) - - selections.append(root_selections.replace(SIGIL, suffix)) - fragments.update(operation_fragments) - variables.update( - {f"{name}{suffix}": value for name, value in operation_variables.items()} - ) - - signature = f"({', '.join(variable_definitions)})" if variable_definitions else "" - document = "\n\n".join( - [ - "\n".join( - [ - f"{operation_type} {MERGED_OPERATION_NAME}{signature} {{", - *selections, - "}", - ] - ), - *fragments.values(), - ], - ) - return dumps( - { - "operationName": MERGED_OPERATION_NAME, - "query": document, - "variables": variables, - }, - ).encode() - - -def _split_response_name(key: str, /) -> tuple[str, int]: - response_name, index = key.rsplit(_INDEX_SEPARATOR, 1) - return response_name, int(index) - - -def split_merged_data( - data: Mapping[str, object], /, *, count: int -) -> Sequence[Mapping[str, object]]: - """Preallocated, so that an operation whose every root field `@skip` left out still gets its own, empty, data rather than shifting the others'.""" - if count == 1: - return [data] - - split: list[dict[str, object]] = [{} for _ in range(count)] - - for key, value in data.items(): - response_name, index = _split_response_name(key) - split[index][response_name] = value - - return split - - -def split_merged_errors( - errors: Sequence[Error], /, *, count: int -) -> Sequence[Sequence[Error]]: - """An error's path starts with the suffixed response name of the root field it concerns, which tells its operation. - - An error without a path concerns them all. - """ - if count == 1: - return [errors] - - split: list[list[Error]] = [[] for _ in range(count)] - - for error in errors: - path = error.get("path") - - if not path or not isinstance(path[0], str): - for operation_errors in split: - operation_errors.append(error) - continue - - response_name, index = _split_response_name(path[0]) - split[index].append({**error, "path": [response_name, *path[1:]]}) - - return split diff --git a/src/graphql_codegen/runtime/_operation_type.py b/src/graphql_codegen/runtime/_operation_type.py new file mode 100644 index 0000000..da23901 --- /dev/null +++ b/src/graphql_codegen/runtime/_operation_type.py @@ -0,0 +1,11 @@ +# Copied into each generated package, where this line says not to edit it. + +from typing import Final, Literal + +type OperationType = Literal["query", "mutation", "subscription"] + +# Generated operations pass these rather than a string so they do not have to repeat their type in an assignment annotation. +# Pyrefly promotes an inferred literal to `str` in a comprehension's element type: a bare `Final` (which ruff wants) infers it, while `Final[Literal[...]]` declares it. +QUERY: Final[Literal["query"]] = "query" # noqa: PYI064 +MUTATION: Final[Literal["mutation"]] = "mutation" # noqa: PYI064 +SUBSCRIPTION: Final[Literal["subscription"]] = "subscription" # noqa: PYI064 diff --git a/src/graphql_codegen/runtime/_prepare.py b/src/graphql_codegen/runtime/_prepare.py deleted file mode 100644 index 3d8e6e2..0000000 --- a/src/graphql_codegen/runtime/_prepare.py +++ /dev/null @@ -1,250 +0,0 @@ -# Copied into each generated package, where this line says not to edit it. - -from collections.abc import Callable, Mapping, Sequence -from json import JSONDecodeError, loads -from typing import Any, Final, assert_never, cast, final - -from ._literal import OperationType -from ._merge import encode_body, split_merged_data, split_merged_errors -from ._reflection import Convert -from .error import ( - ClientError, - Error, - ExecutionError, - ProtocolError, - RequestError, - UnexpectedNullError, -) -from .injection import _Injectors -from .operation import Operation, Request - - -@final -class PreparedRequest[Data]: - """A call without its IO and awaiting, which are the clients' part.""" - - __slots__ = ("_body", "parse") - - def __init__(self, body: bytes, /, *, parse: Callable[[bytes], Data]) -> None: - self._body: Final = body - self.parse: Final = parse - - def __bytes__(self) -> bytes: - return self._body - - -def resolve_variables( - operation: Operation[OperationType, Any, object], - variables: Mapping[str, object], - /, - *, - injectors: _Injectors | None = None, -) -> Mapping[str, object]: - """The caller's are never mutated: serializing copies them, and injecting copies what it writes into.""" - serialize = operation._variables_serializer - values = ( - variables - if serialize is None - else cast(Mapping[str, object], serialize(variables)) - ) - return ( - values - if injectors is None - else injectors._inject(values, injections=operation._injections) - ) - - -def prepare[Data]( - request: Request[OperationType, Data], /, *, injectors: _Injectors | None = None -) -> PreparedRequest[Data]: - operation = request._operation - variables = resolve_variables(operation, request._variables, injectors=injectors) - - def parse(raw: bytes, /) -> Data: - try: - return parse_data(operation, raw) - except ClientError as error: - error.add_note(_origin_note(operation._name, variables)) - - if isinstance(error, ExecutionError): - match request._on_execution_error: - case "raise": - pass - case "return": - # `Data` is the request's data type or its error: see `Request.returning_error()`. - return cast(Data, error) - case _ as never: - assert_never(never) - - raise - - return PreparedRequest(operation._request_body(variables), parse=parse) - - -def parse_data[Data]( - operation: Operation[OperationType, Any, Data], raw: bytes, / -) -> Data: - """Nothing is validated: the data is trusted to match its static type, from this cast on.""" - data = parse_response(raw, partial_data_parser=operation._partial_data_parser) - - if operation._data_parser is not None: - operation._data_parser(data) - - return cast(Data, data) - - -def _execution_error[Data]( - operation: Operation[OperationType, Any, Data], - errors: Sequence[Error], - /, - *, - data: Mapping[str, object] | None, - extensions: Mapping[str, object] | None, -) -> ExecutionError[Data]: - return ExecutionError[Data]( - errors, - data=data, - extensions=extensions, - partial_data_parser=operation._partial_data_parser, - ) - - -def parse_response( - raw: bytes, /, *, partial_data_parser: Convert | None -) -> Mapping[str, object]: - """A response with errors but no `data` entry is the spec's request error result.""" - try: - payload = loads(raw) - except JSONDecodeError as error: - raise ProtocolError( - f"Expected a JSON response but got: {raw[:200]!r}." - ) from error - - if not isinstance(payload, dict): - raise ProtocolError(f"Expected a JSON object response but got: {raw[:200]!r}.") - - errors = payload.get("errors") - - if errors: - # Trusted to be the spec's errors, as the data is trusted to match its type. - formatted = cast(list[Error], errors) - extensions = payload.get("extensions") - - if "data" not in payload: - raise RequestError(formatted, extensions=extensions) - - raise ExecutionError( - formatted, - data=payload["data"], - extensions=extensions, - partial_data_parser=partial_data_parser, - ) - - data = payload.get("data") - - if not isinstance(data, dict): - raise ProtocolError( - f"Expected the response to carry `data` but got: {raw[:200]!r}." - ) - - # The keys of a JSON object are strings. - return cast(Mapping[str, object], data) - - -def _origin_note(operation_name: str, variables: Mapping[str, object], /) -> str: - """The variables are shown by :func:`repr`, so that a :class:`str` subclass redacting its own keeps a secret out of logs.""" - return f"Raised by `{operation_name}` with variables {variables!r}." - - -def _raises_execution_error(request: Request[OperationType, object], /) -> bool: - match request._on_execution_error: - case "raise": - return True - case "return": - return False - case _ as never: - assert_never(never) - - -def _prepare_merged( - requests: Sequence[Request[OperationType, object]], - /, - *, - injectors: _Injectors | None, -) -> PreparedRequest[tuple[object, ...]]: - operations = [request._operation for request in requests] - # Resolved now rather than when the response comes back, like a lone request's. - variables = [ - resolve_variables(request._operation, request._variables, injectors=injectors) - for request in requests - ] - body = encode_body(list(zip(operations, variables, strict=True))) - count = len(requests) - - def parse(raw: bytes, /) -> tuple[object, ...]: - failures: dict[int, ExecutionError[object]] = {} - - try: - data = parse_response(raw, partial_data_parser=None) - except ExecutionError as error: - # With no data, the merge failed as a whole, which is no one request's error to return. - if error.data is None: - raise - - split = split_merged_data(error.data, count=count) - - for index, (operation, operation_variables, operation_errors) in enumerate( - zip( - operations, - variables, - split_merged_errors(error.errors, count=count), - strict=True, - ) - ): - if operation_errors: - failure = _execution_error( - operation, - operation_errors, - data=split[index], - extensions=error.extensions, - ) - failure.add_note(_origin_note(operation._name, operation_variables)) - failures[index] = failure - - # A call returns or raises: when one failing request raises its error, the group holds every failure, those of requests returning theirs included. - if any(_raises_execution_error(requests[index]) for index in failures): - raise ExceptionGroup( - f"{len(failures)} of the {count} merged operations raised.", - list(failures.values()), - ) from None - else: - split = split_merged_data(data, count=count) - - for index, (operation, operation_variables, operation_data) in enumerate( - zip(operations, variables, split, strict=True) - ): - if index not in failures and operation._data_parser is not None: - try: - operation._data_parser(operation_data) - except UnexpectedNullError as null_error: - null_error.add_note( - _origin_note(operation._name, operation_variables) - ) - raise - - return tuple( - failures.get(index, operation_data) - for index, operation_data in enumerate(split) - ) - - return PreparedRequest(body, parse=parse) - - -def prepare_requests( - requests: object, /, *, injectors: _Injectors | None -) -> PreparedRequest[Any] | None: - if isinstance(requests, Request): - return prepare(requests, injectors=injectors) - - assert isinstance(requests, Sequence) - return _prepare_merged(requests, injectors=injectors) if requests else None diff --git a/src/graphql_codegen/runtime/_reflection.py b/src/graphql_codegen/runtime/_reflection.py index 201b69e..f792d42 100644 --- a/src/graphql_codegen/runtime/_reflection.py +++ b/src/graphql_codegen/runtime/_reflection.py @@ -30,19 +30,19 @@ type Mode = Literal["parse", "partial parse", "serialize"] """What a builder's converters do. -- ``"parse"`` converts the data of a response in place, and raises on the null of a `@nonNull` field. -- ``"partial parse"`` converts the data of a response with errors in place, propagating the null of a `@nonNull` field to its nearest nullable parent, as the server propagates that of a non-null field which raised. -- ``"serialize"`` copies variables, converting what they hold to what goes on the wire. +- `"parse"` converts the data of a response in place, and raises on the null of a `@nonNull` field. +- `"partial parse"` does the same for a response with errors, but moves the null of a `@nonNull` field up to its nearest nullable parent, as the server does for a non-null field. +- `"serialize"` converts a copy of the variables to what goes on the wire. """ type Convert = Callable[[object], object] -"""A parser may convert a mapping in place, a serializer never does.""" +"""A parser converts in place, a serializer copies: a response is ours, the variables are the caller's.""" _QUALIFIERS: Final = frozenset({NotRequired, ReadOnly, Required}) NON_NULL: Final = SentinelType("NON_NULL") -"""Marks a `@nonNull` field, whose null is legal for the schema but not for the query: null there raises :class:`UnexpectedNullError` instead of passing.""" +"""Marks a `@nonNull` field, whose null raises `UnexpectedNullError`.""" @final @@ -53,7 +53,6 @@ class Codec[Value, Wire]: def _unwrap(type_: object, /) -> tuple[object, tuple[object, ...]]: - """Split *type_* from its :data:`typing.Annotated` metadata, which says what a plain type cannot, such as :data:`NON_NULL` or a :class:`Codec`, and which type checkers ignore.""" metadata: list[object] = [] while True: @@ -101,10 +100,9 @@ def _children(unwrapped_type: object, /) -> tuple[object, ...]: @final class _Builder: - """Builds converters for one mode, caching them, so that the work is paid once per type rather than once per call, and tying the knot on recursive types. + """Builds the converters of one mode, once per type. - The build recurses over the grammar of types (:class:`typing.TypedDict`, lists, `X | None`, type aliases, and unions told apart by `__typename` or, for `@oneOf` inputs, by their single key) rather than over the shapes of any particular document. - Nesting, lists of lists, a `@nonNull` under a list, and recursive inputs thus need no special case. + It recurses over the grammar of types, not over the shape of any document, so nesting, lists of lists, and recursive inputs need no special case. """ def __init__(self, mode: Mode, /) -> None: @@ -122,7 +120,7 @@ def __init__(self, mode: Mode, /) -> None: self._partial: Final = partial self._needs: Final[dict[object, bool]] = {} self._converters: Final[dict[object, Convert]] = {} - # Taken by `build()` alone: a recursive type caches a forwarding converter before its real one exists, which another thread must not get, and call, until it resolves. + # A recursive type caches a forwarding converter before its real one exists, which another thread must not call. self._lock: Final = Lock() def _needs_work(self, type_: object, /) -> bool: @@ -132,11 +130,7 @@ def _needs_work(self, type_: object, /) -> bool: return self._needs[type_] def _solve_needs(self, root: object, /) -> None: - """Settle whether each type reachable from *root* needs work, as a least fixed point. - - Types can reach each other in cycles, such as a filter combining filters, so a type cannot be settled from its children alone: it is settled with everything it reaches. - - """ + # A least fixed point, since types reach each other in cycles, such as a filter combining filters. children: dict[object, tuple[object, ...]] = {} needs: dict[object, bool] = {} pending = [root] @@ -170,7 +164,7 @@ def _solve_needs(self, root: object, /) -> None: self._needs.update(needs) def build(self, type_: object, /) -> Convert | None: - """The converter for *type_*, or ``None`` if its values need no work, so that a response needing nothing goes from the transport to the caller untouched.""" + """Return the converter of *type_*, or `None` if its values need no work.""" with self._lock: return self._build(type_) @@ -181,22 +175,21 @@ def _converter(self, type_: object, /) -> Convert: if type_ in self._converters: return self._converters[type_] - # Recursive types reach themselves while being built: they get a forwarding converter, which reads the real one once it exists. + # A recursive type reaches itself while being built, and gets a forwarding converter until then. cell: list[Convert] = [] self._converters[type_] = lambda value: cell[0](value) # noqa: PLW0108 - converter = self._compute_build(type_) + converter = self._new_converter(type_) cell.append(converter) self._converters[type_] = converter return converter - def _compute_build(self, type_: object, /) -> Convert: + def _new_converter(self, type_: object, /) -> Convert: inner, metadata = _unwrap(type_) codecs = [item for item in metadata if isinstance(item, Codec)] convert: Convert | None = None if codecs: (codec,) = codecs - # It annotates this very type, so it converts its values. convert = cast(Convert, codec.decode if self._parses else codec.encode) elif self._needs_work(inner): convert = self._build_structure(inner) @@ -228,11 +221,10 @@ def _build_typed_dict(self, type_: object, /) -> Convert: def convert(value: object, /) -> object: assert isinstance(value, Mapping) - # A response is the client's own, so it is converted in place; variables are the caller's, who may reuse them, so they are copied. result = cast(dict[str, object], value) if in_place else dict(value) for key, convert_value in steps: - # A key may be absent: an optional input, or a field the server skipped. + # Absent for an optional input, or a field the server skipped. if key in result: try: result[key] = convert_value(result[key]) @@ -245,7 +237,7 @@ def convert(value: object, /) -> object: return convert def _build_union(self, type_: object, members: tuple[object, ...], /) -> Convert: - # A sentinel, such as `OMITTED`, is never converted, as `None` is not. + # A sentinel, such as `OMITTED`, is not converted, like `None`. non_null = tuple( member for member in members @@ -275,7 +267,6 @@ def convert_nullable(value: object, /) -> object: return convert_nullable def _build_dispatch(self, type_: object, members: tuple[object, ...], /) -> Convert: - """Tell the members of a union apart, by `__typename` or else by their single key.""" typed_dicts = [_unwrap(member)[0] for member in members] if not all(is_typeddict(member) for member in typed_dicts): @@ -295,7 +286,7 @@ def _build_dispatch(self, type_: object, members: tuple[object, ...], /) -> Conv def convert_by_typename(value: object, /) -> object: assert isinstance(value, Mapping) - # A member added after generation is not converted: nothing is validated. + # A member added after codegen passes as is: nothing is validated. converter = by_typename.get(value["__typename"]) return value if converter is None else converter(value) @@ -316,7 +307,7 @@ def convert_by_typename(value: object, /) -> object: def convert_by_key(value: object, /) -> object: assert isinstance(value, Mapping) (key,) = value - # A struct's payload may hold a member added after generation, which is not converted either. + # A member added after codegen passes as is, like above. converter = by_key.get(key) return value if converter is None else converter(value) @@ -356,7 +347,6 @@ def check(value: object, /) -> object: def build_parser(type_: object, /, *, partial: bool) -> Convert | None: - """A *partial* parser is for the data of a response with errors, where a `@nonNull` field is null when it raised: the null propagates to the field's nearest nullable parent, and a :class:`UnexpectedNullError` only escapes when there is none.""" return (_PARTIAL_PARSE if partial else _PARSE).build(type_) @@ -368,7 +358,7 @@ def build_injector_serializers( injector_functions_type: object, / ) -> dict[str, Convert | None]: return { - # Each hint is `Callable[[], T]`, whose `T` tells the serializer. + # Each hint is `Callable[[], T]`: the serializer is `T`'s. name: build_serializer(get_args(_unwrap(hint)[0])[-1]) for name, hint in get_type_hints( injector_functions_type, include_extras=True diff --git a/src/graphql_codegen/runtime/_sigil.py b/src/graphql_codegen/runtime/_sigil.py index 7884ceb..c1a9f72 100644 --- a/src/graphql_codegen/runtime/_sigil.py +++ b/src/graphql_codegen/runtime/_sigil.py @@ -3,9 +3,11 @@ from typing import Final SIGIL: Final = "§" -"""Where an operation's index in a merge goes in its document: after each root field's response name, as its alias, and after each variable's name. +"""Marks where a merge writes the operation's index. -An operation run alone drops it, leaving each root field aliased as itself, which GraphQL executes as if it were not aliased. -A document without it cannot be merged: a subscription's, or that of an operation with directives of its own. -The generator escapes it in strings, so that every one left is a sigil. +The generator puts it after the alias of each root field, adding the alias when the document has none, and after each variable's name. +A merge replaces it with the index, so that the root fields and variables of two operations cannot collide. +A request sent alone drops it, and with it the aliases the generator added. +A subscription or an operation with directives of its own cannot be merged, so their documents hold none. +It is escaped in strings, so every one in a document is the generator's. """ diff --git a/src/graphql_codegen/runtime/_transport.py b/src/graphql_codegen/runtime/_transport.py deleted file mode 100644 index 226faaf..0000000 --- a/src/graphql_codegen/runtime/_transport.py +++ /dev/null @@ -1,21 +0,0 @@ -# Copied into each generated package, where this line says not to edit it. - -from collections.abc import AsyncGenerator, Awaitable, Callable, Generator -from typing import Concatenate - -type Transport[**Params] = Callable[Concatenate[bytes, Params], bytes] -"""The spec leaves the transport mechanism to the implementation, and so does this library. -Bytes in, bytes out keeps it sans-IO: it owns neither the transport nor the connection, so it works under any IO model. - -""" - -type AsyncTransport[**Params] = Callable[Concatenate[bytes, Params], Awaitable[bytes]] - -type SubscriptionTransport[**Params] = Callable[ - Concatenate[bytes, Params], Generator[bytes, None, None] -] -"""A generator rather than any iterator, because closing the response stream closes it: its cleanup is where it unsubscribes.""" - -type AsyncSubscriptionTransport[**Params] = Callable[ - Concatenate[bytes, Params], AsyncGenerator[bytes, None] -] diff --git a/src/graphql_codegen/runtime/client.py b/src/graphql_codegen/runtime/client.py deleted file mode 100644 index 8cf0f47..0000000 --- a/src/graphql_codegen/runtime/client.py +++ /dev/null @@ -1,384 +0,0 @@ -# Copied into each generated package, where this line says not to edit it. - -from collections.abc import AsyncGenerator, Callable, Generator, Sequence -from contextlib import aclosing, closing -from typing import Any, Final, Generic, Literal, final, overload - -from ._compat import TypeVar -from ._prepare import prepare, prepare_requests -from ._transport import ( - AsyncSubscriptionTransport, - AsyncTransport, - SubscriptionTransport, - Transport, -) -from .injection import _Injectors -from .operation import Request - -# A client's transport, whose signature types the arguments each call accepts: none, unless it says otherwise. -# Covariant, so that a client over a transport is a client over any `Protocol` the transport satisfies. -# Legacy `TypeVar`s, since a default for a PEP 695 type parameter needs Python 3.13. -_Transport_co = TypeVar( - "_Transport_co", bound=Transport[...], covariant=True, default=Transport[[]] -) -_AsyncTransport_co = TypeVar( - "_AsyncTransport_co", - bound=AsyncTransport[...], - covariant=True, - default=AsyncTransport[[]], -) -_SubscriptionTransport_co = TypeVar( - "_SubscriptionTransport_co", - bound=SubscriptionTransport[...], - covariant=True, - default=SubscriptionTransport[[]], -) -_AsyncSubscriptionTransport_co = TypeVar( - "_AsyncSubscriptionTransport_co", - bound=AsyncSubscriptionTransport[...], - covariant=True, - default=AsyncSubscriptionTransport[[]], -) - - -def _response_stream[Data]( - bodies: Generator[bytes, None, None], parse: Callable[[bytes], Data], / -) -> Generator[Data, None, None]: - # Closing the response stream, or an error in one of its responses, closes the transport's. - with closing(bodies): - for body in bodies: - yield parse(body) - - -async def _async_response_stream[Data]( - bodies: AsyncGenerator[bytes, None], parse: Callable[[bytes], Data], / -) -> AsyncGenerator[Data, None]: - async with aclosing(bodies): - async for body in bodies: - yield parse(body) - - -@final -class Client(Generic[_Transport_co]): - """Run query and mutation requests over *transport*, each call accepting the transport's parameters. - - Several operations of one type run merged, in one call to the transport: a tuple of up to 6 returns a tuple typed position by position, and any other sequence a tuple of their common data type. - A response with errors raises a :class:`ResponseError`, and a merge an :exc:`ExceptionGroup` of a :class:`ExecutionError` per operation its errors concern, or the unsplit error when its data is null. - A request from :meth:`~Request.returning_error` gets its :class:`ExecutionError` back in place of its data instead, unless the call raises for another. - - The transport's parameters are read off its signature and checked, so that the library never has to know what they are:: - - def transport(body: bytes, /, *, timeout: float | None = None) -> bytes: ... - - client = Client(transport) - client(ListBooks({"first": 10}), timeout=5.0) - - Code taking a client names its type after the transport's signature, written once as a :class:`typing.Protocol`, which the transport a client is built from is checked against:: - - class Transport(Protocol): - def __call__(self, body: bytes, /, *, timeout: float | None = None) -> bytes: ... - - Client = runtime.Client[Transport] - - def describe(isbn: ISBN, /, *, client: Client) -> str: ... - - describe(isbn, client=Client(transport)) - - A plain alias rather than a `type` statement, since only the former can be called to build a client. - A client is generic over its transport rather than over its parameters, because a :class:`typing.ParamSpec`'s value cannot be spelled with keyword parameters, and PEP 692 only unpacks a concrete :class:`typing.TypedDict`, never a type parameter. - """ - - def __init__( - self, - transport: _Transport_co, - /, - *, - injectors: _Injectors | None = None, - ) -> None: - self.transport: Final = transport - self.injectors: Final = injectors - - @overload - def __call__[**Params, Type: Literal["query", "mutation"], Data]( - self: "Client[Transport[Params]]", - request: Request[Type, Data], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> Data: ... - @overload - def __call__[**Params, Type: (Literal["query"], Literal["mutation"]), D1, D2]( - self: "Client[Transport[Params]]", - requests: tuple[Request[Type, D1], Request[Type, D2]], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2]: ... - @overload - def __call__[**Params, Type: (Literal["query"], Literal["mutation"]), D1, D2, D3]( - self: "Client[Transport[Params]]", - requests: tuple[Request[Type, D1], Request[Type, D2], Request[Type, D3]], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2, D3]: ... - @overload - def __call__[ - **Params, - Type: (Literal["query"], Literal["mutation"]), - D1, - D2, - D3, - D4, - ]( - self: "Client[Transport[Params]]", - requests: tuple[ - Request[Type, D1], Request[Type, D2], Request[Type, D3], Request[Type, D4] - ], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2, D3, D4]: ... - @overload - def __call__[ - **Params, - Type: (Literal["query"], Literal["mutation"]), - D1, - D2, - D3, - D4, - D5, - ]( - self: "Client[Transport[Params]]", - requests: tuple[ - Request[Type, D1], - Request[Type, D2], - Request[Type, D3], - Request[Type, D4], - Request[Type, D5], - ], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2, D3, D4, D5]: ... - @overload - def __call__[ - **Params, - Type: (Literal["query"], Literal["mutation"]), - D1, - D2, - D3, - D4, - D5, - D6, - ]( - self: "Client[Transport[Params]]", - requests: tuple[ - Request[Type, D1], - Request[Type, D2], - Request[Type, D3], - Request[Type, D4], - Request[Type, D5], - Request[Type, D6], - ], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2, D3, D4, D5, D6]: ... - @overload - def __call__[**Params, Type: (Literal["query"], Literal["mutation"]), Data]( - self: "Client[Transport[Params]]", - requests: Sequence[Request[Type, Data]], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[Data, ...]: ... - def __call__(self, requests: Any, /, *args: Any, **kwargs: Any) -> Any: - prepared = prepare_requests(requests, injectors=self.injectors) - return ( - () - if prepared is None - else prepared.parse(self.transport(bytes(prepared), *args, **kwargs)) - ) - - -@final -class AsyncClient(Generic[_AsyncTransport_co]): - def __init__( - self, - transport: _AsyncTransport_co, - /, - *, - injectors: _Injectors | None = None, - ) -> None: - self.transport: Final = transport - self.injectors: Final = injectors - - @overload - async def __call__[**Params, Type: Literal["query", "mutation"], Data]( - self: "AsyncClient[AsyncTransport[Params]]", - request: Request[Type, Data], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> Data: ... - @overload - async def __call__[ - **Params, - Type: (Literal["query"], Literal["mutation"]), - D1, - D2, - ]( - self: "AsyncClient[AsyncTransport[Params]]", - requests: tuple[Request[Type, D1], Request[Type, D2]], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2]: ... - @overload - async def __call__[ - **Params, - Type: (Literal["query"], Literal["mutation"]), - D1, - D2, - D3, - ]( - self: "AsyncClient[AsyncTransport[Params]]", - requests: tuple[Request[Type, D1], Request[Type, D2], Request[Type, D3]], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2, D3]: ... - @overload - async def __call__[ - **Params, - Type: (Literal["query"], Literal["mutation"]), - D1, - D2, - D3, - D4, - ]( - self: "AsyncClient[AsyncTransport[Params]]", - requests: tuple[ - Request[Type, D1], Request[Type, D2], Request[Type, D3], Request[Type, D4] - ], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2, D3, D4]: ... - @overload - async def __call__[ - **Params, - Type: (Literal["query"], Literal["mutation"]), - D1, - D2, - D3, - D4, - D5, - ]( - self: "AsyncClient[AsyncTransport[Params]]", - requests: tuple[ - Request[Type, D1], - Request[Type, D2], - Request[Type, D3], - Request[Type, D4], - Request[Type, D5], - ], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2, D3, D4, D5]: ... - @overload - async def __call__[ - **Params, - Type: (Literal["query"], Literal["mutation"]), - D1, - D2, - D3, - D4, - D5, - D6, - ]( - self: "AsyncClient[AsyncTransport[Params]]", - requests: tuple[ - Request[Type, D1], - Request[Type, D2], - Request[Type, D3], - Request[Type, D4], - Request[Type, D5], - Request[Type, D6], - ], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[D1, D2, D3, D4, D5, D6]: ... - @overload - async def __call__[**Params, Type: (Literal["query"], Literal["mutation"]), Data]( - self: "AsyncClient[AsyncTransport[Params]]", - requests: Sequence[Request[Type, Data]], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> tuple[Data, ...]: ... - async def __call__(self, requests: Any, /, *args: Any, **kwargs: Any) -> Any: - prepared = prepare_requests(requests, injectors=self.injectors) - return ( - () - if prepared is None - else prepared.parse(await self.transport(bytes(prepared), *args, **kwargs)) - ) - - -@final -class SubscriptionClient(Generic[_SubscriptionTransport_co]): - """Run subscription requests over *transport*, returning their response stream, which closing unsubscribes. - - A response with errors raises out of the stream. - """ - - def __init__( - self, - transport: _SubscriptionTransport_co, - /, - *, - injectors: _Injectors | None = None, - ) -> None: - self.transport: Final = transport - self.injectors: Final = injectors - - def __call__[**Params, Data]( - self: "SubscriptionClient[SubscriptionTransport[Params]]", - request: Request[Literal["subscription"], Data], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> Generator[Data, None, None]: - prepared = prepare(request, injectors=self.injectors) - return _response_stream( - self.transport(bytes(prepared), *args, **kwargs), prepared.parse - ) - - -@final -class AsyncSubscriptionClient(Generic[_AsyncSubscriptionTransport_co]): - def __init__( - self, - transport: _AsyncSubscriptionTransport_co, - /, - *, - injectors: _Injectors | None = None, - ) -> None: - self.transport: Final = transport - self.injectors: Final = injectors - - def __call__[**Params, Data]( - self: "AsyncSubscriptionClient[AsyncSubscriptionTransport[Params]]", - request: Request[Literal["subscription"], Data], - /, - *args: Params.args, - **kwargs: Params.kwargs, - ) -> AsyncGenerator[Data, None]: - prepared = prepare(request, injectors=self.injectors) - return _async_response_stream( - self.transport(bytes(prepared), *args, **kwargs), prepared.parse - ) diff --git a/src/graphql_codegen/runtime/error.py b/src/graphql_codegen/runtime/error.py index 4a6fd8c..cc1e64a 100644 --- a/src/graphql_codegen/runtime/error.py +++ b/src/graphql_codegen/runtime/error.py @@ -7,16 +7,46 @@ from ._compat import TypedDict, TypeVar +class Error(Exception): + """The base of every error the generated package raises. + + Your transport's own errors, such as a timeout or an HTTP status, are not among them. + """ + + +@final +class ProtocolError(Error): + """The response was not a GraphQL response, such as an HTML error page from a proxy.""" + + +@final +class UnexpectedNullError(Error): + """A field marked `@nonNull` was null. + + `path` names the exact value, list indices included. + """ + + def __init__(self, path: list[str | int], /) -> None: + self.path: Final = path + super().__init__() + + @override + def __str__(self) -> str: + formatted = "".join( + f"[{segment}]" if isinstance(segment, int) else f".{segment}" + for segment in self.path + ).removeprefix(".") + return f"Expected `{formatted}` to not be null." + + class Location(TypedDict): line: Required[int] column: Required[int] -class Error(TypedDict): - """An error as a server sends it, in the spec's shape, which graphql-core's :class:`~graphql.GraphQLFormattedError` follows too. - - Not graphql-core's :class:`~graphql.GraphQLError`, because four of its eight attributes (:attr:`~graphql.GraphQLError.nodes`, :attr:`~graphql.GraphQLError.source`, :attr:`~graphql.GraphQLError.positions`, and :attr:`~graphql.GraphQLError.original_error`) are server-side execution state a client cannot fill, and importing it would put graphql-core in the runtime for nothing. - """ +# Not graphql-core's `GraphQLError`: half of its attributes are server-side state, and importing it would make the generated package depend on graphql-core. +class GraphqlError(TypedDict): + """An error as the server sends it, in the spec's shape.""" message: Required[str] locations: NotRequired[Sequence[Location]] @@ -24,23 +54,15 @@ class Error(TypedDict): extensions: NotRequired[object] -class ClientError(Exception): - """Base of every error a generated client raises. - - A transport's own errors, such as a timeout or an HTTP status, pass through as they are: the client does not know what carries its requests. - Not :func:`typing.final`: it exists so a caller can catch anything this library raises with one `except`. - """ - - -class ResponseError(ClientError): - """The response carried errors, in either of the two shapes the spec gives such a response. - - A response carrying any error raises, so that the data a client returns never holds a `null` standing for an error. - Not :func:`typing.final`: it exists so a caller can catch both shapes with one `except`. - """ +class ResponseError(Error): + """Raised for any error, even with data beside it: a field that failed is `null` there, which looks like a null field.""" def __init__( - self, errors: Sequence[Error], /, *, extensions: Mapping[str, object] | None + self, + errors: Sequence[GraphqlError], + /, + *, + extensions: Mapping[str, object] | None, ) -> None: assert errors, "Cannot raise without at least one error." self.errors: Final = tuple(errors) @@ -50,13 +72,10 @@ def __init__( @final class RequestError(ResponseError): - """The response is the spec's request error result: the request failed before execution, so it has no data. - - A syntax error, a validation failure, or an invalid variable value, for instance. - """ + """The request failed before execution, so the response has no data.""" -# The type of an execution error's parsed data: the operation's data type when a client returns the error, typed by its request, and a plain mapping when `except` catches it, since that names no operation. +# The default types what `except` catches, which names no operation: `Mapping[str, object]`. # A legacy `TypeVar`, since a default for a PEP 695 type parameter needs Python 3.13. _Data_co = TypeVar("_Data_co", covariant=True, default=Mapping[str, object]) @@ -71,7 +90,7 @@ class ExecutionError(ResponseError, Generic[_Data_co]): data: Final[Mapping[str, object] | None] def parse_data(self) -> _Data_co | None: - """Return the data converted as in a response without errors, in a fresh copy. + """Return a fresh copy of the data, converted as in a response without errors. A `@nonNull` field that raised moves its `null` up to its nearest nullable parent, as the server does for a non-null field. A custom scalar's codec may raise on a value it cannot decode. @@ -87,12 +106,11 @@ def parse_data(self) -> _Data_co | None: except UnexpectedNullError: return None - # Nothing is validated: the data is trusted to match its type, as in a response without errors. return cast(_Data_co, data) def __init__( self, - errors: Sequence[Error], + errors: Sequence[GraphqlError], /, *, data: Mapping[str, object] | None, @@ -102,34 +120,3 @@ def __init__( super().__init__(errors, extensions=extensions) self.data = data self._partial_data_parser: Final = partial_data_parser - - -@final -class ProtocolError(ClientError): - """The response was not a GraphQL response. - - Usually an HTML error page from a proxy. - Without this the caller would get a bare :exc:`json.JSONDecodeError` from somewhere inside the client. - """ - - -@final -class UnexpectedNullError(ClientError): - """A field marked `@nonNull` was null. - - The schema declares the field nullable, so only the client can assert otherwise. - - The path names the exact value, list indices included: it is completed on the way up by each enclosing object and list, which costs nothing unless the error is raised. - """ - - def __init__(self, path: list[str | int], /) -> None: - self.path: Final = path - super().__init__() - - @override - def __str__(self) -> str: - formatted = "".join( - f"[{segment}]" if isinstance(segment, int) else f".{segment}" - for segment in self.path - ).removeprefix(".") - return f"Expected `{formatted}` to not be null." diff --git a/src/graphql_codegen/runtime/injection.py b/src/graphql_codegen/runtime/injection.py index e88ad28..890fde2 100644 --- a/src/graphql_codegen/runtime/injection.py +++ b/src/graphql_codegen/runtime/injection.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Mapping from typing import Final, cast, final -from ._reflection import Convert, build_injector_serializers +from ._reflection import build_injector_serializers if sys.version_info >= (3, 15): OMITTED = sentinel("OMITTED") # noqa: F821 @@ -12,9 +12,9 @@ from typing_extensions import Sentinel OMITTED = Sentinel("OMITTED") -"""What an injector returns to leave its value out rather than send ``null``. +"""What an injector returns to leave its value out, rather than send `null`. -The spec tells the two apart: a nullable input accepts both, a non-null one neither, and a server may treat them differently. +A nullable input accepts both, and a server may treat them differently. """ @@ -23,10 +23,7 @@ @final class _Injectors: - """Built by the ``injectors()`` function of a generated package's ``injection`` module, from a mapping the package's types check. - - Not a mapping itself, so that a client cannot be given injectors that skipped that check: only a client calls it. - """ + """Only the generated `injectors()` builds one, so `Request.prepare()` never calls a function the package's types did not check.""" def __init__( self, functions: Mapping[str, object], /, *, injector_functions_type: object @@ -43,39 +40,26 @@ def _inject( *, injections: Mapping[str, frozenset[tuple[str, ...]]], ) -> Mapping[str, object]: - """Calling the injectors is the one impure step, each once, so that every path of a request receives the same value.""" - values = { - name: function() - for name, function in self._functions.items() - if name in injections - } - return _inject_values( - variables, values, serializers=self._serializers, injections=injections - ) + for name, function in self._functions.items(): + if name not in injections: + continue + # Called once per request, so every path gets the same value. + value = function() -def _inject_values( - variables: Mapping[str, object], - values: Mapping[str, object], - /, - *, - serializers: Mapping[str, Convert | None], - injections: Mapping[str, frozenset[tuple[str, ...]]], -) -> Mapping[str, object]: - for name, value in values.items(): - if value is OMITTED: - continue + if value is OMITTED: + continue - serialize = serializers[name] - serialized = value if serialize is None else serialize(value) + serialize = self._serializers[name] + serialized = value if serialize is None else serialize(value) - for path in injections[name]: - injected = _inject_into(variables, path, key=name, value=serialized) + for path in injections[name]: + injected = _inject_into(variables, path, key=name, value=serialized) - if injected is not None: - variables = injected + if injected is not None: + variables = injected - return variables + return variables def _inject_into( @@ -86,10 +70,6 @@ def _inject_into( key: str, value: object, ) -> dict[str, object] | None: - """Copied along the path, since the caller may reuse what it passed. - - ``None`` when an object on the path is absent or null, as a nullable input the caller left out is: there is nothing to inject into. - """ if not path: return {**container, key: value} @@ -99,7 +79,6 @@ def _inject_into( if child is None: return None - # The generator only emits paths through input objects. assert isinstance(child, Mapping), ( f"Expected `{head}` to hold an input object to inject `{key}` into." ) diff --git a/src/graphql_codegen/runtime/merge.py b/src/graphql_codegen/runtime/merge.py new file mode 100644 index 0000000..ca4cfde --- /dev/null +++ b/src/graphql_codegen/runtime/merge.py @@ -0,0 +1,324 @@ +# Copied into each generated package, where this line says not to edit it. + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from functools import cache +from json import dumps +from typing import Any, Final, Literal, assert_never, cast, final, overload, override + +from ._operation_type import OperationType +from ._sigil import SIGIL +from .error import ExecutionError, GraphqlError, UnexpectedNullError +from .injection import _Injectors +from .operation import ( + _OperationRequest, + _origin_note, + _parse_response, + _resolve_variables, +) +from .request import PreparedRequest, Request + + +@final +class _MergedRequest[Type: OperationType, Data](Request[Type, Data]): + def __init__(self, requests: Sequence[_OperationRequest[Type, object]], /) -> None: + if not requests: + raise ValueError("Cannot merge no request.") + + super().__init__(operation_type=requests[0]._operation_type) + self._requests: Final = tuple(requests) + + @override + def __repr__(self) -> str: + return f"merge({', '.join(map(repr, self._requests))})" + + @override + def prepare(self, *, injectors: _Injectors | None = None) -> PreparedRequest[Data]: + # Only `merge()`'s overloads know the tuple's type. + return cast( + PreparedRequest[Data], + _prepare_merged(self._operation_type, self._requests, injectors=injectors), + ) + + +# Constrained rather than bound, so every merged request has the same type, and none is a subscription. +# Typed position by position up to 6 requests, as many as typeshed's `asyncio.gather()`: https://github.com/python/typeshed/blob/1ee8b928886a93b060bdafbf1e30dc0bb7c85848/stdlib/asyncio/tasks.pyi#L108-L155 +@overload +def merge[Type: (Literal["query"], Literal["mutation"]), D1, D2]( + first: _OperationRequest[Type, D1], second: _OperationRequest[Type, D2], / +) -> Request[Type, tuple[D1, D2]]: ... +@overload +def merge[Type: (Literal["query"], Literal["mutation"]), D1, D2, D3]( + first: _OperationRequest[Type, D1], + second: _OperationRequest[Type, D2], + third: _OperationRequest[Type, D3], + /, +) -> Request[Type, tuple[D1, D2, D3]]: ... +@overload +def merge[Type: (Literal["query"], Literal["mutation"]), D1, D2, D3, D4]( + first: _OperationRequest[Type, D1], + second: _OperationRequest[Type, D2], + third: _OperationRequest[Type, D3], + fourth: _OperationRequest[Type, D4], + /, +) -> Request[Type, tuple[D1, D2, D3, D4]]: ... +@overload +def merge[Type: (Literal["query"], Literal["mutation"]), D1, D2, D3, D4, D5]( + first: _OperationRequest[Type, D1], + second: _OperationRequest[Type, D2], + third: _OperationRequest[Type, D3], + fourth: _OperationRequest[Type, D4], + fifth: _OperationRequest[Type, D5], + /, +) -> Request[Type, tuple[D1, D2, D3, D4, D5]]: ... +@overload +def merge[Type: (Literal["query"], Literal["mutation"]), D1, D2, D3, D4, D5, D6]( + first: _OperationRequest[Type, D1], + second: _OperationRequest[Type, D2], + third: _OperationRequest[Type, D3], + fourth: _OperationRequest[Type, D4], + fifth: _OperationRequest[Type, D5], + sixth: _OperationRequest[Type, D6], + /, +) -> Request[Type, tuple[D1, D2, D3, D4, D5, D6]]: ... +@overload +def merge[Type: (Literal["query"], Literal["mutation"]), Data]( + requests: Sequence[_OperationRequest[Type, Data]], / +) -> Request[Type, tuple[Data, ...]]: ... +def merge(*args: Any) -> Request[Any, Any]: + """Return one request for several queries, or several mutations, whose data is a tuple of their data. + + Positional requests are typed position by position, a sequence by its common data type. + + The errors of a response are split per request: `PreparedRequest.parse()` raises them as an `ExceptionGroup`, unless every failing request is from `returning_error()`, in which case each gets its error in its place. + A response with no data raises its error unsplit. + """ + # The sequence overload takes one argument, the others take the requests themselves. + requests = args[0] if len(args) == 1 else args + return _MergedRequest(requests) + + +def _prepare_merged( + operation_type: OperationType, + requests: Sequence[_OperationRequest[OperationType, object]], + /, + *, + injectors: _Injectors | None, +) -> PreparedRequest[tuple[object, ...]]: + operations = [request._operation for request in requests] + variables = [ + _resolve_variables(request._operation, request._variables, injectors=injectors) + for request in requests + ] + count = len(requests) + # Sent as is when alone, so the server knows it by its own name. + body = ( + operations[0]._request_body(variables[0]) + if count == 1 + else _encode_body( + operation_type, + [ + _Part( + name=operation._name, + document=operation._document, + variables=operation_variables, + ) + for operation, operation_variables in zip( + operations, variables, strict=True + ) + ], + ) + ) + + def parse(raw: bytes, /) -> tuple[object, ...]: + failures: dict[int, ExecutionError[object]] = {} + + try: + data = _parse_response(raw, partial_data_parser=None) + except ExecutionError as error: + # With no data, the merge failed as a whole: no request's error to return. + if error.data is None: + raise + + split = _split_merged_data(error.data, count=count) + + for index, (operation, operation_variables, operation_errors) in enumerate( + zip( + operations, + variables, + _split_merged_errors(error.errors, count=count), + strict=True, + ) + ): + if operation_errors: + failure = ExecutionError[object]( + operation_errors, + data=split[index], + extensions=error.extensions, + partial_data_parser=operation._partial_data_parser, + ) + failure.add_note(_origin_note(operation._name, operation_variables)) + failures[index] = failure + + # One raising request raises the group of every failure, those of requests returning theirs included. + if any(_raises_execution_error(requests[index]) for index in failures): + raise ExceptionGroup( + f"{len(failures)} of the {count} merged operations raised.", + list(failures.values()), + ) from None + else: + split = _split_merged_data(data, count=count) + + for index, (operation, operation_variables, operation_data) in enumerate( + zip(operations, variables, split, strict=True) + ): + if index not in failures and operation._data_parser is not None: + try: + operation._data_parser(operation_data) + except UnexpectedNullError as null_error: + null_error.add_note( + _origin_note(operation._name, operation_variables) + ) + raise + + return tuple( + failures.get(index, operation_data) + for index, operation_data in enumerate(split) + ) + + return PreparedRequest(body, parse=parse) + + +def _raises_execution_error( + request: _OperationRequest[OperationType, object], / +) -> bool: + match request._on_execution_error: + case "raise": + return True + case "return": + return False + case _ as never: + assert_never(never) + + +_MERGED_OPERATION_NAME: Final = "MergedOperation" + +_INDEX_SEPARATOR: Final = "_" + + +@final +@dataclass(frozen=True, kw_only=True) +class _Part: + name: str + document: str + variables: Mapping[str, object] + + +@cache +def _template_parts(document: str, /) -> tuple[str, str, Mapping[str, str]]: + operation, *fragment_definitions = document.split("\n\n") + header, *root_selections, _ = operation.split("\n") + # No directives on an operation holding the sigil, so the header ends with its variable definitions. + variable_definitions = ( + header[header.index("(") + 1 : header.rindex(")")] if "(" in header else "" + ) + fragments = { + definition.split(" ", 2)[1]: definition for definition in fragment_definitions + } + return variable_definitions, "\n".join(root_selections), fragments + + +def _encode_body(operation_type: OperationType, parts: Sequence[_Part], /) -> bytes: + assert len(parts) > 1 + + variable_definitions: list[str] = [] + selections: list[str] = [] + fragments: dict[str, str] = {} + variables: dict[str, object] = {} + + for index, part in enumerate(parts): + if SIGIL not in part.document: + raise ValueError( + f"Cannot merge `{part.name}`: its document has no `{SIGIL}` where its index would go." + ) + + definitions, root_selections, operation_fragments = _template_parts( + part.document + ) + suffix = f"{_INDEX_SEPARATOR}{index}" + + if definitions: + variable_definitions.append(definitions.replace(SIGIL, suffix)) + + selections.append(root_selections.replace(SIGIL, suffix)) + fragments.update(operation_fragments) + variables.update( + {f"{name}{suffix}": value for name, value in part.variables.items()} + ) + + signature = f"({', '.join(variable_definitions)})" if variable_definitions else "" + document = "\n\n".join( + [ + "\n".join( + [ + f"{operation_type} {_MERGED_OPERATION_NAME}{signature} {{", + *selections, + "}", + ] + ), + *fragments.values(), + ], + ) + return dumps( + { + "operationName": _MERGED_OPERATION_NAME, + "query": document, + "variables": variables, + }, + ).encode() + + +def _split_response_name(key: str, /) -> tuple[str, int]: + response_name, index = key.rsplit(_INDEX_SEPARATOR, 1) + return response_name, int(index) + + +def _split_merged_data( + data: Mapping[str, object], /, *, count: int +) -> Sequence[Mapping[str, object]]: + # Preallocated: an operation whose every root field was skipped still gets its own, empty, data. + if count == 1: + return [data] + + split: list[dict[str, object]] = [{} for _ in range(count)] + + for key, value in data.items(): + response_name, index = _split_response_name(key) + split[index][response_name] = value + + return split + + +def _split_merged_errors( + errors: Sequence[GraphqlError], /, *, count: int +) -> Sequence[Sequence[GraphqlError]]: + # An error's path starts with a suffixed root field, which tells its operation; one without a path concerns them all. + if count == 1: + return [errors] + + split: list[list[GraphqlError]] = [[] for _ in range(count)] + + for error in errors: + path = error.get("path") or () + + if not path or not isinstance(path[0], str): + for operation_errors in split: + operation_errors.append(error) + continue + + response_name, index = _split_response_name(path[0]) + split[index].append({**error, "path": [response_name, *path[1:]]}) + + return split diff --git a/src/graphql_codegen/runtime/operation.py b/src/graphql_codegen/runtime/operation.py index f849399..7e1896f 100644 --- a/src/graphql_codegen/runtime/operation.py +++ b/src/graphql_codegen/runtime/operation.py @@ -1,50 +1,52 @@ # Copied into each generated package, where this line says not to edit it. +from __future__ import annotations + +import re from collections.abc import Mapping from functools import cached_property -from json import dumps +from json import JSONDecodeError, dumps, loads from types import MappingProxyType -from typing import Any, Final, Generic, TypeVar, final, override +from typing import Any, Final, assert_never, cast, final, override -from ._literal import ExecutionErrorHandling, OperationType +from ._execution_error_handling import ExecutionErrorHandling +from ._operation_type import OperationType from ._reflection import Convert, build_parser, build_serializer from ._sigil import SIGIL -from .error import ExecutionError +from .error import Error, ExecutionError, GraphqlError, ProtocolError, RequestError +from .injection import _Injectors +from .request import PreparedRequest, Request + +# A root field the generator aliased as itself, for the suffix a merge adds. +_REDUNDANT_ALIAS: Final = re.compile( + rf"^(?P +)(?P\w+){SIGIL}: (?P=name)\b", re.MULTILINE +) -_VARIABLES_KEY: Final = ',"variables":' _NO_INJECTIONS: Final[Mapping[str, frozenset[tuple[str, ...]]]] = MappingProxyType( dict[str, frozenset[tuple[str, ...]]]() ) -# Declared, since PEP 695 infers variance from every method. -# Covariant, so that an operation of one type, or its request, is also one of any type. -_Type_co = TypeVar("_Type_co", bound=OperationType, covariant=True) -# Both held by `variables_type` and taken by `__call__`. -_Variables = TypeVar("_Variables", bound=Mapping[str, object]) -_Data_co = TypeVar("_Data_co", covariant=True) - @final -class Operation(Generic[_Type_co, _Variables, _Data_co]): - """A generated operation, parameterized by its type, what it sends, and what it returns, in the order things happen. +class Operation[Type: OperationType, Variables: Mapping[str, object], Data]: + """A generated operation, typed by its operation type, its variables, and its data. - Its type is a parameter, so that clients tell operation types apart statically: a subscription client takes subscriptions only, and a merge takes operations of one type. + The operation type is a type parameter, so a transport can take subscriptions only, and a merge operations of one type. """ def __init__( self, *, - operation_type: _Type_co, + operation_type: Type, name: str, document: str, - variables_type: type[_Variables], + variables_type: type[Variables], injections: Mapping[str, frozenset[tuple[str, ...]]] = _NO_INJECTIONS, - data_type: type[_Data_co], + data_type: type[Data], ) -> None: - """*document* has the merge sigil wherever its index in a merge goes, and *injections* the path of every object receiving each injector's value, by injector name, `()` being the variables themselves. + """*injections* maps each injector's name to the paths of the objects receiving its value, `()` being the variables themselves. - The key an injection writes is the injector's name, which the variables' type lacks, so that callers cannot pass it. - What crosses the wire is read off *variables_type* and *data_type* at runtime: they are the only description of it. + What crosses the wire is read off *variables_type* and *data_type* at runtime. """ self._operation_type: Final = operation_type self._name: Final = name @@ -53,23 +55,23 @@ def __init__( self._injections: Final = injections self._data_type: Final = data_type - def __call__(self, variables: _Variables, /) -> "Request[_Type_co, _Data_co]": - """Return the request running this operation with *variables*, checked here, before anything runs. + def __call__(self, variables: Variables, /) -> _OperationRequest[Type, Data]: + """Return the request of this operation with *variables*. - Their type is already bound here, so every type checker checks them exactly, whereas from a function taking both an operation and its variables, some type checkers would infer a wider type. + A method rather than a function taking both, so every type checker checks the variables against their exact type. """ - return Request(operation=self, variables=variables, on_execution_error="raise") + return _OperationRequest( + operation=self, variables=variables, on_execution_error="raise" + ) @override def __repr__(self) -> str: - # The operation, rather than its document. return ( f"Operation(operation_type={self._operation_type!r}, name={self._name!r})" ) @cached_property def _data_parser(self) -> Convert | None: - """Only custom scalars with a codec and `@nonNull` fields cost anything at runtime.""" return build_parser(self._data_type, partial=False) @cached_property @@ -82,11 +84,12 @@ def _variables_serializer(self) -> Convert | None: @cached_property def _request_prefix(self) -> bytes: - """Serialized once, since the document's string escaping costs as much as the document is long.""" - envelope = dumps( - {"operationName": self._name, "query": self._document.replace(SIGIL, "")} + # Escaping the document costs as much as it is long, so it is done once. + document = _REDUNDANT_ALIAS.sub(r"\g\g", self._document).replace( + SIGIL, "" ) - return (envelope[:-1] + _VARIABLES_KEY).encode() + envelope = dumps({"operationName": self._name, "query": document}) + return f'{envelope[:-1]},"variables":'.encode() def _request_body(self, variables: Mapping[str, object], /) -> bytes: # `json.dumps()`'s default separators are what make the concatenation valid JSON. @@ -94,22 +97,16 @@ def _request_body(self, variables: Mapping[str, object], /) -> bytes: @final -class Request(Generic[_Type_co, _Data_co]): - """An operation with the variable values its caller gives, not yet those the client injects, as a :class:`requests.Request` does not yet hold the headers its session adds. - - It runs nothing: `ListBooks({"first": 10})` is a value that a client runs, synchronously or not. - """ - - __slots__ = ("_on_execution_error", "_operation", "_variables") - +class _OperationRequest[Type: OperationType, Data](Request[Type, Data]): def __init__( self, *, - # The variables' type has served its purpose once they are checked. - operation: Operation[_Type_co, Any, _Data_co], + # `Any`: `Operation.__call__` already checked the variables. + operation: Operation[Type, Any, Data], variables: Mapping[str, object], on_execution_error: ExecutionErrorHandling, ) -> None: + super().__init__(operation_type=operation._operation_type) self._operation: Final = operation self._variables: Final = variables self._on_execution_error: Final = on_execution_error @@ -120,16 +117,119 @@ def __repr__(self) -> str: def returning_error( self, - ) -> "Request[_Type_co, _Data_co | ExecutionError[_Data_co]]": - """Return a copy of this request whose :class:`~.error.ExecutionError` a client returns in place of its data instead of raising it. - - The returned error is typed by the request, and so is its :meth:`~.error.ExecutionError.parse_data`, which no `except` clause can express; a caller tells it apart from the data before use. - Every other error still raises, since it means that the request as a whole failed. - - """ - # A request's data type being covariant, one returning its data is one returning its data or its error. - return Request( + ) -> _OperationRequest[Type, Data | ExecutionError[Data]]: + """Return a copy of this request for which `PreparedRequest.parse()` returns its `ExecutionError` instead of raising it.""" + # Sound since `Data` is covariant: a request returning its data is one returning its data or its error. + return _OperationRequest( operation=self._operation, variables=self._variables, on_execution_error="return", ) + + @override + def prepare(self, *, injectors: _Injectors | None = None) -> PreparedRequest[Data]: + operation = self._operation + variables = _resolve_variables(operation, self._variables, injectors=injectors) + on_execution_error = self._on_execution_error + + def parse(raw: bytes, /) -> Data: + try: + return _parse_data(operation, raw) + except Error as error: + error.add_note(_origin_note(operation._name, variables)) + + if isinstance(error, ExecutionError): + match on_execution_error: + case "raise": + pass + case "return": + # `Data` includes the error: see `returning_error()`. + return cast(Data, error) + case _ as never: + assert_never(never) + + raise + + return PreparedRequest(operation._request_body(variables), parse=parse) + + +def _resolve_variables( + operation: Operation[OperationType, Any, object], + variables: Mapping[str, object], + /, + *, + injectors: _Injectors | None = None, +) -> Mapping[str, object]: + serialize = operation._variables_serializer + values = ( + variables + if serialize is None + else cast(Mapping[str, object], serialize(variables)) + ) + + if injectors is not None: + return injectors._inject(values, injections=operation._injections) + + # A runtime check, since a request's type does not say what it injects. + if operation._injections: + raise ValueError( + f"Cannot prepare `{operation._name}` without injectors: it injects {', '.join(sorted(operation._injections))}." + ) + + return values + + +def _parse_data[Data]( + operation: Operation[OperationType, Any, Data], raw: bytes, / +) -> Data: + data = _parse_response(raw, partial_data_parser=operation._partial_data_parser) + + if operation._data_parser is not None: + operation._data_parser(data) + + return cast(Data, data) + + +def _parse_response( + raw: bytes, /, *, partial_data_parser: Convert | None +) -> Mapping[str, object]: + try: + payload = loads(raw) + except JSONDecodeError as error: + raise ProtocolError( + f"Expected a JSON response but got: {raw[:200]!r}." + ) from error + + if not isinstance(payload, dict): + raise ProtocolError(f"Expected a JSON object response but got: {raw[:200]!r}.") + + errors = payload.get("errors") + + if errors: + formatted = cast(list[GraphqlError], errors) + extensions = payload.get("extensions") + + # The spec's request error result has no `data` entry at all. + if "data" not in payload: + raise RequestError(formatted, extensions=extensions) + + raise ExecutionError( + formatted, + data=payload["data"], + extensions=extensions, + partial_data_parser=partial_data_parser, + ) + + data = payload.get("data") + + if not isinstance(data, dict): + raise ProtocolError( + f"Expected the response to carry `data` but got: {raw[:200]!r}." + ) + + return cast(Mapping[str, object], data) + + +def _origin_note(operation_name: str, variables: Mapping[str, object], /) -> str: + # `repr()`, so a `str` subclass redacting its own keeps a secret out of logs. + return f"Raised by `{operation_name}` with variables {variables!r}." diff --git a/src/graphql_codegen/runtime/request.py b/src/graphql_codegen/runtime/request.py new file mode 100644 index 0000000..994ac50 --- /dev/null +++ b/src/graphql_codegen/runtime/request.py @@ -0,0 +1,34 @@ +# Copied into each generated package, where this line says not to edit it. + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Callable +from typing import Final, final + +from ._operation_type import OperationType +from .injection import _Injectors + + +class Request[Type: OperationType, Data](ABC): + def __init__(self, *, operation_type: Type) -> None: + """Private.""" + self._operation_type: Final = operation_type + + @abstractmethod + def prepare(self, *, injectors: _Injectors | None = None) -> PreparedRequest[Data]: + """*injectors* are called once, here, so the prepared request always sends the same values.""" + + +@final +class PreparedRequest[Data]: + def __init__(self, body: bytes, /, *, parse: Callable[[bytes], Data]) -> None: + """Private.""" + self._body: Final = body + self._parse: Final = parse + + def __bytes__(self) -> bytes: + return self._body + + def parse(self, raw: bytes, /) -> Data: + return self._parse(raw) diff --git a/src/graphql_codegen/scalar.py b/src/graphql_codegen/scalar.py index ede2650..22f7bdf 100644 --- a/src/graphql_codegen/scalar.py +++ b/src/graphql_codegen/scalar.py @@ -2,23 +2,23 @@ from functools import cached_property from typing import final +from graphql_codegen._error_note import error_note from graphql_codegen._generator.dotted_name import Reference, parse_dotted_name -from graphql_codegen._note import error_note @final @dataclass(frozen=True, kw_only=True) class Codec: - """The functions converting a scalar's value between JSON and Python, such as a :class:`~datetime.datetime` sent as an ISO string.""" + """The functions converting a scalar's values between JSON and Python, such as a `datetime` sent as an ISO 8601 string.""" decode: str - """The dotted name of the function converting its JSON value to its Python one.""" + """The dotted name of the function turning the JSON value into the Python one.""" encode: str - """The dotted name of the function converting its Python value to its JSON one.""" + """The dotted name of the function turning the Python value into the JSON one.""" def __post_init__(self) -> None: - # Parsed eagerly, so that no codec holds a dotted name it cannot use. + # Fail on a bad name now, not at first use. _ = self._decode_reference, self._encode_reference @cached_property @@ -35,22 +35,22 @@ def _encode_reference(self) -> Reference: @final @dataclass(frozen=True, kw_only=True) class Scalar: - """How a scalar is typed, and converted if its Python value differs from its JSON one. + """How a scalar is typed, and converted when its Python value differs from its JSON one. - Not only a custom scalar: a built-in one can be given a type of its own too, such as `ID` typed `my_app.UserId`. + A built-in scalar can be given a type too, such as `ID` typed `my_app.UserId`. """ type: str """The dotted name of its Python type. - When :attr:`codec` is ``None``, it must describe the decoded JSON value: a builtin, such as :class:`int`, or a :class:`typing.NewType` or type alias of one, such as `my_app.Isbn = NewType("Isbn", str)`. + Without a codec, it must be the type JSON decodes to: a builtin such as `int`, or a `NewType` or alias of one. """ codec: Codec | None = None - """When ``None``, the value stays as JSON decodes it.""" + """When `None`, the value stays as JSON decodes it.""" def __post_init__(self) -> None: - # Parsed eagerly, so that no scalar holds a dotted name it cannot use. + # Fail on a bad name now, not at first use. _ = self._type_reference @cached_property diff --git a/tests/__resources__/import_every_module.py b/tests/__resources__/import_every_module.py index 77715cb..443a340 100644 --- a/tests/__resources__/import_every_module.py +++ b/tests/__resources__/import_every_module.py @@ -1,10 +1,3 @@ -"""Import every module of a package, so that any module failing to import fails the script. - - python import_every_module.py ... - -The directories are searched before any other, for the package and for everything it imports. -""" - import importlib import pkgutil import sys @@ -13,6 +6,6 @@ sys.path[:0] = directories package = importlib.import_module(name) -# A subpackage failing to import is yielded before `walk_packages()` ignores it, so importing it here raises. +# `walk_packages()` ignores a subpackage that fails to import, so each is imported here. for module in pkgutil.walk_packages(package.__path__, prefix=f"{name}."): importlib.import_module(module.name) diff --git a/tests/_graphql_server.py b/tests/_graphql_server.py index 78485f6..431e96a 100644 --- a/tests/_graphql_server.py +++ b/tests/_graphql_server.py @@ -7,8 +7,6 @@ class GraphqlHandler(Protocol): - """What a local GraphQL server answers for each request.""" - def __call__(self, query: str, /, *, headers: Mapping[str, str]) -> object: ... diff --git a/tests/_server.py b/tests/_server.py index 899841f..789c240 100644 --- a/tests/_server.py +++ b/tests/_server.py @@ -1,22 +1,21 @@ -from collections.abc import Callable, Iterator +from collections.abc import Callable, Generator from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from threading import Thread from typing import override type PostHandler = Callable[[BaseHTTPRequestHandler], None] -"""How a local HTTP server handles each POST request.""" @contextmanager -def serve(post_handler: PostHandler, /) -> Iterator[str]: +def serve(post_handler: PostHandler, /) -> Generator[str]: class Handler(BaseHTTPRequestHandler): def do_POST(self) -> None: post_handler(self) @override def log_message(self, format: str, *args: object) -> None: - """Keep the test's output clean.""" + pass host = "127.0.0.1" diff --git a/tests/conftest.py b/tests/conftest.py index 1ceff47..95f05cf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,12 +8,13 @@ import pytest from graphql import GraphQLSchema +import bookshop.transport from graphql_codegen import generate from graphql_codegen._cli._graphql_config import read_graphql_config from graphql_codegen.generate import _Params from tests._bookshop import BOOKSHOP_DIRECTORY from tests._graphql_server import GraphqlHandler, answer_graphql -from tests._server import serve +from tests._server import PostHandler, serve @pytest.fixture(name="_bookshop_args", scope="session") @@ -52,7 +53,7 @@ def assert_type_checks(package: Path, /) -> None: str(package.parent), ], "pyright": ["--pythonpath", sys.executable], - # Its default preset for a project without configuration reports none of the mistakes the others catch. + # Pyrefly's default preset reports none of the mistakes the other checkers catch. "pyrefly": [ "check", "--preset", @@ -77,10 +78,22 @@ def assert_type_checks(package: Path, /) -> None: @pytest.fixture(name="graphql_server") def graphql_server_fixture() -> Iterator[Callable[[GraphqlHandler], str]]: - """Start a local GraphQL server answering as the given function does, returning its endpoint's URL, until the test ends.""" with ExitStack() as stack: yield ( lambda graphql_handler: ( stack.enter_context(serve(answer_graphql(graphql_handler))) + "/graphql" ) ) + + +@pytest.fixture(name="bookshop_server") +def bookshop_server_fixture( + monkeypatch: pytest.MonkeyPatch, +) -> Iterator[Callable[[PostHandler], None]]: + with ExitStack() as stack: + + def start(post_handler: PostHandler, /) -> None: + url = stack.enter_context(serve(post_handler)) + monkeypatch.setattr(bookshop.transport, "BASE_URL", url) + + yield start diff --git a/tests/test_bookshop.py b/tests/test_bookshop.py index 511f94e..2512416 100644 --- a/tests/test_bookshop.py +++ b/tests/test_bookshop.py @@ -3,6 +3,7 @@ from asyncio import run as run_async from collections.abc import Callable, Mapping from decimal import Decimal +from http.server import BaseHTTPRequestHandler from pathlib import Path from typing import Final, cast from uuid import UUID @@ -24,15 +25,14 @@ status_label, track, ) -from bookshop.app_graphql import OnOrderStatusChanged -from bookshop.async_app import title -from bookshop.client.injection import injectors -from bookshop.client.runtime import RequestError -from bookshop.client.schema import Address, OrderStatus +from bookshop.async_app import title, watch as watch_async from bookshop.get_order_graphql import GetOrder +from bookshop.graphql.injection import injectors +from bookshop.graphql.runtime import RequestError +from bookshop.graphql.schema import Address, OrderStatus from bookshop.scalar import ISBN -from bookshop.transport import Client from tests._bookshop import BOOKSHOP_DIRECTORY +from tests._server import PostHandler _KEY: Final = UUID("6f1c2b9e-0d4a-4c3e-9b7a-2e5f8d1c3a40") _BOOK: Final = { @@ -47,32 +47,49 @@ type _Handle = Callable[[httpx2.Request], httpx2.Response] +type _Respond = Callable[[Mapping[str, object]], object] +type _StartServer = Callable[[PostHandler], None] def test_the_bookshop_type_checks(assert_type_checks: Callable[[Path], None]) -> None: - """Its client, and the code using it as a user's would.""" assert_type_checks(BOOKSHOP_DIRECTORY) @pytest.fixture(name="mocked_http") def mocked_http_fixture(monkeypatch: pytest.MonkeyPatch) -> Callable[[_Handle], None]: - """Make the bookshop's httpx2 client answer with the given handler instead of reaching a server.""" - def mock(handle: _Handle, /) -> None: monkeypatch.setattr( bookshop.async_transport, "http", httpx2.AsyncClient( transport=httpx2.MockTransport(handle), - base_url="https://bookshop.example", + base_url=bookshop.async_transport.http.base_url, + headers=bookshop.async_transport.http.headers, ), ) return mock +@pytest.fixture(name="fixed_key") +def fixed_key_fixture(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + bookshop.transport, "INJECTORS", injectors({"idempotencyKey": lambda: _KEY}) + ) + + +def _answering(respond: _Respond, /) -> PostHandler: + def handle(handler: BaseHTTPRequestHandler, /) -> None: + request = json.loads(handler.rfile.read(int(handler.headers["Content-Length"]))) + payload = json.dumps(respond(request)).encode() + handler.send_response(200) + handler.end_headers() + handler.wfile.write(payload) + + return handle + + def _events(*statuses: str) -> httpx2.Response: - """A stream of the order's statuses, then its completion, as a GraphQL over Server-Sent Events server sends it.""" events = [ f"event: next\ndata: {json.dumps({'data': {'orderStatusChanged': {'id': 'o1', 'status': status}}})}\n\n" for status in statuses @@ -93,14 +110,7 @@ def handle(request: httpx2.Request, /) -> httpx2.Response: return httpx2.Response(200, json={"data": {"book": _BOOK}}) mocked_http(handle) - book_title = run_async( - title( - ISBN("9780141439518"), - client=bookshop.async_transport.AsyncClient( - bookshop.async_transport.transport - ), - ) - ) + book_title = run_async(title(ISBN("9780141439518"))) assert book_title == "Persuasion" @@ -111,11 +121,7 @@ def test_an_http_error_is_httpx2_s_own(mocked_http: Callable[[_Handle], None]) - ) with pytest.raises(httpx2.HTTPStatusError) as error_info: - run_async( - bookshop.async_transport.AsyncClient(bookshop.async_transport.transport)( - GetOrder({"id": "o1"}) - ) - ) + run_async(bookshop.async_transport.send(GetOrder({"id": "o1"}))) assert ( error_info.value.response.status_code, @@ -138,57 +144,43 @@ def test_a_request_error_is_read_whatever_its_status( ) with pytest.raises(RequestError, match="Syntax Error"): - run_async( - bookshop.async_transport.AsyncClient(bookshop.async_transport.transport)( - GetOrder({"id": "o1"}) - ) - ) + run_async(bookshop.async_transport.send(GetOrder({"id": "o1"}))) -def test_a_subscription_goes_over_httpx2( - mocked_http: Callable[[_Handle], None], +@pytest.mark.parametrize( + ("events", "statuses"), + [ + pytest.param( + ["SHIPPED", "DELIVERED", "CANCELED"], + ["SHIPPED", "DELIVERED"], + id="until the order is delivered", + ), + pytest.param(["SHIPPED"], ["SHIPPED"], id="until the server completes"), + ], +) +def test_watching_an_order_asynchronously_follows_its_statuses( + events: list[str], statuses: list[str], mocked_http: Callable[[_Handle], None] ) -> None: - mocked_http(lambda _request: _events("SHIPPED", "DELIVERED")) - - async def statuses() -> list[str]: - events = bookshop.async_transport.AsyncSubscriptionClient( - bookshop.async_transport.subscription_transport - )(OnOrderStatusChanged({"orderId": "o1"})) - return [event["orderStatusChanged"]["status"] async for event in events] + mocked_http(lambda _request: _events(*events)) - assert run_async(statuses()) == ["SHIPPED", "DELIVERED"] + assert run_async(watch_async("o1")) == statuses -def _client(respond: Callable[[Mapping[str, object]], object], /) -> Client: - """A client whose transport answers each request with what *respond* returns for it.""" +def test_describing_a_book(bookshop_server: _StartServer) -> None: + bookshop_server(_answering(lambda _request: {"data": {"book": _BOOK}})) - def transport(body: bytes, /, *, timeout: float | None = None) -> bytes: - return json.dumps(respond(json.loads(body))).encode() + assert describe(ISBN("9780141439518")) == "Persuasion, by Jane Austen, costs 8.99." - return Client(transport, injectors=injectors({"idempotencyKey": lambda: _KEY})) +def test_describing_a_missing_book(bookshop_server: _StartServer) -> None: + bookshop_server(_answering(lambda _request: {"data": {"book": None}})) -def test_describing_a_book() -> None: - assert ( - describe( - ISBN("9780141439518"), - client=_client(lambda _request: {"data": {"book": _BOOK}}), - ) - == "Persuasion, by Jane Austen, costs 8.99." - ) + assert describe(ISBN("9780000000000")) == "No such book." -def test_describing_a_missing_book() -> None: - assert ( - describe( - ISBN("9780000000000"), - client=_client(lambda _request: {"data": {"book": None}}), - ) - == "No such book." - ) - - -def test_a_book_and_similar_publications_come_in_one_request() -> None: +def test_a_book_and_similar_publications_come_in_one_request( + bookshop_server: _StartServer, +) -> None: def respond(request: Mapping[str, object], /) -> object: assert request["operationName"] == "MergedOperation" search = [ @@ -197,26 +189,31 @@ def respond(request: Mapping[str, object], /) -> object: ] return {"data": {"book_0": _BOOK, "search_1": search}} - assert book_and_similar( - ISBN("9780141439518"), "Austen", client=_client(respond) - ) == ( + bookshop_server(_answering(respond)) + + assert book_and_similar(ISBN("9780141439518"), "Austen") == ( "Persuasion", ["Emma", "Jane Austen"], ) -def test_books_and_publications_are_looked_up_in_one_request() -> None: +def test_books_and_publications_are_looked_up_in_one_request( + bookshop_server: _StartServer, +) -> None: def respond(request: Mapping[str, object], /) -> object: assert request["operationName"] == "MergedOperation" publication = {"__typename": "Audiobook", "title": "Emma", "duration": 723} return {"data": {"book_0": _BOOK, "publication_1": publication}} - results = look_up([ISBN("9780141439518")], ["p1"], client=_client(respond)) + bookshop_server(_answering(respond)) + results = look_up([ISBN("9780141439518")], ["p1"]) assert [list(data) for data in results] == [["book"], ["publication"]] -def test_a_saved_search_runs_with_its_filter_as_saved() -> None: +def test_a_saved_search_runs_with_its_filter_as_saved( + bookshop_server: _StartServer, +) -> None: saved = {"and": [{"genre": "FICTION"}, {"not": {"author": "a1"}}]} requests: list[Mapping[str, object]] = [] @@ -228,7 +225,8 @@ def respond(request: Mapping[str, object], /) -> object: else {"data": {"books": [{**_BOOK, "genre": "FICTION"}]}} ) - titles = run_saved_search("classics", client=_client(respond)) + bookshop_server(_answering(respond)) + titles = run_saved_search("classics") assert titles == ["Persuasion"] assert requests[1]["variables"] == {"filter": saved} @@ -255,20 +253,20 @@ def respond(request: Mapping[str, object], /) -> object: pytest.param(None, "No such publication.", id="missing"), ], ) -def test_telling_a_publication_s_length(publication: object, description: str) -> None: - response = {"data": {"publication": publication}} +def test_telling_a_publication_s_length( + publication: object, description: str, bookshop_server: _StartServer +) -> None: + bookshop_server(_answering(lambda _request: {"data": {"publication": publication}})) - assert length("p1", client=_client(lambda _request: response)) == description + assert length("p1") == description @pytest.mark.parametrize( ("status", "label"), [ - pytest.param("SHIPPED", "On its way", id="a member the client knows"), + pytest.param("SHIPPED", "On its way", id="a member known at codegen"), pytest.param( - "OUT_FOR_DELIVERY", - "Out for delivery", - id="a member added after generation", + "OUT_FOR_DELIVERY", "Out for delivery", id="a member added after codegen" ), ], ) @@ -276,22 +274,27 @@ def test_labeling_an_order_status(status: str, label: str) -> None: assert status_label(cast(OrderStatus, status)) == label -def test_a_price_goes_out_and_comes_back_through_its_codec() -> None: +def test_a_price_goes_out_and_comes_back_through_its_codec( + bookshop_server: _StartServer, +) -> None: requests: list[Mapping[str, object]] = [] def respond(request: Mapping[str, object], /) -> object: requests.append(request) return {"data": {"books": [{**_BOOK, "price": "8.99", "genre": "FICTION"}]}} - assert cheaper_than(Decimal("10.00"), client=_client(respond)) == [ - "Persuasion: 8.99" - ] + bookshop_server(_answering(respond)) + + assert cheaper_than(Decimal("10.00")) == ["Persuasion: 8.99"] assert [request["variables"] for request in requests] == [ {"filter": {"priceBelow": "10.00"}} ] -def test_an_order_gets_its_idempotency_key_injected() -> None: +@pytest.mark.usefixtures("fixed_key") +def test_an_order_gets_its_idempotency_key_injected( + bookshop_server: _StartServer, +) -> None: requests: list[Mapping[str, object]] = [] def respond(request: Mapping[str, object], /) -> object: @@ -313,8 +316,9 @@ def respond(request: Mapping[str, object], /) -> object: "postalCode": "BA1", "country": "GB", } + bookshop_server(_answering(respond)) - assert order("1", address, client=_client(respond)) == "Order o1: 8.99 at 14:05." + assert order("1", address) == "Order o1: 8.99 at 14:05." ((request),) = requests assert request["variables"] == { "input": { @@ -325,25 +329,28 @@ def respond(request: Mapping[str, object], /) -> object: } -def test_canceling_orders_reports_each_failure() -> None: +@pytest.mark.usefixtures("fixed_key") +def test_canceling_orders_reports_each_failure(bookshop_server: _StartServer) -> None: canceled = {"id": "o1", "status": "CANCELED"} failing = { "data": {"cancelOrder_0": canceled, "cancelOrder_1": None}, "errors": [{"message": "Already shipped.", "path": ["cancelOrder_1"]}], } - variables = {"input": {"order": "o2", "idempotencyKey": str(_KEY)}} + bookshop_server( + _answering( + lambda request: ( + failing + if request["operationName"] == "MergedOperation" + else {"data": {"cancelOrder": canceled}} + ) + ) + ) - assert cancel(["o1", "o2"], client=_client(lambda _request: failing)) == [ + assert cancel(["o1", "o2"]) == [ f"Raised by `CancelOrder` with variables {variables!r}. Already shipped.", ] - assert ( - cancel( - ["o1"], - client=_client(lambda _request: {"data": {"cancelOrder": canceled}}), - ) - == [] - ) + assert cancel(["o1"]) == [] @pytest.mark.parametrize( @@ -363,20 +370,22 @@ def test_canceling_orders_reports_each_failure() -> None: ), ], ) -def test_tracking_an_order(response: object, description: str) -> None: - assert track("o1", client=_client(lambda _request: response)) == description +def test_tracking_an_order( + response: object, description: str, bookshop_server: _StartServer +) -> None: + bookshop_server(_answering(lambda _request: response)) + + assert track("o1") == description def test_the_quickstart_prints_an_order( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + bookshop_server: _StartServer, capsys: pytest.CaptureFixture[str] ) -> None: - """The README's first example.""" - - def transport(body: bytes, /, *, timeout: float | None = None) -> bytes: - assert json.loads(body)["variables"] == {"id": "o1"} - return json.dumps({"data": {"order": _ORDER}}).encode() + def respond(request: Mapping[str, object], /) -> object: + assert request["variables"] == {"id": "o1"} + return {"data": {"order": _ORDER}} - monkeypatch.setattr(bookshop.transport, "transport", transport) + bookshop_server(_answering(respond)) runpy.run_module("bookshop.quickstart") captured = capsys.readouterr() diff --git a/tests/test_cli.py b/tests/test_cli.py index 82613ae..9c8b3b8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,21 +12,20 @@ "command", [ pytest.param([sys.executable, "-m", "graphql_codegen"], id="python -m"), - # Next to the interpreter, as `uv run` finds it: the script `pyproject.toml` declares. + # The script `pyproject.toml` declares, next to the interpreter as `uv run` finds it. pytest.param( [shutil.which("graphql-codegen", path=Path(sys.executable).parent)], id="script", ), ], ) -def test_the_cli_generates_the_committed_bookshop_client( +def test_the_cli_generates_the_committed_bookshop_package( command: list[str], tmp_path: Path ) -> None: - # What its config generates: the package and the modules next to its documents. - generated = ("client", "*_graphql.py") + generated = ("graphql", "*_graphql.py") for pattern in generated: - # Matching nothing, a pattern would let the copy hold what the command must write. + # Guards the test: a pattern matching nothing would leave the copy holding what the command must write. assert any(BOOKSHOP_DIRECTORY.glob(pattern)), pattern directory = tmp_path / "bookshop" diff --git a/tests/test_client.py b/tests/test_client.py deleted file mode 100644 index 9e9be5c..0000000 --- a/tests/test_client.py +++ /dev/null @@ -1,441 +0,0 @@ -import inspect -import json -import shutil -import subprocess -import sys -from asyncio import run as run_async -from collections.abc import AsyncGenerator, Callable, Generator -from contextlib import aclosing, closing -from decimal import Decimal -from pathlib import Path -from types import EllipsisType -from typing import ( - Any, - Final, - Protocol, - assert_type, - get_args, - get_origin, - get_overloads, -) - -import pytest - -from bookshop.app_graphql import ( - CancelOrder, - CancelOrderData, - GetBook, - GetBookData, - ListBooks, - OnOrderStatusChanged, -) -from bookshop.client.runtime import ( - AsyncClient, - AsyncSubscriptionClient, - Client, - ExecutionError, - Request, - RequestError, - SubscriptionClient, - UnexpectedNullError, -) -from bookshop.scalar import ISBN - -_CARD: Final = { - "id": "1", - "title": "Persuasion", - "price": "8.99", - "author": None, -} -_BOOK: Final = {**_CARD, "isbn": "9780141439518", "pageCount": 249} -_GET_BOOK: Final = GetBook({"lookup": {"isbn": ISBN("9780141439518")}}) -_ON_STATUS_CHANGED: Final = OnOrderStatusChanged({"orderId": "o1"}) - - -def _respond(payload: object, /) -> Callable[[bytes], bytes]: - """A transport answering every request with *payload*.""" - return lambda _body: json.dumps(payload).encode() - - -def _status_event(status: str, /) -> bytes: - return json.dumps( - {"data": {"orderStatusChanged": {"id": "o1", "status": status}}} - ).encode() - - -def test_a_request_is_a_value_that_runs_nothing() -> None: - """Making it checks the variables before any side effect, and it runs as often as a client is called with it.""" - sent: list[bytes] = [] - - def transport(body: bytes, /) -> bytes: - sent.append(body) - return json.dumps({"data": {"book": _BOOK}}).encode() - - assert isinstance(_GET_BOOK, Request) - assert not sent - - client = Client(transport) - - assert client(_GET_BOOK) == client(_GET_BOOK) - assert len(sent) == 2 - assert json.loads(sent[0])["variables"] == {"lookup": {"isbn": "9780141439518"}} - - -def test_transport_arguments_reach_the_transport_checked() -> None: - """Unknown or mistyped arguments are type errors, as the unused-suppression checks prove, and a transport without parameters takes none.""" - timeouts: list[float | None] = [] - - def transport(body: bytes, /, *, timeout: float | None = None) -> bytes: - timeouts.append(timeout) - return _respond({"data": {"books": []}})(body) - - async def async_transport(body: bytes, /, *, timeout: float | None = None) -> bytes: - return transport(body, timeout=timeout) - - client = Client(transport) - client(ListBooks({}), timeout=5.0) - client(ListBooks({})) - run_async(AsyncClient(async_transport)(ListBooks({}), timeout=1.0)) - - assert timeouts == [5.0, None, 1.0] - - with pytest.raises(TypeError): - client(ListBooks({}), timout=5.0) # ty: ignore[no-matching-overload] - client(ListBooks({}), timeout="slow") # ty: ignore[no-matching-overload] - - with pytest.raises(TypeError): - Client(_respond({"data": {"books": []}}))(ListBooks({}), timeout=5.0) # ty: ignore[no-matching-overload] - - -def test_a_client_s_type_is_named_after_its_transport_s_signature() -> None: - """A `Protocol` of the signature names it, parameters included, as the unused-suppression checks prove.""" - - class Transport(Protocol): - def __call__(self, body: bytes, /, *, timeout: float) -> bytes: ... - - def transport(body: bytes, /, *, timeout: float) -> bytes: - return _respond({"data": {"books": []}})(body) - - client: Client[Transport] = Client(transport) - client(ListBooks({}), timeout=5.0) - - with pytest.raises(TypeError): - client(ListBooks({})) # ty: ignore[no-matching-overload] - - # A client over a transport without the parameter is not one. - other: Client[Transport] = Client(_respond({"data": {"books": []}})) # ty: ignore[invalid-assignment] - assert other is not client - - -def test_variables_and_operation_types_are_checked_statically() -> None: - """As the unused-suppression checks prove: nothing is validated at runtime.""" - assert isinstance(GetBook({"lookup": 1}), Request) # ty: ignore[invalid-argument-type] - # A client returning one data does not take a subscription, nor a subscription client a query. - Client(_respond({"data": {}}))(_ON_STATUS_CHANGED) # ty: ignore[no-matching-overload] - SubscriptionClient(lambda _body: iter([]))(ListBooks({})) # ty: ignore[invalid-argument-type] - - -def test_an_unknown_enum_member_reaches_the_caller_untouched() -> None: - """Enums are closed `Literal`s, but nothing checks it at runtime, so a newer server breaks nothing. - - A caller keeping a catch-all arm handles the member, and one keeping `assert_never` is told by the type checker the day the schema grows. - """ - data = Client(_respond({"data": {"books": [{**_CARD, "genre": "COOKING"}]}}))( - ListBooks({}) - ) - (book,) = data["books"] - - assert book["genre"] == "COOKING" - - -def test_a_sequence_of_operations_returns_a_tuple_of_their_common_data() -> None: - """Synchronously or not, an empty sequence sending nothing.""" - canceled = { - "data": { - "cancelOrder_0": {"id": "o1", "status": "CANCELED"}, - "cancelOrder_1": None, - } - } - cancellations = [ - CancelOrder({"input": {"order": "o1"}}), - CancelOrder({"input": {"order": "o2"}}), - ] - - async def async_transport(body: bytes, /) -> bytes: - return _respond(canceled)(body) - - data = Client(_respond(canceled))(cancellations) - - assert_type(data, tuple[CancelOrderData, ...]) - assert data == ( - {"cancelOrder": {"id": "o1", "status": "CANCELED"}}, - {"cancelOrder": None}, - ) - assert run_async(AsyncClient(async_transport)(cancellations)) == data - assert Client(_respond(None))([]) == () - - -def test_a_query_and_a_mutation_cannot_share_a_request() -> None: - """Not rejected statically by ty, hence at runtime.""" - with pytest.raises(ValueError, match="different types"): - Client(_respond({"data": {}}))( - (ListBooks({}), CancelOrder({"input": {"order": "o1"}})) - ) - - -def test_a_request_returning_its_error_gets_it_in_place_of_its_data() -> None: - """Typed by the request, as `assert_type` proves, its parsed data included; an error meaning that the request as a whole failed still raises.""" - down = _respond( - {"data": {"book": None}, "errors": [{"message": "Down.", "path": ["book"]}]} - ) - result = Client(down)(_GET_BOOK.returning_error()) - - assert_type(result, GetBookData | ExecutionError[GetBookData]) - assert isinstance(result, ExecutionError) - assert_type(result.parse_data(), GetBookData | None) - # `book` is `@nonNull`, so its null moves up to the data itself. - assert (result.data, result.parse_data()) == ({"book": None}, None) - assert result.__notes__ == [ - "Raised by `GetBook` with variables {'lookup': {'isbn': '9780141439518'}}." - ] - assert Client(_respond({"data": {"book": _BOOK}}))(_GET_BOOK.returning_error()) == { - "book": {**_BOOK, "price": Decimal("8.99")} - } - - with pytest.raises(RequestError): - Client(_respond({"errors": [{"message": "Syntax Error"}]}))( - _GET_BOOK.returning_error() - ) - - -def test_a_merge_returns_each_request_s_error_in_its_place_unless_one_raises() -> None: - cancellations = ( - CancelOrder({"input": {"order": "o1"}}), - CancelOrder({"input": {"order": "o2"}}), - ) - one_failing = _respond( - { - "data": { - "cancelOrder_0": {"id": "o1", "status": "CANCELED"}, - "cancelOrder_1": None, - }, - "errors": [{"message": "Already shipped.", "path": ["cancelOrder_1"]}], - }, - ) - first, second = Client(one_failing)( - (cancellations[0].returning_error(), cancellations[1].returning_error()) - ) - - assert_type(second, CancelOrderData | ExecutionError[CancelOrderData]) - assert first == {"cancelOrder": {"id": "o1", "status": "CANCELED"}} - assert isinstance(second, ExecutionError) - assert second.errors == ({"message": "Already shipped.", "path": ["cancelOrder"]},) - - both_failing = _respond( - { - "data": {"cancelOrder_0": None, "cancelOrder_1": None}, - "errors": [ - {"message": "Unknown.", "path": ["cancelOrder_0"]}, - {"message": "Already shipped.", "path": ["cancelOrder_1"]}, - ], - }, - ) - - with pytest.raises(ExceptionGroup) as group_info: - Client(both_failing)((cancellations[0], cancellations[1].returning_error())) - - assert [str(error) for error in group_info.value.exceptions] == [ - "Unknown.", - "Already shipped.", - ] - - -def test_a_merge_s_errors_are_split_per_operation_unless_its_data_is_null() -> None: - """Each error holds its operation's own errors, paths, and data, and a note naming it; `except*` catches the unsplit error too.""" - cancellations = [ - CancelOrder({"input": {"order": "o1"}}), - CancelOrder({"input": {"order": "o2"}}), - ] - failing = _respond( - { - "data": { - "cancelOrder_0": {"id": "o1", "status": "CANCELED"}, - "cancelOrder_1": None, - }, - "errors": [{"message": "Already shipped.", "path": ["cancelOrder_1"]}], - }, - ) - - with pytest.raises(ExceptionGroup) as group_info: - Client(failing)(cancellations) - - (error,) = group_info.value.exceptions - assert isinstance(error, ExecutionError) - assert error.errors == ({"message": "Already shipped.", "path": ["cancelOrder"]},) - assert error.data == error.parse_data() == {"cancelOrder": None} - assert error.__notes__ == [ - "Raised by `CancelOrder` with variables {'input': {'order': 'o2'}}." - ] - - with pytest.raises(ExecutionError): - Client(_respond({"data": None, "errors": [{"message": "Down."}]}))( - cancellations - ) - - -@pytest.mark.parametrize( - ("requests", "payload", "error_type", "note"), - [ - pytest.param( - _GET_BOOK, - {"data": None, "errors": [{"message": "Down.", "path": ["book"]}]}, - ExecutionError, - "Raised by `GetBook` with variables {'lookup': {'isbn': '9780141439518'}}.", - id="an execution error", - ), - pytest.param( - (ListBooks({}), _GET_BOOK), - {"data": {"books_0": [], "book_1": None}}, - UnexpectedNullError, - "Raised by `GetBook` with variables {'lookup': {'isbn': '9780141439518'}}.", - id="an unexpected null in a merge", - ), - ], -) -def test_an_error_reading_a_response_notes_its_operation_and_the_variables_sent( - requests: Any, payload: object, error_type: type[Exception], note: str -) -> None: - with pytest.raises(error_type) as error_info: - Client(_respond(payload))(requests) - - assert error_info.value.__notes__ == [note] - - -def _longest_typed_tuple(client: type[Client] | type[AsyncClient], /) -> int: - """The most operations a client types position by position, read off its overloads.""" - return max( - len(get_args(returned)) - for overload in get_overloads(client.__call__) - if get_origin(returned := inspect.signature(overload).return_annotation) - is tuple - and not any( - isinstance(argument, EllipsisType) for argument in get_args(returned) - ) - ) - - -def test_the_clients_type_as_many_operations_as_asyncio_gather(tmp_path: Path) -> None: - """Typeshed's limit for `asyncio.gather()`, the closest analogue, rather than one of ours. - - Read off the stubs ty vendors, since `asyncio.gather()` has no overloads at runtime: each arity is a call of its own, whose line in ty's report tells it apart. - """ - ty = shutil.which("ty", path=str(Path(sys.executable).parent)) - assert ty is not None - header = [ - "import asyncio", - "from typing import reveal_type", - "", - "async def value() -> int: ...", - "", - "async def main() -> None:", - ] - calls = [ - f" reveal_type(asyncio.gather({', '.join(['value()'] * arity)}))" - for arity in range(1, 13) - ] - probe = tmp_path / "probe.py" - probe.write_text("\n".join([*header, *calls, ""]), encoding="utf-8") - report = json.loads( - subprocess.run( - [ty, "check", "--output-format", "gitlab", str(probe)], - capture_output=True, - check=False, - cwd=tmp_path, - encoding="utf-8", - ).stdout, - ) - typed_arities = [ - diagnostic["location"]["positions"]["begin"]["line"] - len(header) - for diagnostic in report - if diagnostic["check_name"] == "revealed-type" - and "tuple[" in diagnostic["description"] - ] - - assert {_longest_typed_tuple(Client), _longest_typed_tuple(AsyncClient)} == { - max(typed_arities) - } - - -def test_a_subscription_returning_its_errors_streams_past_them() -> None: - def subscription_transport(_body: bytes, /) -> Generator[bytes, None, None]: - yield json.dumps({"data": None, "errors": [{"message": "Lagging."}]}).encode() - yield _status_event("DELIVERED") - - lagging, delivered = SubscriptionClient(subscription_transport)( - _ON_STATUS_CHANGED.returning_error() - ) - - assert isinstance(lagging, ExecutionError) - assert (str(lagging), lagging.parse_data()) == ("Lagging.", None) - assert delivered == {"orderStatusChanged": {"id": "o1", "status": "DELIVERED"}} - - -def test_closing_a_subscription_unsubscribes() -> None: - """The transport's cleanup runs when the caller stops listening, not only when the server stops.""" - unsubscribed: list[str] = [] - - def subscription_transport(_body: bytes, /) -> Generator[bytes, None, None]: - try: - while True: - yield _status_event("SHIPPED") - finally: - unsubscribed.append("sync") - - async def async_subscription_transport( - _body: bytes, / - ) -> AsyncGenerator[bytes, None]: - try: - while True: - yield _status_event("SHIPPED") - finally: - unsubscribed.append("async") - - async def listen_once() -> None: - async with aclosing( - AsyncSubscriptionClient(async_subscription_transport)(_ON_STATUS_CHANGED) - ) as events: - async for _event in events: - break - - with closing( - SubscriptionClient(subscription_transport)(_ON_STATUS_CHANGED) - ) as events: - next(events) - assert not unsubscribed - - run_async(listen_once()) - - assert unsubscribed == ["sync", "async"] - - -def test_a_response_with_errors_raises_out_of_the_stream_and_unsubscribes() -> None: - unsubscribed: list[bool] = [] - - def subscription_transport(_body: bytes, /) -> Generator[bytes, None, None]: - try: - yield _status_event("SHIPPED") - yield b'{"data": null, "errors": [{"message": "Boom."}]}' - yield _status_event("DELIVERED") - finally: - unsubscribed.append(True) - - events = SubscriptionClient(subscription_transport)(_ON_STATUS_CHANGED) - - event = next(events) - - assert event["orderStatusChanged"]["status"] == "SHIPPED" - - with pytest.raises(ExecutionError, match="Boom"): - next(events) - - assert unsubscribed == [True] diff --git a/tests/test_emit.py b/tests/test_emit.py index 69d3e76..d59aaac 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -24,7 +24,7 @@ ), pytest.param( "schema/input.py", - "lines: _typing.Required[_abc.Sequence[OrderLineInput]]", + "lines: _abc.Sequence[OrderLineInput]", id="a non-null input field without default is required", ), pytest.param( @@ -49,7 +49,7 @@ ), pytest.param( "schema/input.py", - "class _BookLookup_id(_compat.TypedDict, closed=True):\n id: _typing.Required[_builtins.str]", + "class _BookLookup_id(_compat.TypedDict, closed=True):\n id: _builtins.str", id="the key a oneOf member sets is not null", ), pytest.param( @@ -59,7 +59,7 @@ ), pytest.param( "schema/input.py", - "_BookFilter_and = _compat.TypedDict('_BookFilter_and', {'and': _typing.Required[_abc.Sequence[_BookFilter_Ref]]}, closed=True)", + "_BookFilter_and = _compat.TypedDict('_BookFilter_and', {'and': _abc.Sequence[_BookFilter_Ref]}, closed=True)", id="a keyword key is declared functionally, and a recursive reference deferred", ), pytest.param( @@ -104,7 +104,7 @@ ), pytest.param( "../app_graphql.py", - 'lookup: _typing.Required[_input.BookLookup]\n """An identifier or an ISBN."""', + 'lookup: _input.BookLookup\n """An identifier or an ISBN."""', id="a variable's description is its docstring", ), pytest.param( @@ -119,12 +119,12 @@ ), pytest.param( "../app_graphql.py", - "_runtime.Operation(operation_type='subscription', name='OnOrderStatusChanged',", + "_runtime.Operation(operation_type=_operation_type.SUBSCRIPTION, name='OnOrderStatusChanged',", id="a subscription is an operation of its type", ), ], ) -def test_the_bookshop_client_holds( +def test_the_bookshop_package_holds( path: str, snippet: str, bookshop_files: Mapping[PurePosixPath, bytes] ) -> None: assert snippet in bookshop_files[PurePosixPath(path)].decode() @@ -137,16 +137,6 @@ def test_the_bookshop_client_holds( "schema/input.py", "RestockInput", id="an input type no operation reaches" ), pytest.param("schema/enum.py", "Membership", id="an enum no operation reaches"), - pytest.param( - "schema/input.py", - "TYPE_CHECKING", - id="an import guard, needless since inputs reference each other lazily", - ), - pytest.param( - "schema/enum.py", - "%future added value", - id="an enum sentinel, needless since nothing validates", - ), pytest.param( "../app_graphql.py", "subscription OnOrderStatusChanged($orderId§", @@ -154,7 +144,7 @@ def test_the_bookshop_client_holds( ), ], ) -def test_the_bookshop_client_lacks( +def test_the_bookshop_package_lacks( path: str, snippet: str, bookshop_files: Mapping[PurePosixPath, bytes] ) -> None: assert snippet not in bookshop_files[PurePosixPath(path)].decode() @@ -163,7 +153,6 @@ def test_the_bookshop_client_lacks( def test_only_a_package_re_exports( bookshop_files: Mapping[PurePosixPath, bytes], ) -> None: - """A module of a package, or next to a document, exports only what it defines: none imports a name as itself, PEP 484's re-export, however its helpers' aliases are settled.""" for path, content in bookshop_files.items(): if path.name != "__init__.py" and path.parts[0] != "runtime": assert not [ @@ -176,17 +165,16 @@ def test_only_a_package_re_exports( def test_typename_is_never_declared_in_a_class_body( bookshop_files: Mapping[PurePosixPath, bytes], ) -> None: - """Python mangles `__typename` in a class body, silently breaking every payload.""" + # Python would mangle it, silently breaking every payload. for path, source in bookshop_files.items(): for node in ast.walk(ast.parse(source)): if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): assert node.target.id != "__typename", path -def test_a_document_goes_on_the_wire_triple_quoted_without_what_only_the_client_needs( +def test_a_document_goes_on_the_wire_triple_quoted_without_its_client_directive( bookshop_files: Mapping[PurePosixPath, bytes], ) -> None: - """Descriptions and `@nonNull` leave it, and it reads as GraphQL wherever it sits.""" source = bookshop_files[PurePosixPath("../app_graphql.py")].decode() document = source.split('document="""')[1].split('"""')[0] @@ -349,13 +337,15 @@ def test_a_document_goes_on_the_wire_triple_quoted_without_what_only_the_client_ "class _QData_publication_Book(_QData_publication_Book_Base, Pages, closed=True):" ], [], - id="an inline fragment's own selections need none, gathered into the branches the enclosing `__typename` tells apart", + id="a fragment selecting `__typename` is still a base, whose key a branch narrows", ), pytest.param( 'query Q { publication(id: "1") { __typename ... on Printed { pageCount ... on Book { isbn } } } }', - ["class _QData_publication_Book("], + [ + "class _QData_publication_Book(_QData_publication_Book_Base, closed=True):\n pageCount: _builtins.int\n isbn: _builtins.object" + ], [], - id="a fragment selecting `__typename` is still a base, whose key a branch narrows", + id="a condition nested in another gathers into the members of its type", ), pytest.param( 'query Q { order(id: "1") { id } }', @@ -420,7 +410,7 @@ def test_the_package_needs_nothing_but_the_standard_library(tmp_path: Path) -> N # No `site`, and so no site-packages. "-S", str(Path(__file__).parent / "__resources__" / "import_every_module.py"), - "bookshop.client", + "bookshop.graphql", str(BOOKSHOP_DIRECTORY.parent), str(tmp_path), ], diff --git a/tests/test_error.py b/tests/test_error.py index 969b30c..896cd3d 100644 --- a/tests/test_error.py +++ b/tests/test_error.py @@ -1,11 +1,11 @@ from graphql import GraphQLError, GraphQLFormattedError, Source, SourceLocation -from graphql_codegen.runtime import Error, Location +from graphql_codegen.runtime import GraphqlError, Location def test_an_error_has_the_keys_graphql_core_formats_one_with() -> None: - """Only which are required differs: the spec requires `message`, which graphql-core leaves optional.""" - assert Error.__required_keys__ | Error.__optional_keys__ == ( + # Only `message` differs: required by the spec, optional in graphql-core. + assert GraphqlError.__required_keys__ | GraphqlError.__optional_keys__ == ( GraphQLFormattedError.__required_keys__ | GraphQLFormattedError.__optional_keys__ ) @@ -17,8 +17,8 @@ def test_a_location_has_the_keys_graphql_core_formats_one_with() -> None: ) -def test_an_error_graphql_core_formats_is_one_a_client_reads() -> None: - """Built key by key, so that type checkers check that each of graphql-core's value types is one ours accepts.""" +def test_an_error_graphql_core_formats_is_one_the_runtime_reads() -> None: + # Key by key, so that the type checkers compare each value type. formatted = GraphQLError( "boom", source=Source("query Q { book }"), @@ -26,7 +26,7 @@ def test_an_error_graphql_core_formats_is_one_a_client_reads() -> None: path=["book"], extensions={"code": "NOT_FOUND"}, ).formatted - error: Error = { + error: GraphqlError = { "message": formatted["message"], "locations": formatted["locations"], "path": formatted["path"], diff --git a/tests/test_generate.py b/tests/test_generate.py index 9020c3e..270a55f 100644 --- a/tests/test_generate.py +++ b/tests/test_generate.py @@ -79,7 +79,7 @@ "from ...money import encode as _encode", ], [], - id="a path starting with two dots or more is relative to the package's directory", + id="a path starting with two dots or more is relative to the generated package's directory", ), pytest.param( _PRICE, @@ -87,7 +87,7 @@ "document/GraphQL_request.py", ["price: _builtins.object"], [], - id="an unconfigured custom scalar is opaque", + id="a custom scalar left out of the config is typed `object`", ), pytest.param( _SAVED_SEARCH, @@ -149,22 +149,20 @@ ), pytest.param( _PLACE_ORDER, - Config(injector_names={"idempotencyKey"}), + Config(injector_names={"note"}), "injection.py", [ - "idempotencyKey: _typing.NotRequired[_abc.Callable[[], _builtins.object | None | _OMITTED]]", + "note: _abc.Callable[[], _builtins.str | None | _OMITTED]", "def injectors(functions: InjectorFunctions, /) -> _injection._Injectors:", ], [], - id="an injector is typed by what it injects, and may be left out or return `OMITTED` where it is nullable", + id="an injector is required, typed by what it injects, and may return `OMITTED` where it is nullable", ), pytest.param( - f'{_PLACE_ORDER} mutation N($idempotencyKey: UUID!) {{ cancelOrder(input: {{order: "o1", idempotencyKey: $idempotencyKey}}) {{ id }} }}', - Config(injector_names={"idempotencyKey"}), + f"{_PLACE_ORDER} query N($note: String!) {{ savedSearch(name: $note) {{ value }} }}", + Config(injector_names={"note"}), "injection.py", - [ - "idempotencyKey: _typing.NotRequired[_abc.Callable[[], _builtins.object]]" - ], + ["note: _abc.Callable[[], _builtins.str]"], [], id="an injector takes the strictest nullability of its positions, since it cannot tell which one it is called for", ), @@ -173,7 +171,7 @@ Config(injector_names={"in"}), "injection.py", [ - "InjectorFunctions = _compat.TypedDict('InjectorFunctions', {'in': _typing.NotRequired[_abc.Callable[[], _builtins.str]]}, closed=True)" + "InjectorFunctions = _compat.TypedDict('InjectorFunctions', {'in': _abc.Callable[[], _builtins.str]}, closed=True)" ], [], id="an injector named like a keyword is declared functionally", @@ -200,7 +198,6 @@ def test_a_config_field_shapes_the_package( def _message(error: Exception, /) -> str: - """Its message alone, without the location a `GraphQLError` renders.""" return error.message if isinstance(error, GraphQLError) else str(error) @@ -214,7 +211,7 @@ def _message(error: Exception, /) -> str: document_sibling_module=DocumentSiblingModule( name="_{document}_gql", package_location=PackageLocation( - module_root=PurePosixPath("/src"), package="client" + module_root=PurePosixPath("/src"), package="api" ), ), ), @@ -363,7 +360,6 @@ def _message(error: Exception, /) -> str: def test_an_input_is_rejected( schema: str, document: str, config: Config, errors: list[Exception] ) -> None: - """A wrong config value raises a `ValueError`, and a problem in the document or the schema a `GraphQLError`, graphql-core's validation errors all in one group.""" with pytest.raises((ExceptionGroup, GraphQLError, ValueError)) as error_info: generate(document=parse(document), schema=build_schema(schema), config=config) @@ -386,12 +382,12 @@ def test_an_input_is_rejected( ), pytest.param( lambda: Scalar(type="Decimal"), - "Expected an absolute dotted name, such as `datetime.datetime`, a dotted name relative to the package's directory, such as `..scalars.decode`, or a builtin, such as `int`, but got `Decimal`.\nIn `type`.", + "Expected a builtin (`str`), an absolute dotted name (`decimal.Decimal`), or a path relative to the generated package's directory (`..scalar.decode_datetime`), but got `Decimal`.\nIn `type`.", id="a scalar type named by an unknown builtin, since the config may come from a file, which static typing does not check", ), pytest.param( lambda: Scalar(type="decimal.2"), - "Expected an absolute dotted name, such as `datetime.datetime`, a dotted name relative to the package's directory, such as `..scalars.decode`, or a builtin, such as `int`, but got `decimal.2`.\nIn `type`.", + "Expected a builtin (`str`), an absolute dotted name (`decimal.Decimal`), or a path relative to the generated package's directory (`..scalar.decode_datetime`), but got `decimal.2`.\nIn `type`.", id="a scalar type that is not a dotted name", ), ], @@ -399,25 +395,10 @@ def test_an_input_is_rejected( def test_a_config_field_cannot_be_constructed_invalid( construct: Callable[[], object], error: str ) -> None: - """Whether the config comes from Python or from a config file, with a note naming the field that is wrong where the message does not.""" with pytest.raises(ValueError, match=f"^{re.escape(error)}$"): construct() -def test_a_problem_found_on_a_node_without_location_is_its_bare_message( - bookshop_schema: GraphQLSchema, -) -> None: - """As for a document parsed with `no_location=True`, which `generate()` accepts too.""" - with pytest.raises(GraphQLError) as error_info: - generate( - document=parse(_PLACE_ORDER, no_location=True), - schema=bookshop_schema, - config=Config(injector_names={"quantity"}), - ) - - assert str(error_info.value) == error_info.value.message - - def _documents(sources: dict[str, str], /) -> DocumentNode: return DocumentNode( definitions=tuple( @@ -435,7 +416,6 @@ def _generated( sibling_module_name: str | None, bookshop_schema: GraphQLSchema, ) -> dict[PurePosixPath, bytes]: - """The package `client` for documents named by the keys of *sources*, under `app/`, their modules named *sibling_module_name* in `app/`, or in the package when ``None``.""" return generate( document=_documents({f"app/{name}": body for name, body in sources.items()}), schema=bookshop_schema, @@ -445,7 +425,7 @@ def _generated( else DocumentSiblingModule( name=sibling_module_name, package_location=PackageLocation( - module_root=PurePosixPath(), package="client" + module_root=PurePosixPath(), package="api" ), ), ), @@ -457,11 +437,8 @@ def test_a_document_s_module_is_named_after_it_as_a_module_can_be( ) -> None: files = _generated( { - # An underscore the name puts next to the stem merges with the stem's own. "_restrictions.graphql": "query A { books { title } }", - # A character no identifier holds becomes `_`, and a leading digit gets one before it. "2-fast.graphql": "query B { books { ...Card } }", - # A document of fragments alone has a module too. "card.graphql": "fragment Card on Book { title }", }, sibling_module_name="_{document}_gql", @@ -496,7 +473,7 @@ def test_a_fragment_of_another_document_is_imported_from_its_module( def test_a_name_the_document_package_re_exports_steps_aside_for_modules_then_operations( bookshop_schema: GraphQLSchema, ) -> None: - """Rather than being rejected, since a document is easy to rename the day it matters.""" + # Not rejected: a document is easy to rename the day it matters. files = _generated( { "Get.graphql": "query Other { books { title } }", @@ -511,10 +488,9 @@ def test_a_name_the_document_package_re_exports_steps_aside_for_modules_then_ope assert "from .other import Get_1 as Get_1, Other_1 as Other_1" in reexports -def test_a_fragment_goes_next_to_the_operations_of_its_document( +def test_a_fragment_goes_next_to_the_operations_of_its_document_and_is_imported_absolutely_elsewhere( bookshop_schema: GraphQLSchema, ) -> None: - """Imported, absolutely, by the module of another document spreading it, and spelled clear of its own document's names: an operation's data type gives way.""" files = _generated( { "card.graphql": "query Get { books { ...GetData } } fragment GetData on Book { title }", @@ -594,11 +570,11 @@ def test_a_document_s_module_holds_its_operations_importing_the_package_absolute ) for snippet in [ - "from client import runtime as _runtime", - "from client.schema import enum as _enum\n", + "from api import runtime as _runtime", + "from api.schema import enum as _enum\n", "class GetData_1(_compat.TypedDict, closed=True):", - "Get: _runtime.Operation[_typing.Literal['query'], GetVariables, GetData_1]", - "GetData: _runtime.Operation[_typing.Literal['query'], GetDataVariables, GetDataData]", + "Get: _typing.Final = _runtime.Operation(operation_type=_operation_type.QUERY, name='Get', document=", + "GetData: _typing.Final = _runtime.Operation(operation_type=_operation_type.QUERY, name='GetData', document=", ]: assert snippet in source diff --git a/tests/test_graphql_config.py b/tests/test_graphql_config.py index 6cd840c..8f5b609 100644 --- a/tests/test_graphql_config.py +++ b/tests/test_graphql_config.py @@ -2,6 +2,7 @@ import re import sys from collections.abc import Mapping +from dataclasses import fields from pathlib import Path, PurePosixPath from typing import Final @@ -10,23 +11,28 @@ from graphql_codegen import Config, PackageLocation from graphql_codegen._cli._graphql_config import ( + _EXTENSION_NAME, _get_loader, _interpolate, _parse_graphql_config, - _parse_project, + _parse_params, _Project, read_graphql_config, ) from graphql_codegen._cli._schema_pointer import Globs from tests._bookshop import copy_bookshop -_EXTENSION: Final = {"moduleRoot": ".", "package": "client"} +_EXTENSION: Final = {"moduleRoot": ".", "package": "api"} _PROJECT: Final = { "schema": "schema.graphqls", "documents": "*.graphql", - "extensions": {"pythonCodegen": _EXTENSION}, + "extensions": {_EXTENSION_NAME: _EXTENSION}, } -_IN_EXTENSION: Final = "\nIn `extensions.pythonCodegen`.\nIn project `default`." +_IN_EXTENSION: Final = f"\nIn `extensions.{_EXTENSION_NAME}`.\nIn project `default`." + + +def _camel_case(field_name: str, /) -> str: + return re.sub(r"_(\w)", lambda match: match[1].upper(), field_name) @pytest.mark.parametrize( @@ -35,7 +41,7 @@ pytest.param("graphql.config.json", json.dumps(_PROJECT), id="json"), pytest.param( "graphql.config.toml", - 'schema = "schema.graphqls"\ndocuments = "*.graphql"\n\n[extensions.pythonCodegen]\nmoduleRoot = "."\npackage = "client"\n', + f'schema = "schema.graphqls"\ndocuments = "*.graphql"\n\n[extensions.{_EXTENSION_NAME}]\nmoduleRoot = "."\npackage = "api"\n', id="toml", ), pytest.param( @@ -46,13 +52,12 @@ def test_every_format_python_can_read_is_read( name: str, content: str, tmp_path: Path ) -> None: - """Each project's arguments are keyed by its package's directory, which its module root, relative to the config file's, is re-rooted at.""" copy_bookshop(tmp_path) (tmp_path / name).write_text(content, encoding="utf-8") (directory,) = read_graphql_config(tmp_path / name) - assert directory == tmp_path / "client" + assert directory == tmp_path / "api" def test_a_yaml_config_without_the_extra_names_it( @@ -85,11 +90,11 @@ def test_every_project_with_the_extension_is_read() -> None: projects = { "shop": { **_PROJECT, - "extensions": {"pythonCodegen": {**_EXTENSION, "package": "shop"}}, + "extensions": {_EXTENSION_NAME: {**_EXTENSION, "package": "shop"}}, }, "back_office": { **_PROJECT, - "extensions": {"pythonCodegen": {**_EXTENSION, "package": "back_office"}}, + "extensions": {_EXTENSION_NAME: {**_EXTENSION, "package": "back_office"}}, }, # For editors only. "server_tests": {"schema": "schema.graphqls", "documents": "*.graphql"}, @@ -103,27 +108,15 @@ def test_every_project_with_the_extension_is_read() -> None: } == {"shop", "back_office"} -@pytest.mark.parametrize( - "extension", - [ - pytest.param( - { - **_EXTENSION, - "documentSiblingModule": None, - "injectorNames": None, - "nonNullDirectiveName": None, - "scalars": None, - "structInterfaceName": None, - }, - id="null, like left out", - ), - ], -) -def test_every_config_key_but_where_the_package_goes_is_optional( - extension: dict[str, object], -) -> None: +def test_every_config_key_but_where_the_package_goes_is_optional() -> None: + # Null, like left out. + extension = { + **_EXTENSION, + **{_camel_case(field.name): None for field in fields(Config)}, + } + ((project),) = _parse_graphql_config( - {**_PROJECT, "extensions": {"pythonCodegen": extension}}, + {**_PROJECT, "extensions": {_EXTENSION_NAME: extension}}, directory=PurePosixPath(), ).values() @@ -138,16 +131,16 @@ def test_every_config_key_but_where_the_package_goes_is_optional( ), pytest.param( {"schema": "schema.graphqls", "documents": "*.graphql"}, - "Expected a project with a `pythonCodegen` extension.", + f"Expected a project with a `{_EXTENSION_NAME}` extension.", id="no extension", ), pytest.param( {"projects": dict.fromkeys(("shop", "back_office"), _PROJECT)}, - "Expected `shop` and `back_office` to generate into directories of their own, but both generate into `client`.", + "Expected `shop` and `back_office` to generate into directories of their own, but both generate into `api`.", id="two projects into one directory, which would replace each other's", ), pytest.param( - {"documents": "*.graphql", "extensions": {"pythonCodegen": _EXTENSION}}, + {"documents": "*.graphql", "extensions": {_EXTENSION_NAME: _EXTENSION}}, "Expected `schema` to be set.\nIn project `default`.", id="no schema", ), @@ -169,7 +162,7 @@ def test_a_config_without_a_project_to_generate_fails( id="unknown key", ), pytest.param( - {"moduleRoot": 3, "package": "client"}, + {"moduleRoot": 3, "package": "api"}, f"Expected a string, but got `3`.\nIn `moduleRoot`.{_IN_EXTENSION}", id="a module root that is not a string", ), @@ -189,8 +182,8 @@ def test_a_config_without_a_project_to_generate_fails( id="what the config checks, located by notes", ), pytest.param( - {"moduleRoot": ".", "package": "my-client"}, - f"Expected the package to be a dotted name, such as `my_app._graphql`, but got `my-client`.{_IN_EXTENSION}", + {"moduleRoot": ".", "package": "my-api"}, + f"Expected the package to be a dotted name, such as `my_app._graphql`, but got `my-api`.{_IN_EXTENSION}", id="what the package location checks", ), pytest.param( @@ -203,7 +196,7 @@ def test_a_config_without_a_project_to_generate_fails( } }, }, - f"Expected an absolute dotted name, such as `datetime.datetime`, a dotted name relative to the package's directory, such as `..scalars.decode`, or a builtin, such as `int`, but got `.x`.\nIn `decode`.\nIn `codec`.\nIn `Money`.\nIn `scalars`.{_IN_EXTENSION}", + f"Expected a builtin (`str`), an absolute dotted name (`decimal.Decimal`), or a path relative to the generated package's directory (`..scalar.decode_datetime`), but got `.x`.\nIn `decode`.\nIn `codec`.\nIn `Money`.\nIn `scalars`.{_IN_EXTENSION}", id="what a scalar checks, located by one note per level", ), pytest.param( @@ -228,7 +221,7 @@ def test_a_malformed_extension_is_reported_where_it_is( ) -> None: with pytest.raises((TypeError, ValueError), match=f"^{re.escape(message)}$"): _parse_graphql_config( - {**_PROJECT, "extensions": {"pythonCodegen": extension}}, + {**_PROJECT, "extensions": {_EXTENSION_NAME: extension}}, directory=PurePosixPath(), ) @@ -238,14 +231,14 @@ def test_an_invalid_schema_is_reported() -> None: name="default", schema=Globs(patterns=()), documents=[], - package_location=PackageLocation(module_root=PurePosixPath(), package="client"), + package_location=PackageLocation(module_root=PurePosixPath(), package="api"), config=Config(), ) with pytest.raises( ValueError, match=re.escape("Cannot build the schema: Unknown type 'Missing'.") ): - _parse_project( + _parse_params( project, schema=[Source("type Query { book: Missing }")], documents=[], @@ -314,7 +307,7 @@ def test_an_invalid_schema_is_reported() -> None: def test_an_environment_variable_is_replaced_before_parsing( text: str, environment: Mapping[str, str], interpolated: str ) -> None: - """Including graphql-config's own cases: https://github.com/graphql-hive/graphql-config/blob/3091fcc7259c2feeb9866994a6c3ca4fc6ddb7a0/test/config.spec.ts#L173-L202.""" + # Includes graphql-config's own cases: https://github.com/graphql-hive/graphql-config/blob/3091fcc7259c2feeb9866994a6c3ca4fc6ddb7a0/test/config.spec.ts#L173-L202 assert _interpolate(text, environment=environment) == interpolated diff --git a/tests/test_introspection.py b/tests/test_introspection.py index 3838c02..21902a6 100644 --- a/tests/test_introspection.py +++ b/tests/test_introspection.py @@ -27,7 +27,6 @@ def _generated_module() -> bytes: document=parse( Source(_DOCUMENT.read_text(encoding="utf-8"), _DOCUMENT.as_posix()) ), - # Every schema answers introspection. schema=build_schema("type Query { _: Boolean }"), config=Config( document_sibling_module=DocumentSiblingModule( @@ -44,7 +43,7 @@ def _generated_module() -> bytes: def test_the_introspection_document_s_module_is_what_the_library_generates() -> None: - # The rest of the generated package is the library itself. + # Only this module is checked in: the CLI uses the library's own runtime rather than a copy. assert _MODULE.read_bytes() == _generated_module() @@ -74,7 +73,6 @@ def handle(query: str, /, *, headers: Mapping[str, str]) -> object: def test_an_older_endpoint_is_asked_only_what_it_knows( graphql_server: Callable[[GraphqlHandler], str], bookshop_schema: GraphQLSchema ) -> None: - """As a server predating every introspection field added since the October 2016 edition of the spec.""" later = {"description", "isDeprecated", "isOneOf", "isRepeatable", "specifiedByURL"} queries: list[str] = [] @@ -89,7 +87,7 @@ def handle(query: str, /, *, headers: Mapping[str, str]) -> object: for type_ in result["data"].values() for field in type_["fields"] } - # Names the server really answers, rather than misspelled ones that would strip nothing. + # Guards the test: a misspelled name would strip nothing. assert later <= known, later - known for type_ in result["data"].values(): diff --git a/tests/test_merge.py b/tests/test_merge.py index b62a84c..e575d76 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -1,23 +1,21 @@ import json from itertools import product +from typing import Any import pytest from graphql import GraphQLSchema, OperationDefinitionNode, parse, validate import bookshop.app_graphql import bookshop.get_order_graphql -from bookshop.client.runtime import Operation as BookshopOperation -from bookshop.client.runtime._merge import encode_body as encode_bookshop_body +from bookshop.graphql.runtime import ( + Operation as BookshopOperation, + merge as merge_bookshop, +) +from bookshop.transport import INJECTORS from graphql_codegen._generator._merge import ( print_merge_template, ) -from graphql_codegen.runtime import Error, Operation -from graphql_codegen.runtime._literal import OperationType -from graphql_codegen.runtime._merge import ( - encode_body, - split_merged_data, - split_merged_errors, -) +from graphql_codegen.runtime import ExecutionError, Operation, Request, merge _PLACE_ORDER = """ mutation PlaceOrder($order: PlaceOrderInput!, $note: String! = "gift") { @@ -44,8 +42,7 @@ """ -def _operation(source: str, /) -> Operation[OperationType, dict[str, object], object]: - """An operation whose document is the template the generator prints.""" +def _operation(source: str, /) -> Operation[Any, dict[str, object], object]: document = parse(source, no_location=True) (definition,) = ( definition @@ -62,20 +59,23 @@ def _operation(source: str, /) -> Operation[OperationType, dict[str, object], ob ) +def _sent(request: Request[Any, object], /) -> dict[str, Any]: + return json.loads(bytes(request.prepare())) + + +def _received[Data](request: Request[Any, Data], payload: object, /) -> Data: + return request.prepare().parse(json.dumps(payload).encode()) + + def test_operations_merge_into_one() -> None: - """Each operation's variables and root fields get its index, and a fragment several spread is defined once.""" place_order = _operation(_PLACE_ORDER) - request = json.loads( - encode_body( - [ - (place_order, {"order": 1, "note": "n"}), - (_operation(_CANCEL_ORDER), {"cancellation": 2}), - (place_order, {"order": 3}), - ] - ), + request = merge( + place_order({"order": 1, "note": "n"}), + _operation(_CANCEL_ORDER)({"cancellation": 2}), + place_order({"order": 3}), ) - assert request == { + assert _sent(request) == { "operationName": "MergedOperation", "query": """mutation MergedOperation($order_0: PlaceOrderInput!, $note_0: String! = "gift", $cancellation_1: CancelOrderInput, $order_2: PlaceOrderInput!, $note_2: String! = "gift") { placed_0: placeOrder(input: $order_0, channel: "web") { @@ -102,28 +102,43 @@ def test_operations_merge_into_one() -> None: }""", "variables": {"order_0": 1, "note_0": "n", "cancellation_1": 2, "order_2": 3}, } + assert _received( + request, {"data": {"placed_0": 1, "addNote_0": 2, "addNote_2": 3}} + ) == ( + {"placed": 1, "addNote": 2}, + {}, + {"addNote": 3}, + ) @pytest.mark.parametrize( - ("source", "query"), + ("source", "name", "query"), [ pytest.param( 'query Q @cached { a(name: "§") }', + "Q", 'query Q @cached {\n a(name: "\\u00a7")\n}', id="or one that cannot be merged, whose `§` in a string is still escaped, so that it is not taken for a sigil", ), + pytest.param( + _PLACE_ORDER, + "PlaceOrder", + 'mutation PlaceOrder($order: PlaceOrderInput!, $note: String! = "gift") {\n placed: placeOrder(input: $order, channel: "web") {\n ...OrderSummary\n total\n }\n addNote(note: $note) {\n id\n }\n}\n\nfragment OrderSummary on Order {\n id\n}', + id="keeping an alias of its own", + ), pytest.param( _CANCEL_ORDER, - "mutation CancelOrder($cancellation: CancelOrderInput) {\n cancelOrder: cancelOrder(input: $cancellation) {\n __typename\n }\n}", - id="without its sigils, under its own name, each root field aliased as itself", + "CancelOrder", + "mutation CancelOrder($cancellation: CancelOrderInput) {\n cancelOrder(input: $cancellation) {\n __typename\n }\n}", + id="without its sigils and the aliases they made redundant, under its own name", ), ], ) -def test_a_lone_operation_is_sent_without_its_sigils(source: str, query: str) -> None: - operation = _operation(source) - - assert json.loads(encode_body([(operation, {"x": 1})])) == { - "operationName": operation._name, +def test_a_lone_operation_is_sent_without_its_sigils( + source: str, name: str, query: str +) -> None: + assert _sent(_operation(source)({"x": 1})) == { + "operationName": name, "query": query, "variables": {"x": 1}, } @@ -159,62 +174,68 @@ def test_a_lone_operation_is_sent_without_its_sigils(source: str, query: str) -> ) def test_an_operation_merged_with_itself(source: str, snippets: list[str]) -> None: operation = _operation(source) - query = json.loads(encode_body([(operation, {}), (operation, {})]))["query"] + query = _sent(merge(operation({}), operation({})))["query"] for snippet in snippets: assert snippet in query -@pytest.mark.parametrize( - ("sources", "message"), - [ - pytest.param([], "Cannot merge no operation.", id="no operation"), - pytest.param( - [_CANCEL_ORDER, "query Q { a }"], - "different types: mutation, query", - id="operations of different types", - ), - pytest.param( - ["query Q @cached { a }"] * 2, - "Cannot merge `Q`: its document has no `§`", - id="an operation with directives of its own, which would apply to the whole merge, so that its template has no sigil, as a subscription's has none", - ), - ], -) -def test_a_merge_is_rejected(sources: list[str], message: str) -> None: - with pytest.raises(ValueError, match=message): - encode_body([(_operation(source), {}) for source in sources]) - +def test_an_operation_with_directives_of_its_own_cannot_be_merged() -> None: + # They would apply to the whole merge, so its template has no sigil, as a subscription's has none. + operation = _operation("query Q @cached { a }") -def test_the_data_of_a_merge_splits_back_in_order() -> None: - assert split_merged_data( - {"placed_0": 1, "addNote_0": 2, "addNote_2": 3}, count=3 - ) == [{"placed": 1, "addNote": 2}, {}, {"addNote": 3}] + with pytest.raises(ValueError, match="Cannot merge `Q`: its document has no `§`"): + merge(operation({}), operation({})).prepare() def test_the_errors_of_a_merge_split_back_to_their_operations_with_their_own_paths() -> ( None ): - assert split_merged_errors( - [ - {"message": "a", "path": ["cancelOrder_1", "lines", 0]}, - {"message": "b"}, - # A response name holding the separator is split at its last one. - {"message": "c", "path": ["gift_wrap_0"]}, - ], - count=2, - ) == [ - [{"message": "b"}, {"message": "c", "path": ["gift_wrap"]}], - [{"message": "a", "path": ["cancelOrder", "lines", 0]}, {"message": "b"}], - ] + gift_wrap = _operation("mutation GiftWrap { gift_wrap { id } }") + first, second = _received( + merge( + gift_wrap({}).returning_error(), + _operation(_CANCEL_ORDER)({}).returning_error(), + ), + { + "data": {"gift_wrap_0": None, "cancelOrder_1": None}, + "errors": [ + {"message": "a", "path": ["cancelOrder_1", "lines", 0]}, + {"message": "b"}, + # A name holding the separator splits at its last one. + {"message": "c", "path": ["gift_wrap_0"]}, + ], + }, + ) + + assert isinstance(first, ExecutionError) + assert isinstance(second, ExecutionError) + assert (first.data, first.errors) == ( + {"gift_wrap": None}, + ({"message": "b"}, {"message": "c", "path": ["gift_wrap"]}), + ) + assert (second.data, second.errors) == ( + {"cancelOrder": None}, + ({"message": "a", "path": ["cancelOrder", "lines", 0]}, {"message": "b"}), + ) -def test_the_data_and_errors_of_a_lone_operation_are_its_own_uncopied() -> None: - data = {"placed": 13} - errors: list[Error] = [{"message": "boom", "path": ["placed"]}] +def test_a_merge_of_one_request_sends_and_reads_it_as_itself() -> None: + request = merge([_operation(_CANCEL_ORDER)({"cancellation": 2}).returning_error()]) + (error,) = _received( + request, + { + "data": {"cancelOrder": None}, + "errors": [{"message": "boom", "path": ["cancelOrder"]}], + }, + ) - assert split_merged_data(data, count=1)[0] is data - assert split_merged_errors(errors, count=1)[0] is errors + assert _sent(request)["operationName"] == "CancelOrder" + assert isinstance(error, ExecutionError) + assert (error.data, error.errors) == ( + {"cancelOrder": None}, + ({"message": "boom", "path": ["cancelOrder"]},), + ) @pytest.mark.parametrize( @@ -246,7 +267,6 @@ def test_what_cannot_keep_its_shape_in_a_merge_is_inlined_in_the_template( def test_every_bookshop_operation_merges_with_every_other_of_its_type_into_a_valid_document( bookshop_schema: GraphQLSchema, ) -> None: - """Exhaustive over the corpus, which covers every shape the generator prints, rather than sampled.""" operations = [ value for module in (bookshop.app_graphql, bookshop.get_order_graphql) @@ -264,14 +284,14 @@ def test_every_bookshop_operation_merges_with_every_other_of_its_type_into_a_val } for operation in operations: - lone = json.loads(encode_bookshop_body([(operation, {})]))["query"] - assert not validate(bookshop_schema, parse(lone)), operation._name + lone = json.loads(bytes(operation({}).prepare(injectors=INJECTORS)))["query"] + assert not validate(bookshop_schema, parse(lone)), operation for first, second in product(mergeable, repeat=2): if first._operation_type != second._operation_type: continue - merged = json.loads(encode_bookshop_body([(first, {}), (second, {})]))["query"] - assert not validate(bookshop_schema, parse(merged)), ( - f"{first.name} and {second.name}" - ) + merged = json.loads( + bytes(merge_bookshop(first({}), second({})).prepare(injectors=INJECTORS)) + )["query"] + assert not validate(bookshop_schema, parse(merged)), (first, second) diff --git a/tests/test_naming.py b/tests/test_naming.py index d6a47a8..e926c3b 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -52,7 +52,7 @@ _compat: _input } -# Before `str`, which references it: pyrefly reads a forward reference to a type named like a builtin as the builtin. +# Before `str`, which references it: pyrefly reads a forward reference named like a builtin as the builtin. input input { x: Int } @@ -93,7 +93,6 @@ @pytest.fixture(name="files", scope="module") def files_fixture() -> Mapping[PurePosixPath, bytes]: - """The package of documents built to collide: operations named like helper aliases, builtins, keywords, and `__debug__`, an operation named like another's data type, names differing only by case, and keys named like everything else.""" return generate( document=parse(_DOCUMENT), schema=build_schema(_SCHEMA), config=Config() ) @@ -121,9 +120,12 @@ def _bound_names(tree: ast.Module, /) -> list[str]: names.extend( alias.asname or alias.name.split(".")[0] for alias in statement.names - # A star binds its module's names, which GraphQL's one type name space keeps apart from the other's. + # A star import binds names GraphQL's single type namespace already keeps apart. if alias.name != "*" ) + # An overload binds nothing: its implementation does. + case ast.FunctionDef(decorator_list=[ast.Name(id="overload")]): + pass case ast.ClassDef(name=bound) | ast.FunctionDef(name=bound): names.append(bound) case ( @@ -154,7 +156,7 @@ def test_every_module_binds_each_name_once( [ pytest.param( "document/GraphQL_request.py", - "\n_compat: _runtime.Operation[", + "\n_compat: _typing_1.Final = _runtime.Operation(", id="an operation named like a helper alias keeps its name", ), pytest.param( @@ -164,12 +166,12 @@ def test_every_module_binds_each_name_once( ), pytest.param( "document/GraphQL_request.py", - "\nstr: _runtime.Operation[", + "\nstr: _typing_1.Final = _runtime.Operation(", id="so does one named like a builtin", ), pytest.param( "document/GraphQL_request.py", - "\nBookData: _runtime.Operation[", + "\nBookData: _typing_1.Final = _runtime.Operation(", id="and one named like another's data type", ), pytest.param( @@ -179,17 +181,17 @@ def test_every_module_binds_each_name_once( ), pytest.param( "document/GraphQL_request.py", - "\nbook: _runtime.Operation[", + "\nbook: _typing_1.Final = _runtime.Operation(", id="two names differing only by case each keep their own, since a module is not a file", ), pytest.param( "document/GraphQL_request.py", - "\nclass_: _runtime.Operation[", + "\nclass_: _typing_1.Final = _runtime.Operation(", id="a keyword, which cannot be bound, gets PEP 8's trailing underscore", ), pytest.param( "document/GraphQL_request.py", - "\n__debug___: _runtime.Operation[", + "\n__debug___: _typing_1.Final = _runtime.Operation(", id="and so does `__debug__`, the one other name that cannot be", ), pytest.param( @@ -213,7 +215,7 @@ def test_the_package_imports_and_every_type_resolves(package_directory: Path) -> for path in package_directory.rglob("*.py") ] operations: set[str] = set() - # The package's own copy: the library's classes are not the ones it instantiates. + # The package's own runtime: its operations are not instances of the library's classes. runtime = importlib.import_module("adversarial.runtime") reflection = importlib.import_module("adversarial.runtime._reflection") @@ -222,7 +224,7 @@ def test_the_package_imports_and_every_type_resolves(package_directory: Path) -> for value in vars(module).values(): if is_typeddict(value): - # Resolves every annotation, which fails if a key shadowed a name it uses. + # Fails if a key shadows a name an annotation uses. get_type_hints(value, include_extras=True) elif isinstance(value, runtime.Operation): operations.add(value._name) diff --git a/tests/test_prepare.py b/tests/test_prepare.py new file mode 100644 index 0000000..106e202 --- /dev/null +++ b/tests/test_prepare.py @@ -0,0 +1,234 @@ +import json +from decimal import Decimal +from typing import Any, Final, assert_type +from uuid import UUID + +import pytest + +import bookshop.transport +from bookshop.app_graphql import ( + CancelOrder, + CancelOrderData, + GetBook, + GetBookData, + ListBooks, + OnOrderStatusChanged, +) +from bookshop.graphql.injection import injectors +from bookshop.graphql.runtime import ( + ExecutionError, + Request, + RequestError, + UnexpectedNullError, + merge, +) +from bookshop.scalar import ISBN + +_CARD: Final = { + "id": "1", + "title": "Persuasion", + "price": "8.99", + "author": None, +} +_BOOK: Final = {**_CARD, "isbn": "9780141439518", "pageCount": 249} +_GET_BOOK: Final = GetBook({"lookup": {"isbn": ISBN("9780141439518")}}) +_ON_STATUS_CHANGED: Final = OnOrderStatusChanged({"orderId": "o1"}) +_KEY: Final = UUID(int=0) +_INJECTORS: Final = injectors({"idempotencyKey": lambda: _KEY}) + + +def _parse[Data](request: Request[Any, Data], payload: object, /) -> Data: + return request.prepare(injectors=_INJECTORS).parse(json.dumps(payload).encode()) + + +def _status_event(status: str, /) -> bytes: + return json.dumps( + {"data": {"orderStatusChanged": {"id": "o1", "status": status}}} + ).encode() + + +def test_creating_a_request_has_no_side_effect() -> None: + assert isinstance(_GET_BOOK, Request) + prepared = _GET_BOOK.prepare(injectors=_INJECTORS) + + assert bytes(prepared) == bytes(_GET_BOOK.prepare(injectors=_INJECTORS)) + assert json.loads(bytes(prepared))["variables"] == { + "lookup": {"isbn": "9780141439518"} + } + + +def test_variables_and_operation_types_are_checked_statically() -> None: + # Each suppression is checked as used: nothing is validated at runtime. + assert isinstance(GetBook({"lookup": 1}), Request) # ty: ignore[invalid-argument-type] + # A merge takes one operation type, never a subscription, and a subscription reader takes no query. + assert isinstance(merge(ListBooks({}), _ON_STATUS_CHANGED), Request) # ty: ignore[invalid-argument-type] + assert isinstance( + merge(ListBooks({}), CancelOrder({"input": {"order": "o1"}})), # ty: ignore[invalid-argument-type] + Request, + ) + # A generator, so nothing runs. + bookshop.transport.subscribe(ListBooks({})) # ty: ignore[invalid-argument-type] + # Every injector is required. + injectors({}) # ty: ignore[invalid-argument-type, missing-typed-dict-key] + # A merge has no error of its own to return: each merged request returns or raises its own. + assert not hasattr(merge(ListBooks({}), ListBooks({})), "returning_error") + + +def test_an_unknown_enum_member_reaches_the_caller_untouched() -> None: + data = _parse(ListBooks({}), {"data": {"books": [{**_CARD, "genre": "COOKING"}]}}) + (book,) = data["books"] + + assert book["genre"] == "COOKING" + + +def test_a_merge_is_a_request_and_a_sequence_merges_into_a_tuple_of_its_common_data() -> ( + None +): + canceled = { + "data": { + "cancelOrder_0": {"id": "o1", "status": "CANCELED"}, + "cancelOrder_1": None, + } + } + cancellations = [ + CancelOrder({"input": {"order": "o1"}}), + CancelOrder({"input": {"order": "o2"}}), + ] + merged = merge(cancellations) + + assert isinstance(merged, Request) + assert repr(merged) == f"merge({cancellations[0]!r}, {cancellations[1]!r})" + + data = _parse(merged, canceled) + + assert_type(data, tuple[CancelOrderData, ...]) + assert data == ( + {"cancelOrder": {"id": "o1", "status": "CANCELED"}}, + {"cancelOrder": None}, + ) + + with pytest.raises(ValueError, match="Cannot merge no request"): + merge([]) + + +def test_an_operation_injecting_a_value_cannot_be_prepared_without_injectors() -> None: + with pytest.raises( + ValueError, match="without injectors: it injects idempotencyKey" + ): + CancelOrder({"input": {"order": "o1"}}).prepare() + + +def test_a_request_returning_its_error_gets_it_in_place_of_its_data() -> None: + down = {"data": {"book": None}, "errors": [{"message": "Down.", "path": ["book"]}]} + result = _parse(_GET_BOOK.returning_error(), down) + + assert_type(result, GetBookData | ExecutionError[GetBookData]) + assert isinstance(result, ExecutionError) + assert_type(result.parse_data(), GetBookData | None) + # `book` is `@nonNull`, so its null moves up to the data itself. + assert (result.data, result.parse_data()) == ({"book": None}, None) + assert result.__notes__ == [ + "Raised by `GetBook` with variables {'lookup': {'isbn': '9780141439518'}}." + ] + assert _parse(_GET_BOOK.returning_error(), {"data": {"book": _BOOK}}) == { + "book": {**_BOOK, "price": Decimal("8.99")} + } + + with pytest.raises(RequestError): + _parse(_GET_BOOK.returning_error(), {"errors": [{"message": "Syntax Error"}]}) + + +def test_a_merge_returns_each_request_s_error_in_its_place_unless_one_raises() -> None: + cancellations = ( + CancelOrder({"input": {"order": "o1"}}), + CancelOrder({"input": {"order": "o2"}}), + ) + one_failing = { + "data": { + "cancelOrder_0": {"id": "o1", "status": "CANCELED"}, + "cancelOrder_1": None, + }, + "errors": [{"message": "Already shipped.", "path": ["cancelOrder_1"]}], + } + first, second = _parse( + merge(cancellations[0].returning_error(), cancellations[1].returning_error()), + one_failing, + ) + + assert_type(second, CancelOrderData | ExecutionError[CancelOrderData]) + assert first == {"cancelOrder": {"id": "o1", "status": "CANCELED"}} + assert isinstance(second, ExecutionError) + assert second.errors == ({"message": "Already shipped.", "path": ["cancelOrder"]},) + + both_failing = { + "data": {"cancelOrder_0": None, "cancelOrder_1": None}, + "errors": [ + {"message": "Unknown.", "path": ["cancelOrder_0"]}, + {"message": "Already shipped.", "path": ["cancelOrder_1"]}, + ], + } + + with pytest.raises(ExceptionGroup) as group_info: + _parse( + merge(cancellations[0], cancellations[1].returning_error()), both_failing + ) + + assert [str(error) for error in group_info.value.exceptions] == [ + "Unknown.", + "Already shipped.", + ] + + +def test_a_merge_s_errors_are_split_per_operation_unless_its_data_is_null() -> None: + cancellations = merge( + [ + CancelOrder({"input": {"order": "o1"}}), + CancelOrder({"input": {"order": "o2"}}), + ] + ) + failing = { + "data": { + "cancelOrder_0": {"id": "o1", "status": "CANCELED"}, + "cancelOrder_1": None, + }, + "errors": [{"message": "Already shipped.", "path": ["cancelOrder_1"]}], + } + + with pytest.raises(ExceptionGroup) as group_info: + _parse(cancellations, failing) + + (error,) = group_info.value.exceptions + assert isinstance(error, ExecutionError) + assert error.errors == ({"message": "Already shipped.", "path": ["cancelOrder"]},) + assert error.data == error.parse_data() == {"cancelOrder": None} + assert error.__notes__ == [ + f"Raised by `CancelOrder` with variables {{'input': {{'order': 'o2', 'idempotencyKey': '{_KEY}'}}}}." + ] + + with pytest.raises(ExecutionError): + _parse(cancellations, {"data": None, "errors": [{"message": "Down."}]}) + + +def test_an_unexpected_null_in_a_merge_notes_its_operation_and_the_variables_sent() -> ( + None +): + with pytest.raises(UnexpectedNullError) as error_info: + _parse( + merge(ListBooks({}), _GET_BOOK), {"data": {"books_0": [], "book_1": None}} + ) + + assert error_info.value.__notes__ == [ + "Raised by `GetBook` with variables {'lookup': {'isbn': '9780141439518'}}." + ] + + +def test_a_subscription_returning_its_errors_parses_past_them() -> None: + prepared = _ON_STATUS_CHANGED.returning_error().prepare(injectors=_INJECTORS) + lagging = prepared.parse( + json.dumps({"data": None, "errors": [{"message": "Lagging."}]}).encode() + ) + delivered = prepared.parse(_status_event("DELIVERED")) + + assert isinstance(lagging, ExecutionError) + assert (str(lagging), lagging.parse_data()) == ("Lagging.", None) + assert delivered == {"orderStatusChanged": {"id": "o1", "status": "DELIVERED"}} diff --git a/tests/test_readme.py b/tests/test_readme.py index b92c763..ab533f3 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -19,7 +19,6 @@ [ pytest.param( block, - # A path alone would repeat, since several excerpts may show one file. id=f"line {_README.count(chr(10), 0, block.start()) + 1}" + (f": {block['path']}" if block["path"] else ""), ) @@ -27,12 +26,6 @@ ], ) def test_a_code_block_is_a_file_of_the_package(block: re.Match[str]) -> None: - """Each block is a file of the package, verbatim, so that the README shows no code that the tests do not run or type check. - - Only a shell command, which nothing runs, may stand on its own; a marker's path is relative to the README, like a link. - - An excerpt keeps the file's text in order, a blank line or a `# …` one standing for what it leaves out. - """ if block["path"] is None: assert block["language"] == "shell", ( "Expected the block to follow a `` or `` marker naming the file it shows." @@ -49,6 +42,7 @@ def test_a_code_block_is_a_file_of_the_package(block: re.Match[str]) -> None: position = 0 + # A blank line or a `# …` line stands for what the excerpt leaves out. for piece in re.split(r"\n(?: *# …)?\n", block["code"]): position = source.find(piece, position) assert position != -1, ( diff --git a/tests/test_reflection.py b/tests/test_reflection.py index b70038c..aef176c 100644 --- a/tests/test_reflection.py +++ b/tests/test_reflection.py @@ -8,7 +8,7 @@ from graphql_codegen.runtime import UnexpectedNullError -# As generated types are: `typing.is_typeddict` once missed `typing_extensions.TypedDict`, silently making a transform a no-op. +# From `_compat`, as generated types are: `typing.is_typeddict()` once missed `typing_extensions.TypedDict`. from graphql_codegen.runtime._compat import ReadOnly, TypedDict from graphql_codegen.runtime._reflection import ( NON_NULL, @@ -87,8 +87,6 @@ class _Variables(TypedDict, closed=True): class _SavedSearch(TypedDict): - """A struct's payload, typed by an input type, comes back in a response.""" - filter: _Filter @@ -113,17 +111,17 @@ def parse() -> None: parsed.append(convert({"author": None})) other = Thread(target=parse) - compute_build = _Builder._compute_build + new_converter = _Builder._new_converter - def compute_build_while_other_parses(self: _Builder, type_: object, /) -> object: + def new_converter_while_other_parses(self: _Builder, type_: object, /) -> object: if type_ is _LatestBook: other.start() - # Long enough for the other thread to parse, were it not waiting for this build. + # Long enough for the other thread to finish, were it not waiting on this build. other.join(timeout=0.1) - return compute_build(self, type_) + return new_converter(self, type_) - monkeypatch.setattr(_Builder, "_compute_build", compute_build_while_other_parses) + monkeypatch.setattr(_Builder, "_new_converter", new_converter_while_other_parses) builder.build(_LatestBook) other.join() @@ -208,7 +206,7 @@ def test_a_type_needing_nothing_builds_to_none() -> None: _Search, {"search": [{"__typename": "Audiobook", "publishedAt": _W}]}, {"search": [{"__typename": "Audiobook", "publishedAt": _W}]}, - id="a member added after generation passes untouched", + id="a member added after codegen passes untouched", ), pytest.param( _SavedSearch, diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 12cd01d..b25658f 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -1,6 +1,6 @@ import json from collections.abc import Callable -from typing import Annotated, Final, Literal +from typing import Annotated, Any, Final, Literal import pytest @@ -12,12 +12,6 @@ RequestError, ) from graphql_codegen.runtime._compat import TypedDict -from graphql_codegen.runtime._prepare import ( - parse_data, - parse_response, - prepare, - resolve_variables, -) from graphql_codegen.runtime._reflection import NON_NULL from graphql_codegen.runtime.injection import _Injectors @@ -32,12 +26,7 @@ ) -def test_the_request_prefix_is_serialized_once() -> None: - assert _LIST_BOOKS._request_prefix is _LIST_BOOKS._request_prefix - - def test_a_repr_names_the_operation_rather_than_dumping_its_document() -> None: - """So that a traceback or a debugger shows which operation it is.""" assert ( repr(_LIST_BOOKS({})) == "Request(operation=Operation(operation_type='query', name='ListBooks'), variables={}, on_execution_error='raise')" @@ -45,19 +34,18 @@ def test_a_repr_names_the_operation_rather_than_dumping_its_document() -> None: def test_a_request_is_the_spec_s_json_envelope() -> None: - assert json.loads(bytes(prepare(_LIST_BOOKS({"first": 2})))) == { + assert json.loads(bytes(_LIST_BOOKS({"first": 2}).prepare())) == { "operationName": "ListBooks", "query": "query ListBooks { books { title } }", "variables": {"first": 2}, } - assert json.loads(bytes(prepare(_LIST_BOOKS({}))))["variables"] == {} + assert json.loads(bytes(_LIST_BOOKS({}).prepare()))["variables"] == {} def test_a_response_without_data_is_a_request_error() -> None: with pytest.raises(RequestError) as error_info: - parse_response( - b'{"errors": [{"message": "Syntax Error"}], "extensions": {"cost": 1}}', - partial_data_parser=None, + _LIST_BOOKS({}).prepare().parse( + b'{"errors": [{"message": "Syntax Error"}], "extensions": {"cost": 1}}' ) assert error_info.value.extensions == {"cost": 1} @@ -67,9 +55,8 @@ def test_a_response_with_data_and_errors_is_an_execution_error_holding_the_data( None ): with pytest.raises(ExecutionError) as error_info: - parse_response( - b'{"errors": [{"message": "boom", "path": ["b"]}, {"message": "bang"}], "data": {"a": 1, "b": null}}', - partial_data_parser=None, + _LIST_BOOKS({}).prepare().parse( + b'{"errors": [{"message": "boom", "path": ["b"]}, {"message": "bang"}], "data": {"a": 1, "b": null}}' ) assert error_info.value.data == {"a": 1, "b": None} @@ -94,7 +81,7 @@ def test_what_is_not_a_graphql_response_is_a_protocol_error( raw: bytes, message: str ) -> None: with pytest.raises(ProtocolError, match=message): - parse_response(raw, partial_data_parser=None) + _LIST_BOOKS({}).prepare().parse(raw) class _GetBook_book(TypedDict): @@ -117,29 +104,22 @@ class _GetBookData(TypedDict): ) -@pytest.mark.parametrize( - ("data", "path", "expected"), - [ - pytest.param( - {"book": {"title": None}, "slogan": "Read on."}, - ["book", "title"], - {"book": None, "slogan": "Read on."}, - id="to its nearest nullable parent", - ), - ], -) -def test_the_null_of_a_field_asserted_non_null_that_raised_moves_up_in_a_copy( - data: dict[str, object], path: list[str], expected: dict[str, object] | None -) -> None: +def test_the_null_of_a_field_asserted_non_null_that_raised_moves_up_to_its_nearest_nullable_parent_in_a_copy() -> ( + None +): + data = {"book": {"title": None}, "slogan": "Read on."} + with pytest.raises(ExecutionError) as error_info: - parse_data( - _GET_BOOK, + _GET_BOOK({}).prepare().parse( json.dumps( - {"data": data, "errors": [{"message": "boom", "path": path}]} - ).encode(), + { + "data": data, + "errors": [{"message": "boom", "path": ["book", "title"]}], + } + ).encode() ) - assert error_info.value.parse_data() == expected + assert error_info.value.parse_data() == {"book": None, "slogan": "Read on."} assert error_info.value.data == data @@ -153,6 +133,18 @@ def _idempotency_key(function: Callable[[], str | OMITTED | None], /) -> _Inject ) +def _sent( + operation: Operation[Literal["mutation"], dict[str, object], dict[str, object]], + variables: dict[str, object], + /, + *, + injectors: _Injectors, +) -> Any: + return json.loads(bytes(operation(variables).prepare(injectors=injectors)))[ + "variables" + ] + + def _mutation( document: str, /, *, injections: dict[str, frozenset[tuple[str, ...]]] ) -> Operation[Literal["mutation"], dict[str, object], dict[str, object]]: @@ -166,8 +158,9 @@ def _mutation( ) -def test_an_injector_is_called_once_and_its_value_written_at_every_path() -> None: - """The caller's variables are left as passed, since the caller may reuse them.""" +def test_an_injector_is_called_once_and_written_at_every_path_without_touching_the_caller_s_variables() -> ( + None +): operation = _mutation( "mutation PlaceOrder($input: PlaceOrderInput!) { placeOrder(input: $input) { id } }", injections={"idempotencyKey": frozenset({(), ("input",), ("input", "gift")})}, @@ -180,9 +173,7 @@ def idempotency_key() -> str: variables: dict[str, object] = {"input": {"gift": {"message": "Enjoy!"}}} - assert resolve_variables( - operation, variables, injectors=_idempotency_key(idempotency_key) - ) == { + assert _sent(operation, variables, injectors=_idempotency_key(idempotency_key)) == { "input": { "gift": {"message": "Enjoy!", "idempotencyKey": "k1"}, "idempotencyKey": "k1", @@ -199,34 +190,19 @@ def test_nothing_is_injected_into_an_input_the_caller_left_out() -> None: injections={"idempotencyKey": frozenset({("input",)})}, ) - assert ( - resolve_variables(operation, {}, injectors=_idempotency_key(lambda: "k")) == {} - ) - assert resolve_variables( + assert _sent(operation, {}, injectors=_idempotency_key(lambda: "k")) == {} + assert _sent( operation, {"input": None}, injectors=_idempotency_key(lambda: "k") ) == {"input": None} -def test_an_injected_value_is_omitted_sent_null_or_left_out_without_its_injector() -> ( - None -): +def test_an_injected_value_is_omitted_or_sent_null() -> None: operation = _mutation( "mutation CancelOrder($idempotencyKey: ID) { cancelOrder(idempotencyKey: $idempotencyKey) { id } }", injections={"idempotencyKey": frozenset({()})}, ) - assert ( - resolve_variables(operation, {}, injectors=_idempotency_key(lambda: OMITTED)) - == {} - ) - assert resolve_variables( - operation, {}, injectors=_idempotency_key(lambda: None) - ) == {"idempotencyKey": None} - assert ( - resolve_variables( - operation, - {}, - injectors=_Injectors({}, injector_functions_type=_IdempotencyKeyInjector), - ) - == {} - ) + assert _sent(operation, {}, injectors=_idempotency_key(lambda: OMITTED)) == {} + assert _sent(operation, {}, injectors=_idempotency_key(lambda: None)) == { + "idempotencyKey": None + } diff --git a/tests/test_source.py b/tests/test_source.py index 5fe18a2..3f432eb 100644 --- a/tests/test_source.py +++ b/tests/test_source.py @@ -35,30 +35,14 @@ def test_sources_are_read_in_sorted_order_and_named_by_their_paths( ] -@pytest.mark.parametrize( - ("glob", "error", "message"), - [ - pytest.param( - "missing/*.graphql", - ValueError, - "Expected `missing/*.graphql` to match at least one file.", - id="matching nothing", - ), - pytest.param( - "/*.graphql", - NotImplementedError, - "Non-relative patterns are unsupported", - id="absolute, which pathlib rejects", - ), - ], -) -def test_each_glob_must_match_a_file( - glob: str, error: type[Exception], message: str, tmp_path: Path -) -> None: +def test_each_glob_must_match_a_file(tmp_path: Path) -> None: copy_bookshop(tmp_path) - with pytest.raises(error, match=f"^{re.escape(message)}$"): - read_sources(["*.graphql", glob], directory=tmp_path) + with pytest.raises( + ValueError, + match=f"^{re.escape('Expected `missing/*.graphql` to match at least one file.')}$", + ): + read_sources(["*.graphql", "missing/*.graphql"], directory=tmp_path) @pytest.mark.parametrize( diff --git a/tests/test_transport.py b/tests/test_transport.py index 9463856..f16d861 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1,22 +1,20 @@ import json -from collections.abc import Callable, Iterator -from contextlib import ExitStack, closing +from collections.abc import Callable +from contextlib import closing from http.server import BaseHTTPRequestHandler +from socket import SHUT_RDWR from threading import Event from typing import Final from urllib.error import HTTPError -from uuid import UUID import pytest import bookshop.transport from bookshop.app import watch -from bookshop.app_graphql import GetBook, OnOrderStatusChanged, PlaceOrder -from bookshop.client.injection import injectors -from bookshop.client.runtime import Client, RequestError, SubscriptionClient -from bookshop.client.schema import Address +from bookshop.app_graphql import GetBook, OnOrderStatusChanged +from bookshop.graphql.runtime import ExecutionError, RequestError from bookshop.scalar import ISBN -from tests._server import PostHandler, serve +from tests._server import PostHandler _GET_BOOK: Final = GetBook({"lookup": {"isbn": ISBN("9780141439518")}}) _ON_STATUS_CHANGED: Final = OnOrderStatusChanged({"orderId": "o1"}) @@ -31,7 +29,6 @@ def _status_event(status: str, /, *, indent: int | None = None) -> str: - """An event whose data spans one line per line of its JSON, which *indent* multiplies.""" data = {"data": {"orderStatusChanged": {"id": "o1", "status": status}}} lines = json.dumps(data, indent=indent).splitlines() return "event: next\n" + "".join(f"data: {line}\n" for line in lines) + "\n" @@ -47,20 +44,6 @@ def _start_stream(handler: BaseHTTPRequestHandler, /) -> None: handler.end_headers() -@pytest.fixture(name="bookshop_server") -def bookshop_server_fixture( - monkeypatch: pytest.MonkeyPatch, -) -> Iterator[Callable[[PostHandler], None]]: - """Point the bookshop's transports at a real server on a local socket, handling requests as told, until the test ends: what a mocked transport cannot show.""" - with ExitStack() as stack: - - def start(post_handler: PostHandler, /) -> None: - url = stack.enter_context(serve(post_handler)) - monkeypatch.setattr(bookshop.transport, "BASE_URL", url) - - yield start - - def test_a_call_goes_over_a_socket_and_its_response_comes_back( bookshop_server: Callable[[PostHandler], None], ) -> None: @@ -73,7 +56,7 @@ def handle(handler: BaseHTTPRequestHandler, /) -> None: handler.wfile.write(json.dumps({"data": {"book": _BOOK}}).encode()) bookshop_server(handle) - data = Client(bookshop.transport.transport)(_GET_BOOK) + data = bookshop.transport.send(_GET_BOOK) assert data["book"] is not None assert data["book"]["title"] == "Persuasion" @@ -83,48 +66,47 @@ def handle(handler: BaseHTTPRequestHandler, /) -> None: assert json.loads(body)["variables"] == {"lookup": {"isbn": "9780141439518"}} -def test_a_dropped_connection_sends_the_same_body_again( +def test_a_dropped_connection_is_retried_with_the_same_body( bookshop_server: Callable[[PostHandler], None], ) -> None: - bodies: list[bytes] = [] + received: list[bytes] = [] def handle(handler: BaseHTTPRequestHandler, /) -> None: - bodies.append(_read_body(handler)) + received.append(_read_body(handler)) - # The order is placed, but its response is lost. - if len(bodies) == 1: - handler.close_connection = True + if len(received) == 1: + handler.connection.shutdown(SHUT_RDWR) return - placed = { - "id": "o1", - "status": "PENDING", - "total": "8.99", - "placedAt": "2026-09-26T14:05:00+02:00", - } handler.send_response(200) handler.end_headers() - handler.wfile.write(json.dumps({"data": {"placeOrder": placed}}).encode()) + handler.wfile.write(json.dumps({"data": {"book": _BOOK}}).encode()) bookshop_server(handle) - client = Client( - bookshop.transport.transport, - injectors=injectors({"idempotencyKey": lambda: UUID(int=len(bodies))}), - ) - address: Address = { - "street": "1 Main St", - "city": "Bath", - "postalCode": "BA1", - "country": "UK", - } - data = client( - PlaceOrder({"input": {"lines": [{"book": "1"}], "shippingAddress": address}}) - ) - - assert data["placeOrder"]["id"] == "o1" - - first, second = bodies - assert first == second + data = bookshop.transport.send(_GET_BOOK) + + assert data["book"]["title"] == "Persuasion" + assert len(received) == 2 + assert received[0] == received[1] + + +def test_a_connection_dropping_every_time_gives_up( + bookshop_server: Callable[[PostHandler], None], +) -> None: + attempts = 0 + + def handle(handler: BaseHTTPRequestHandler, /) -> None: + nonlocal attempts + attempts += 1 + _read_body(handler) + handler.connection.shutdown(SHUT_RDWR) + + bookshop_server(handle) + + with pytest.raises(ConnectionError): + bookshop.transport.send(_GET_BOOK) + + assert attempts == 3 def test_a_timeout_reaches_the_socket( @@ -136,14 +118,14 @@ def handle(_handler: BaseHTTPRequestHandler, /) -> None: bookshop_server(handle) with pytest.raises(TimeoutError): - Client(bookshop.transport.transport)(_GET_BOOK, timeout=0.05) + bookshop.transport.send(_GET_BOOK, timeout=0.05) def test_an_http_error_is_urllib_s_own( bookshop_server: Callable[[PostHandler], None], ) -> None: def handle(handler: BaseHTTPRequestHandler, /) -> None: - # Read first: closing with the request unread resets the connection, which may lose the response. + # Read the body first, or closing resets the connection and may lose the response. _read_body(handler) handler.send_response(503) handler.end_headers() @@ -152,7 +134,7 @@ def handle(handler: BaseHTTPRequestHandler, /) -> None: bookshop_server(handle) with pytest.raises(HTTPError) as error_info: - Client(bookshop.transport.transport)(_GET_BOOK) + bookshop.transport.send(_GET_BOOK) with error_info.value as error: assert (error.code, error.read()) == (503, b"Try later.") @@ -162,7 +144,7 @@ def test_a_request_error_is_read_whatever_its_status( bookshop_server: Callable[[PostHandler], None], ) -> None: def handle(handler: BaseHTTPRequestHandler, /) -> None: - # Read first: closing with the request unread resets the connection, which may lose the response. + # Read the body first, or closing resets the connection and may lose the response. _read_body(handler) handler.send_response(400) handler.send_header("Content-Type", "application/graphql-response+json") @@ -172,14 +154,12 @@ def handle(handler: BaseHTTPRequestHandler, /) -> None: bookshop_server(handle) with pytest.raises(RequestError, match="Syntax Error"): - Client(bookshop.transport.transport)(_GET_BOOK) + bookshop.transport.send(_GET_BOOK) def test_a_subscription_yields_each_event_until_the_server_completes_it( bookshop_server: Callable[[PostHandler], None], ) -> None: - """A comment, and an event whose data spans two lines, as the protocol allows, and nothing read after `complete`.""" - def handle(handler: BaseHTTPRequestHandler, /) -> None: _start_stream(handler) handler.wfile.write( @@ -193,9 +173,7 @@ def handle(handler: BaseHTTPRequestHandler, /) -> None: ) bookshop_server(handle) - events = SubscriptionClient(bookshop.transport.subscription_transport)( - _ON_STATUS_CHANGED - ) + events = bookshop.transport.subscribe(_ON_STATUS_CHANGED) assert [event["orderStatusChanged"]["status"] for event in events] == [ "SHIPPED", @@ -203,6 +181,40 @@ def handle(handler: BaseHTTPRequestHandler, /) -> None: ] +def test_an_event_with_errors_raises_out_of_the_stream_and_closes_the_connection( + bookshop_server: Callable[[PostHandler], None], +) -> None: + disconnected = Event() + + def handle(handler: BaseHTTPRequestHandler, /) -> None: + _start_stream(handler) + handler.wfile.write( + ( + _status_event("SHIPPED") + + 'event: next\ndata: {"data": null, "errors": [{"message": "Boom."}]}\n\n' + ).encode() + ) + + try: + # Bounded, so that a failed close still ends the thread. + for _ in range(500): + handler.wfile.write(_status_event("DELIVERED").encode()) + handler.wfile.flush() + Event().wait(0.01) + except OSError: + disconnected.set() + + bookshop_server(handle) + events = bookshop.transport.subscribe(_ON_STATUS_CHANGED) + + assert next(events)["orderStatusChanged"]["status"] == "SHIPPED" + + with pytest.raises(ExecutionError, match="Boom"): + next(events) + + assert disconnected.wait(5) + + def test_closing_a_subscription_closes_the_connection( bookshop_server: Callable[[PostHandler], None], ) -> None: @@ -212,7 +224,7 @@ def handle(handler: BaseHTTPRequestHandler, /) -> None: _start_stream(handler) try: - # Bounded, so that a failure to unsubscribe ends the thread too. + # Bounded, so that a failed unsubscribe still ends the thread. for _ in range(500): handler.wfile.write(_status_event("SHIPPED").encode()) handler.wfile.flush() @@ -222,11 +234,7 @@ def handle(handler: BaseHTTPRequestHandler, /) -> None: bookshop_server(handle) - with closing( - SubscriptionClient(bookshop.transport.subscription_transport)( - _ON_STATUS_CHANGED - ) - ) as events: + with closing(bookshop.transport.subscribe(_ON_STATUS_CHANGED)) as events: next(events) assert not disconnected.is_set() @@ -259,8 +267,6 @@ def handle(handler: BaseHTTPRequestHandler, /) -> None: bookshop_server(handle) - statuses_seen = watch( - "o1", client=SubscriptionClient(bookshop.transport.subscription_transport) - ) + statuses_seen = watch("o1") assert statuses_seen == statuses diff --git a/uv.lock b/uv.lock index 6d48837..d77f9b8 100644 --- a/uv.lock +++ b/uv.lock @@ -125,7 +125,7 @@ wheels = [ [[package]] name = "graphql-codegen" -version = "0.1.3" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "graphql-core" }, @@ -241,11 +241,11 @@ wheels = [ [[package]] name = "iniconfig" -version = "2.3.0" +version = "2.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, ] [[package]] @@ -293,18 +293,19 @@ wheels = [ [[package]] name = "prek" -version = "0.5.4" +version = "0.5.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/f1/8f3143530bf43fa82f8873f4e536f318b6143dd5c09171aba0d465e21f04/prek-0.5.4.tar.gz", hash = "sha256:7f5bc061880141e362ef422e52188d0090794c0622e442e3157686d1e342780a", size = 565182, upload-time = "2026-09-28T04:54:43.453Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/7b/11538b8e6cd7e269261feae62a2d08818fdcf8f43c3b1a253cec01a5488d/prek-0.5.5.tar.gz", hash = "sha256:16bf596768bb754779c7d42317e9ca16ac609c0cce7d13bae0829944ab40a26a", size = 570428, upload-time = "2026-10-05T06:08:59.165Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/a7/f3a4a252c0846c5846dd86118ee0c1fddb7381dc35e3d44c2a5f3cea1757/prek-0.5.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:550ecaae0601f9405526c866a57cc6882ed1e9d8696ac1117b8703bb37d087eb", size = 6011113, upload-time = "2026-09-28T04:54:28.107Z" }, - { url = "https://files.pythonhosted.org/packages/1b/e3/26638cf090b620e63e60a639b8e7d7eb38b9fbea74a0827a182fc922cf8e/prek-0.5.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a7ef67cc23e23c6e9c6ca0b27a7b12c19f16774d36b1c88ed035f2639995718d", size = 5534338, upload-time = "2026-09-28T04:54:30.214Z" }, - { url = "https://files.pythonhosted.org/packages/a0/08/64b668eb088204650b96183cea739b56e6b179130c4398a8a83cbe9b125a/prek-0.5.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:59f2206850f643bafe46b28b4431525f8dcc5b7a763f271179bff10b8157870d", size = 5840937, upload-time = "2026-09-28T04:54:32.226Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ee/a0f847194eb6a2b67921b4c94c4fbf3f33a785affb190c6a2097344dbdd6/prek-0.5.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d5b0cab1219a7bc9cc90785c7436f563765440616543ede7dfa3ba5c332cede", size = 6234354, upload-time = "2026-09-28T04:54:34.176Z" }, - { url = "https://files.pythonhosted.org/packages/32/9a/14186d90ed29b46650452068d6bd1ac233ca0ea41a3b35081c37f6b77b1c/prek-0.5.4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:8fc22db8132dab9aed4e152bf6a58a7f791f4c91c74305d65d96c48b8ec0bc62", size = 5845649, upload-time = "2026-09-28T04:54:36.252Z" }, - { url = "https://files.pythonhosted.org/packages/e0/88/ff5bbbada4098f59740ab767f5c2f82d4110b90ea7db309a9bcf21f3dcb9/prek-0.5.4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:19d4f3986dcd1dfa15f9417e32a75fb2ba084156dbd7ace161f0764281b8064b", size = 6351516, upload-time = "2026-09-28T04:54:38.539Z" }, - { url = "https://files.pythonhosted.org/packages/c8/1a/0cc410232ef42750b3bf34babd6def4a3943ec98259d9fecda3b26fcd63f/prek-0.5.4-py3-none-win_amd64.whl", hash = "sha256:3100233ba802eff6a4b09bc045c8f50d599bb9d810fa234823bc1642938b224f", size = 5755956, upload-time = "2026-09-28T04:54:40.227Z" }, - { url = "https://files.pythonhosted.org/packages/98/78/3fd53cc54468db37462885d3139eae40bb469d51beae4575c986b190ab6e/prek-0.5.4-py3-none-win_arm64.whl", hash = "sha256:9fc4ad2d647f70a8a3a9900849e1dd89472cf5a71d830f083b62cf87a0406e27", size = 5502478, upload-time = "2026-09-28T04:54:41.973Z" }, + { url = "https://files.pythonhosted.org/packages/2e/77/189e554ea92baf6c1d33733b3e9d1ed819ab7d77e57eb2e3ead0bb1d8060/prek-0.5.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:50fbceb0f728b504023f6e6c9037ac0656436949d94f7a85bac5542e6c1a4a75", size = 6038669, upload-time = "2026-10-05T06:08:39.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/ea/fc857c6b3f1e3fdb4d81bdca2051076de82e986cb1455a01b351ac7a5cd1/prek-0.5.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2f610927b95e84b28173a4c810892a7b5d5bb55bee792ddf97178fa97654633e", size = 5548955, upload-time = "2026-10-05T06:08:41.428Z" }, + { url = "https://files.pythonhosted.org/packages/47/c3/2bab54f6393fb9f247225681d476086e24fa6242f329e9ca1e5bd638ec50/prek-0.5.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:120ef7d4b6bb58155e74827743c266e48501efb0e4c37ff726b14fe6e86a126b", size = 5848228, upload-time = "2026-10-05T06:08:44.051Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f9/1279e1c83819de5904db1a09f4afc589932a2e62c7063713b1fe3a54ee46/prek-0.5.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f6c453a12c21757b23bbbc97415aef78527841ad3f9e61f52cb35cf012f93e5", size = 6371296, upload-time = "2026-10-05T06:08:46.278Z" }, + { url = "https://files.pythonhosted.org/packages/93/fc/e566be5919eaffef4cc22488f16e4bbfd08961734bd67c06f85d405e4cbc/prek-0.5.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73d509c0d9556171de2502bada4962be7a25871e7b23e782549074e1416dc850", size = 6269922, upload-time = "2026-10-05T06:08:48.645Z" }, + { url = "https://files.pythonhosted.org/packages/d2/39/a48d8e6203055bff7bb734b1f2f9aede4a4539bee6ab4c9273dbc01fa546/prek-0.5.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:76c867fdbd4e4f19e6b6b2988bd1e1407d1324170ebcf1428e4e34f8f0751a99", size = 5861518, upload-time = "2026-10-05T06:08:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/b7/03/690fba9543033e25ea2e7b2ae1cca1ae8d8c2e32295f0d66f4b50934b8c7/prek-0.5.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:c69f60c2b3b4d481e38d0eab3b97ac05ec05d7648ae690925ae95ca9d22c7643", size = 6383685, upload-time = "2026-10-05T06:08:52.868Z" }, + { url = "https://files.pythonhosted.org/packages/8c/04/1e9ccf3a61a3bfd4107b83973f7afde9177b6ff8f2d5d4b00125e77c2100/prek-0.5.5-py3-none-win_amd64.whl", hash = "sha256:15bff9039349466ea9bf6b0c173b822ff08f8ae4cfbfdea782492e519c06d874", size = 5784759, upload-time = "2026-10-05T06:08:55.056Z" }, + { url = "https://files.pythonhosted.org/packages/41/d9/bb6811e438d3c74d26cb5d72e6343606e0a7a6283fee87521189f4ba1191/prek-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3db3191d2509830c75438e20956a47c2259867d18a4106064ed75088b968a0c6", size = 5517355, upload-time = "2026-10-05T06:08:57.193Z" }, ] [[package]] @@ -318,21 +319,21 @@ wheels = [ [[package]] name = "pyrefly" -version = "1.3.1" +version = "1.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/7e/082cc1b392318bacfbc087732524f3e089e9e7fe45b7645c4649b6f1f1e9/pyrefly-1.3.1.tar.gz", hash = "sha256:b774f1dfac82867fc16fe2175d80a630e2a72a6835d5b7e492568d6bfb6b05e7", size = 6679145, upload-time = "2026-09-15T05:09:34.49Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/78/b6ec12acb586606618c71ebe1008af59e1ca50e667895f1946fa7b656619/pyrefly-1.3.2.tar.gz", hash = "sha256:30a68ff5429a1546ec08a2d913dfe442c9553a2940d274de18833d8eda939b5e", size = 6679591, upload-time = "2026-09-28T22:10:06.292Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/bc/f09b5bd7eed796349bd93627aa2c3086df7ed0a114dafc3d2b2841599ee7/pyrefly-1.3.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a2a7dfed824fb066aa8a7c8d856c152b98e998fb84b954d5f892fb9834c0bd1", size = 15090117, upload-time = "2026-09-15T05:08:58.477Z" }, - { url = "https://files.pythonhosted.org/packages/78/24/a31e7de8e0f310c9f0753826d0df8b6a8ff86793941a6bd88ba24b735633/pyrefly-1.3.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37a1a1fb41e7190fe3964a877875dd809d6ee2887809e2c3d6e5f25a69805bfd", size = 14430773, upload-time = "2026-09-15T05:09:01.67Z" }, - { url = "https://files.pythonhosted.org/packages/75/e1/022352beb186f36d6213a95a975c42676ee418ff5e730629378d25564c5e/pyrefly-1.3.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45b7650bc651a5a784701f2fb4f579da9a421c4d1b5fd91f9048f4529e46093c", size = 14877762, upload-time = "2026-09-15T05:09:04.767Z" }, - { url = "https://files.pythonhosted.org/packages/19/2c/d62a1b445aad1fd46375b83393b8a67da53883aeeba73d269ce71b3e8fd1/pyrefly-1.3.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad57f38bc73679c14a5f62995a2bd3bc43649b7270c858487e2eab78a6f95362", size = 16118225, upload-time = "2026-09-15T05:09:07.828Z" }, - { url = "https://files.pythonhosted.org/packages/32/67/3c3fa8a13e3ce8d8322ffb3627255a41a52261f17793e1a24e9214f10857/pyrefly-1.3.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3506b1252e9f5d755d0693c5a46d46b85579d36534f6e22a41eb8e098d0dde90", size = 16051197, upload-time = "2026-09-15T05:09:10.81Z" }, - { url = "https://files.pythonhosted.org/packages/19/d1/84f81b9e32288b5d11c75e006f67e0f6b78b85da575673bd1a21524c8172/pyrefly-1.3.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d5a17c1ad655e5f93a6e8e0be90b8db28c6015fe07d6ed4e054891c9e14ef8", size = 15440062, upload-time = "2026-09-15T05:09:14.641Z" }, - { url = "https://files.pythonhosted.org/packages/46/3c/2aa0c5ad6a2957a728bb38cfab70d1e9cafad21b0b1c1d92df74a3cf22bd/pyrefly-1.3.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c61e9dcf888beb81ca0e0c60ea4442640fdcf2f1f65bfe3d9f5d89b85516eb03", size = 14898534, upload-time = "2026-09-15T05:09:17.823Z" }, - { url = "https://files.pythonhosted.org/packages/03/5d/2974f249fb2e285f966485719d977c5a2248cc4866403af94d935775ff15/pyrefly-1.3.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a78eed1c53f37807f0e2997be8edbe833b91eb9915f2cb553a1bdcd599e7105c", size = 15477758, upload-time = "2026-09-15T05:09:20.964Z" }, - { url = "https://files.pythonhosted.org/packages/68/79/d31f8f0abf5b0610ec24183286ee8a1ee5b76c9aaaa91091399d7e1aaf60/pyrefly-1.3.1-py3-none-win32.whl", hash = "sha256:ea0d0349f1bc80fe8e00a7b3640e40db7486398b685f87b9bd5d2207063668f0", size = 14133005, upload-time = "2026-09-15T05:09:25.03Z" }, - { url = "https://files.pythonhosted.org/packages/3f/23/1859f47f536314755d80c4ec2a76d2698435ae8b73604bcd1dfa57ecb39b/pyrefly-1.3.1-py3-none-win_amd64.whl", hash = "sha256:4c24e7d99df9237916c33be23714219a7c8eaf22bad2ac0c3e1d45adcf7df8fb", size = 15066557, upload-time = "2026-09-15T05:09:28.265Z" }, - { url = "https://files.pythonhosted.org/packages/92/2a/73e3721921c2bb2c950a9f8c362ee5e2367556b5d4416eb4f8f88503e66f/pyrefly-1.3.1-py3-none-win_arm64.whl", hash = "sha256:1ed85e2b957a78e93353125d890ce3668ce450e49b74025a15c87b6e8ec7f3d1", size = 14348209, upload-time = "2026-09-15T05:09:31.422Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/144a78839822abad55628881bcb45f8173e6d8b87976897c9105884b5a6b/pyrefly-1.3.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:626dbaee16ae1169a10a4ba3293583b5ab0320102f35efaad9e03457a2fc646c", size = 15088488, upload-time = "2026-09-28T22:09:40.743Z" }, + { url = "https://files.pythonhosted.org/packages/23/aa/599756bd42e6533ee9261ebd288f846ab453b00debaef2b4789893967809/pyrefly-1.3.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2d5fc704dbf0e990dc4f15cb1ead64f1a325ae27383a50c76cfcaf0387f926bb", size = 14429996, upload-time = "2026-09-28T22:09:43.316Z" }, + { url = "https://files.pythonhosted.org/packages/90/0c/4b0678889136b1514ecd25cec11974d5bfdaeaaf9f5fe8a766ca45ec4ab5/pyrefly-1.3.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89dbe8a33e735fa96023c33ce5dfd81af9b0bdd2ee771e8d69b2ebffc7e0fbc7", size = 14871810, upload-time = "2026-09-28T22:09:45.42Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/a96236391c6bb0d8b50af85e0ea6c0c56206f739931ac3031f9e000325ee/pyrefly-1.3.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ec0c31715d48bff279f548b85a93b5bc43d2c7bd1492a8cd703a9185cb089e5", size = 16113462, upload-time = "2026-09-28T22:09:47.619Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/d4d0966aa7008ebd8481a6d05aa53202ad40c9597705eeee87ba6e0e2025/pyrefly-1.3.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b3de468bed691e40dc6271284c8dcc05e649d7ed45280ef3d07332741efe34c", size = 16043341, upload-time = "2026-09-28T22:09:50.083Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d1/52d9ea1b7048c7316d9ff7594c170b1c06d37547a75d140c8deea7b9ddfd/pyrefly-1.3.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb94365fe5f854cb4fe82f600c4e2453160d328610b12981004851ee0b6e8aeb", size = 15437183, upload-time = "2026-09-28T22:09:52.56Z" }, + { url = "https://files.pythonhosted.org/packages/08/ec/24f83e4a397fa9d5c416bb4d378ee1bb18fe48bdd5698fe541636f5352f3/pyrefly-1.3.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4f730d2e515ac25827fc68f343a7aa02b1072e0062df6a430346a4ffdfb5db2f", size = 14892976, upload-time = "2026-09-28T22:09:54.916Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/a473f8e9ec31ba0682ebe44f4e51167a22a4cbdc8bfd87365b9aca23be20/pyrefly-1.3.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:60f440e1626bd5cc4a41ee90cecba80796ed01ebd6c15d9f933efb8d07ffe2b8", size = 15469689, upload-time = "2026-09-28T22:09:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/d0/df/779092365e8e469fc8e7349e1c26c35b8a73df9e6be80a00265fc26a2b2b/pyrefly-1.3.2-py3-none-win32.whl", hash = "sha256:b31684189de44a25a5bf00bdf3ad16be44a7b14654702d6018bce1043357202c", size = 14135550, upload-time = "2026-09-28T22:09:59.757Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/466333e7f2891f8c357b5c226559c39ba8dde4f90be09189ecc771a086f5/pyrefly-1.3.2-py3-none-win_amd64.whl", hash = "sha256:693960cdb247d1f76b9e09b3f2554dae20034f7d442ccd4e9b3e8962a3622363", size = 15067523, upload-time = "2026-09-28T22:10:02.099Z" }, + { url = "https://files.pythonhosted.org/packages/18/32/ee66df92c692c0686d82c9ecef3876ccb0386aa318bf507016f0121a8f78/pyrefly-1.3.2-py3-none-win_arm64.whl", hash = "sha256:f7424b21331670522980ff13e08bd143659879a4d92f4832282c7d7c41a9b5d6", size = 14348271, upload-time = "2026-09-28T22:10:04.253Z" }, ] [[package]] @@ -417,61 +418,61 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.9" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, - { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, - { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, - { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, - { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, - { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, - { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, - { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, - { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, - { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, - { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, - { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, - { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, - { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, - { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, - { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" }, + { url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" }, + { url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" }, + { url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" }, + { url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" }, + { url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" }, + { url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" }, ] [[package]] name = "truststore" -version = "0.10.4" +version = "0.10.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, + { url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" }, ] [[package]] name = "ty" -version = "0.0.84" +version = "0.0.87" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/f6/34f8869c45ea87fe6a931ffa824b1fe4428167173543d92988d631add355/ty-0.0.84.tar.gz", hash = "sha256:0cefdb0cd5d399418dbe83c349ba605047b7dff815ae6f460c80e8522b40be67", size = 7409537, upload-time = "2026-09-24T13:16:24.367Z" } +sdist = { url = "https://files.pythonhosted.org/packages/17/15/8a1e8008a4580c3c7bbf2a2e1a6e0001abbd265728426ffe7fc3eab29e98/ty-0.0.87.tar.gz", hash = "sha256:0f844e1fe0725a69753d22b46cb3dce653f3331ae8e332e46b0f22349a244093", size = 7941034, upload-time = "2026-10-13T23:07:08.993Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/0c/600648778e1c79133ce6b6a5c4c2531ae16b90c9b30f5041b38b574f61b8/ty-0.0.84-py3-none-linux_armv6l.whl", hash = "sha256:868438e2b9abfc835dd5b5b07ed693db95d8348ac40e3228252511d63ae87695", size = 13935333, upload-time = "2026-09-24T13:15:31.167Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3b/27f06f49945c36170765fad82076898039ae3873338e9865f876dd62f12c/ty-0.0.84-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b00f39b521f0b559cde0f6abf8dd47213b513b04f33d58efeb9b98486a16bb17", size = 13511328, upload-time = "2026-09-24T13:15:34.446Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b6/b12dd130e7f30141f0645614f454c925991cd389b1cc6c8284fd6bf32326/ty-0.0.84-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f78c2915567f2ee62ce0c24d86091bc76f81577677755e242d09951db588b9a", size = 13445445, upload-time = "2026-09-24T13:15:37.476Z" }, - { url = "https://files.pythonhosted.org/packages/44/96/f012de4b3c1d9a4773326621380d768b4d48c47e620dc012775c5e01c7d7/ty-0.0.84-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61155cad0921e890d86d1d911a40b3a0e924ef4bdc3b4f3865a6c89c7e3012dc", size = 13471235, upload-time = "2026-09-24T13:15:40.64Z" }, - { url = "https://files.pythonhosted.org/packages/05/32/dca630f5f5353fa7bf67eebccf1549df99b16656d21071d0df6677c826a5/ty-0.0.84-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:417c3bf14947ed16b0b92976ab333c27915590354e96d4f581709068d3811355", size = 13680704, upload-time = "2026-09-24T13:15:43.817Z" }, - { url = "https://files.pythonhosted.org/packages/44/8d/33702c8f62f05ad45b33644fb914d483de6a0a7bc8e51884e9b8773e6d69/ty-0.0.84-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e06ef117759f654f5379efe9ae79bda8a186016c016f1023d05c34f8e15c1546", size = 14518923, upload-time = "2026-09-24T13:15:46.802Z" }, - { url = "https://files.pythonhosted.org/packages/c8/a1/da3246e6ef2ae0e842bca4058b25c54bee55ab058ffff4ee54e72517ec01/ty-0.0.84-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a05d1d53db8b64410ab38c6e32c5898510584869d1d5dd9c0605db81c9aef7df", size = 15027731, upload-time = "2026-09-24T13:15:49.755Z" }, - { url = "https://files.pythonhosted.org/packages/20/17/b0099098a560aeb4f9fde6c912f9a81622c3e44e6555e1fbe322b368ec31/ty-0.0.84-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b92400494ab855cfb1204ac1101e3f53b2d09a8e21d171758b2cf9acdab1809e", size = 14748260, upload-time = "2026-09-24T13:15:52.713Z" }, - { url = "https://files.pythonhosted.org/packages/60/d5/32cd67aedb271f006e716ad093806c96c5d7e2af86be8255469dbd3d76c8/ty-0.0.84-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af17eb391ae3027fed9be1780bea555c2b8a25ba68e184c627b4981b2c9eaab7", size = 14205858, upload-time = "2026-09-24T13:15:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/43/c4/638ac62ca2e8eca1b92e3e16273085744fada728b993500819b5c258ca15/ty-0.0.84-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:44cf06af0e7ec78ea6d8ff33a3450fc19627756a4fba681f7410d4e3ef63047a", size = 14573196, upload-time = "2026-09-24T13:15:59.165Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f6/9ee81c5d8921e5fd5f10889ae563c95cd3272b31df2ada528919ca1cef6b/ty-0.0.84-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c6e56b443ff9ba462e34bfbc1e582004c2719ed35f8f93a433744b0ba0afb2d9", size = 13480760, upload-time = "2026-09-24T13:16:02.313Z" }, - { url = "https://files.pythonhosted.org/packages/f6/83/fbe5d1177667ae16d84c3242846b143809c9cc09568b333e5a9b4d5908f6/ty-0.0.84-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b6562fcc48820c1030e2a4509ef2c14ef93043ca4860280074314ac3a825b95", size = 13696346, upload-time = "2026-09-24T13:16:05.391Z" }, - { url = "https://files.pythonhosted.org/packages/86/9a/1b4ac9ddc34cc0ab4624bc219825d66af52f1ad87f4778ad08ad03fa06d5/ty-0.0.84-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dee7421451efcbd70f03e95a8f2c8d9e5ec3d35edcd27b4095ee00ee062abfc4", size = 13959410, upload-time = "2026-09-24T13:16:08.661Z" }, - { url = "https://files.pythonhosted.org/packages/c9/04/70e289437a67dfd686056ab4fd91880261a8233033a6a95d29393741fb97/ty-0.0.84-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:31e96307cf621babe969307dd657783d672e2d8bdbd06493710da635bf63fa07", size = 14350294, upload-time = "2026-09-24T13:16:11.865Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f4/ae7dbd118425c850981d23efb850f0d9d7f6b8d2b852bde2767434f668b4/ty-0.0.84-py3-none-win32.whl", hash = "sha256:4bdc85f91cbaae35825f2b17bb4ac5de0563367875cb0f42b2bf07a1bf7ff2ba", size = 13176439, upload-time = "2026-09-24T13:16:15.049Z" }, - { url = "https://files.pythonhosted.org/packages/29/ec/994d87252f065dda5a500bc8afc726d18f6582d81d6f4ff1feb8a267c800/ty-0.0.84-py3-none-win_amd64.whl", hash = "sha256:71cc9aa7a7c89d4e12c6a814e9c0c3310ad5bee44efedbd6f5a42d91e9ac0f0d", size = 13962879, upload-time = "2026-09-24T13:16:18.191Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1c/e6e91852b72566b1ab9aca504712857dfb89324c12092a5bd61d42d34427/ty-0.0.84-py3-none-win_arm64.whl", hash = "sha256:6c9d3718c4a0d318bc9c92af9b2aa85c3294e7dff54cf1d69ad79568f56516da", size = 13656453, upload-time = "2026-09-24T13:16:21.51Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/6462292e264fc1fcd61bf3179d197e4e7cc21fe95f25660e6b59b6494af3/ty-0.0.87-py3-none-linux_armv6l.whl", hash = "sha256:1b78781402464a152da80b37d4af503912c0f8b882134cdfcdc2dbbd3f914024", size = 14134045, upload-time = "2026-10-13T23:06:18.791Z" }, + { url = "https://files.pythonhosted.org/packages/67/12/6c93cf5f2ce2a00176e8d580d06fc6deb79c13def13c64d833b25860c34f/ty-0.0.87-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7e4a164821ca74733bcaf1a5cca05e0fb7b2bd7b846385b3aa2a042a60d0a0bc", size = 13705845, upload-time = "2026-10-13T23:06:22.031Z" }, + { url = "https://files.pythonhosted.org/packages/f1/03/7cc79acc2a01b3e7146ae8c3df2445c7fe4930e54c3d8b9c052bfa27f389/ty-0.0.87-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d38ebf4da3fb234676236b38e03d24014aa3522af8533c6f0f5eccd4a9de147e", size = 12795984, upload-time = "2026-10-13T23:06:24.969Z" }, + { url = "https://files.pythonhosted.org/packages/8a/be/db90201ef1ce9a79e5cefbf7d8faa80eadc2b4f05a7a32ac126b1d33c815/ty-0.0.87-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6ed0a498db9fa8ffb421014698fbca6fa32699d9544156aeeda763b8d8984d0", size = 13659572, upload-time = "2026-10-13T23:06:27.974Z" }, + { url = "https://files.pythonhosted.org/packages/e7/6e/8e59624cbb81358fd6560fe7a2d0994b6531052dd9729e972b8a362325ca/ty-0.0.87-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9a3538293a2aef66fb618cf70ef60f5dca23479dc008ad4ff4b861cff043e78", size = 13860982, upload-time = "2026-10-13T23:06:31.115Z" }, + { url = "https://files.pythonhosted.org/packages/61/ba/6f444935dc40ba16805aedefcd0ee942a682de1fb85c794620233320cbab/ty-0.0.87-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:73d540684ec438b5087cbb218898b6030395055698e9132fccc52267e5820522", size = 14834270, upload-time = "2026-10-13T23:06:34.406Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ef/6339f9af05479215a5f351c287e86b2acacd941c918dbe3f702dced32c97/ty-0.0.87-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:61c3fded55b32db1a6944f12079c859c7d03da17cd666858ae8acca5a6c15af7", size = 15218871, upload-time = "2026-10-13T23:06:37.224Z" }, + { url = "https://files.pythonhosted.org/packages/9c/98/fa7b9a8dce2248436b13728ad6462f47293c01141ec4afc0184073e6d93f/ty-0.0.87-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75bf154f9f4ea24c3fa2b8d610aee4bfb2ee7ba8f78d9cd763a896a829a87a2a", size = 15134752, upload-time = "2026-10-13T23:06:40.231Z" }, + { url = "https://files.pythonhosted.org/packages/3a/62/dc4d7135c5ea1bf6f5429509113e1c8092ff4e55399f527c508edaba20bb/ty-0.0.87-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8caa3316ea81e794fcd98d1e4d3aaa5fae84790154d88c3e85ac3bd01c537bd", size = 14510734, upload-time = "2026-10-13T23:06:43.029Z" }, + { url = "https://files.pythonhosted.org/packages/35/76/2fd26f136ae967095f222ce75ad58d2c8472da355fc4a6d24662d8c98123/ty-0.0.87-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:930b045a2965f3332d5dbad8ed47076aaeb5c6ead6addf3d4d4e10bc37929901", size = 14681985, upload-time = "2026-10-13T23:06:45.867Z" }, + { url = "https://files.pythonhosted.org/packages/fe/b9/38ea15b4c4c5167f9c93ab5062fe438acff9e7851af615c7580722da2aaa/ty-0.0.87-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:547226807687a240bb2e3c52ef37cdf25b2b33f200dcc659bfa3aac61028b259", size = 13592754, upload-time = "2026-10-13T23:06:48.469Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ce/ca367cee9e4ea5cee7b0b60c37f1d091789e4c784192003ba7695dc18f53/ty-0.0.87-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2791e65f320cd8af1a7b46eed458b09710efb455add9138d4a973352b171ca30", size = 13881344, upload-time = "2026-10-13T23:06:51.55Z" }, + { url = "https://files.pythonhosted.org/packages/02/3b/042a2dd75d83f7bd94afe2c1fd25dbbbbe01ec56c27a4108804068f8e0d2/ty-0.0.87-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4a1eb91eb6de896a3f93847e171d1cf63e3490518bfa3cc69b0e635e9d16d7d0", size = 14259114, upload-time = "2026-10-13T23:06:54.476Z" }, + { url = "https://files.pythonhosted.org/packages/21/99/500885b4ee1f825bde8b3c45ef42dff869d4f30328f34bac90c75065953e/ty-0.0.87-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ed384e2d5e72b14664344e676d091f7ec693ad580d69cd204e01732542e4bf7", size = 14573555, upload-time = "2026-10-13T23:06:57.159Z" }, + { url = "https://files.pythonhosted.org/packages/79/39/09f9274119899ed4e15630a544735c31b32f6bbaeea4c40a788cecf8df3d/ty-0.0.87-py3-none-win32.whl", hash = "sha256:12c85c47d62d3b03c3ef00e1e74a31788aac5de3102d86b687d928d889cfbe83", size = 13137336, upload-time = "2026-10-13T23:06:59.816Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/ecfc716c98bb1eeac9d8743221403c5e7f2f0ed69858af881a0e5b775739/ty-0.0.87-py3-none-win_amd64.whl", hash = "sha256:767219b8fedd2f48875c1ea3557f39734642073a2e039c6ab67ce5a50b65ddf0", size = 13660330, upload-time = "2026-10-13T23:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/94/89/aa0eb234360cb6c8efc6ac45694000dfe3c3e590a30e87f2f9631190d339/ty-0.0.87-py3-none-win_arm64.whl", hash = "sha256:db4aa3a464c43d155ac8a3387a76173183ce4c141a8de1b6cb956e6a5a5e7d52", size = 13217514, upload-time = "2026-10-13T23:07:06.177Z" }, ] [[package]]