From ffef65bd58fcaafc5558485a893074d1a4437c33 Mon Sep 17 00:00:00 2001 From: Thibault Derousseaux <6574550+tibdex@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:30:08 -0400 Subject: [PATCH] Release 1.0 --- README.md | 114 ++++---- bookshop/app_graphql.py | 40 ++- bookshop/get_order_graphql.py | 4 +- bookshop/graphql.config.yml | 2 +- bookshop/graphql/_scalar.py | 19 -- bookshop/graphql/injection.py | 4 +- bookshop/graphql/runtime/_reflection.py | 4 +- bookshop/graphql/runtime/error.py | 14 +- bookshop/graphql/runtime/operation.py | 5 +- .../graphql/{schema/input.py => schema.py} | 45 ++- bookshop/graphql/schema/__init__.py | 4 - bookshop/graphql/schema/enum.py | 7 - pyproject.toml | 4 +- src/graphql_codegen/_cli/_graphql_config.py | 5 +- src/graphql_codegen/_generator/_annotation.py | 39 +-- src/graphql_codegen/_generator/_ast_nodes.py | 22 +- src/graphql_codegen/_generator/_data_type.py | 14 +- src/graphql_codegen/_generator/_imports.py | 31 +-- src/graphql_codegen/_generator/_injector.py | 2 +- src/graphql_codegen/_generator/_naming.py | 26 +- src/graphql_codegen/_generator/_operation.py | 2 +- src/graphql_codegen/_generator/_scalar.py | 10 +- .../_generator/_schema_type.py | 256 +++++++++--------- src/graphql_codegen/_generator/package.py | 154 ++--------- src/graphql_codegen/config.py | 6 +- src/graphql_codegen/runtime/__init__.py | 2 +- src/graphql_codegen/runtime/_compat.py | 2 +- .../runtime/_execution_error_handling.py | 2 +- .../runtime/_operation_type.py | 2 +- src/graphql_codegen/runtime/_reflection.py | 6 +- src/graphql_codegen/runtime/_sigil.py | 2 +- src/graphql_codegen/runtime/error.py | 16 +- src/graphql_codegen/runtime/injection.py | 2 +- src/graphql_codegen/runtime/merge.py | 2 +- src/graphql_codegen/runtime/operation.py | 7 +- src/graphql_codegen/runtime/request.py | 2 +- tests/__resources__/import_every_module.py | 11 +- tests/_bookshop.py | 1 + tests/test_cli.py | 4 +- tests/test_emit.py | 69 +++-- tests/test_generate.py | 49 +--- tests/test_naming.py | 11 +- uv.lock | 144 +++++----- 43 files changed, 478 insertions(+), 689 deletions(-) delete mode 100644 bookshop/graphql/_scalar.py rename bookshop/graphql/{schema/input.py => schema.py} (60%) delete mode 100644 bookshop/graphql/schema/__init__.py delete mode 100644 bookshop/graphql/schema/enum.py diff --git a/README.md b/README.md index 277a94b..9df47dc 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ type Order { } ``` -Write your operations in `.graphql` files: +Write your operations in GraphQL document files: @@ -74,7 +74,7 @@ The generator reads [graphql-config](https://the-guild.dev/graphql/config), the # An SDL file here, but globs, introspection results, and server URLs work too. schema: schema.graphqls documents: "*.graphql" -# This library's config, under its extension name. +# graphql-codegen's config, under its extension name. extensions: pythonCodegen: # Where imports start, relative to this file's directory. @@ -92,7 +92,7 @@ graphql-codegen bookshop/graphql.config.yml 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; +- holds only the `enum` and `input` types your operations reach, so 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. @@ -140,11 +140,11 @@ 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. -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. +Client-side validation is redundant[^breaking-changes]. +On the other hand, you still want to know that `order["status"]` exists and holds an `OrderStatus`, and to learn when an edited operation breaks that. +A type checker is the best way to get that safety. -This library therefore generates exact types for your type checker and leaves each response as decoded from JSON. +The generated package therefore types exactly the variables your operations take and the fields they select, and leaves each response as decoded from JSON. Only [custom scalars](#custom-scalars) with a codec are converted, and only fields asserted [non-null](#non-null-fields) are checked. > [!NOTE] @@ -155,17 +155,17 @@ Only [custom scalars](#custom-scalars) with a codec are converted, and only fiel ### Operation types Each operation gets a type for its variables and one for its data, both keyed by the names the GraphQL document uses. -Wherever GraphQL lets a value be one of several things, its Python type is a union: +Wherever GraphQL lets a value be one of several things, it becomes a Python union `type`: - a selection on a [`union`](https://spec.graphql.org/September2025/#sec-Unions) or an [`interface`](https://spec.graphql.org/September2025/#sec-Interfaces) is one of several types, and becomes one type per concrete type, told apart by [`__typename`](https://spec.graphql.org/September2025/#sec-Type-Name-Introspection) (which the generator selects for you); - 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. +- 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 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-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 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. +A validating client would reject the new value before your code sees it. +Here, your code gets it as sent and decides what to do with it. One way is to accept the unknown value in a `case _:` arm: @@ -192,7 +192,7 @@ The other way is to reject it with `case _ as never: assert_never(never)`, which - raises an `AssertionError` at runtime; - 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: +[`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: @@ -247,7 +247,10 @@ Only the names the generator adds, such as `_builtins` or `_GetBookData_book`, a ### 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/graphql/runtime/__init__.py) is limited to: +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. + +The small [sans-IO](https://sans-io.readthedocs.io) runtime copied into the generated package therefore has a [public API](https://github.com/tibdex/graphql-codegen/blob/main/bookshop/graphql/runtime/__init__.py) limited to: @@ -268,20 +271,15 @@ from .operation import Operation as Operation, OperationRequest as OperationRequ from .request import PreparedRequest as PreparedRequest, Request as Request ``` -The generated package does no IO: +In particular: -- `Request.prepare()` returns a `PreparedRequest`; -- `PreparedRequest.__bytes__()` returns the request body to send; -- `PreparedRequest.parse()` returns the response body as typed data. - -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. +1. `Request.prepare()` returns a `PreparedRequest`; +2. `PreparedRequest.__bytes__()` returns the request body to send; +3. `PreparedRequest.parse()` returns the response body as typed data. -Writing a transport is short, and its few lines do not grow as you add operations. +You compose these methods with your own transport, built on any HTTP client (synchronous, asynchronous, or callback-based) or anything else that carries `bytes`. +The transport takes only a few lines, and they 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): @@ -374,12 +372,9 @@ async def watch(order_id: str, /) -> list[OrderStatus]: ### Merging -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. +Sometimes you only know at runtime which operations go together, and writing every combination as its own operation in a document file is impractical. 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. - -`merge()` takes several queries, or several mutations: +Here, you can use `merge()` to combine several queries, or several mutations, written ahead of time into one request, while each result keeps its exact type: @@ -393,8 +388,7 @@ def book_and_similar(isbn: ISBN, text: str, /) -> tuple[str, list[str]]: assert_type(search_data, SearchData) ``` -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: +A list built at runtime can be merged too, and its results are typed as the union of its operations' data types: @@ -413,9 +407,14 @@ def look_up( ) ``` +As a bonus, the server may answer faster, since it sees the operations together rather than as separate requests for overlapping data. + ### Errors -A response with errors raises a `RequestError` when the request failed before execution, and an `ExecutionError` when it carries partial data: +On a response with errors, `PreparedRequest.parse()` raises: + +- a `RequestError` in place of data, when the request failed before execution; +- an `ExecutionError` beside partial data, when some fields failed. @@ -434,10 +433,11 @@ class ExecutionError(ResponseError, Generic[_Data_co]): When several operations are merged, an `ExceptionGroup` holds one for each operation that fails. -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: +In an `except ExecutionError as error:` block, `error.parse_data()` returns a `Mapping[str, object] | None`, since Python types what a call returns, but not what it raises. +You can instead call `returning_error()` to get a request for which `PreparedRequest.parse()` returns its `ExecutionError` rather than raising it, so the partial data keeps the operation's type: - 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; +- when [merging](#merging), you choose for each request whether its error is returned or raised; - a subscription carries on past an event with errors. @@ -481,7 +481,7 @@ def cancel(order_ids: list[str], /) -> list[str]: ### Custom scalars [Custom scalars](https://spec.graphql.org/September2025/#sec-Scalars.Custom-Scalars) travel as JSON values in a format decided by the server, such as a date as an ISO 8601 string. -This library lets you give each one a Python type, with a codec converting its values when they differ from their JSON form: +You can give each one a Python type, with a codec converting its values when they differ from their JSON form: @@ -534,7 +534,7 @@ def encode_datetime(value: datetime, /) -> str: > [!TIP] > 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")`. +> For example, with Pydantic, build `adapter = TypeAdapter(YourType)` once, then use `decode = adapter.validate_python` and `encode = partial(adapter.dump_python, mode="json")`. `Request.prepare()` then encodes each scalar's values, and `PreparedRequest.parse()` decodes them, so your code only ever handles their Python types: @@ -556,8 +556,8 @@ def cheaper_than(limit: Decimal, /) -> list[str]: ### Non-null fields Schemas often make fields nullable, such as a lookup that may find nothing. -Yet you may know more than the schema, such as that a lookup will succeed, or want your code to fail fast on a `null` without writing `if value is None: raise …` at every use. -This library brings the idea of [Client Controlled Nullability](https://github.com/graphql/graphql-wg/blob/main/rfcs/ClientControlledNullability.md) to any server through a client directive: +However, you may know more than the schema, such as that a lookup will succeed, or want your code to fail fast on a `null` without writing `if value is None: raise …` at every use. +You can declare that through a client directive (adapted from the [Client Controlled Nullability](https://github.com/graphql/graphql-wg/blob/main/rfcs/ClientControlledNullability.md) RFC) without any support from the server: @@ -620,14 +620,17 @@ def describe(isbn: ISBN, /) -> str: ### Structs -A selection set has a fixed depth, so data of unbounded depth, such as a tree, can only come back as a JSON scalar. +A selection set has a fixed depth, so data of unbounded depth, such as a tree, can only come back as an untyped custom scalar. The [Struct RFC](https://github.com/graphql/graphql-wg/blob/main/rfcs/Struct.md) proposes a new `struct` keyword: selected without a selection set, a field of a struct type returns its value whole, like a scalar. -This library brings that idea to today's servers with a convention, without waiting for the new keyword: +Until the RFC is accepted and widely adopted, you can get the same result with servers following this convention: + +> - a designated `interface` has a single field of any name, typed with any custom scalar; +> - each `type` that `implements` this interface is named after an `input` followed by the interface's name; +> - the field's payload in each such `type` has the shape of that `input` (even a recursive one). -> - each `type` that `implements` a designated `interface` carries that scalar in the interface's single field; -> - that field's payload is typed by the `input` the `type` is named after, even a recursive one. +The generated package then types the payload with that `input`, and never mentions the custom scalar. -For instance: +The bookshop names that interface `Struct`: @@ -713,20 +716,22 @@ def run_saved_search(name: str, /) -> list[str]: ### Injectors -Some concerns are better centralized than spread over every call. +Some values are better supplied in one place than at every call. -For instance, an idempotency key makes retrying a mutation safe: +Idempotency is a good example. +An idempotency key makes retrying a mutation safe: 1. the connection drops after the server placed an order; 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 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. +However, GraphQL has no built-in idempotency, so the key ends up as an argument or an input field of each mutation needing one, and getting hold of a key becomes every caller's concern. + +A database transaction ID is another example: when several requests share one transaction on the server, each needs that ID, but the functions sending them should not have to pass it around. -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: +You can instead supply such a value in one place, with an injector given to `Request.prepare()`. +The injector's value goes to every variable or input field with the name given in the config. +No variables' type accepts it, so no caller can pass one by mistake: @@ -737,7 +742,7 @@ extensions: injectorNames: [idempotencyKey] ``` -`PlaceOrderInput` holds one, for instance: +`PlaceOrderInput` holds one: @@ -806,9 +811,9 @@ ATTEMPTS: Final = 3 ### Colocation Most other codegen libraries gather every operation in one generated module, often inside a single class. -A feature's operations then live away from its `.graphql` files and the code calling them. +A feature's generated operations then live away from its documents and the code calling them. A project split into several packages cannot have each package own its operations either. -This library generates each operation as a module-level constant, so it can be colocated with both the document it comes from and the code calling it: +Since this library turns each document into its own Python module, you can opt into generating that module next to its document: @@ -831,7 +836,6 @@ from bookshop.graphql.schema import OrderStatus > [!NOTE] > When the config does not set `documentSiblingModule`, each document's operations and fragments go into a module of the generated package's `document` subpackage instead, such as `document/get_order.py` for `get_order.graphql`. -> The subpackage re-exports them all, [lazily](https://docs.python.org/3.15/reference/simple_stmts.html#compatibility-via-lazy-modules) from Python 3.15, so that a module is only imported when one of its operations or fragments is used. ## Python API @@ -865,7 +869,7 @@ def generate(**args: Unpack[_Params]) -> dict[PurePosixPath, bytes]: ``` [^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. +The generator 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 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). diff --git a/bookshop/app_graphql.py b/bookshop/app_graphql.py index 8af9cec..fb2c31a 100644 --- a/bookshop/app_graphql.py +++ b/bookshop/app_graphql.py @@ -6,9 +6,7 @@ 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 +from bookshop.graphql import schema as _schema class _BookCard_author(_compat.TypedDict, closed=True): name: _builtins.str @@ -17,12 +15,12 @@ class BookCard(_compat.TypedDict): """What a book shows in a list.""" id: _builtins.str title: _builtins.str - price: _scalar.Money + price: _schema.Money author: _BookCard_author | None """Null for an anthology, which has no single author.""" class GetBookVariables(_compat.TypedDict, closed=True): - lookup: _input.BookLookup + lookup: _schema.BookLookup """An identifier or an ISBN.""" withReviews: _typing.NotRequired[_builtins.bool] @@ -30,10 +28,10 @@ class _GetBookData_book_reviews(_compat.TypedDict, closed=True): rating: _builtins.int """From 1 to 5.""" text: _builtins.str | None - postedAt: _scalar.DateTime + postedAt: _schema.DateTime class _GetBookData_book(BookCard, closed=True): - isbn: _scalar.ISBN + isbn: _schema.ISBN pageCount: _builtins.int reviews: _typing.NotRequired[_builtins.list[_GetBookData_book_reviews]] @@ -71,19 +69,19 @@ class SearchVariables(_compat.TypedDict, closed=True): class _SearchData_search_Book(_SearchData_search_Book_Base, closed=True): title: _builtins.str - price: _scalar.Money + price: _schema.Money _SearchData_search_Magazine_Base = _compat.TypedDict('_SearchData_search_Magazine_Base', {'__typename': _compat.ReadOnly[_typing.Literal['Magazine']]}) class _SearchData_search_Magazine(_SearchData_search_Magazine_Base, closed=True): title: _builtins.str - price: _scalar.Money + price: _schema.Money _SearchData_search_Audiobook_Base = _compat.TypedDict('_SearchData_search_Audiobook_Base', {'__typename': _compat.ReadOnly[_typing.Literal['Audiobook']]}) class _SearchData_search_Audiobook(_SearchData_search_Audiobook_Base, closed=True): title: _builtins.str - price: _scalar.Money + price: _schema.Money _SearchData_search_Author_Base = _compat.TypedDict('_SearchData_search_Author_Base', {'__typename': _compat.ReadOnly[_typing.Literal['Author']]}) @@ -110,11 +108,11 @@ class SearchData(_compat.TypedDict, closed=True): }""", variables_type=SearchVariables, data_type=SearchData) class ListBooksVariables(_compat.TypedDict, closed=True): - filter: _typing.NotRequired[_input.BookFilter | None] + filter: _typing.NotRequired[_schema.BookFilter | None] first: _typing.NotRequired[_builtins.int | None] class _ListBooksData_books(BookCard, closed=True): - genre: _enum.Genre + genre: _schema.Genre class ListBooksData(_compat.TypedDict, closed=True): books: _builtins.list[_ListBooksData_books] @@ -144,7 +142,7 @@ class GetPublicationVariables(_compat.TypedDict, closed=True): class _GetPublicationData_publication_Book(_GetPublicationData_publication_Book_Base, closed=True): title: _builtins.str pages: _builtins.int - isbn: _scalar.ISBN + isbn: _schema.ISBN _GetPublicationData_publication_Magazine_Base = _compat.TypedDict('_GetPublicationData_publication_Magazine_Base', {'__typename': _compat.ReadOnly[_typing.Literal['Magazine']]}) @@ -186,7 +184,7 @@ class GetSavedSearchVariables(_compat.TypedDict, closed=True): name: _builtins.str class _GetSavedSearchData_savedSearch(_compat.TypedDict, closed=True): - value: _input.BookFilter | None + value: _schema.BookFilter | None class GetSavedSearchData(_compat.TypedDict, closed=True): savedSearch: _GetSavedSearchData_savedSearch | None @@ -199,13 +197,13 @@ class GetSavedSearchData(_compat.TypedDict, closed=True): }""", variables_type=GetSavedSearchVariables, data_type=GetSavedSearchData) class PlaceOrderVariables(_compat.TypedDict, closed=True): - input: _input.PlaceOrderInput + input: _schema.PlaceOrderInput class _PlaceOrderData_placeOrder(_compat.TypedDict, closed=True): id: _builtins.str - status: _enum.OrderStatus - total: _scalar.Money - placedAt: _scalar.DateTime + status: _schema.OrderStatus + total: _schema.Money + placedAt: _schema.DateTime class PlaceOrderData(_compat.TypedDict, closed=True): placeOrder: _PlaceOrderData_placeOrder @@ -221,11 +219,11 @@ class PlaceOrderData(_compat.TypedDict, closed=True): }""", variables_type=PlaceOrderVariables, injections={'idempotencyKey': _builtins.frozenset({('input',)})}, data_type=PlaceOrderData) class CancelOrderVariables(_compat.TypedDict, closed=True): - input: _input.CancelOrderInput + input: _schema.CancelOrderInput class _CancelOrderData_cancelOrder(_compat.TypedDict, closed=True): id: _builtins.str - status: _enum.OrderStatus + status: _schema.OrderStatus class CancelOrderData(_compat.TypedDict, closed=True): cancelOrder: _CancelOrderData_cancelOrder | None @@ -246,7 +244,7 @@ class OnOrderStatusChangedVariables(_compat.TypedDict, closed=True): class _OnOrderStatusChangedData_orderStatusChanged(_compat.TypedDict, closed=True): id: _builtins.str - status: _enum.OrderStatus + status: _schema.OrderStatus class OnOrderStatusChangedData(_compat.TypedDict, closed=True): orderStatusChanged: _OnOrderStatusChangedData_orderStatusChanged diff --git a/bookshop/get_order_graphql.py b/bookshop/get_order_graphql.py index 57bf698..80527da 100644 --- a/bookshop/get_order_graphql.py +++ b/bookshop/get_order_graphql.py @@ -5,14 +5,14 @@ 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 +from bookshop.graphql import schema as _schema class GetOrderVariables(_compat.TypedDict, closed=True): id: _builtins.str class _GetOrderData_order(_compat.TypedDict, closed=True): id: _builtins.str - status: _enum.OrderStatus + status: _schema.OrderStatus class GetOrderData(_compat.TypedDict, closed=True): order: _GetOrderData_order | None diff --git a/bookshop/graphql.config.yml b/bookshop/graphql.config.yml index de5ac23..616d0b3 100644 --- a/bookshop/graphql.config.yml +++ b/bookshop/graphql.config.yml @@ -1,7 +1,7 @@ # An SDL file here, but globs, introspection results, and server URLs work too. schema: schema.graphqls documents: "*.graphql" -# This library's config, under its extension name. +# graphql-codegen's config, under its extension name. extensions: pythonCodegen: # Where imports start, relative to this file's directory. diff --git a/bookshop/graphql/_scalar.py b/bookshop/graphql/_scalar.py deleted file mode 100644 index 9f86206..0000000 --- a/bookshop/graphql/_scalar.py +++ /dev/null @@ -1,19 +0,0 @@ -# Code generated by graphql-codegen. DO NOT EDIT. - -import builtins as _builtins -import typing as _typing -from .runtime import _reflection -from datetime import datetime as _datetime -from ..scalar import decode_datetime as _decode_datetime -from ..scalar import encode_datetime as _encode_datetime -from ..scalar import ISBN as _ISBN -from decimal import Decimal as _Decimal -from uuid import UUID as _UUID - -type DateTime = _typing.Annotated[_datetime, _reflection.Codec(decode=_decode_datetime, encode=_encode_datetime)] - -type ISBN = _ISBN - -type Money = _typing.Annotated[_Decimal, _reflection.Codec(decode=_Decimal, encode=_builtins.str)] - -type UUID = _typing.Annotated[_UUID, _reflection.Codec(decode=_UUID, encode=_builtins.str)] diff --git a/bookshop/graphql/injection.py b/bookshop/graphql/injection.py index d712124..87af2fe 100644 --- a/bookshop/graphql/injection.py +++ b/bookshop/graphql/injection.py @@ -3,13 +3,13 @@ import collections.abc as _abc from .runtime import _compat from .runtime import injection as _injection -from . import _scalar +from . import schema as _schema 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: _abc.Callable[[], _scalar.UUID] + idempotencyKey: _abc.Callable[[], _schema.UUID] def injectors(functions: InjectorFunctions, /) -> _injection._Injectors: """Return what `Request.prepare()` calls to supply the injected values, each serialized as its type says.""" diff --git a/bookshop/graphql/runtime/_reflection.py b/bookshop/graphql/runtime/_reflection.py index 7611ed6..00cf3ac 100644 --- a/bookshop/graphql/runtime/_reflection.py +++ b/bookshop/graphql/runtime/_reflection.py @@ -229,7 +229,7 @@ def convert(value: object, /) -> object: try: result[key] = convert_value(result[key]) except UnexpectedNullError as error: - error.path.insert(0, key) + error._path.insert(0, key) raise return result @@ -323,7 +323,7 @@ def convert(value: object, /) -> object: try: result.append(convert_item(item)) except UnexpectedNullError as error: - error.path.insert(0, index) + error._path.insert(0, index) raise return result diff --git a/bookshop/graphql/runtime/error.py b/bookshop/graphql/runtime/error.py index 4468cc3..0cabd70 100644 --- a/bookshop/graphql/runtime/error.py +++ b/bookshop/graphql/runtime/error.py @@ -27,14 +27,20 @@ class UnexpectedNullError(Error): """ def __init__(self, path: list[str | int], /) -> None: - self.path: Final = path + """Private.""" + # Each level the error unwinds through prepends its key or index. + self._path: Final = path super().__init__() + @property + def path(self) -> Sequence[str | int]: + return self._path + @override def __str__(self) -> str: formatted = "".join( f"[{segment}]" if isinstance(segment, int) else f".{segment}" - for segment in self.path + for segment in self._path ).removeprefix(".") return f"Expected `{formatted}` to not be null." @@ -51,7 +57,7 @@ class GraphqlError(TypedDict): message: Required[str] locations: NotRequired[Sequence[Location]] path: NotRequired[Sequence[str | int]] - extensions: NotRequired[object] + extensions: NotRequired[Mapping[str, object]] class ResponseError(Error): @@ -64,6 +70,7 @@ def __init__( *, extensions: Mapping[str, object] | None, ) -> None: + """Private.""" assert errors, "Cannot raise without at least one error." self.errors: Final = tuple(errors) self.extensions: Final = extensions @@ -117,6 +124,7 @@ def __init__( extensions: Mapping[str, object] | None, partial_data_parser: Callable[[object], object] | None, ) -> None: + """Private.""" super().__init__(errors, extensions=extensions) self.data = data self._partial_data_parser: Final = partial_data_parser diff --git a/bookshop/graphql/runtime/operation.py b/bookshop/graphql/runtime/operation.py index ef07bd9..ab1e7bb 100644 --- a/bookshop/graphql/runtime/operation.py +++ b/bookshop/graphql/runtime/operation.py @@ -44,10 +44,7 @@ def __init__( 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. - """ + """Private.""" self._operation_type: Final = operation_type self._name: Final = name self._document: Final = document diff --git a/bookshop/graphql/schema/input.py b/bookshop/graphql/schema.py similarity index 60% rename from bookshop/graphql/schema/input.py rename to bookshop/graphql/schema.py index 64a8074..218438d 100644 --- a/bookshop/graphql/schema/input.py +++ b/bookshop/graphql/schema.py @@ -4,38 +4,53 @@ import builtins as _builtins import collections.abc as _abc import typing as _typing -from ..runtime import _compat -from ..schema import enum as _enum -from .. import _scalar +from .runtime import _compat +from .runtime import _reflection +from datetime import datetime as _datetime +from ..scalar import decode_datetime as _decode_datetime +from ..scalar import encode_datetime as _encode_datetime +from ..scalar import ISBN as _ISBN +from decimal import Decimal as _Decimal +from uuid import UUID as _UUID -type _BookFilter_Ref = BookFilter +type DateTime = _typing.Annotated[_datetime, _reflection.Codec(decode=_decode_datetime, encode=_encode_datetime)] + +type Money = _typing.Annotated[_Decimal, _reflection.Codec(decode=_Decimal, encode=_builtins.str)] + +type ISBN = _ISBN + +type UUID = _typing.Annotated[_UUID, _reflection.Codec(decode=_UUID, encode=_builtins.str)] + +type Genre = _typing.Literal['FICTION', 'NONFICTION', 'POETRY', 'CHILDREN'] + +type OrderStatus = _typing.Literal['PENDING', 'SHIPPED', 'DELIVERED', 'CANCELED'] + +type BookLookup = _BookLookup_id | _BookLookup_isbn +"""A book, found by exactly one of its identifiers.""" class _BookLookup_id(_compat.TypedDict, closed=True): id: _builtins.str class _BookLookup_isbn(_compat.TypedDict, closed=True): - isbn: _scalar.ISBN + isbn: ISBN -type BookLookup = _BookLookup_id | _BookLookup_isbn -"""A book, found by exactly one of its identifiers.""" +type BookFilter = _BookFilter_genre | _BookFilter_author | _BookFilter_priceBelow | _BookFilter_and | _BookFilter_or | _BookFilter_not +"""A recursive filter on books.""" class _BookFilter_genre(_compat.TypedDict, closed=True): - genre: _enum.Genre + genre: Genre class _BookFilter_author(_compat.TypedDict, closed=True): author: _builtins.str class _BookFilter_priceBelow(_compat.TypedDict, closed=True): - priceBelow: _scalar.Money + priceBelow: Money -_BookFilter_and = _compat.TypedDict('_BookFilter_and', {'and': _abc.Sequence[_BookFilter_Ref]}, closed=True) +_BookFilter_and = _compat.TypedDict('_BookFilter_and', {'and': _abc.Sequence[BookFilter]}, closed=True) -_BookFilter_or = _compat.TypedDict('_BookFilter_or', {'or': _abc.Sequence[_BookFilter_Ref]}, closed=True) +_BookFilter_or = _compat.TypedDict('_BookFilter_or', {'or': _abc.Sequence[BookFilter]}, 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.""" +_BookFilter_not = _compat.TypedDict('_BookFilter_not', {'not': BookFilter}, closed=True) class PlaceOrderInput(_compat.TypedDict, closed=True): lines: _abc.Sequence[OrderLineInput] diff --git a/bookshop/graphql/schema/__init__.py b/bookshop/graphql/schema/__init__.py deleted file mode 100644 index 1340aa7..0000000 --- a/bookshop/graphql/schema/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# Code generated by graphql-codegen. DO NOT EDIT. - -from .enum import * -from .input import * diff --git a/bookshop/graphql/schema/enum.py b/bookshop/graphql/schema/enum.py deleted file mode 100644 index 635f927..0000000 --- a/bookshop/graphql/schema/enum.py +++ /dev/null @@ -1,7 +0,0 @@ -# Code generated by graphql-codegen. DO NOT EDIT. - -import typing as _typing - -type Genre = _typing.Literal['FICTION', 'NONFICTION', 'POETRY', 'CHILDREN'] - -type OrderStatus = _typing.Literal['PENDING', 'SHIPPED', 'DELIVERED', 'CANCELED'] diff --git a/pyproject.toml b/pyproject.toml index c0874c9..b3e1d93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires = ["uv-build>=0.12.23,<0.14"] [project] authors = [{ name = "Thibault Derousseaux" }] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3 :: Only", # The versions CI tests. @@ -35,7 +35,7 @@ license-files = ["LICENSE"] name = "graphql-codegen" readme = "README.md" requires-python = ">=3.12" -version = "0.2.1" +version = "1.0.0" [project.optional-dependencies] # Only reading a YAML graphql-config file needs it. diff --git a/src/graphql_codegen/_cli/_graphql_config.py b/src/graphql_codegen/_cli/_graphql_config.py index 5e2e53a..ca3b7f3 100644 --- a/src/graphql_codegen/_cli/_graphql_config.py +++ b/src/graphql_codegen/_cli/_graphql_config.py @@ -243,7 +243,10 @@ def _parse_extension( default=None, ) injector_names = optional( - unread, "injectorNames", parse_strings, default=frozenset() + unread, + "injectorNames", + lambda value: set(parse_strings(value)), + default=set(), ) non_null_directive_name = optional( unread, "nonNullDirectiveName", parse_string, default=None diff --git a/src/graphql_codegen/_generator/_annotation.py b/src/graphql_codegen/_generator/_annotation.py index de1f747..ed56b74 100644 --- a/src/graphql_codegen/_generator/_annotation.py +++ b/src/graphql_codegen/_generator/_annotation.py @@ -27,11 +27,8 @@ from graphql_codegen._generator._naming import ( ABC, BUILTINS, - ENUM, - INPUT, - SCALAR, + SCHEMA, TYPING, - SchemaSpellings, ) from graphql_codegen._generator.spelling import SettledSpelling @@ -58,11 +55,9 @@ class AnnotationBuilder: def __init__( self, *, - scalar_spellings: Mapping[str, SettledSpelling], - schema_spellings: SchemaSpellings, + type_spellings: Mapping[str, SettledSpelling], ) -> None: - self._scalar_spellings: Final = scalar_spellings - self._type_spellings: Final = schema_spellings.type_spellings + self._type_spellings: Final = type_spellings def build( self, @@ -71,14 +66,14 @@ def build( *, position: Position, object_annotation: ast.expr | None, - bare_inputs: bool, + bare_schema_types: bool, ) -> ast.expr: if isinstance(type_, GraphQLNonNull): return self._build_non_null( type_.of_type, position=position, object_annotation=object_annotation, - bare_inputs=bare_inputs, + bare_schema_types=bare_schema_types, ) return optional( @@ -86,7 +81,7 @@ def build( type_, position=position, object_annotation=object_annotation, - bare_inputs=bare_inputs, + bare_schema_types=bare_schema_types, ), ) @@ -97,7 +92,7 @@ def _build_non_null( *, position: Position, object_annotation: ast.expr | None, - bare_inputs: bool, + bare_schema_types: bool, ) -> ast.expr: match type_: case GraphQLList(): @@ -105,7 +100,7 @@ def _build_non_null( type_.of_type, position=position, object_annotation=object_annotation, - bare_inputs=bare_inputs, + bare_schema_types=bare_schema_types, ) match position: case "input": @@ -116,27 +111,21 @@ def _build_non_null( assert_never(never) return subscript(container, element) - case GraphQLScalarType(): - return self._scalar_annotation(type_.name) + case GraphQLScalarType() if type_.name not in self._type_spellings: + return builtin(BUILT_IN_SCALARS.get(type_.name, "object")) case GraphQLEnumType() if is_introspection_type(type_): # 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), ) - case GraphQLEnumType(): - return qualified(ENUM, self._type_spellings[type_.name]) - case GraphQLInputObjectType(): + case GraphQLScalarType() | GraphQLEnumType() | GraphQLInputObjectType(): spelling = self._type_spellings[type_.name] - return name(spelling) if bare_inputs else qualified(INPUT, spelling) + return ( + name(spelling) if bare_schema_types else qualified(SCHEMA, spelling) + ) case _: assert object_annotation is not None, ( f"Expected the caller to name the type for `{type_}`." ) return object_annotation - - def _scalar_annotation(self, scalar_name: str, /) -> ast.expr: - if scalar_name in self._scalar_spellings: - return qualified(SCALAR, self._scalar_spellings[scalar_name]) - - 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 373673d..84b78c1 100644 --- a/src/graphql_codegen/_generator/_ast_nodes.py +++ b/src/graphql_codegen/_generator/_ast_nodes.py @@ -1,6 +1,6 @@ import ast from ast import literal_eval -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Mapping, Sequence from io import StringIO from itertools import accumulate, pairwise from textwrap import indent @@ -33,7 +33,7 @@ def constant(value: str | int | EllipsisType | None, /) -> ast.Constant: names=[ast.alias(name="annotations")], level=0, ) -"""Input types reference each other in cycles, so the input module postpones its annotations instead of quoting them. +"""Input types reference each other in cycles, so the schema module postpones its annotations instead of quoting them. Under PEP 563, a TypedDict's `__required_keys__` then reports every key as required: read these types with `typing.get_type_hints()`. """ @@ -108,24 +108,6 @@ def class_definition( ) -def relative_import_from( - module: str | None, - names: Iterable[str], - /, - *, - level: int, - re_export: bool, -) -> ast.ImportFrom: - return ast.ImportFrom( - module=module, - names=[ - ast.alias(name=imported, asname=imported if re_export else None) - for imported in names - ], - level=level, - ) - - def module(body: Sequence[ast.stmt], /) -> ast.Module: return ast.Module(body=list(body), type_ignores=[]) diff --git a/src/graphql_codegen/_generator/_data_type.py b/src/graphql_codegen/_generator/_data_type.py index 2813631..9525f11 100644 --- a/src/graphql_codegen/_generator/_data_type.py +++ b/src/graphql_codegen/_generator/_data_type.py @@ -25,10 +25,9 @@ ) from graphql_codegen._generator._naming import ( COMPAT, - INPUT, REFLECTION, + SCHEMA, TYPING, - SchemaSpellings, nested_type_name, ) from graphql_codegen._generator._schema import as_non_null @@ -125,14 +124,14 @@ def __init__( *, schema: GraphQLSchema, annotations: AnnotationBuilder, - schema_spellings: SchemaSpellings, + type_spellings: Mapping[str, SettledSpelling], resolve_fragment: Callable[[str], SelectionSet], struct_types: StructTypes | None, non_null_directive_name: str | None, ) -> None: self._schema: Final = schema self._annotations: Final = annotations - self._schema_spellings: Final = schema_spellings + self._type_spellings: Final = type_spellings self._resolve_fragment: Final = resolve_fragment self._struct_types: Final = struct_types self._non_null_directive_name: Final = non_null_directive_name @@ -444,8 +443,7 @@ def _emit_field( if struct_input_name is not None: annotation: ast.expr = qualified( - INPUT, - self._schema_spellings.type_spellings[struct_input_name], + SCHEMA, self._type_spellings[struct_input_name] ) if not is_non_null_type(field_type): @@ -455,7 +453,7 @@ def _emit_field( field_type, position="data", object_annotation=None, - bare_inputs=False, + bare_schema_types=False, ) else: normalized = self._normalize(field.selection_set) @@ -483,7 +481,7 @@ def _emit_field( field_type, position="data", object_annotation=object_annotation, - bare_inputs=False, + bare_schema_types=False, ) if non_null: diff --git a/src/graphql_codegen/_generator/_imports.py b/src/graphql_codegen/_generator/_imports.py index 41d9c36..a9f799d 100644 --- a/src/graphql_codegen/_generator/_imports.py +++ b/src/graphql_codegen/_generator/_imports.py @@ -8,22 +8,18 @@ ABC, BUILTINS, COMPAT, - ENUM, INJECTION, - INPUT, OMITTED, OPERATION_TYPE, REFLECTION, RUNTIME, - SCALAR, + SCHEMA, TYPING, - SchemaSpellings, ) from graphql_codegen._generator.spelling import ast_str -SCHEMA_PACKAGE = "schema" +SCHEMA_MODULE = "schema" DOCUMENT_PACKAGE = "document" -SCALAR_MODULE = "_scalar" INJECTION_MODULE = "injection" RUNTIME_PACKAGE = "runtime" @@ -74,7 +70,6 @@ def helper_imports( /, *, module: GeneratedModule, - schema_spellings: SchemaSpellings, ) -> list[ast.stmt]: used = { node.id @@ -100,6 +95,7 @@ def helper_imports( (REFLECTION, RUNTIME_PACKAGE, "_reflection"), (RUNTIME, None, RUNTIME_PACKAGE), (OMITTED, RUNTIME_PACKAGE, "OMITTED"), + (SCHEMA, None, SCHEMA_MODULE), ): if ast_str(helper) in used: imports.append( @@ -108,25 +104,4 @@ def helper_imports( ) ) - for helper, schema_module in ( - (ENUM, schema_spellings.enum_module), - (INPUT, schema_spellings.input_module), - ): - if ast_str(helper) in used: - imports.append( - _from_package( - SCHEMA_PACKAGE, - name=schema_module, - alias=ast_str(helper), - module=module, - ), - ) - - if ast_str(SCALAR) in used: - imports.append( - _from_package( - None, name=SCALAR_MODULE, alias=ast_str(SCALAR), module=module - ) - ) - return imports diff --git a/src/graphql_codegen/_generator/_injector.py b/src/graphql_codegen/_generator/_injector.py index 98a0e42..c3f77ac 100644 --- a/src/graphql_codegen/_generator/_injector.py +++ b/src/graphql_codegen/_generator/_injector.py @@ -195,7 +195,7 @@ def emit_injector_module( for injector_name, type_ in sorted(injected_types.items()): value = annotations.build( - type_, position="input", object_annotation=None, bare_inputs=False + type_, position="input", object_annotation=None, bare_schema_types=False ) # A nullable input accepts omission too, while a non-null one accepts neither. diff --git a/src/graphql_codegen/_generator/_naming.py b/src/graphql_codegen/_generator/_naming.py index e01d786..7894d26 100644 --- a/src/graphql_codegen/_generator/_naming.py +++ b/src/graphql_codegen/_generator/_naming.py @@ -1,4 +1,4 @@ -from collections.abc import Collection, Mapping, Sequence +from collections.abc import Collection, Sequence from dataclasses import dataclass from typing import Final, final @@ -13,12 +13,10 @@ ABC: Final = PendingSpelling("_abc") BUILTINS: Final = PendingSpelling("_builtins") RUNTIME: Final = PendingSpelling("_runtime") -ENUM: Final = PendingSpelling("_enum") -INPUT: Final = PendingSpelling("_input") +SCHEMA: Final = PendingSpelling("_schema") INJECTION: Final = PendingSpelling("_injection") OPERATION_TYPE: Final = PendingSpelling("_operation_type") REFLECTION: Final = PendingSpelling("_reflection") -SCALAR: Final = PendingSpelling("_scalar") OMITTED: Final = PendingSpelling("_OMITTED") """Imported by name: pyright only takes a sentinel as a type when it is spelled bare.""" @@ -56,23 +54,3 @@ def operation_spellings( ) for operation in operations } - - -@final -@dataclass(frozen=True, kw_only=True) -class SchemaSpellings: - type_spellings: Mapping[str, SettledSpelling] - - enum_module: SettledSpelling - input_module: SettledSpelling - - -def schema_spellings(type_names: Collection[str], /) -> SchemaSpellings: - types = settle_names(sorted(type_names), taken=set()) - # The package re-exports every type, so its modules must not be spelled like any of them. - modules = settle_names(["enum", "input"], taken=set(types.values())) - return SchemaSpellings( - type_spellings=types, - enum_module=modules["enum"], - input_module=modules["input"], - ) diff --git a/src/graphql_codegen/_generator/_operation.py b/src/graphql_codegen/_generator/_operation.py index 67b26b8..0765828 100644 --- a/src/graphql_codegen/_generator/_operation.py +++ b/src/graphql_codegen/_generator/_operation.py @@ -127,7 +127,7 @@ def emit_variables_type( variable_type, position="input", object_annotation=None, - bare_inputs=False, + bare_schema_types=False, ), required=is_non_null_type(variable_type) and definition.default_value is None, diff --git a/src/graphql_codegen/_generator/_scalar.py b/src/graphql_codegen/_generator/_scalar.py index 550baa4..4646b7b 100644 --- a/src/graphql_codegen/_generator/_scalar.py +++ b/src/graphql_codegen/_generator/_scalar.py @@ -22,9 +22,9 @@ def check_scalars(scalars: Mapping[str, Scalar], /, *, schema: GraphQLSchema) -> raise ValueError(f"Expected `{scalar_name}` to be a scalar of the schema.") # noqa: TRY004 -def emit_scalars( +def emit_scalar_aliases( scalars: Mapping[str, Scalar], /, *, spellings: Mapping[str, SettledSpelling] -) -> list[ast.stmt]: +) -> tuple[list[ast.ImportFrom], dict[str, ast.stmt]]: imported: dict[Import, PendingSpelling] = {} def reference(path: Reference, /) -> ast.expr: @@ -38,7 +38,7 @@ def reference(path: Reference, /) -> ast.expr: case _ as never: assert_never(never) - body: list[ast.stmt] = [] + aliases: dict[str, ast.stmt] = {} for scalar_name, scalar in sorted(scalars.items()): if scalar.codec is None: @@ -63,7 +63,7 @@ def reference(path: Reference, /) -> ast.expr: ), ) - body.append(type_alias(spellings[scalar_name], value)) + aliases[scalar_name] = type_alias(spellings[scalar_name], value) imports = [ ast.ImportFrom( @@ -73,4 +73,4 @@ def reference(path: Reference, /) -> ast.expr: ) for import_, spelling in imported.items() ] - return [*imports, *body] + return imports, aliases diff --git a/src/graphql_codegen/_generator/_schema_type.py b/src/graphql_codegen/_generator/_schema_type.py index 7075c5c..899645a 100644 --- a/src/graphql_codegen/_generator/_schema_type.py +++ b/src/graphql_codegen/_generator/_schema_type.py @@ -1,5 +1,5 @@ import ast -from collections.abc import Mapping, Set as AbstractSet +from collections.abc import Mapping, Sequence, Set as AbstractSet from graphql import ( DocumentNode, @@ -8,6 +8,7 @@ GraphQLInputField, GraphQLInputObjectType, GraphQLNamedType, + GraphQLScalarType, GraphQLSchema, TypeInfo, TypeInfoVisitor, @@ -24,14 +25,14 @@ from graphql_codegen._generator._ast_nodes import ( constant, documented, - module, name, qualified, subscript, type_alias, union, ) -from graphql_codegen._generator._naming import TYPING, SchemaSpellings +from graphql_codegen._generator._naming import TYPING +from graphql_codegen._generator._scalar import emit_scalar_aliases from graphql_codegen._generator._schema import as_non_null from graphql_codegen._generator._structs import StructTypes, get_struct_input_name from graphql_codegen._generator._typed_dict import Key, emit_closed @@ -40,6 +41,7 @@ SettledSpelling, ast_str, ) +from graphql_codegen.scalar import Scalar def _is_required(field: GraphQLInputField, /) -> bool: @@ -52,16 +54,6 @@ def _is_required(field: GraphQLInputField, /) -> bool: ) -def _reached[Type: GraphQLNamedType]( - schema: GraphQLSchema, kind: type[Type], /, *, schema_spellings: SchemaSpellings -) -> list[Type]: - return [ - type_ - for type_ in schema.type_map.values() - if isinstance(type_, kind) and type_.name in schema_spellings.type_spellings - ] - - def get_reached_type_names( document: DocumentNode, /, @@ -126,27 +118,6 @@ def enter_field(self, node: FieldNode, *_args: object) -> None: return frozenset(reached) -def emit_enums( - schema: GraphQLSchema, /, *, schema_spellings: SchemaSpellings -) -> list[ast.stmt]: - return [ - statement - for enum_type in _reached( - schema, GraphQLEnumType, schema_spellings=schema_spellings - ) - for statement in documented( - type_alias( - schema_spellings.type_spellings[enum_type.name], - subscript( - qualified(TYPING, "Literal"), - *(constant(member) for member in enum_type.values), - ), - ), - text=enum_type.description or "", - ) - ] - - def _emit_one_of( input_type: GraphQLInputObjectType, /, @@ -157,15 +128,21 @@ def _emit_one_of( bare_names: frozenset[str], ) -> list[ast.stmt]: """`closed=True` is what rejects a value with two keys: `total=` only governs the declared keys.""" - body: list[ast.stmt] = [] - member_spellings: list[PendingSpelling] = [] + member_spellings = { + field_name: PendingSpelling(f"_{spelling}_{field_name}") + for field_name in fields + } + assert member_spellings, f"`{input_type.name}` is `@oneOf` but has no field." + # The union first: a `type` statement is lazy, so a member naming it, as a recursive filter does, finds it bound. + body = documented( + type_alias(spelling, union(*map(name, member_spellings.values()))), + text=input_type.description or "", + ) for field_name, field in fields.items(): - member_spelling = PendingSpelling(f"_{spelling}_{field_name}") - member_spellings.append(member_spelling) body.extend( emit_closed( - member_spelling, + member_spellings[field_name], [ Key( name=field_name, @@ -173,7 +150,7 @@ def _emit_one_of( annotation=annotations.build( as_non_null(field.type), position="input", - bare_inputs=True, + bare_schema_types=True, object_annotation=None, ), description=field.description or "", @@ -185,119 +162,132 @@ def _emit_one_of( ), ) - assert member_spellings, f"`{input_type.name}` is `@oneOf` but has no field." - body.extend( - documented( - type_alias( - spelling, - union(*(name(member_spelling) for member_spelling in member_spellings)), - ), - text=input_type.description or "", - ), - ) return body -def emit_inputs( +def emit_schema_module( schema: GraphQLSchema, /, *, annotations: AnnotationBuilder, - schema_spellings: SchemaSpellings, + type_spellings: Mapping[str, SettledSpelling], + scalars: Mapping[str, Scalar], injector_names: AbstractSet[str], ) -> list[ast.stmt]: - input_types = _reached( - schema, GraphQLInputObjectType, schema_spellings=schema_spellings - ) - bare_names = frozenset( - schema_spellings.type_spellings[input_type.name] for input_type in input_types - ) + # A single module for every kind, since no two types of a schema share a name: https://spec.graphql.org/September2025/#sec-Schema + # Every type is bound bare in the module, so no key may shadow one in a class body. + bare_names = frozenset(type_spellings.values()) + imports, scalar_aliases = emit_scalar_aliases(scalars, spellings=type_spellings) body: list[ast.stmt] = [] - for input_type in input_types: - fields = { - field_name: field - for field_name, field in input_type.fields.items() - if field_name not in injector_names - } - - if input_type.is_one_of: - body.extend( - _emit_one_of( - input_type, - fields=fields, - annotations=annotations, - spelling=schema_spellings.type_spellings[input_type.name], - bare_names=bare_names, - ), - ) - continue - - body.extend( - emit_closed( - schema_spellings.type_spellings[input_type.name], - [ - Key( - name=field_name, - annotation=annotations.build( - field.type, - position="input", - bare_inputs=True, - object_annotation=None, + # In the schema's order, which graphql-core keeps. + for type_ in schema.type_map.values(): + match type_: + case GraphQLScalarType() if type_.name in scalar_aliases: + body.append(scalar_aliases[type_.name]) + case GraphQLEnumType() if type_.name in type_spellings: + body.extend( + documented( + type_alias( + type_spellings[type_.name], + subscript( + qualified(TYPING, "Literal"), + *(constant(member) for member in type_.values), + ), ), - required=_is_required(field), - description=field.description or "", + text=type_.description or "", ) - for field_name, field in fields.items() - ], - description=input_type.description or "", - bare_names=bare_names, - ), - ) - - return [*_defer_functional_references(body, input_names=bare_names), *body] - - -def _defer_functional_references( - body: list[ast.stmt], /, *, input_names: frozenset[SettledSpelling] -) -> list[ast.stmt]: - """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: - if not ( - isinstance(statement, ast.Assign) and isinstance(statement.value, ast.Call) - ): - continue - - for node in ast.walk(statement.value): - if isinstance(node, ast.Name) and node.id in input_names: - alias = aliases.setdefault( - SettledSpelling(node.id), PendingSpelling(f"_{node.id}_Ref") ) - node.id = ast_str(alias) + case GraphQLInputObjectType() if type_.name in type_spellings: + body.extend( + _emit_input( + type_, + annotations=annotations, + spelling=type_spellings[type_.name], + injector_names=injector_names, + bare_names=bare_names, + ) + ) + case _: + pass return [ - type_alias(alias, name(input_name)) for input_name, alias in aliases.items() + *imports, + *_defer_forward_references(body, schema_names=bare_names), + *body, ] -def emit_schema_package( - schema: GraphQLSchema, /, *, schema_spellings: SchemaSpellings -) -> ast.Module: - exports = { - schema_spellings.enum_module: _reached( - schema, GraphQLEnumType, schema_spellings=schema_spellings - ), - schema_spellings.input_module: _reached( - schema, GraphQLInputObjectType, schema_spellings=schema_spellings - ), +def _emit_input( + input_type: GraphQLInputObjectType, + /, + *, + annotations: AnnotationBuilder, + spelling: SettledSpelling, + injector_names: AbstractSet[str], + bare_names: frozenset[str], +) -> list[ast.stmt]: + fields = { + field_name: field + for field_name, field in input_type.fields.items() + if field_name not in injector_names } - return module( + + if input_type.is_one_of: + return _emit_one_of( + input_type, + fields=fields, + annotations=annotations, + spelling=spelling, + bare_names=bare_names, + ) + + return emit_closed( + spelling, [ - ast.ImportFrom(module=module_name, names=[ast.alias(name="*")], level=1) - for module_name, types in exports.items() - # An empty `from ... import` is a syntax error. - if types + Key( + name=field_name, + annotation=annotations.build( + field.type, + position="input", + bare_schema_types=True, + object_annotation=None, + ), + required=_is_required(field), + description=field.description or "", + ) + for field_name, field in fields.items() ], + description=input_type.description or "", + bare_names=bare_names, ) + + +def _defer_forward_references( + body: Sequence[ast.stmt], /, *, schema_names: frozenset[str] +) -> list[ast.stmt]: + """A functional TypedDict's values are evaluated when the statement runs, unlike annotations, so a type defined later is referenced through a lazy `type` alias.""" + aliases: dict[SettledSpelling, PendingSpelling] = {} + defined: set[str] = set() + + for statement in body: + match statement: + case ast.Assign(targets=[ast.Name(id=bound)], value=ast.Call() as call): + for node in ast.walk(call): + if ( + isinstance(node, ast.Name) + and node.id in schema_names + and node.id not in defined + ): + alias = aliases.setdefault( + SettledSpelling(node.id), PendingSpelling(f"_{node.id}_Ref") + ) + node.id = ast_str(alias) + + defined.add(bound) + case ast.TypeAlias(name=ast.Name(id=bound)) | ast.ClassDef(name=bound): + defined.add(bound) + case _: + pass + + return [type_alias(alias, name(type_name)) for type_name, alias in aliases.items()] diff --git a/src/graphql_codegen/_generator/package.py b/src/graphql_codegen/_generator/package.py index 45b1cdb..f19c5a7 100644 --- a/src/graphql_codegen/_generator/package.py +++ b/src/graphql_codegen/_generator/package.py @@ -23,12 +23,10 @@ from graphql_codegen._generator._annotation import AnnotationBuilder from graphql_codegen._generator._ast_nodes import ( FUTURE_ANNOTATIONS, - constant, import_as, module, name, qualified, - relative_import_from, unparse, ) from graphql_codegen._generator._data_type import DataTypeEmitter @@ -42,8 +40,7 @@ DOCUMENT_PACKAGE, INJECTION_MODULE, RUNTIME_PACKAGE, - SCALAR_MODULE, - SCHEMA_PACKAGE, + SCHEMA_MODULE, GeneratedModule, OutsideModule, PackageModule, @@ -57,9 +54,7 @@ from graphql_codegen._generator._merge import print_merge_template from graphql_codegen._generator._naming import ( OperationSpellings, - SchemaSpellings, operation_spellings, - schema_spellings, ) from graphql_codegen._generator._operation import ( description_text, @@ -67,12 +62,10 @@ emit_variables_type, strip_for_server, ) -from graphql_codegen._generator._scalar import check_scalars, emit_scalars +from graphql_codegen._generator._scalar import check_scalars from graphql_codegen._generator._schema import prepare_schema from graphql_codegen._generator._schema_type import ( - emit_enums, - emit_inputs, - emit_schema_package, + emit_schema_module, get_reached_type_names, ) from graphql_codegen._generator._selection import SelectionResolver @@ -169,9 +162,7 @@ def _dotted_module_name( ) -_RUNTIME_MARKER: Final = ( - b"# Copied into each generated package, where this line says not to edit it.\n" -) +_RUNTIME_MARKER: Final = b"# Copied into each generated package, where this line becomes a `DO NOT EDIT` header.\n" """The first line of every runtime module, swapped for the generated file comment.""" @@ -233,13 +224,7 @@ def __init__( modules = document_modules( sorted({*operation_names, *fragment_names}), sibling_module=sibling_module ) - # 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 - else set() - ) - operation_spellings = settle_names(sorted(operations), taken=module_spellings) + operation_spellings = settle_names(sorted(operations), taken=set()) injections = { operation_name: get_injections( @@ -265,7 +250,6 @@ def __init__( } self._fragments: Final = fragments self._fragment_names_by_document: Final = fragment_names - self._module_spellings: Final = module_spellings self._fragment_documents: Final = fragment_documents self._document_modules: Final = modules self._imported_module_dotted_names: Final = ( @@ -307,28 +291,17 @@ def _resolver(self) -> SelectionResolver: return SelectionResolver(self._fragments, schema=self._schema) @cached_property - def _schema_spellings(self) -> SchemaSpellings: - return schema_spellings( - get_reached_type_names( - self._document, - schema=self._schema, - struct_types=self._struct_types, - injector_names=self._injector_names, - ), + def _type_spellings(self) -> dict[str, SettledSpelling]: + reached = get_reached_type_names( + self._document, + schema=self._schema, + struct_types=self._struct_types, + injector_names=self._injector_names, ) - - @cached_property - def _scalar_spellings(self) -> dict[str, SettledSpelling]: - return settle_names(sorted(self._scalars), taken=set()) + return settle_names(sorted({*reached, *self._scalars}), taken=set()) @cached_property def _fragment_spellings(self) -> dict[str, SettledSpelling]: - if self._document_sibling_module is None: - return settle_names( - sorted(self._fragments), - taken={*self._module_spellings, *self._operation_spellings.values()}, - ) - return { fragment_name: spelling for document, names in self._fragment_names_by_document.items() @@ -345,10 +318,7 @@ def _fragment_spellings(self) -> dict[str, SettledSpelling]: @cached_property def _annotations(self) -> AnnotationBuilder: - return AnnotationBuilder( - scalar_spellings=self._scalar_spellings, - schema_spellings=self._schema_spellings, - ) + return AnnotationBuilder(type_spellings=self._type_spellings) def _module_source( self, @@ -358,9 +328,7 @@ def _module_source( generated_module: GeneratedModule, preamble: Sequence[ast.stmt], ) -> str: - imports = helper_imports( - body, module=generated_module, schema_spellings=self._schema_spellings - ) + imports = helper_imports(body, module=generated_module) tree = module([*preamble, *imports, *body]) settle_module(tree) @@ -376,7 +344,7 @@ def _data_types(self) -> DataTypeEmitter: return DataTypeEmitter( schema=self._schema, annotations=self._annotations, - schema_spellings=self._schema_spellings, + type_spellings=self._type_spellings, resolve_fragment=lambda fragment_name: self._resolver.resolve( self._fragments[fragment_name] ), @@ -545,59 +513,6 @@ def _import_document_module( case _ as never: assert_never(never) - def _emit_document_reexports( - self, modules: Mapping[PurePosixPath, PurePosixPath], / - ) -> str: - """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())], - value=ast.List( - elts=[ - # Fully qualified from `__name__`, so that the package works wherever it is written. - ast.BinOp( - left=ast.Name(id="__name__", ctx=ast.Load()), - op=ast.Add(), - right=constant(f".{stem}"), - ) - for stem, _ in stems - ], - ctx=ast.Load(), - ), - lineno=0, - ) - return unparse( - module( - [ - lazy_modules, - *( - relative_import_from( - stem, - sorted( - [ - *( - self._operation_spellings[name] - for name in self._operation_names_by_document.get( - document, [] - ) - ), - *( - self._fragment_spellings[name] - for name in self._fragment_names_by_document.get( - document, [] - ) - ), - ] - ), - level=1, - re_export=True, - ) - for stem, document in stems - ), - ], - ), - ) - def _emit_document_modules(self) -> dict[PurePosixPath, str]: files: dict[PurePosixPath, str] generated_module: GeneratedModule @@ -605,11 +520,7 @@ def _emit_document_modules(self) -> dict[PurePosixPath, str]: match self._document_sibling_module: case None: modules = self._document_modules - files = { - PurePosixPath(DOCUMENT_PACKAGE, "__init__.py"): _generated( - self._emit_document_reexports(modules) - ) - } + files = {PurePosixPath(DOCUMENT_PACKAGE, "__init__.py"): _generated("")} generated_module = PackageModule(path=(DOCUMENT_PACKAGE,)) case DocumentSiblingModule(package_location=location): directory = location._package_directory @@ -630,42 +541,19 @@ def _emit_document_modules(self) -> dict[PurePosixPath, str]: return files def emit(self) -> dict[PurePosixPath, bytes]: - schema_spellings = self._schema_spellings document_files = self._emit_document_modules() files: dict[PurePosixPath, str] = { PurePosixPath("__init__.py"): _generated(""), - PurePosixPath(f"{SCALAR_MODULE}.py"): _generated( - self._module_source( - emit_scalars(self._scalars, spellings=self._scalar_spellings), - generated_module=PackageModule(path=()), - preamble=(), - ) - ), - PurePosixPath(SCHEMA_PACKAGE, "__init__.py"): _generated( - unparse( - emit_schema_package(self._schema, schema_spellings=schema_spellings) - ) - ), - PurePosixPath( - SCHEMA_PACKAGE, f"{schema_spellings.enum_module}.py" - ): _generated( + PurePosixPath(f"{SCHEMA_MODULE}.py"): _generated( self._module_source( - emit_enums(self._schema, schema_spellings=schema_spellings), - generated_module=PackageModule(path=(SCHEMA_PACKAGE,)), - preamble=(), - ) - ), - PurePosixPath( - SCHEMA_PACKAGE, f"{schema_spellings.input_module}.py" - ): _generated( - self._module_source( - emit_inputs( + emit_schema_module( self._schema, annotations=self._annotations, - schema_spellings=schema_spellings, + type_spellings=self._type_spellings, + scalars=self._scalars, injector_names=self._injector_names, ), - generated_module=PackageModule(path=(SCHEMA_PACKAGE,)), + generated_module=PackageModule(path=()), preamble=[FUTURE_ANNOTATIONS], ) ), diff --git a/src/graphql_codegen/config.py b/src/graphql_codegen/config.py index 94b57a1..e9bea23 100644 --- a/src/graphql_codegen/config.py +++ b/src/graphql_codegen/config.py @@ -1,4 +1,4 @@ -from collections.abc import Collection, Mapping +from collections.abc import Mapping, Set as AbstractSet from dataclasses import dataclass, field from typing import final @@ -12,9 +12,9 @@ @dataclass(frozen=True, kw_only=True) class Config: document_sibling_module: DocumentSiblingModule | None = None - """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.""" + """When `None`, each document's operations and fragments go to a module named after it in the generated package's `document` subpackage.""" - injector_names: Collection[str] = frozenset() + injector_names: AbstractSet[str] = frozenset() """The names of the variables and input fields that injectors supply. They are left out of the generated types, and must have the same type wherever they appear, nullability aside. diff --git a/src/graphql_codegen/runtime/__init__.py b/src/graphql_codegen/runtime/__init__.py index 61ad905..f2b1467 100644 --- a/src/graphql_codegen/runtime/__init__.py +++ b/src/graphql_codegen/runtime/__init__.py @@ -1,4 +1,4 @@ -# Copied into each generated package, where this line says not to edit it. +# Copied into each generated package, where this line becomes a `DO NOT EDIT` header. from .error import ( Error as Error, diff --git a/src/graphql_codegen/runtime/_compat.py b/src/graphql_codegen/runtime/_compat.py index 6999bfb..5ea0900 100644 --- a/src/graphql_codegen/runtime/_compat.py +++ b/src/graphql_codegen/runtime/_compat.py @@ -1,4 +1,4 @@ -# Copied into each generated package, where this line says not to edit it. +# Copied into each generated package, where this line becomes a `DO NOT EDIT` header. import sys diff --git a/src/graphql_codegen/runtime/_execution_error_handling.py b/src/graphql_codegen/runtime/_execution_error_handling.py index 6241dc1..009ce4c 100644 --- a/src/graphql_codegen/runtime/_execution_error_handling.py +++ b/src/graphql_codegen/runtime/_execution_error_handling.py @@ -1,4 +1,4 @@ -# Copied into each generated package, where this line says not to edit it. +# Copied into each generated package, where this line becomes a `DO NOT EDIT` header. from typing import Literal diff --git a/src/graphql_codegen/runtime/_operation_type.py b/src/graphql_codegen/runtime/_operation_type.py index da23901..af69fe7 100644 --- a/src/graphql_codegen/runtime/_operation_type.py +++ b/src/graphql_codegen/runtime/_operation_type.py @@ -1,4 +1,4 @@ -# Copied into each generated package, where this line says not to edit it. +# Copied into each generated package, where this line becomes a `DO NOT EDIT` header. from typing import Final, Literal diff --git a/src/graphql_codegen/runtime/_reflection.py b/src/graphql_codegen/runtime/_reflection.py index f792d42..58fd12f 100644 --- a/src/graphql_codegen/runtime/_reflection.py +++ b/src/graphql_codegen/runtime/_reflection.py @@ -1,4 +1,4 @@ -# Copied into each generated package, where this line says not to edit it. +# Copied into each generated package, where this line becomes a `DO NOT EDIT` header. from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass @@ -229,7 +229,7 @@ def convert(value: object, /) -> object: try: result[key] = convert_value(result[key]) except UnexpectedNullError as error: - error.path.insert(0, key) + error._path.insert(0, key) raise return result @@ -323,7 +323,7 @@ def convert(value: object, /) -> object: try: result.append(convert_item(item)) except UnexpectedNullError as error: - error.path.insert(0, index) + error._path.insert(0, index) raise return result diff --git a/src/graphql_codegen/runtime/_sigil.py b/src/graphql_codegen/runtime/_sigil.py index c1a9f72..6a20e38 100644 --- a/src/graphql_codegen/runtime/_sigil.py +++ b/src/graphql_codegen/runtime/_sigil.py @@ -1,4 +1,4 @@ -# Copied into each generated package, where this line says not to edit it. +# Copied into each generated package, where this line becomes a `DO NOT EDIT` header. from typing import Final diff --git a/src/graphql_codegen/runtime/error.py b/src/graphql_codegen/runtime/error.py index cc1e64a..0f6f778 100644 --- a/src/graphql_codegen/runtime/error.py +++ b/src/graphql_codegen/runtime/error.py @@ -1,4 +1,4 @@ -# Copied into each generated package, where this line says not to edit it. +# Copied into each generated package, where this line becomes a `DO NOT EDIT` header. from collections.abc import Callable, Mapping, Sequence from copy import deepcopy @@ -27,14 +27,20 @@ class UnexpectedNullError(Error): """ def __init__(self, path: list[str | int], /) -> None: - self.path: Final = path + """Private.""" + # Each level the error unwinds through prepends its key or index. + self._path: Final = path super().__init__() + @property + def path(self) -> Sequence[str | int]: + return self._path + @override def __str__(self) -> str: formatted = "".join( f"[{segment}]" if isinstance(segment, int) else f".{segment}" - for segment in self.path + for segment in self._path ).removeprefix(".") return f"Expected `{formatted}` to not be null." @@ -51,7 +57,7 @@ class GraphqlError(TypedDict): message: Required[str] locations: NotRequired[Sequence[Location]] path: NotRequired[Sequence[str | int]] - extensions: NotRequired[object] + extensions: NotRequired[Mapping[str, object]] class ResponseError(Error): @@ -64,6 +70,7 @@ def __init__( *, extensions: Mapping[str, object] | None, ) -> None: + """Private.""" assert errors, "Cannot raise without at least one error." self.errors: Final = tuple(errors) self.extensions: Final = extensions @@ -117,6 +124,7 @@ def __init__( extensions: Mapping[str, object] | None, partial_data_parser: Callable[[object], object] | None, ) -> None: + """Private.""" super().__init__(errors, extensions=extensions) self.data = data self._partial_data_parser: Final = partial_data_parser diff --git a/src/graphql_codegen/runtime/injection.py b/src/graphql_codegen/runtime/injection.py index 890fde2..275b39c 100644 --- a/src/graphql_codegen/runtime/injection.py +++ b/src/graphql_codegen/runtime/injection.py @@ -1,4 +1,4 @@ -# Copied into each generated package, where this line says not to edit it. +# Copied into each generated package, where this line becomes a `DO NOT EDIT` header. import sys from collections.abc import Callable, Mapping diff --git a/src/graphql_codegen/runtime/merge.py b/src/graphql_codegen/runtime/merge.py index c1f37da..4e22a7a 100644 --- a/src/graphql_codegen/runtime/merge.py +++ b/src/graphql_codegen/runtime/merge.py @@ -1,4 +1,4 @@ -# Copied into each generated package, where this line says not to edit it. +# Copied into each generated package, where this line becomes a `DO NOT EDIT` header. from __future__ import annotations diff --git a/src/graphql_codegen/runtime/operation.py b/src/graphql_codegen/runtime/operation.py index ff105bb..e19e584 100644 --- a/src/graphql_codegen/runtime/operation.py +++ b/src/graphql_codegen/runtime/operation.py @@ -1,4 +1,4 @@ -# Copied into each generated package, where this line says not to edit it. +# Copied into each generated package, where this line becomes a `DO NOT EDIT` header. from __future__ import annotations @@ -44,10 +44,7 @@ def __init__( 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. - """ + """Private.""" self._operation_type: Final = operation_type self._name: Final = name self._document: Final = document diff --git a/src/graphql_codegen/runtime/request.py b/src/graphql_codegen/runtime/request.py index 994ac50..c0aa505 100644 --- a/src/graphql_codegen/runtime/request.py +++ b/src/graphql_codegen/runtime/request.py @@ -1,4 +1,4 @@ -# Copied into each generated package, where this line says not to edit it. +# Copied into each generated package, where this line becomes a `DO NOT EDIT` header. from __future__ import annotations diff --git a/tests/__resources__/import_every_module.py b/tests/__resources__/import_every_module.py index 443a340..1c94d25 100644 --- a/tests/__resources__/import_every_module.py +++ b/tests/__resources__/import_every_module.py @@ -1,11 +1,8 @@ import importlib -import pkgutil import sys -name, *directories = sys.argv[1:] -sys.path[:0] = directories -package = importlib.import_module(name) +directory, dependencies, *names = sys.argv[1:] +sys.path[:0] = [directory, dependencies] -# `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) +for name in names: + importlib.import_module(name) diff --git a/tests/_bookshop.py b/tests/_bookshop.py index 050cc5e..3b5bc8a 100644 --- a/tests/_bookshop.py +++ b/tests/_bookshop.py @@ -3,6 +3,7 @@ from typing import Final BOOKSHOP_DIRECTORY: Final = Path(__file__).parents[1] / "bookshop" +BOOKSHOP_GENERATED_PACKAGE_DIRECTORY: Final = BOOKSHOP_DIRECTORY / "graphql" def copy_bookshop(directory: Path, /) -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 9c8b3b8..997beff 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,7 +5,7 @@ import pytest -from tests._bookshop import BOOKSHOP_DIRECTORY +from tests._bookshop import BOOKSHOP_DIRECTORY, BOOKSHOP_GENERATED_PACKAGE_DIRECTORY @pytest.mark.parametrize( @@ -22,7 +22,7 @@ def test_the_cli_generates_the_committed_bookshop_package( command: list[str], tmp_path: Path ) -> None: - generated = ("graphql", "*_graphql.py") + generated = (BOOKSHOP_GENERATED_PACKAGE_DIRECTORY.name, "*_graphql.py") for pattern in generated: # Guards the test: a pattern matching nothing would leave the copy holding what the command must write. diff --git a/tests/test_emit.py b/tests/test_emit.py index d59aaac..3dbae6e 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -1,5 +1,6 @@ import ast import json +import os import shutil import subprocess import sys @@ -11,56 +12,61 @@ from graphql import GraphQLSchema, build_schema, parse from graphql_codegen import Config, generate -from tests._bookshop import BOOKSHOP_DIRECTORY +from tests._bookshop import BOOKSHOP_DIRECTORY, BOOKSHOP_GENERATED_PACKAGE_DIRECTORY @pytest.mark.parametrize( ("path", "snippet"), [ pytest.param( - "schema/enum.py", + "schema.py", "type Genre = _typing.Literal['FICTION', 'NONFICTION', 'POETRY', 'CHILDREN']", id="an enum is a closed literal", ), pytest.param( - "schema/input.py", + "schema.py", "lines: _abc.Sequence[OrderLineInput]", id="a non-null input field without default is required", ), pytest.param( - "schema/input.py", + "schema.py", "giftWrap: _typing.NotRequired[_builtins.bool]", id="a non-null input field with a default is not", ), pytest.param( - "schema/input.py", + "schema.py", "coupons: _typing.NotRequired[_abc.Sequence[_builtins.str]]", id="nor is a list with a default", ), pytest.param( - "schema/input.py", + "schema.py", "note: _typing.NotRequired[_builtins.str | None]", id="a nullable input field may be left out or null", ), pytest.param( - "schema/input.py", + "schema.py", "type BookLookup = _BookLookup_id | _BookLookup_isbn", id="a oneOf input is a union of single-key dicts", ), pytest.param( - "schema/input.py", + "schema.py", "class _BookLookup_id(_compat.TypedDict, closed=True):\n id: _builtins.str", id="the key a oneOf member sets is not null", ), pytest.param( - "schema/input.py", + "schema.py", "from __future__ import annotations", id="input types reference each other lazily", ), pytest.param( - "schema/input.py", - "_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", + "schema.py", + 'type BookFilter = _BookFilter_genre | _BookFilter_author | _BookFilter_priceBelow | _BookFilter_and | _BookFilter_or | _BookFilter_not\n"""A recursive filter on books."""\n\nclass _BookFilter_genre(', + id="a oneOf input's union comes before its members", + ), + pytest.param( + "schema.py", + "_BookFilter_and = _compat.TypedDict('_BookFilter_and', {'and': _abc.Sequence[BookFilter]}, closed=True)", + id="so that a keyword key, declared functionally, names it directly", ), pytest.param( "../app_graphql.py", @@ -69,7 +75,7 @@ ), pytest.param( "../app_graphql.py", - "class _GetPublicationData_publication_Book(_GetPublicationData_publication_Book_Base, closed=True):\n title: _builtins.str\n pages: _builtins.int\n isbn: _scalar.ISBN\n\n", + "class _GetPublicationData_publication_Book(_GetPublicationData_publication_Book_Base, closed=True):\n title: _builtins.str\n pages: _builtins.int\n isbn: _schema.ISBN\n\n", id="a member gathers the fields of every condition it meets", ), pytest.param( @@ -104,7 +110,7 @@ ), pytest.param( "../app_graphql.py", - 'lookup: _input.BookLookup\n """An identifier or an ISBN."""', + 'lookup: _schema.BookLookup\n """An identifier or an ISBN."""', id="a variable's description is its docstring", ), pytest.param( @@ -134,9 +140,9 @@ def test_the_bookshop_package_holds( ("path", "snippet"), [ pytest.param( - "schema/input.py", "RestockInput", id="an input type no operation reaches" + "schema.py", "RestockInput", id="an input type no operation reaches" ), - pytest.param("schema/enum.py", "Membership", id="an enum no operation reaches"), + pytest.param("schema.py", "Membership", id="an enum no operation reaches"), pytest.param( "../app_graphql.py", "subscription OnOrderStatusChanged($orderId§", @@ -273,27 +279,31 @@ def test_a_document_goes_on_the_wire_triple_quoted_without_its_client_directive( pytest.param( 'query Q($withGenre: Boolean!) { book(lookup: {id: "1"}) { title genre @include(if: $withGenre) never: genre @include(if: false) always: genre @skip(if: false) } }', [ - " title: _builtins.str\n genre: _typing.NotRequired[_enum.Genre]\n always: _enum.Genre\n" + " title: _builtins.str\n genre: _typing.NotRequired[_schema.Genre]\n always: _schema.Genre\n" ], ["never: _"], id="a field skip or include may leave out is not required", ), pytest.param( 'query Q($withTitle: Boolean!) { book(lookup: {id: "1"}) { genre ...Title @include(if: $withTitle) } } fragment Title on Book { title }', - [" genre: _enum.Genre\n title: _typing.NotRequired[_builtins.str]\n"], + [ + " genre: _schema.Genre\n title: _typing.NotRequired[_builtins.str]\n" + ], ["(Title)", "(Title,", ", Title)", ", Title,"], id="a conditional spread is expanded, its keys not required", ), pytest.param( 'query Q($withGenre: Boolean!) { book(lookup: {id: "1"}) { title ... @include(if: $withGenre) { title genre } } }', - [" title: _builtins.str\n genre: _typing.NotRequired[_enum.Genre]\n"], + [ + " title: _builtins.str\n genre: _typing.NotRequired[_schema.Genre]\n" + ], [], id="a field selected unconditionally somewhere is required", ), pytest.param( 'query Q { __type(name: "Book") { kind } }', ["kind: _typing.Literal['SCALAR', 'OBJECT',"], - ["_enum."], + ["_schema."], id="an introspection enum is spelled where it is selected", ), pytest.param( @@ -352,7 +362,7 @@ def test_a_document_goes_on_the_wire_triple_quoted_without_its_client_directive( [ "class _QData_order(_compat.TypedDict, closed=True):\n id: _builtins.str" ], - ["_enum", "_input"], + ["_schema"], id="an operation reaching no enum and no input imports neither", ), ], @@ -395,13 +405,26 @@ def test_a_description_is_the_docstring_of_what_it_describes_whatever_its_quotes ] -def test_the_package_needs_nothing_but_the_standard_library(tmp_path: Path) -> None: +def test_every_generated_module_needs_nothing_but_the_standard_library( + bookshop_files: Mapping[PurePosixPath, bytes], tmp_path: Path +) -> None: if sys.version_info < (3, 15): spec = find_spec("typing_extensions") assert spec is not None assert spec.origin is not None shutil.copy(spec.origin, tmp_path) + # Every file `generate()` writes, sibling modules included. + modules = [ + os.path.relpath( + BOOKSHOP_GENERATED_PACKAGE_DIRECTORY / path.with_suffix(""), + BOOKSHOP_DIRECTORY.parent, + ) + .replace(os.sep, ".") + .removesuffix(".__init__") + for path in bookshop_files + ] + subprocess.run( [ sys.executable, @@ -410,9 +433,9 @@ 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.graphql", str(BOOKSHOP_DIRECTORY.parent), str(tmp_path), + *modules, ], check=True, ) diff --git a/tests/test_generate.py b/tests/test_generate.py index 270a55f..47773d0 100644 --- a/tests/test_generate.py +++ b/tests/test_generate.py @@ -45,7 +45,7 @@ pytest.param( _PRICE, Config(scalars=_MONEY), - "_scalar.py", + "schema.py", [ "from decimal import Decimal as _Decimal", "from my_app.money import decode as _decode", @@ -57,7 +57,7 @@ pytest.param( _PRICE, Config(scalars={"ISBN": Scalar(type="str")}), - "_scalar.py", + "schema.py", ["type ISBN = _builtins.str"], [], id="an identity scalar named by a bare name is a builtin", @@ -72,7 +72,7 @@ ) } ), - "_scalar.py", + "schema.py", [ "from ..money import Money as _Money", "from .. import decode_money as _decode_money", @@ -93,14 +93,14 @@ _SAVED_SEARCH, Config(struct_interface_name="Struct"), "document/GraphQL_request.py", - ["value: _input.BookFilter | None"], + ["value: _schema.BookFilter | None"], [], id="a struct payload is typed with its input type", ), pytest.param( _SAVED_SEARCH, Config(struct_interface_name="Struct"), - "schema/input.py", + "schema.py", ["type BookFilter = "], ["PlaceOrderInput"], id="which it reaches even when no variable does", @@ -117,7 +117,7 @@ 'query Q { savedSearch(name: "classics") { value @nonNull } }', Config(non_null_directive_name="nonNull", struct_interface_name="Struct"), "document/GraphQL_request.py", - ["value: _typing.Annotated[_input.BookFilter, _reflection.NON_NULL]"], + ["value: _typing.Annotated[_schema.BookFilter, _reflection.NON_NULL]"], [], id="and not optional when asserted non-null", ), @@ -132,7 +132,7 @@ pytest.param( _PLACE_ORDER, Config(injector_names={"idempotencyKey"}), - "schema/input.py", + "schema.py", ["class PlaceOrderInput("], ["idempotencyKey"], id="an injected field is left out of its input type", @@ -470,24 +470,6 @@ def test_a_fragment_of_another_document_is_imported_from_its_module( assert "books: _builtins.list[_card.Card]" in source -def test_a_name_the_document_package_re_exports_steps_aside_for_modules_then_operations( - bookshop_schema: GraphQLSchema, -) -> None: - # Not rejected: a document is easy to rename the day it matters. - files = _generated( - { - "Get.graphql": "query Other { books { title } }", - "other.graphql": "query Get { books { ...Other } } fragment Other on Book { title }", - }, - sibling_module_name=None, - bookshop_schema=bookshop_schema, - ) - reexports = files[PurePosixPath("document/__init__.py")].decode() - - assert "from .Get import Other as Other" in reexports - 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_and_is_imported_absolutely_elsewhere( bookshop_schema: GraphQLSchema, ) -> None: @@ -571,7 +553,7 @@ def test_a_document_s_module_holds_its_operations_importing_the_package_absolute for snippet in [ "from api import runtime as _runtime", - "from api.schema import enum as _enum\n", + "from api import schema as _schema\n", "class GetData_1(_compat.TypedDict, closed=True):", "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=", @@ -579,21 +561,6 @@ def test_a_document_s_module_holds_its_operations_importing_the_package_absolute assert snippet in source -def test_the_document_package_re_exports_lazily_from_python_3_15( - bookshop_schema: GraphQLSchema, -) -> None: - source = generate( - document=parse("query Q { books { title } }"), - schema=bookshop_schema, - config=Config(), - )[PurePosixPath("document/__init__.py")].decode() - - assert ( - source - == "# Code generated by graphql-codegen. DO NOT EDIT.\n\n__lazy_modules__ = [__name__ + '.GraphQL_request']\n\nfrom .GraphQL_request import Q as Q\n" - ) - - def test_a_fragment_s_type_follows_those_of_the_fragments_it_spreads( bookshop_schema: GraphQLSchema, ) -> None: diff --git a/tests/test_naming.py b/tests/test_naming.py index e926c3b..422b258 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -55,6 +55,8 @@ # Before `str`, which references it: pyrefly reads a forward reference named like a builtin as the builtin. input input { x: Int + # A key no class body can declare, of an enum defined after it. + class: Later } input str { @@ -74,6 +76,10 @@ enum None { X } + +enum Later { + Y +} """ _DOCUMENT: Final = """ @@ -194,11 +200,6 @@ def test_every_module_binds_each_name_once( "\n__debug___: _typing_1.Final = _runtime.Operation(", id="and so does `__debug__`, the one other name that cannot be", ), - pytest.param( - "schema/__init__.py", - "from .enum_1 import *\nfrom .input_1 import *\n", - id="and a schema module for the types named like it", - ), ], ) def test_a_public_name_is_exact_or_pep_8_escaped_and_an_added_one_steps_aside( diff --git a/uv.lock b/uv.lock index 27045d1..50bb693 100644 --- a/uv.lock +++ b/uv.lock @@ -125,7 +125,7 @@ wheels = [ [[package]] name = "graphql-codegen" -version = "0.2.1" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "graphql-core" }, @@ -193,20 +193,20 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.13.1" +version = "2.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/ce417c397afa5181f0e3c6ff01a4d01639414e1adef08730d97571297151/httpcore2-2.16.0-py3-none-any.whl", hash = "sha256:5c4358319f2602d984ad93182c9fee5160f22e9da7babdda870a5e7c4e2dc34f", size = 85070, upload-time = "2026-10-21T07:09:42.802Z" }, ] [[package]] name = "httpx2" -version = "2.13.1" +version = "2.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform != 'emscripten'" }, @@ -216,9 +216,9 @@ dependencies = [ { name = "truststore", marker = "sys_platform != 'emscripten'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/73/5ca2cedf5e4e50423d1c258877865a6b62793633b561c58499f8aeb33b7e/httpx2-2.16.0.tar.gz", hash = "sha256:0d54c98128e8f2ff7fbbde0fca8c359e5f032edeaaaac443cdb7f31f685bcd7f", size = 103116, upload-time = "2026-10-21T07:09:46.348Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3d/2e4bc4fd1ef9770871669adb348d612a6e4786b303a23f9935b81c48dd60/httpx2-2.16.0-py3-none-any.whl", hash = "sha256:fd2c5d7909e8d86fe92d53020c310ca0e4e431b838c207c5754b9bbf0308d49a", size = 97880, upload-time = "2026-10-21T07:09:44.01Z" }, ] [[package]] @@ -284,28 +284,28 @@ wheels = [ [[package]] name = "pluggy" -version = "1.6.0" +version = "1.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, ] [[package]] name = "prek" -version = "0.5.5" +version = "0.5.7" source = { registry = "https://pypi.org/simple" } -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" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/0c/985c6f0fe1671e639fb97bf5a60f3945369f26d4ccf5a8a1b063d357e6da/prek-0.5.7.tar.gz", hash = "sha256:91049700f0f13b4575deb44045dd6dd51f7c4a70c53ec5d452b09925962e3b35", size = 580753, upload-time = "2026-10-21T00:46:48.231Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/0c/36/56c107d100cabfb7e7c7f2e62d363052c77c3fcf664707b66ef9e3b865a4/prek-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:621835811891d11d3feb5ca47102ca97973c5b4e6fbd1b35b1293d412d29d9cc", size = 6062121, upload-time = "2026-10-21T00:46:31.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/59/1868e2d6b3581eb9f327cb7db4270c4f38e80c5b175517e439e106eac704/prek-0.5.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ef10cf46c0660b011fc067b7ce20dc78f2c44b6f905bf8dc49402353f67283b1", size = 5686422, upload-time = "2026-10-21T00:46:34.006Z" }, + { url = "https://files.pythonhosted.org/packages/7f/e3/90901432ee7ca288b46872552ca8f658b2262cf56d9af3c1eb0416f206da/prek-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:b38075f9363066d72d1cd887b5a260ec938516fea9ea44def53e592ee75090d5", size = 5817816, upload-time = "2026-10-21T00:46:35.869Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/f8e24b15906e433850a2c8409ae3a9270724b2a5d4a71b8c4eadafeb201b/prek-0.5.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6a8f7734931c4b6a5f2efca121fabed7979408df233ce6d9b043f4148873021c", size = 6276177, upload-time = "2026-10-21T00:46:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/4b/15/c83456872211706839ff326474aa9f2d870eef7ee5ec2c436446519779d2/prek-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c139c494fb1d9826688f8107d1514fa2a58f8f4a0f0527b68e7970fa50a8b723", size = 6140221, upload-time = "2026-10-21T00:46:39.29Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b5/996b05447d4c2a4399947a7c5c910b2cd2c544b3074c11153ae3e21b4112/prek-0.5.7-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:e86139ab5662124818dc1957854d8281f635414016e545ab72e63365c995824b", size = 5812638, upload-time = "2026-10-21T00:46:41.006Z" }, + { url = "https://files.pythonhosted.org/packages/83/e5/60e0e76cd6819d75026b3a8147ddbd2b3fcab27c02236bce8b3a559e0e64/prek-0.5.7-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:94bd6a93ccf8ebb1d30b822161515e3e31ea916ce7411febe8ceb5f607eb840d", size = 6298681, upload-time = "2026-10-21T00:46:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/54/97/e14b12e7d8fbdb468581d778fb892c855fbca325d4344cc535c12e2851a5/prek-0.5.7-py3-none-win_amd64.whl", hash = "sha256:fa69837b6d1d2b6be5fe7fe4cd27fd5606f2155e784fec55ee7df7ed9f9bf211", size = 5692294, upload-time = "2026-10-21T00:46:44.385Z" }, + { url = "https://files.pythonhosted.org/packages/bf/28/9dc15c0644cc6019c16c5cdd44e5004b641f5e910488f830e80dfdd11042/prek-0.5.7-py3-none-win_arm64.whl", hash = "sha256:239ccb081e3766d83a6a9a5ebee1d5cbb7986fc8e4f230e785423d409cff95e3", size = 5626111, upload-time = "2026-10-21T00:46:46.341Z" }, ] [[package]] @@ -319,21 +319,21 @@ wheels = [ [[package]] name = "pyrefly" -version = "1.3.2" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } -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" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/3e/ea6893be167fa14307d7f9a924bed9ecbbbd91b01953890b9b3c58ed5359/pyrefly-1.4.0.tar.gz", hash = "sha256:bb57c004a4842cfe93f93aebb5203ca6209a5f0c8bda47e8bc289e70940fd910", size = 6962062, upload-time = "2026-10-15T16:54:11.819Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/6a/17/dfce6611acc00cb26667e5e1011438cc510f3067f961e0f07f7a0c68f416/pyrefly-1.4.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:460cec39bc8743915e9c2c8c10ba926ad0b29b099d89c1859fb97e0059f42fa7", size = 15705624, upload-time = "2026-10-15T16:53:43.412Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a7b96442123fa6ed733de0d1ea933e35d6fc6b7c9cf16e05279abb493493/pyrefly-1.4.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a6370d686b6cbbac8869832f09a52012888371956fbc68d446a349bc85ccd43", size = 14978669, upload-time = "2026-10-15T16:53:46.546Z" }, + { url = "https://files.pythonhosted.org/packages/23/97/dfcfec6a9d8948ab4b2746f7e87558a82bffabb7a54c22e7d3ce4bdc1932/pyrefly-1.4.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f1b16923a6583a6764e8aec07787e2ef4df644c7030294cba71339783a9451c", size = 15438858, upload-time = "2026-10-15T16:53:48.904Z" }, + { url = "https://files.pythonhosted.org/packages/31/12/6fa414aa87e398d6ddc7145a5d993b0975fc3511198dbb72c47873653ab6/pyrefly-1.4.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:688ad4cb4d6e711d67026fdcfb37b599f86061efe11ba5704287330e7041a69e", size = 16852241, upload-time = "2026-10-15T16:53:51.496Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/2d7312262cf6481d6ad46eed55b8f2592c3cff60f95cc2f30a114cd1484e/pyrefly-1.4.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5f3c9dddb6f3128beb49cf4e0f5cb88d4d17f039008d8e0c2be653966acd234", size = 16751917, upload-time = "2026-10-15T16:53:54.39Z" }, + { url = "https://files.pythonhosted.org/packages/2c/65/b44a1cb23667bcbcdc88558cbef79fa9e07a1152c2a1da148036b1a2e0d1/pyrefly-1.4.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6951f51687304caebfedf13dd1a943aeafdbc95abcc4e24a683b97467a77007", size = 16066345, upload-time = "2026-10-15T16:53:57.436Z" }, + { url = "https://files.pythonhosted.org/packages/a6/07/7ca6ff0c53e3a90ecdab05790d33f430fca5c48687613a0b182744166d98/pyrefly-1.4.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:838de4d4a8b9b8ed39bedd0b8b55629c69661d73a6fa3a3a4adff24b9dbeb399", size = 15461223, upload-time = "2026-10-15T16:54:00.364Z" }, + { url = "https://files.pythonhosted.org/packages/61/be/ec7b2f38a898ce5b2219dda730e87d31fc8cfb2108a3d5844f9d590f1281/pyrefly-1.4.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:afb9ce2e0ae3e6106fb45f6d47536ac92b6423e601e08401cdefa850066444f5", size = 16119099, upload-time = "2026-10-15T16:54:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/6e/16/1b9549168b38a575d9c168e17b4b4ee30028d602ec0f7b07c198ef265dbe/pyrefly-1.4.0-py3-none-win32.whl", hash = "sha256:839db536d0ac29f1b23e2d974fb911075c2283e3e623755c45d826aafac175bc", size = 14858880, upload-time = "2026-10-15T16:54:05.114Z" }, + { url = "https://files.pythonhosted.org/packages/03/02/18f075d4de0e255f184f42fc28c1f0ecc9f49dd528678479fc90cf12246c/pyrefly-1.4.0-py3-none-win_amd64.whl", hash = "sha256:7aff37c2b711c8240ed650da0c6148237b327bf7e5dfacee65884f1adea22305", size = 15726220, upload-time = "2026-10-15T16:54:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/92/62/92947076681550904c72a9dd03b24e92694ede9a48548f5e487c849e66ea/pyrefly-1.4.0-py3-none-win_arm64.whl", hash = "sha256:88879f0cb749e5fea81d9ff4c2d5431a70349c458bcdf0b61de72c79214d90a5", size = 14943690, upload-time = "2026-10-15T16:54:09.689Z" }, ] [[package]] @@ -418,27 +418,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.17.0" +version = "0.17.1" source = { registry = "https://pypi.org/simple" } -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" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e7/e6d5749983b020e6cb1055abec0a6f361a8e94f0184cdcf56d4760995f02/ruff-0.17.1.tar.gz", hash = "sha256:5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f", size = 5323349, upload-time = "2026-10-15T15:23:18.64Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/2e/78/90c46ca6250709f7c37e397b60d06061adee8d0f7d13959ed6a8e49b14fd/ruff-0.17.1-py3-none-linux_armv6l.whl", hash = "sha256:1796c41eafec66f4635125dd1e074a7aa27b8d20c187d484360acf11d247fc4a", size = 10147528, upload-time = "2026-10-15T15:22:33.667Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d2/a2e28bc35b083ae51fa07dfba5280a667c8b01ecaee7802fc8849dcd5c46/ruff-0.17.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8eadb09a788010be29c895418bdfa513e4e1c65e64b94e5b19e4cd381f6cc7fb", size = 10225974, upload-time = "2026-10-15T15:22:36.328Z" }, + { url = "https://files.pythonhosted.org/packages/8e/71/7a9029a81c8206ee01a98f6cfeeb56e6c095af86857581a37045c46d7e94/ruff-0.17.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:29b454f18ca3eeaf37fbf140841dcf27d36109e29dee9aae8acecb68c26c6f74", size = 9270361, upload-time = "2026-10-15T15:22:38.651Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/e075e9c917cb0575297880c96768304e881240171c870cbd8d9b7b54bec2/ruff-0.17.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872e03dd231744d6c5ec5c8af6be52b71323fc6ee096e4c8b34d9f844ee3b978", size = 9938300, upload-time = "2026-10-15T15:22:41.284Z" }, + { url = "https://files.pythonhosted.org/packages/87/27/0b4bb926a49de2383f161272b4d863571a4c7ecdbf595a6bc58813a13367/ruff-0.17.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c0d6cb5f8fbac77eec9af49f4fc0eaf856b05545e372b0694d24b4e76b1c640", size = 9948464, upload-time = "2026-10-15T15:22:43.681Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/518d57e0e02aecb8ea4acac6b57b51a53173d25fa734952bfdd7d531e88e/ruff-0.17.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78beced4a3c94dfedbee4b999b95b6a27b35820fdc0afbacc6d37a8da26c1cd1", size = 10638639, upload-time = "2026-10-15T15:22:46.081Z" }, + { url = "https://files.pythonhosted.org/packages/01/82/d89a4e498ade59e6cee52d67f8da569019372212b87468c1fdeae884601c/ruff-0.17.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7feefc63921202894f0e158b0e32f45a5950d70a7fb693ccb9264816c25b1c05", size = 11337238, upload-time = "2026-10-15T15:22:48.894Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7c/3b0fdef9352656317c977086fe1d802902406e4f2a0d8578da90c0e6d1c5/ruff-0.17.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684154861a819bf1f433669d2a0ce9c3e54677c65b8ebb866cc2a7ec86345785", size = 10896327, upload-time = "2026-10-15T15:22:51.45Z" }, + { url = "https://files.pythonhosted.org/packages/62/86/5566290219708ce2928f3a29c4f843a1185b341ec6111c4631a33c5ceced/ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189", size = 10350811, upload-time = "2026-10-15T15:22:54.401Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d2/ca8462dabb3c413990d49703ba306c30c862195a75448d080fa8fcce9775/ruff-0.17.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8f9aa5b5bd5fa1937803ea609c8f153b8de39bf3e0e149897ae34b0c5b52f467", size = 10569994, upload-time = "2026-10-15T15:22:57.142Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ad/dd1864c1f1db17c8cfb297ce70b352388e6977620f66f7182c52855407df/ruff-0.17.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:80e53b0e5faa3c5667f035daaa032893ae42b7cfe708c886d39ed131e54688ab", size = 10059672, upload-time = "2026-10-15T15:23:00.084Z" }, + { url = "https://files.pythonhosted.org/packages/65/45/3f56cf1d2168c2165cdd5ac6f9668aee66a4cfdd4caf2b2cc5ccb2b1d568/ruff-0.17.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:056c5d5fa0ac790051ae165d7035c7bf956d02de2db557f2e9b31d877a505fca", size = 9947803, upload-time = "2026-10-15T15:23:03.132Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4b/5457cb2be4c1d1ea5e919558131a0f197a4a454a0530d9e30820e8206e4c/ruff-0.17.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd8f5a55b10f371642eacd0e8665a42a8b97da15c9a7187c79e697b77b5e0ce4", size = 10351807, upload-time = "2026-10-15T15:23:05.628Z" }, + { url = "https://files.pythonhosted.org/packages/49/fb/db15b4f8fa24a1e36b99e1401f42ef6df16ee299a48670d5d262b771e7d7/ruff-0.17.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ce05e6f3973611d9550a600e04bb531061e368baba31610eb57c1509a8f21a0a", size = 10765716, upload-time = "2026-10-15T15:23:08.092Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e8/1e186578076aff7a80735f625005fde678412ab609cd323ed182eb7d6b87/ruff-0.17.1-py3-none-win32.whl", hash = "sha256:1de0030a8aa78c65c5fecb84b2e5c390ac684c0452875a02afc7c083d49a496a", size = 9895093, upload-time = "2026-10-15T15:23:10.582Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2e/da3d9589bae8e8b58f3a48bd5bc0b980232fb14a83bf02c3be7660f87c1b/ruff-0.17.1-py3-none-win_amd64.whl", hash = "sha256:5df76f16580fab58d0f6c98a3ac254a4abceb7b2489b8b678335869cff77b3d7", size = 10097955, upload-time = "2026-10-15T15:23:13.309Z" }, + { url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" }, ] [[package]] @@ -452,27 +452,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.87" +version = "0.0.90" source = { registry = "https://pypi.org/simple" } -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" } +sdist = { url = "https://files.pythonhosted.org/packages/68/b1/613b5534624442a9118828d7a9e54d50b16f0f5c8b67c80eb6623f33b921/ty-0.0.90.tar.gz", hash = "sha256:25cef63ec07a0254c2f5d885a63b001d0bfa437b20caa0a408f4f52e66b133e7", size = 7996206, upload-time = "2026-10-20T14:25:19.477Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/b2/33/0e3395afa2c31c0e8e91547629858d2a5de213c69af237e6e50f37545565/ty-0.0.90-py3-none-linux_armv6l.whl", hash = "sha256:0dedb54977b40bc86638fec2c90eefd593f3a6683003a86b7e6cb67c17bcc476", size = 14281945, upload-time = "2026-10-20T14:24:21.018Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/5d632b2f88d5c18991a4936da8643c7f204cfbc4980f8c7cd9080af988ad/ty-0.0.90-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3d7c30ceaac76dfbfcf36b72d36272d21c3898e98fc4de296aa9cbae956dec9d", size = 13786355, upload-time = "2026-10-20T14:24:24.392Z" }, + { url = "https://files.pythonhosted.org/packages/79/08/0d2f37bb4f766ab9630f01b07f4d11f1f7a6a6188f920d5ca9fa8a4676b1/ty-0.0.90-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c0b7e4d9ae0939294f0a18652de9ad7d81da2d7f567cf93f1edda1263008c124", size = 12856535, upload-time = "2026-10-20T14:24:27.386Z" }, + { url = "https://files.pythonhosted.org/packages/e4/77/70658f6b80c345ae0c12f2192a5cc89b8ca13eccfe06616b0fac3806c261/ty-0.0.90-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76c4a095a86a132df374e652894fe08392aeb1601d6347e1eb4b58808094a302", size = 13696342, upload-time = "2026-10-20T14:24:30.413Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3e/693049f93653d2f6412bb0c2c0af1d01675e31643af29bef7177d3ff7e79/ty-0.0.90-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4e24a8aa07c05d9d14648289e79a88b7c7c2c522c58b53bb5f466a6ba4704e22", size = 14014180, upload-time = "2026-10-20T14:24:33.783Z" }, + { url = "https://files.pythonhosted.org/packages/27/08/19e373f97501eaadc8fc42e8c07cd54a64e375fbcf1470797be13590d5ba/ty-0.0.90-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40c3ea8fc1dfcf7502f71b93b12e80da1f7a8a5fd7e55ec9feabcf4ac39f72ea", size = 14935189, upload-time = "2026-10-20T14:24:37.459Z" }, + { url = "https://files.pythonhosted.org/packages/33/54/3331984f2c896a7383b6b18c86e968a63dd046baf503119a8e8e7682c752/ty-0.0.90-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1af75532bbe3e305089c30806fa5e804f8b01de231c1fa07918f7f3b335626da", size = 15311482, upload-time = "2026-10-20T14:24:40.87Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f3/179acf8d730c2c864767ffcc9081f5ea6fdb1b34ef711f1bd226200e9cff/ty-0.0.90-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f09449b34d837a044dab043aeb2b4b6ff2d2cef5eaf8116c7c19d34cc2a16a21", size = 15278844, upload-time = "2026-10-20T14:24:44.539Z" }, + { url = "https://files.pythonhosted.org/packages/71/7e/661bc2fa20e0ac84c4d5f92e4c098ec667a0e920851a6d10c10a0251f0dc/ty-0.0.90-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc868ffa7dfcb3169eea3ba7d634e5a3283c7133fb932c6e47a2e16620d26ca5", size = 14558774, upload-time = "2026-10-20T14:24:47.814Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2e/3f65f61f4545f75255373be7646a606e004bb4cdc851e236bf17ca94bdc2/ty-0.0.90-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e2405206533dc896a2b711e781f2955d5883d85536f4df0ac262fca6517103c2", size = 14788158, upload-time = "2026-10-20T14:24:51.223Z" }, + { url = "https://files.pythonhosted.org/packages/1e/10/3c91a529591667b89d63c86806bfe2ac56d6989c7e0a09ee0964b7512deb/ty-0.0.90-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0dca4a29e8778285d2fc204af19254571486106fd14aed60769bfd272c795f6f", size = 13687177, upload-time = "2026-10-20T14:24:54.611Z" }, + { url = "https://files.pythonhosted.org/packages/21/b2/1b6d48606c67614bfb0c8e61a5d67a053ca1e3c5956ddb584f8569b8dc3d/ty-0.0.90-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d004b8e4e898fead929eceea669b73936444671748e0dd44e624cc109346c0dd", size = 14027780, upload-time = "2026-10-20T14:24:59.045Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c4/a0771debf725312524ca1434f8a146d37e573d45257ca4bebb9cc8e35bbc/ty-0.0.90-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a6be182da4281b4581c4a1b27c6a0caebeed7212e4bdc98c6b8fe0d9bc36dea0", size = 14355890, upload-time = "2026-10-20T14:25:03.034Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4c/036724b6936ffc38e28ff5e20ab7972d609c65a3dd9645f3d10f57cfd16f/ty-0.0.90-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0ba6fe0a24ff4cbc5034f3aa1c88b3ad1397315dd3369357e694fd157a11864e", size = 14692306, upload-time = "2026-10-20T14:25:07.461Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2a/c74fd5fc3fb5b046fe06a9067cabf5417b73e28249dc9af90ef72700f8fd/ty-0.0.90-py3-none-win32.whl", hash = "sha256:4c46a79caf84c4da0f7293e16bd769bd467c7cbc38015cac7608ab4e5ec8ba9c", size = 13235434, upload-time = "2026-10-20T14:25:10.545Z" }, + { url = "https://files.pythonhosted.org/packages/d2/54/be5cd6b17d8087a5e4c26892311c07e39d42b66c925b0f06cfc51a9ce83b/ty-0.0.90-py3-none-win_amd64.whl", hash = "sha256:8d77c16d20201b7a07ccaafb610eaf60488783dbba9865c4f9182e584f1426f8", size = 13855668, upload-time = "2026-10-20T14:25:13.648Z" }, + { url = "https://files.pythonhosted.org/packages/33/68/6dea089a897e8b314504eecc2633de3c8fe14f70e71a35606d14a2ba169e/ty-0.0.90-py3-none-win_arm64.whl", hash = "sha256:bfb63e74eeedb0a29d49cf58d611c540bfdfc0975dfce26efc6575bcf439208d", size = 13330853, upload-time = "2026-10-20T14:25:16.642Z" }, ] [[package]]