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
16 changes: 16 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"recommendations": [
"astral-sh.ty",
"charliermarsh.ruff",
"graphql.vscode-graphql-syntax"
]
}
25 changes: 24 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -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
}
}
417 changes: 223 additions & 194 deletions README.md

Large diffs are not rendered by default.

10 changes: 1 addition & 9 deletions bookshop/__main__.py
Original file line number Diff line number Diff line change
@@ -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")))
104 changes: 51 additions & 53 deletions bookshop/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}."]
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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"]:
Expand All @@ -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}}
)
Expand All @@ -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()
Expand All @@ -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"])
Expand Down
Loading