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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
320 changes: 161 additions & 159 deletions README.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions bookshop/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
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))
40 changes: 24 additions & 16 deletions bookshop/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from datetime import datetime
from decimal import Decimal
from typing import assert_never, assert_type
from uuid import uuid4

from bookshop.app_graphql import (
CancelOrder,
Expand All @@ -19,12 +18,11 @@
Search,
SearchData,
)
from bookshop.client.injection import injectors
from bookshop.client.runtime import ExecutionError, UnexpectedNullError
from bookshop.client.schema import Address, OrderStatus
from bookshop.client.schema import Address, BookFilter, OrderStatus
from bookshop.get_order_graphql import GetOrder, GetOrderData
from bookshop.scalar import ISBN
from bookshop.transport import Client, SubscriptionClient, transport
from bookshop.transport import Client, SubscriptionClient


def describe(isbn: ISBN, /, *, client: Client) -> str:
Expand All @@ -37,9 +35,13 @@ def describe(isbn: ISBN, /, *, client: Client) -> str:
assert error.__notes__ == [f"Raised by `GetBook` with variables {variables!r}."]
return "No such book."

# No `None` check: `@nonNull` took `| None` out of the type.
book = data["book"]

# `@nonNull` only covers `book`, so `author` may still be `None`.
author = book["author"]
by = "an anthology" if author is None else f"by {author['name']}"

assert_type(book["price"], Decimal)
return f"{book['title']}, {by}, costs {book['price']:.2f}."

Expand Down Expand Up @@ -67,8 +69,8 @@ def book_and_similar(
def look_up(
isbns: Sequence[ISBN], publication_ids: Sequence[str], /, *, client: Client
) -> tuple[GetBookData | GetPublicationData, ...]:
"""Fetch the books, then the publications, in one call to the transport."""
return client( # ty: ignore[unsound-return-statement]
# 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),
Expand All @@ -83,8 +85,10 @@ def run_saved_search(name: str, /, *, client: Client) -> list[str]:
if search is None:
return []

book_filter = search["value"]
assert_type(book_filter, BookFilter | None)
# Sent back as is.
books = client(ListBooks({"filter": search["value"]}))
books = client(ListBooks({"filter": book_filter}))
return [book["title"] for book in books["books"]]


Expand Down Expand Up @@ -117,7 +121,19 @@ def status_label(status: OrderStatus, /) -> str:
return "Canceled"
case _:
# A member added after this client was generated.
return "Unknown"
return status.replace("_", " ").capitalize()


def cheaper_than(limit: Decimal, /, *, client: Client) -> list[str]:
data = client(ListBooks({"filter": {"priceBelow": limit}}))
labels: list[str] = []

for book in data["books"]:
price = book["price"]
assert_type(price, Decimal)
labels.append(f"{book['title']}: {price:.2f}")

return labels


def order(book_id: str, address: Address, /, *, client: Client) -> str:
Expand Down Expand Up @@ -178,11 +194,3 @@ def watch(order_id: str, /, *, client: SubscriptionClient) -> list[OrderStatus]:
break

return statuses


if __name__ == "__main__":
client = Client(
transport,
injectors=injectors({"idempotencyKey": uuid4}),
)
print(describe(ISBN("9780141439518"), client=client))
3 changes: 1 addition & 2 deletions bookshop/client/schema/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class _BookFilter_priceBelow(_compat.TypedDict, closed=True):
_BookFilter_not = _compat.TypedDict('_BookFilter_not', {'not': _typing.Required[_BookFilter_Ref]}, closed=True)

type BookFilter = _BookFilter_genre | _BookFilter_author | _BookFilter_priceBelow | _BookFilter_and | _BookFilter_or | _BookFilter_not
"""The books meeting a condition, or a combination of conditions."""
"""A recursive filter on books."""

class PlaceOrderInput(_compat.TypedDict, closed=True):
lines: _typing.Required[_abc.Sequence[OrderLineInput]]
Expand All @@ -56,4 +56,3 @@ class Address(_compat.TypedDict, closed=True):

class CancelOrderInput(_compat.TypedDict, closed=True):
order: _typing.Required[_builtins.str]
reason: _typing.NotRequired[_builtins.str | None]
2 changes: 1 addition & 1 deletion bookshop/quickstart.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
data = client(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 test.
if order is None:
print("No such order.")
else:
Expand Down
5 changes: 1 addition & 4 deletions bookshop/schema.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ type Magazine implements Publication & Printed {
title: String!
price: Money!
pageCount: Int!
issue: Int!
}

type Audiobook implements Publication {
Expand All @@ -96,7 +95,6 @@ type Audiobook implements Publication {
price: Money!
"Its length, in minutes."
duration: Int!
narrator: String
}

type Author {
Expand Down Expand Up @@ -169,7 +167,7 @@ input BookLookup @oneOf {
isbn: ISBN
}

"The books meeting a condition, or a combination of conditions."
"A recursive filter on books."
input BookFilter @oneOf {
genre: Genre
author: ID
Expand Down Expand Up @@ -205,7 +203,6 @@ input CancelOrderInput {
"Makes canceling the same order twice harmless."
idempotencyKey: UUID
order: ID!
reason: String
}

input RestockInput {
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ license-files = ["LICENSE"]
name = "graphql-codegen"
readme = "README.md"
requires-python = ">=3.12"
version = "0.1.2"
version = "0.1.3"

[project.optional-dependencies]
# Only reading a YAML graphql-config file needs it.
Expand Down
4 changes: 2 additions & 2 deletions src/graphql_codegen/_generator/_structs.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ def get_struct_input_name(
) -> 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 `value` key rather than replacing the struct, although a struct only ever has that one key.
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 `value`, all for little convenience.
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.
"""
Expand Down
23 changes: 22 additions & 1 deletion tests/test_bookshop.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import runpy
from asyncio import run as run_async
from collections.abc import Callable, Mapping
from decimal import Decimal
from pathlib import Path
from typing import Final, cast
from uuid import UUID
Expand All @@ -14,6 +15,7 @@
from bookshop.app import (
book_and_similar,
cancel,
cheaper_than,
describe,
length,
look_up,
Expand Down Expand Up @@ -263,13 +265,32 @@ def test_telling_a_publication_s_length(publication: object, description: str) -
("status", "label"),
[
pytest.param("SHIPPED", "On its way", id="a member the client knows"),
pytest.param("RETURNED", "Unknown", id="a member added after generation"),
pytest.param(
"OUT_FOR_DELIVERY",
"Out for delivery",
id="a member added after generation",
),
],
)
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:
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"
]
assert [request["variables"] for request in requests] == [
{"filter": {"priceBelow": "10.00"}}
]


def test_an_order_gets_its_idempotency_key_injected() -> None:
requests: list[Mapping[str, object]] = []

Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.