From cb7885427a6be72fdbd028b905a2fe98f1e0f321 Mon Sep 17 00:00:00 2001 From: Derek Foster Date: Wed, 21 Oct 2026 20:49:38 +0000 Subject: [PATCH 1/6] Replace the gql and ratelimit transports with one requests-based client One transport sends every request, the way node-anvil's does: - `Anvil(api_key, base_url=...)` derives every URL from one host, REST and GraphQL alike. `endpoint_url` and the separate graphql.useanvil.com host are gone. OAuth access tokens are accepted (`access_token`), sent as-is when they are JWTs and base64 encoded otherwise. - Rate limits come from the first response's X-RateLimit headers, over a sliding window like the API's, and a 429 is retried after its Retry-After plus 50ms. Requests made before the first response wait for it. The limiter is thread-safe. - API errors raise AnvilRequestError with the normalized errors, status code, headers and any partial data, for REST and GraphQL alike. Methods return the data itself. - Multipart uploads follow the GraphQL multipart request spec directly. `prepare_graphql_file` takes a path, bytes or a file object; files given as paths are reopened for a retry, file objects rewound. - The queries and mutations live in python_anvil.graphql, copied from node-anvil's src/graphql, and are validated against the API schema from @anvilco/types in the tests. That check found that `casts` and `welds` are paginated now, which the old queries didn't match. - New: get_etch_packet, remove_weld_data, request_graphql, request_rest, response_query and mutation overrides, data_type="stream", a request timeout, and test-client.py for running every method against staging. The HTTP tests run the real client against a local server, so the auth encoding, multipart bodies, rate limit timing and error handling are checked on the wire. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 22 + Makefile | 4 + dev/fetch_schema.py | 63 + docs/advanced/dynamic_queries.md | 45 - examples/create_etch_markdown.py | 2 +- examples/create_etch_markup.py | 2 +- examples/create_etch_upload_file_multipart.py | 29 +- examples/make_graphql_request.py | 113 +- poetry.lock | 590 +-- pyproject.toml | 10 +- python_anvil/__init__.py | 24 +- python_anvil/api.py | 853 ++-- python_anvil/api_resources/mutations/base.py | 26 +- .../mutations/create_etch_packet.py | 85 +- .../api_resources/mutations/forge_submit.py | 69 +- .../mutations/generate_etch_signing_url.py | 16 +- python_anvil/api_resources/payload.py | 2 + python_anvil/api_resources/requests.py | 182 - python_anvil/auth.py | 60 + python_anvil/cli.py | 281 +- python_anvil/constants.py | 46 +- python_anvil/errors.py | 126 + python_anvil/exceptions.py | 11 +- python_anvil/graphql/__init__.py | 11 + python_anvil/graphql/mutations/__init__.py | 14 + .../graphql/mutations/create_etch_packet.py | 75 + .../graphql/mutations/forge_submit.py | 56 + .../mutations/generate_etch_sign_url.py | 12 + .../graphql/mutations/remove_weld_data.py | 9 + python_anvil/graphql/queries/__init__.py | 4 + python_anvil/graphql/queries/etch_packet.py | 41 + python_anvil/http.py | 350 +- python_anvil/rate_limit.py | 183 + python_anvil/upload.py | 265 ++ schema/anvil_schema.graphql | 3500 ++++++++++------- test-client.py | 138 +- tests/conftest.py | 64 + tests/server.py | 154 + tests/test_api.py | 942 +++-- tests/test_auth.py | 72 + tests/test_cli.py | 151 +- tests/test_errors.py | 137 + tests/test_graphql_operations.py | 114 + tests/test_http.py | 834 +++- tests/test_rate_limit.py | 156 + tests/test_upload.py | 265 ++ 46 files changed, 6416 insertions(+), 3792 deletions(-) create mode 100644 dev/fetch_schema.py delete mode 100644 docs/advanced/dynamic_queries.md delete mode 100644 python_anvil/api_resources/requests.py create mode 100644 python_anvil/auth.py create mode 100644 python_anvil/errors.py create mode 100644 python_anvil/graphql/__init__.py create mode 100644 python_anvil/graphql/mutations/__init__.py create mode 100644 python_anvil/graphql/mutations/create_etch_packet.py create mode 100644 python_anvil/graphql/mutations/forge_submit.py create mode 100644 python_anvil/graphql/mutations/generate_etch_sign_url.py create mode 100644 python_anvil/graphql/mutations/remove_weld_data.py create mode 100644 python_anvil/graphql/queries/__init__.py create mode 100644 python_anvil/graphql/queries/etch_packet.py create mode 100644 python_anvil/rate_limit.py create mode 100644 python_anvil/upload.py create mode 100644 tests/conftest.py create mode 100644 tests/server.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_errors.py create mode 100644 tests/test_graphql_operations.py create mode 100644 tests/test_rate_limit.py create mode 100644 tests/test_upload.py diff --git a/CHANGELOG.md b/CHANGELOG.md index aa965544..104328f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- BREAKING: Drop support for Python 3.8 and 3.9, which are end of life. The minimum supported version is now Python 3.10. +- Add support for Python 3.14 and 3.15 ([#95](https://github.com/anvilco/python-anvil/issues/95)). +- BREAKING: The `anvil` command line tool's dependencies, `click` and `tabulate`, are now the `cli` extra: `pip install 'python-anvil[cli]'` ([#96](https://github.com/anvilco/python-anvil/issues/96)). Importing `python_anvil` no longer imports the CLI. +- BREAKING: One `requests`-based transport replaces `gql` for GraphQL and the `ratelimit` decorators for REST. Both dependencies are dropped. + - `Anvil(api_key, base_url=...)` sends every request to one host: REST to `{base_url}/api/...`, GraphQL to `{base_url}/graphql`. `base_url` defaults to production. The `endpoint_url` argument is removed, and `environment` does nothing: the rate limits now come from the API. + - OAuth access tokens are supported: `Anvil(access_token=...)`. JWTs are sent as-is, other tokens base64 encoded, like node-anvil. + - Rate limits are learned from the first response's `X-RateLimit-Limit` and `X-RateLimit-Interval-Ms` headers. Requests made before that response arrives wait for it. The client counts requests over a sliding window, like the API, and only waits when the limit has been reached. A `429` is retried after its `Retry-After` plus 50ms, for as long as it takes. The `retry` option is removed. + - API errors raise `AnvilRequestError`, with the normalized `errors` (GraphQL errors, REST errors and validation errors with their `fields`), the `status_code`, the `headers` and any partial `data`. The REST methods used to raise a bare `Exception`; GraphQL methods raised `gql`'s errors. `AnvilRequestException` and `AnvilException` remain as aliases. + - Methods return the data: the PDF or zip `bytes` for `fill_pdf`, `generate_pdf` and `download_documents`, and the response's `data` dict for the GraphQL methods. The `include_headers` and `debug` options are removed; the headers are on `AnvilRequestError`. + - `data_type="stream"` on the binary methods returns the `requests.Response`, to stream a large PDF or zip. + - Requests time out after 5 minutes by default (`timeout`). They never did before. + - A `requests.Session` can be passed in (`session`), e.g. to use a proxy, and `user_agent`, `request_limit` and `request_limit_ms` match node-anvil's options. +- BREAKING: `request_rest` now sends a request, `request_rest("POST", "/api/v1/...", json=...)`, like node-anvil's `requestREST`. The `RestRequest`, `PlainRequest`, `GraphqlRequest` and `FullyQualifiedRequest` classes and `request_fully_qualified` are removed. +- BREAKING: `generate_etch_signing_url` returns the URL. `get_etch_packet` returns the packet, and `remove_weld_data` whether the data was removed. +- BREAKING: `query` and `mutate` take the operation as a string; `gql` documents and its DSL are gone. `request_graphql` is the same thing with node-anvil's name. +- BREAKING: `get_casts` and `get_welds` query the API's paginated `casts` and `welds` fields, which the old queries no longer matched, and return up to 500 per organization. `get_current_user` no longer selects each organization's casts. +- Add `get_etch_packet` and `remove_weld_data`, and `response_query` and `mutation` options on `create_etch_packet`, `forge_submit` and `get_etch_packet`, matching node-anvil. +- Add `Anvil.prepare_graphql_file` (also `python_anvil.prepare_graphql_file`) for multipart uploads of a path, bytes or a file object, anywhere in a mutation's variables. Files given as open file objects used to be base64 encoded into the JSON; they still are unless prepared this way. +- The `createEtchPacket` mutation gains `organizationEid` and `allowUpdates`, and the mutations and default response fields now match node-anvil's exactly. +- The client's queries and mutations are checked against the API schema in CI, and `make update-schema` refreshes the schema from the published `@anvilco/types` package. +- Add `test-client.py`, which runs every method against a real environment: `ANVIL_API_KEY=... make test-client`. + ## [v5.0.3] - 2025-02-24 - Package import now uses `importlib.metadata` to get the version and throws `PackageNotFoundError` if the package is not diff --git a/Makefile b/Makefile index 79559ce2..624c9d34 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,10 @@ ci: lint test ## Everything CI runs test-client: install ## Run every method against a real environment (staging by default) poetry run python test-client.py +.PHONY: update-schema +update-schema: install ## Refresh schema/anvil_schema.graphql from the published @anvilco/types + poetry run python dev/fetch_schema.py + .PHONY: build build: install ## Build the sdist and wheel into dist/ rm -rf dist diff --git a/dev/fetch_schema.py b/dev/fetch_schema.py new file mode 100644 index 00000000..06d13e6f --- /dev/null +++ b/dev/fetch_schema.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Refreshes schema/anvil_schema.graphql from the published @anvilco/types package. + +@anvilco/types (https://www.npmjs.com/package/@anvilco/types) ships the Anvil +GraphQL schema, regenerated from production after every deploy. node-anvil +validates its operations against that copy, and so does this client: using +the same source keeps the two clients in step, and needs no API key. + +Usage: + poetry run python dev/fetch_schema.py [version] + +Defaults to the latest version. On any failure the existing file is untouched. +""" + +from __future__ import annotations + +import io +import json +import sys +import tarfile +import urllib.request +from pathlib import Path + + +PACKAGE = "@anvilco/types" +REGISTRY = "https://registry.npmjs.org" +SCHEMA_IN_PACKAGE = "package/schema/anvil-schema.graphql" +OUTPUT_PATH = Path(__file__).resolve().parent.parent / "schema" / "anvil_schema.graphql" + + +def fetch_schema(version: str = "latest") -> tuple[str, str]: + """The schema SDL in the given version of @anvilco/types, and that version.""" + metadata_url = f"{REGISTRY}/{PACKAGE.replace('/', '%2F')}" + with urllib.request.urlopen(metadata_url) as response: + metadata = json.load(response) + + if version == "latest": + version = metadata["dist-tags"]["latest"] + tarball_url = metadata["versions"][version]["dist"]["tarball"] + + with urllib.request.urlopen(tarball_url) as response: + tarball = response.read() + + with tarfile.open(fileobj=io.BytesIO(tarball), mode="r:gz") as archive: + member = archive.extractfile(SCHEMA_IN_PACKAGE) + if member is None: + raise FileNotFoundError( + f"{SCHEMA_IN_PACKAGE} is not in {PACKAGE}@{version}" + ) + return member.read().decode("utf-8"), version + + +def main() -> None: + version = sys.argv[1] if len(sys.argv) > 1 else "latest" + schema, version = fetch_schema(version) + OUTPUT_PATH.write_text(schema) + print( + f"Wrote the schema from {PACKAGE}@{version} to {OUTPUT_PATH.relative_to(Path.cwd())}" + ) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/dynamic_queries.md b/docs/advanced/dynamic_queries.md deleted file mode 100644 index 33c8e887..00000000 --- a/docs/advanced/dynamic_queries.md +++ /dev/null @@ -1,45 +0,0 @@ -## Dynamic query building with `gql` - -This library makes use of [`graphql-python/gql`](https://github.com/graphql-python/gql) as its GraphQL client -implementation. This allows us to have a simpler interface when interacting with Anvil's GraphQL API. This also gives us -the ability to use gql's dynamic query -builder. [More info on their documentation page](https://gql.readthedocs.io/en/latest/advanced/dsl_module.html). - -We have a few helper functions to help generate your first dynamic query. These are shown in the example below. -Keep in mind that your IDE will likely not be able to autocomplete any field lookups, so it may help to also -have [Anvil's GraphQL reference page](https://www.useanvil.com/docs/api/graphql/reference/) open as you create your -queries. - -### Example usage - -```python -from gql.dsl import DSLQuery, dsl_gql - -from python_anvil.api import Anvil -from python_anvil.http import get_gql_ds - -# These steps are similar to `gql's` docs on dynamic queries with Anvil helper functions. -# https://gql.readthedocs.io/en/latest/advanced/dsl_module.html - -anvil = Anvil(api_key=MY_API_KEY) - -# Use `ds` to create your queries -ds = get_gql_ds(anvil.gql_client) - -# Create your query in one step -query = ds.Query.currentUser.select( - ds.User.name, - ds.User.email, -) - -# Or, build your query with a chain or multiple steps until you're ready to use it. -query = ds.Query.currentUser.select(ds.User.name) -query.select(ds.User.email) -query.select(ds.User.firstName) -query.select(ds.User.lastName) - -# Once your root query fields are defined, you can put them in an operation using DSLQuery, DSLMutation or DSLSubscription: -final_query = dsl_gql(DSLQuery(query)) - -res = anvil.query(final_query) -``` diff --git a/examples/create_etch_markdown.py b/examples/create_etch_markdown.py index e1dc4db5..9836a567 100644 --- a/examples/create_etch_markdown.py +++ b/examples/create_etch_markdown.py @@ -131,7 +131,7 @@ def main(): # Create your packet # If overriding/adding new fields, use the modified payload from # `packet.create_payload()` - res = anvil.create_etch_packet(payload=packet, include_headers=True) + res = anvil.create_etch_packet(payload=packet) print(res) diff --git a/examples/create_etch_markup.py b/examples/create_etch_markup.py index 410abb84..ac1b2a1b 100644 --- a/examples/create_etch_markup.py +++ b/examples/create_etch_markup.py @@ -136,7 +136,7 @@ def main(): # Create your packet # If overriding/adding new fields, use the modified payload from # `packet.create_payload()` - res = anvil.create_etch_packet(payload=packet, include_headers=True) + res = anvil.create_etch_packet(payload=packet) print(res) diff --git a/examples/create_etch_upload_file_multipart.py b/examples/create_etch_upload_file_multipart.py index e194ba6e..5fd8ab03 100644 --- a/examples/create_etch_upload_file_multipart.py +++ b/examples/create_etch_upload_file_multipart.py @@ -55,21 +55,15 @@ def main(): file_dir = os.path.dirname(os.path.realpath(__file__)) file_path = os.path.join(file_dir, filename) - # You can check manually if your file exists, however, the validator being - # used in the `GraphqlUpload` below will also check if the file exists. + # `prepare_graphql_file` marks the file for a multipart upload: it's streamed + # with the request rather than base64 encoded into the JSON. It takes a + # path, bytes, or an open binary file object. A path is opened when the + # request is sent. # - # if not os.path.exists(file_path): - # raise FileNotFoundError('File does not exist. Please check `file_path` ' - # 'and ensure it points to an existing file.') - - # file data must be read in as _bytes_, not text. - file = open(file_path, "rb") # noqa: SIM115 - - # You can also provide a custom `content_type` if you needed. - # The Anvil library will guess the file's content_type by its file - # extension automatically, but this can be used to force a different - # content_type. - # f1.content_type = "application/pdf" + # The filename and mimetype are taken from the path. Pass `filename=` and + # `mimetype=` to override them, or when uploading bytes or a file object + # that has no name, like a BytesIO. + file = Anvil.prepare_graphql_file(file_path) # Upload the file and define signer field locations. file1 = DocumentUpload( @@ -140,11 +134,8 @@ def main(): # Create your packet # If overriding/adding new fields, use the modified payload from # `packet.create_payload()` - try: - res = anvil.create_etch_packet(payload=packet) - print(res) - finally: - file.close() + res = anvil.create_etch_packet(payload=packet) + print(res) if __name__ == "__main__": diff --git a/examples/make_graphql_request.py b/examples/make_graphql_request.py index 61a88814..dfa16644 100644 --- a/examples/make_graphql_request.py +++ b/examples/make_graphql_request.py @@ -1,9 +1,12 @@ -import os +# Send your own GraphQL queries with the Anvil API client. +# Reference: https://www.useanvil.com/docs/api/graphql/reference/ +# +# Run this from the project root: +# ANVIL_API_KEY=YOUR_KEY python examples/make_graphql_request.py -from gql.dsl import DSLQuery, dsl_gql +import os from python_anvil.api import Anvil -from python_anvil.http import get_gql_ds API_KEY = os.environ.get("ANVIL_API_KEY") @@ -12,82 +15,44 @@ def call_current_user_query(anvil: Anvil) -> dict: - """Get the user data attached to the current API key. - - :param anvil: - :type anvil: Anvil - :return: - """ - # See the reference docs for examples of all queries and mutations: - # https://www.useanvil.com/docs/api/graphql/reference/ - - # You can also use `gql`'s query builder. Below is the equivalent of the - # string above, but can potentially be a better interface if you're - # building a query in multiple steps. See the official `gql` docs for more - # details: https://gql.readthedocs.io/en/stable/advanced/dsl_module.html - - # Use `ds` to create your queries - ds = get_gql_ds(anvil.gql_client) - ds_user_query_builder = ds.Query.currentUser.select( - ds.User.eid, - ds.User.name, - ds.User.organizations.select( - ds.Organization.eid, - ds.Organization.slug, - ds.Organization.name, - ds.Organization.casts.select( - ds.Cast.eid, - ds.Cast.name, - ), - ds.Organization.welds.select( - ds.Weld.eid, - ds.Weld.name, - ), - ), + """Get the user data attached to the current API key.""" + # `request_graphql` (or its alias `query`) takes the query as a string and + # returns the response's `data`. It raises an `AnvilRequestError` if the + # API returns any errors. + res = anvil.request_graphql( + """ + query CurrentUser { + currentUser { + eid + name + organizations { + eid + slug + name + casts { eid name } + welds { eid name } + } + } + } + """ ) - - ds_query = dsl_gql(DSLQuery(ds_user_query_builder)) - - res = anvil.query(query=ds_query, variables=None) return res["currentUser"] -def call_weld_query(anvil: Anvil, weld_eid: str): - """Call the weld query. - - The weld() query is an example of a query that takes variables. - :param anvil: - :type anvil: Anvil - :param weld_eid: - :type weld_eid: str - :return: - """ - - # You can also use `gql`'s query builder. Below is the equivalent of the - # string above, but can potentially be a better interface if you're - # building a query in multiple steps. See the official `gql` docs for more - # details: https://gql.readthedocs.io/en/stable/advanced/dsl_module.html - - # Use `ds` to create your queries - ds = get_gql_ds(anvil.gql_client) - ds_weld_query_builder = ds.Query.weld.args(eid=weld_eid).select( - ds.Weld.eid, - ds.Weld.name, - ds.Weld.forges.select( - ds.Forge.eid, - ds.Forge.slug, - ds.Forge.name, - ), +def call_weld_query(anvil: Anvil, weld_eid: str) -> dict: + """Call the weld query, an example of a query that takes variables.""" + res = anvil.request_graphql( + """ + query WeldQuery($eid: String!) { + weld(eid: $eid) { + eid + name + forges { eid slug name } + } + } + """, + variables={"eid": weld_eid}, ) - - ds_query = dsl_gql(DSLQuery(ds_weld_query_builder)) - - # You can call the query with the string literal and variables like usual - # res = anvil.query(query=weld_query, variables=variables) - - # Or, use only the `dsl_gql` query. `variables` not needed as it was - # already used in `.args()`. - res = anvil.query(query=ds_query) return res["weld"] diff --git a/poetry.lock b/poetry.lock index b5fa2d4d..3862185f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -12,26 +12,6 @@ files = [ {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, ] -[[package]] -name = "anyio" -version = "4.15.1" -description = "High-level concurrency and networking framework on top of asyncio or Trio" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101"}, - {file = "anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94"}, -] - -[package.dependencies] -exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} -idna = ">=2.8" -typing_extensions = {version = ">=4.16.0", markers = "python_version < \"3.15\""} - -[package.extras] -trio = ["trio (>=0.32.0)"] - [[package]] name = "ast-serialize" version = "0.13.1" @@ -99,18 +79,6 @@ files = [ {file = "ast_serialize-0.13.1.tar.gz", hash = "sha256:401cc84e8888d4d76f2c8d296fa006ed1a5ff993b6aff90dd777c03973efd025"}, ] -[[package]] -name = "backoff" -version = "2.2.1" -description = "Function decoration for backoff and retry" -optional = false -python-versions = ">=3.7,<4.0" -groups = ["main"] -files = [ - {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"}, - {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, -] - [[package]] name = "certifi" version = "2026.10.19" @@ -473,7 +441,7 @@ version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["dev"] markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, @@ -486,47 +454,16 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] -[[package]] -name = "gql" -version = "3.6.0b4" -description = "GraphQL client for Python" -optional = false -python-versions = ">=3.8.1" -groups = ["main"] -files = [ - {file = "gql-3.6.0b4-py3-none-any.whl", hash = "sha256:2cdb2921929e3b4759c793d7affed661a2e773e80e88858a5751b5ac51273945"}, - {file = "gql-3.6.0b4.tar.gz", hash = "sha256:6781f00de33cd2049d064124ba5ffd821124e2fd6e87c557c6f592958c979d96"}, -] - -[package.dependencies] -anyio = ">=3.0,<5" -backoff = ">=1.11.1,<3.0" -graphql-core = ">=3.3.0a3,<3.4" -requests = {version = ">=2.26,<3", optional = true, markers = "extra == \"requests\""} -requests_toolbelt = {version = ">=1.0.0,<2", optional = true, markers = "extra == \"requests\""} -yarl = ">=1.6,<2.0" - -[package.extras] -aiohttp = ["aiohttp (>=3.8.0,<4) ; python_version <= \"3.11\"", "aiohttp (>=3.9.0b0,<4) ; python_version > \"3.11\""] -all = ["aiohttp (>=3.8.0,<4) ; python_version <= \"3.11\"", "aiohttp (>=3.9.0b0,<4) ; python_version > \"3.11\"", "botocore (>=1.21,<2)", "httpx (>=0.27.0,<1)", "requests (>=2.26,<3)", "requests_toolbelt (>=1.0.0,<2)", "websockets (>=10.1,<14)"] -botocore = ["botocore (>=1.21,<2)"] -dev = ["aiofiles", "aiohttp (>=3.8.0,<4) ; python_version <= \"3.11\"", "aiohttp (>=3.9.0b0,<4) ; python_version > \"3.11\"", "black (==22.3.0)", "botocore (>=1.21,<2)", "check-manifest (>=0.42,<1)", "flake8 (==7.1.1)", "httpx (>=0.27.0,<1)", "isort (==4.3.21)", "mypy (==1.10)", "parse (==1.15.0)", "pytest (==7.4.2)", "pytest-asyncio (==0.21.1)", "pytest-console-scripts (==1.4.1)", "pytest-cov (==5.0.0)", "requests (>=2.26,<3)", "requests_toolbelt (>=1.0.0,<2)", "sphinx (>=7.0.0,<8) ; python_version <= \"3.9\"", "sphinx (>=8.1.0,<9) ; python_version > \"3.9\"", "sphinx-argparse (==0.4.0)", "sphinx_rtd_theme (>=3.0.2,<4)", "types-aiofiles", "types-requests", "vcrpy (==7.0.0)", "websockets (>=10.1,<14)"] -httpx = ["httpx (>=0.27.0,<1)"] -requests = ["requests (>=2.26,<3)", "requests_toolbelt (>=1.0.0,<2)"] -test = ["aiofiles", "aiohttp (>=3.8.0,<4) ; python_version <= \"3.11\"", "aiohttp (>=3.9.0b0,<4) ; python_version > \"3.11\"", "botocore (>=1.21,<2)", "httpx (>=0.27.0,<1)", "parse (==1.15.0)", "pytest (==7.4.2)", "pytest-asyncio (==0.21.1)", "pytest-console-scripts (==1.4.1)", "pytest-cov (==5.0.0)", "requests (>=2.26,<3)", "requests_toolbelt (>=1.0.0,<2)", "vcrpy (==7.0.0)", "websockets (>=10.1,<14)"] -test-no-transport = ["aiofiles", "parse (==1.15.0)", "pytest (==7.4.2)", "pytest-asyncio (==0.21.1)", "pytest-console-scripts (==1.4.1)", "pytest-cov (==5.0.0)", "vcrpy (==7.0.0)"] -websockets = ["websockets (>=10.1,<14)"] - [[package]] name = "graphql-core" -version = "3.3.0a3" -description = "GraphQL-core is a Python port of GraphQL.js,the JavaScript reference implementation for GraphQL." +version = "3.3.0" +description = "GraphQL-core is a Python port of GraphQL.js, the JavaScript reference implementation for GraphQL." optional = false -python-versions = ">=3.7,<4.0" -groups = ["main"] +python-versions = ">=3.10" +groups = ["dev"] files = [ - {file = "graphql_core-3.3.0a3-py3-none-any.whl", hash = "sha256:b441346e61b2d7465a6f4001334ba1ddb123498a7e9dfe76cd323db8159aa707"}, - {file = "graphql_core-3.3.0a3.tar.gz", hash = "sha256:065d23881d00d0b52b9a7c0a8d63451585549c13f3f05a214923b20dabbda290"}, + {file = "graphql_core-3.3.0-py3-none-any.whl", hash = "sha256:d37fac6ef4dfc3eaa5daa59dcb498d7cbb118439d240993c68fddc4cb1bade44"}, + {file = "graphql_core-3.3.0.tar.gz", hash = "sha256:fd3424e88af3f3211931c6ff96350f1cd9069cf0f1a31b9972899e35d39136b5"}, ] [[package]] @@ -704,190 +641,6 @@ files = [ {file = "librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc"}, ] -[[package]] -name = "multidict" -version = "7.1.0" -description = "multidict implementation" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "multidict-7.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:24ad4921135a1410d95b1f1504f4901e1c64cea680014ce2c3c7a825f4f259fc"}, - {file = "multidict-7.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8b8429361241da973e594d15344a0989f44fd288ea58d33a6221fb7cc0daf27e"}, - {file = "multidict-7.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5c6455f2c11daeee40665c67494cedb426f67dba7375710524071c0c56d739a6"}, - {file = "multidict-7.1.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6120aab922bb3e15800b6655558cf8e0a5cc79518e954d457f064e5b3d5e9bf6"}, - {file = "multidict-7.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b1ce8579a43dfc0e592d93fb1d63dea693e4977980ac4166f26d494cc7a358"}, - {file = "multidict-7.1.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:71acdc6eded0f4b86b5e16c96314887cf2572a8eb5d8038b78583d0c0eb3aa1c"}, - {file = "multidict-7.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b7cd1cb0b363cd43ebf499beca26d201dd8b89eee49fae60205c82ba13ee03a"}, - {file = "multidict-7.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd8a6b3e8f9edb07fe671b02d8c3241c8b641fecce7eb1e36432db3e55e243da"}, - {file = "multidict-7.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a8c826caeb7c08264e0a556df1267531c6ed90cc70506e7e5f4119e2d09f3d7"}, - {file = "multidict-7.1.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c44ced5e5168cdf677f0ae39900863bf2bda7d14a5e13502014005cfe040b8b4"}, - {file = "multidict-7.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b57d4d7021bfd159db9f8f6f862a85a7a6027934643c512f028d6e5c60c4cbd2"}, - {file = "multidict-7.1.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:7fac4250b37d994e3fe42b46ba3c8bfa1614d1d7d8cf1cf23f303099082a9565"}, - {file = "multidict-7.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:966ae0588ac9959a040220063733b33f321d04eaf4e60349b42cd855d232202f"}, - {file = "multidict-7.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:50acd7ee7096949b04482cd7720cb6b85eb9cd9dd5d7ffb6704bfda250261a22"}, - {file = "multidict-7.1.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ed6b7f402f3dabd1d72c798b96cf947005ddd796a5bea7b041bccbd517859a42"}, - {file = "multidict-7.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fadcc96cd6155f35e6d85845fa4fcd37b35885dc8fda77b9f851cdfa538194c1"}, - {file = "multidict-7.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c6b67f08014bfc4aedc22cf6a21010c2530cd5fbeb655730406827fe196296be"}, - {file = "multidict-7.1.0-cp310-cp310-win32.whl", hash = "sha256:0604ff025497a050a2b2dcc4ae0e5cb6477c525e57b89825152c707e88d74d28"}, - {file = "multidict-7.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:36b14886aa3e0b8786ecdaa196374422c7b1c1dcc8764d02b2409f74d47914bc"}, - {file = "multidict-7.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:a2e575129c048bc286d696ed8e49ca148591768b2d77debcc6569f6fb64d0668"}, - {file = "multidict-7.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:41ff3202cc23c800507777df5a4805b402f262b31008c60fdc652aeb6db2f278"}, - {file = "multidict-7.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e50f7775b66c7802f4cb697e986c5acf30ec07301efee95b396c08114e890d67"}, - {file = "multidict-7.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:85cb3ced4fa84949cee12bfe78208b6ece7baf3cbd242b26dcaf773efff8d206"}, - {file = "multidict-7.1.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2128f3358335e0c83688ecb40c19d9d6606cd60784dfbf2e24e980ac2ba87b0d"}, - {file = "multidict-7.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2e718fa9d1d900decbc240a533d5d0baf0947ef464c78a8cd4fa32b4e8f590c"}, - {file = "multidict-7.1.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ecc68f5e47bc6f6f889bbed5bc657b22bb2237ad9ccab8229cb5a0d64f4cb536"}, - {file = "multidict-7.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5f21fda91bd6c34455bd5c312e42aa1334da46cdafb4c533ecd01e0f7f19250b"}, - {file = "multidict-7.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a90453a79423cd7145cc08fc92322dcd7aca4862258f533e03f473226d4b835"}, - {file = "multidict-7.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c54ae1b89e582aa25f213cd8b5eac0bda1724e79299f486baeb3f562bbf82ca5"}, - {file = "multidict-7.1.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ff8dd079e7b5f3438332499233a2a5acfca0741fd0eb3d4ddba0c2d9bc04d19"}, - {file = "multidict-7.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e4ef15d0a29fc2da67fe8ba2301ecabd6f8733696cc2bf0a0cf96a144a20328c"}, - {file = "multidict-7.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5fa296f14068538fced53c6eec86520a2ef3d3d27a0fb134640d03e067986d5f"}, - {file = "multidict-7.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6ab323f0c5490abaf35a78563e1043c7a772eb86d93f359ecc0fd286d1cd3807"}, - {file = "multidict-7.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:88ec4d16e9f58071c9896ea01c4da97cce9d01418fe844ff06eebb00e0a1386a"}, - {file = "multidict-7.1.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9161eb81b8062da824426d3700d4b0d287f0cb0b05923713adfe3bd25e7937ac"}, - {file = "multidict-7.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c564d0758748f38aec56a6b98c6801a427b3a63f39b7cac538b2b2d18ca32740"}, - {file = "multidict-7.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c5e4a362a95b85301d262ef6bed06cc8e4a144ac7e2be874cb4c3c46ae89d754"}, - {file = "multidict-7.1.0-cp311-cp311-win32.whl", hash = "sha256:5d19bb1ec12e385c09215d5d53a243c060c7e8a0aacdba16d933e22902ee380d"}, - {file = "multidict-7.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:396ba9917fe489ec3a5942ae3e29e91324c8b9956f371f7e124c971c71379e7a"}, - {file = "multidict-7.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5ed78742502b8d90ff2816688d407a097c8b5cc6af4343fc5ad7a98df53a7cd"}, - {file = "multidict-7.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ccfb950359a80de0fcd2030ad60ac1b1a861462de3e2ef746697c9256659af21"}, - {file = "multidict-7.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:939d8cd2d8c35e3956f6bc858390b6ccb611e6152b4920d64ab5e98f3fcf39e4"}, - {file = "multidict-7.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f8e95c95039eab6a2dad8c83c38ab87fc5431d28849e0c8a7e2a4e70ba38710d"}, - {file = "multidict-7.1.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:05d12b4bac53abe0c65f3163af2b45894e2e1c0cc55493ac784d52a350047d88"}, - {file = "multidict-7.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e79ed92b1dece6bb57e9b46effd74d7a5d3d00187c85466d880ed184239a698"}, - {file = "multidict-7.1.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fab380fcff8b3555eb2bd04304fa4330909a771a9a9b0dc07666cfc23148a711"}, - {file = "multidict-7.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b5c41e44da74383c924cc5d75ef0a268f301d69305b3c42bd17af685d55e412"}, - {file = "multidict-7.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dbaa7f7c2f0ca8578895fc61fb8c8e50ebb405dad8982f92f4343285c7a3fda"}, - {file = "multidict-7.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed6b7705d49dd07e5e0dd5f5c873fc44047e92d714299b13245b5fecac49d01"}, - {file = "multidict-7.1.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53daa47dd176db64bb35170e3d5d0ae2388c060121201883696278f055a0e70c"}, - {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:542429c796430de924d03b68a6173bb6d79d5c4967d4e9a18de3e501cad55593"}, - {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c44ca6d3cdf4cfcbcd4f928fdcbe87af5fd7319f6ad4169617b7fd6b4527c33c"}, - {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:eb0228c809b2e7eb47921876050af0bc4214b351bad8d8112f70b6ed4288763c"}, - {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6ad60de1f4c702448fc8f1449f05e810f6b7957c08a5b3950c8a792dfb13b50a"}, - {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f79def86aee67b5ba01b2565f1610f262bf88ae53c379f93e5fa29c50fe793be"}, - {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:248dabb89b5aa90b2f7e43e045f048f7e5392ec77b6446d80853ba7117d7bbdf"}, - {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0747a83e7ae617793181a4763ee8b84863cec5c0bbbde70c4394e4c0276c36de"}, - {file = "multidict-7.1.0-cp312-cp312-win32.whl", hash = "sha256:1df055e51fe7491120cc84f3362bd43db186be78d0e4c476acad45e435af9ffb"}, - {file = "multidict-7.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:10202ba98cfb3f7eb60da7ca87a2c458a69b7d0d6e4d4388cd6773ebbce89085"}, - {file = "multidict-7.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0aa1ba3ff7cdda05a1242490612976b2ae1c90fc6200903ef8f53815dcb35c5d"}, - {file = "multidict-7.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:51d7f33be9a4a1a2801430846d72841deea0894eae8381a07e7d90e0f71b3c4b"}, - {file = "multidict-7.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2a964dfeb2aba3663f0536c809aa1ff385f065e89fae57e883fb7edfb4067c2f"}, - {file = "multidict-7.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d02cd23b5af182a49d635ee72be38053767711987a9fd82625b16b93828a0d8c"}, - {file = "multidict-7.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0179698c3c913eb64f32397083747fad20ed0f0a2b7469a08cd1a8a95d14d90e"}, - {file = "multidict-7.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ccf98ee859fe29f874ddd8e637f14ba59108a333492b521acb885a9095244a9c"}, - {file = "multidict-7.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5fa1484f74d011addf2e5f5a0378ec41521989839a05d6051d8067d8ce732423"}, - {file = "multidict-7.1.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1fed3d721f75c25a9fcdd0e362af53f4b20acbcdc63081112f85419ba0ce3444"}, - {file = "multidict-7.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df03e392cae1e05462918abbae06d6100f1e53f67db971ff0ac6c07d9edf7321"}, - {file = "multidict-7.1.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:379f477b98a1e9a77ddc3ccaa8c709d3fb4a288ff54b96e171e637b55b4adbae"}, - {file = "multidict-7.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40f586bc8a084a3671ddcae9e5fbd3228a596bfb63d9f0380f153f9a65b69f08"}, - {file = "multidict-7.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e7f70d912a589e30290ed926f90ddbc3160998359cbad7c9ede1bcee481748c"}, - {file = "multidict-7.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5129cc1f5fec6888e2db0be936dab67242e32c738811c8769aeea93aab4257a8"}, - {file = "multidict-7.1.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1401caec21fd7f002e79ab6806bbfd1f54bb3de6d5e12bd91c6685dce16ad2be"}, - {file = "multidict-7.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c81062e947f4b5a624135a843f6ac4b3c7fe6508300c9fb27347f022ba0c513d"}, - {file = "multidict-7.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:c53be0dd676484a660acc56e4f1cd0dd74bc1255d12fa285e86a3fa9d5f22bf9"}, - {file = "multidict-7.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:41e0c3350d08994ee8640c39884e16514e282f70ba40f5b2299582509a327774"}, - {file = "multidict-7.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:abeec7a89d698aa1c9b4c36bd5e3c746faef0867076e6a2ca27fa5077c4ece26"}, - {file = "multidict-7.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:23f6d325241b0db006ca2841309ed17622137e134930a740a8f1331ec4404791"}, - {file = "multidict-7.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:034b0dc1b7fb8279599c5d8563f86abb4d2454735b06544ecab23c54572ad2bd"}, - {file = "multidict-7.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c8074ad4d67067c87bd0663dfda654f786336078c8fd7d2f6c1aa41de8494cc"}, - {file = "multidict-7.1.0-cp313-cp313-win32.whl", hash = "sha256:7b25c335fc53acf29d4d21dbc19fe39d2824201cdda0448623152cc5917bd259"}, - {file = "multidict-7.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:7de54b49e6da811b0321e412d14efdaa1ee0c0b6609296ea5b9022bc5b2bd843"}, - {file = "multidict-7.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:aeba2c750102051aa51e087c2ccbc79f2724a41c94168f8731e36f54c453551a"}, - {file = "multidict-7.1.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:128ea4142f81a79d430f3d0eb55206093e5eda03a12abbc7b03c34748ff6116b"}, - {file = "multidict-7.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:439a19f7fbbff232ce96682c57e27030b8ac3a4b8121484c94f04bf99d08bfff"}, - {file = "multidict-7.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8090c35199d6b7bc6426bb8bdaf341e64f295cc2624a1fda7860c0837f1acc03"}, - {file = "multidict-7.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b7cc5333fcbfb27327d12612ed72322f221b61c2b69deb1155078c964f86e1a1"}, - {file = "multidict-7.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b0e0040b0d8dd89bd0af9ab18901981e344ffba68bb30b8eabb4eab6c303279b"}, - {file = "multidict-7.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:33376418ab2846b931a72b36cfa16810befc4f49485d0b3f4dc054a4d6d00038"}, - {file = "multidict-7.1.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1101aea5c3eb1d26e090b931c693488af0db9f3d52e68be8d4cdd807dad9841d"}, - {file = "multidict-7.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f996b19ac89e0dae65821ce65f788619e4286f78c62d005ecd3b75b5d9c0892b"}, - {file = "multidict-7.1.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e0d91a4bcb59ac0d7af0d8e0da737332e1b7fe6831e53e47819b1b5349d431b2"}, - {file = "multidict-7.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e96d67914ddbf5466e4476a1cd7ff30a332cbab85ed895207acc3e58c979b6a7"}, - {file = "multidict-7.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:170ba61761f59ab92afcc86ce5534a3f3d0b07c38b339b950a83213f22dd86ec"}, - {file = "multidict-7.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33389fe084e5426d9fd85d7d9ca91a29cd0d88a83c7c96e411aca49a3f9967bc"}, - {file = "multidict-7.1.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77024596b9046572c4e90b34c1ff212346756dc48933f90c53cf6e233660788d"}, - {file = "multidict-7.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ff15531a376dc6f35984443fd1429e4b150c36ce27633e7cc52a9e5318546e20"}, - {file = "multidict-7.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:544f2642a456fa264614e975d921540ee8c3b368b04d5aa1ddbec33241b13e08"}, - {file = "multidict-7.1.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08834fb8b20e1a985c70e8380a10940234b4162de62694458727330376e58b33"}, - {file = "multidict-7.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b4908e17867930b7ac77f89a18dc67308c67c511f037d8580489be86fb585912"}, - {file = "multidict-7.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9b24e1f93b9b586ec03bc7bea1bf021ec90bf2528c729195028a3ca1c266b3f9"}, - {file = "multidict-7.1.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5c93473d0d7cd9bbb370973a9679a62f381c7050d7dff4ad6aaa92e8650f5a79"}, - {file = "multidict-7.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bf14cfcc30b097583d698a6e2b8b68c9bcffab277c485d481881958360c2938d"}, - {file = "multidict-7.1.0-cp314-cp314-win32.whl", hash = "sha256:86bc779a0896e59e4be30a5be5cd6eeffd0b40b6f0e75e730218736b7bfc6f5c"}, - {file = "multidict-7.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:6c9fd50f636a8fa9cb6324cd3eac962fec2bc5bb432452a3b583583a1059acfc"}, - {file = "multidict-7.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:e6906aa4bc62cde2c8aeb8a99a7b4401b241e274ae7b11df67d863d61ab3d5de"}, - {file = "multidict-7.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fabfdd4cf97db033196b51af46b8a681d4785c2a66347f2a5af1b4bbb1182629"}, - {file = "multidict-7.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a177a0ee5cf19931dcaeb3f662bc562754cfa4f4ace2351d9da24a954ef7db94"}, - {file = "multidict-7.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0ae91de396d5c4ac97cb24dbada3d5c91a51454781e0a70476b008f4e879e4f0"}, - {file = "multidict-7.1.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:160bdb3520fdadcaa21e1b98aab2e011265070814ecab3804eb61674becbd400"}, - {file = "multidict-7.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c2144785e42527404bbd5cfd11981fee4abe59a22aded0e498eb711a831d3f3"}, - {file = "multidict-7.1.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7d0b4fec6a8d02d7e95de5cfa913261820f1ce04bd4c0381924de0da523179b8"}, - {file = "multidict-7.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:943a9bce22180ad0f4d32d1b402a0949a4ecfe5a1257b47f54a1b51981d81b86"}, - {file = "multidict-7.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f76ceb623f7ff50df46ac57e1587c479d87a5766319c4f43d0c0a5158896afab"}, - {file = "multidict-7.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98beff85392ce435b28a0971ec21cade61ce8be8b632c9d855475a28ef92d31a"}, - {file = "multidict-7.1.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:71196ebb8d523148e5975396a444de02367f204b53b14e26794c96b2be0ed742"}, - {file = "multidict-7.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:35534b366410a36bb3d6f788691e37a76e4d1da48326b0ada3e5032580dd76af"}, - {file = "multidict-7.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4674b12701c3fcbdf7f88b9e4479701c93bec5da9eb576140d5fcc0092990af"}, - {file = "multidict-7.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ead852a5e906a43fcb6784eeac480f6a67919a51d480c1f80d32ddf9d615475"}, - {file = "multidict-7.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:afe36ca503c2ffe30fb6df82b20389fa3c4035b5d65888a61310921cf3ae91c5"}, - {file = "multidict-7.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a5f0bebb10aae010d3c9ee3abaf83ab2069c718457aea09c15532355dd7e061f"}, - {file = "multidict-7.1.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:b9d9b7d72975521434368fe8aed3f6b522060bf271adabaa5ca6c87c0c08e168"}, - {file = "multidict-7.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:274023bf952f849e0d05eba28a4c1f65f9796430d2b09ec16539386c0f76554c"}, - {file = "multidict-7.1.0-cp314-cp314t-win32.whl", hash = "sha256:7e0bfa161df365ba3c88899ee3b7c94755200967284bdedef8c1b8b43e2c0f2b"}, - {file = "multidict-7.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:34a35be8fb82d37087e8176aba907b9459f03d0e293c80f574c6337a436f4eaa"}, - {file = "multidict-7.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:d7dd46a8fcd7653c09ebe67eae9d4cb6636c7a905d9cbaf587dabcbd4eca6013"}, - {file = "multidict-7.1.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:852c921217f330b3e81a822647ebadeae7e42cf503ec1992d0bfbc90121c09fb"}, - {file = "multidict-7.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cbec738d2ad551c6f70955d7eec95e339380ee1564e2afe86bfee05fed52ceec"}, - {file = "multidict-7.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a5f721a2437390ab69c10c6df5c142478d399af8dfb02e6d823cf2358e8a4748"}, - {file = "multidict-7.1.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:99cf27791129d37e191ff013bfc29bf6631c29edb21680c00978567b91fc5d6b"}, - {file = "multidict-7.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2ba6611fc93c4b169d0e0ea376ebf4b8a529933d1f5f2c2ec7d8f8b93ef58ec2"}, - {file = "multidict-7.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c45629c0049fbdef932dbe408ac2b271fdc8c7d9962ca31160f4a0fc3455fe4f"}, - {file = "multidict-7.1.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cf606cfe3f67984b4064ac605d71e1eba12515fbabf5bd5a34a8952b8800dc66"}, - {file = "multidict-7.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50fdfcb03be719d9573597b095b1175d2e9d0b30d065791dfd9fca727c499442"}, - {file = "multidict-7.1.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a8bba9d1f6db4ef2a6ebfc937a65d36e80e3aada00b382eaf56fea8f639322d5"}, - {file = "multidict-7.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46d4af0afc6eb9867b3ae50605787c80b868e2f52eac3801246034925fe578b8"}, - {file = "multidict-7.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5cc58ebb731200ddb64d55f1b345630fb5f7a8138cdbd242af9dce964a7cb03d"}, - {file = "multidict-7.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b87ad54e8d4adeb0a1f04889504d6ec7f04fb02609220810f51f1b6c66bc1cc"}, - {file = "multidict-7.1.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f7e5dd83a615636b80182bdf446ece57ed61d5d51854acc5d9840631136d4e"}, - {file = "multidict-7.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9bc5e7f843d14a167cdc26fe2d22f6f3aa2feb57919cf3ff034262a57d8d95d0"}, - {file = "multidict-7.1.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:0631eb5f49f67de10bbdc3f64141326dbc62e8d319900966648381ce0845d8ca"}, - {file = "multidict-7.1.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:d1b1b32f3c32f734dde8f36ac1df8e275e768a7b333241cd637cb2538628a4b4"}, - {file = "multidict-7.1.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:159976f9c40f96e3fe0952b708846a43a76bacb114e9cc828816f5080bddd5ec"}, - {file = "multidict-7.1.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:81a0e08c64dfdad27dab687b96f572b23bafa1999a39d1b6f70b3ddbb73e8bd0"}, - {file = "multidict-7.1.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:4e11e7299079718c78f8147e7206c22fe35bab4466d38992420795288a0b8096"}, - {file = "multidict-7.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:34d2ee98e15d5cfe782a431bc913fce3b58cf3fdb34fcb437aeb275cdf9007ab"}, - {file = "multidict-7.1.0-cp315-cp315-win32.whl", hash = "sha256:f376224572d1f5da1c871f969ab04765727f180e70d012d93e07bfc08442c64b"}, - {file = "multidict-7.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:67fcf28db77b385820881521db7435e9f1c607cfaf07db6eb78aa9d1146bde86"}, - {file = "multidict-7.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:c7aafa4dd2f702ee2198005d6cba4309c1e25ed1c201d77beddefa47411bead8"}, - {file = "multidict-7.1.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1348ddc076251cd542f4a99ccda4b7c1f8444e8ab489d3541a978ca5901c7c1f"}, - {file = "multidict-7.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:9267bf8261a779abb2a6eab5f107f5db85b2d1745f2494081c731aaf28738ce3"}, - {file = "multidict-7.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:8a844b8b1685f38a2e8b2f3213b286e2a7abfe67508381780a0d4599ac337c1c"}, - {file = "multidict-7.1.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:18a447d46a3a2f1e61b365cbf5627db7030fdb707dad70c4f2760e5144166ecc"}, - {file = "multidict-7.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88811f890db240a1c82bf0bcd52973763707a552c8113ac3fcebca183afb2fa8"}, - {file = "multidict-7.1.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a60b720c329c0007feae692b7bf91cf17b3f9bd3727be96cc6f9a3336651041b"}, - {file = "multidict-7.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b117ed1cd1a23df0902461c38093408b95971833dcee629112acda25b603c8d0"}, - {file = "multidict-7.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bbcae7a54050b7ad7bc7bf425ba63dea7d2cd31a92246ba787a2ce69a9b98dbc"}, - {file = "multidict-7.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cba2b0b9235fe10e12301d6b4cfba0f353fa668d635f6e988b03623c2cd42ba"}, - {file = "multidict-7.1.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:535173fbcc3933d84f9929d49d7a59a0faec259ee07d07c34c7d2a980b4e3683"}, - {file = "multidict-7.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ea027bdeca1d7e498237634ee4e3a852e2723eef39996dec0ff0f77dff8a2336"}, - {file = "multidict-7.1.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:507151e1e3dee95e9e8159e329aed4f75aa5205ecd6505a4f6be546890eafbe1"}, - {file = "multidict-7.1.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9c10791e9f5ef132effc8fdce2009482c1cfb26618c5fc1b7952a47dd5eb632e"}, - {file = "multidict-7.1.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f16ac8af2804855d3cae5fc3c5ab609c9fd0fc8ecacd92579c05ed3c173396fd"}, - {file = "multidict-7.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b78de22bae456a976f33df34d598dfd16edc9a03df8f4cc8b7c17bdba4c97b4a"}, - {file = "multidict-7.1.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:db77888081431aaa69f3fd3480891746ddce6c2a571f6201869a24e2f06cf423"}, - {file = "multidict-7.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c39dfcaa0bf23443474c0cb58d8d8aea9529c1841d99654cb38e4dada7b1948a"}, - {file = "multidict-7.1.0-cp315-cp315t-win32.whl", hash = "sha256:16b21164797bde6f417066d02775975cc2e15ab8abf80efa55fe85e0b4894020"}, - {file = "multidict-7.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:55392202cb374dd1a1f89a8ce1586644870d9e936752059d053e576acc50bc89"}, - {file = "multidict-7.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f979a077d1c0a9a36dd4fab0d3a36b8de7b593bf935e13df85a380395b2c11ad"}, - {file = "multidict-7.1.0-py3-none-any.whl", hash = "sha256:d9ef29cfd98e17085b4f91bba8fa1570bec6787d5c52ce653ed33a58785585d0"}, - {file = "multidict-7.1.0.tar.gz", hash = "sha256:61a4e5d81b8d4e4ad61964b230129e7a2b914793d96289029078fc9009f074ec"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} - [[package]] name = "mypy" version = "2.4.0" @@ -1021,154 +774,6 @@ files = [ {file = "pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8"}, ] -[[package]] -name = "propcache" -version = "0.5.4" -description = "Accelerated property cache" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "propcache-0.5.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b77c313314524ca9c38fbd70f73515d04597ac58c40c939bc0e71eeb4abff680"}, - {file = "propcache-0.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8f911c395cef73c510bac566da9507bb6a43e7763d0c79138dc60ee53f11207e"}, - {file = "propcache-0.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d83b12902eb8bce151259c86c03ba746600b2d994543de46e370cecf96c452f2"}, - {file = "propcache-0.5.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9281e922c072158c91974d4589f1dbe0fee6d467f284c28e463f9f5a4d933f4"}, - {file = "propcache-0.5.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9f3551b8a35c1df3e7ea4d2d86edee15f0dde1bddd434a71744048683544d0ef"}, - {file = "propcache-0.5.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ec6a85f424afa8d23e0d9a094e5dbb6eda01da91c92b9183cd433768247ffc97"}, - {file = "propcache-0.5.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f574e460d1c8a08384a016fdb09ccf3543433263ed6b2f97104f979e64ea57c2"}, - {file = "propcache-0.5.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8e017eeb7482bed34cdb0d61cf2bcfc88d104bbab296a17cd16a6af8aabc70e"}, - {file = "propcache-0.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f273dcf7149a50527c4fd1f55cfe9eac0f60753f5af544b4c9352578e20c0874"}, - {file = "propcache-0.5.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:fc2461ecc45f17893f8207e73b46ea8ba93e33630e51cf4af3fbc21d47462b1a"}, - {file = "propcache-0.5.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:279655a16973f1ee2bd2fe79973137681642fd9ae0d89215bba263726eb0dc3a"}, - {file = "propcache-0.5.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e9f165403b81fea7e89c932d89046a1e3d9a3a60e8d7ef2f249dccdcb0982bf5"}, - {file = "propcache-0.5.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1783582065a1f07f9d9ee1e992e13f15d7dc8fb1eb3a7476d43eb3f2e69d26bb"}, - {file = "propcache-0.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d605bb239b796e82a81c6709548b2bd460ab73b4590cb0c83de8a2dd9694d0f"}, - {file = "propcache-0.5.4-cp310-cp310-win32.whl", hash = "sha256:141fdbd73748db0cf7636035030aaac383d2efde8f34e7bc24594cc776d225b8"}, - {file = "propcache-0.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:146f48a9e4812611a7581003b1a39de56c34967046310c4171a68ef908c9a745"}, - {file = "propcache-0.5.4-cp310-cp310-win_arm64.whl", hash = "sha256:6c7599df2b57ebeea8de011b5f2f7b85de95e76037d43d34b95e328430275487"}, - {file = "propcache-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:897d1ddf6716e8f47200f7aad9a0efa6cc7586df66c6defa572f9eab379c078e"}, - {file = "propcache-0.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9cbfff4423eef4cc6cafc021469641a2b835f610b2647a6c5281903e21b8670d"}, - {file = "propcache-0.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fc24f209c1b7f7f688b66b98293954f5504279760999b58920ee12dd8471c1d"}, - {file = "propcache-0.5.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62530ca89187827e4a4fe733f971abe81a7542eeea48ff61995f19b64d7199c8"}, - {file = "propcache-0.5.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fc3f7599528db40b1efa0889a620116e2704144495273d66066e8164e45838"}, - {file = "propcache-0.5.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f2d880ff60f45898f4acfa152aac8d04e3ee627d90ff4003491bf92239d5757"}, - {file = "propcache-0.5.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e9368e87a3efc285e559131092c5db643eb8e56de4ee42064d5baec22ef2bb5"}, - {file = "propcache-0.5.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:004e685b315646c410771836e72a44f143bbe624f29653a42687815069a303d5"}, - {file = "propcache-0.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:594eb4c6ec35e7179b058481f4e9f02521b56de16fa577c4b85c76fb1bf8a9f8"}, - {file = "propcache-0.5.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2dba2f02d2d5c09ef8a0e6c1a42aeaa451f4be9898cb00b04fe98717da2eb23b"}, - {file = "propcache-0.5.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c3ef2818d63bc86071e9d2989ae75a1bc32b8f7059cfd9f5abbbee70c32e2ed6"}, - {file = "propcache-0.5.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:dd2ac8f5b643454c2cc6b6118b13da16e88f4a6434fc3ba61aca384029f04f36"}, - {file = "propcache-0.5.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4054acf80d40456a0537f2913b349718649d8d6458a14ab7f48d0ce28c30869d"}, - {file = "propcache-0.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40e94adb1e7d39ff28a8bd8d8b8fbd1df6b9f40976dbe379134f1ce058e532dd"}, - {file = "propcache-0.5.4-cp311-cp311-win32.whl", hash = "sha256:9f86f7259efe2c951f43e57d471c9b41daa5bfc7db9f67189059cf1ae6d77fd9"}, - {file = "propcache-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:e904d4d01f36bd6e197590be1533c44e06058771e0746dd073a8ebb3ef880858"}, - {file = "propcache-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:d42a9a856a4a6e2f6c10f1318c07e7daa498d6593abe745c71dae4521a26ca39"}, - {file = "propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429"}, - {file = "propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8"}, - {file = "propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21"}, - {file = "propcache-0.5.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef"}, - {file = "propcache-0.5.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f"}, - {file = "propcache-0.5.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546"}, - {file = "propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b"}, - {file = "propcache-0.5.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab"}, - {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc"}, - {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937"}, - {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526"}, - {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944"}, - {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031"}, - {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161"}, - {file = "propcache-0.5.4-cp312-cp312-win32.whl", hash = "sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8"}, - {file = "propcache-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb"}, - {file = "propcache-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737"}, - {file = "propcache-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36c0d9db44b523ef93d03341b1c42d69ff01d673c053d1b1c6c3a363bcaa39ba"}, - {file = "propcache-0.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1d52a05dc417279f7e5c7618c5dfbbc29923aaf9bc0a5c1802ddcebf54c61a0"}, - {file = "propcache-0.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44149f46500a0a41b95b4d99c2e586a77319539730607b9892974a092788b111"}, - {file = "propcache-0.5.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbab5f5ff6897c81f355d079010cdae85b02e5a0b518b5251523b8ad8ae9ac3c"}, - {file = "propcache-0.5.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e98c55bde2bcf7db3c70d1aed7ae9aa8aebbf19a250c66645cde44cdb8b867"}, - {file = "propcache-0.5.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db3ae52ccc150dbc84704e9d642743897f3e1c54742ff34cacb661e52e3818a9"}, - {file = "propcache-0.5.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f85915e00dcb1cd9f2f890ead064ed40a27df06f0db65be427b29482ae357572"}, - {file = "propcache-0.5.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2ba30a89035b57b73e00475de948521602f543d79ce01db10b04b36c4c76fc8"}, - {file = "propcache-0.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae58f361bd5dae942717c65d3413b478c70aea9c462599e7b9adad3731db3894"}, - {file = "propcache-0.5.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:96f7c5c15656040ddcbc51e56dc59b58aa25999d743c126abd425b9766ab43e9"}, - {file = "propcache-0.5.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7cc528e760a8af06f2b13e9b9f362cd90c7c718ea61228a96dbd31ba16ed7f47"}, - {file = "propcache-0.5.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:425f8cc86ab5018b4b8d4a23bc8e74d964bd3d757c3702e301aa79be76c53f6c"}, - {file = "propcache-0.5.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5793c7698a53f56f4a1889a4737c7eeb1b7ad0842fa6b1abca22913ff79c8c1"}, - {file = "propcache-0.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c02c0e570c5c7e077b0181a9f3cdb7d4c3617d1cda6b5c95bd5d34022923d82c"}, - {file = "propcache-0.5.4-cp313-cp313-win32.whl", hash = "sha256:3e413d7a4a9b4866b7a761d6060d434b64d23cd35122eda3b026a0bbe8196b25"}, - {file = "propcache-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:0c889f6fa84957bc7e8b4eab71fd16a0455068d5045e3aa40c733071d2b2fd77"}, - {file = "propcache-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:69fc35c0779522da366c563e5faf203ffc1f8ff0021d5b1337fa4efa5be73177"}, - {file = "propcache-0.5.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e6720ba44ad7e72174314d0e1fb0172494cff5c73a3a8a2159c3d2402ff15565"}, - {file = "propcache-0.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cfe0a92ae30151869e67a4b5f5e105e4e03ad30b3f38e5211b5bf77d0881993"}, - {file = "propcache-0.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d759d05634f1b038fb625a66662a8c85e5a8fec912da381b5149ddac107482b"}, - {file = "propcache-0.5.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:251c63dd46a0659bb875cb254dc4c1e79ee91a847c737cd62373295afc2235dc"}, - {file = "propcache-0.5.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a8d5ff04eb1f85698a78d20c62a14676e7b960dcafde09a388d60ad377d355d"}, - {file = "propcache-0.5.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b9100a93b372418d8688f3f2a3e5b45c64d70ca4d6176e121aca1e3bfc1e32f"}, - {file = "propcache-0.5.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc07876cfb079b6f6f36d21ce75784ad6c2c6b563eeac0ed26c2fa2669b85df9"}, - {file = "propcache-0.5.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0951315a6b3142ee2167404d707743f0157c110091342b1aa0accac5cf0e4acf"}, - {file = "propcache-0.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bee7d3aed13d56f54e681df38c3a23031bc9e3863f687d9d598825c9146acd7d"}, - {file = "propcache-0.5.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4e985382be6d15da8d0c2710a6fa7b9070fc9ecdeefb7f580e88373984ec8be3"}, - {file = "propcache-0.5.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e9ab13760aa8b6d0881ae7cb04fd891d8d490cd2554ea8e79bb278399169bcc"}, - {file = "propcache-0.5.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1b2f3bec4261a94019575481c726c29850f72e27907773c75b1de421e20e9f9d"}, - {file = "propcache-0.5.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:720cf832eb2d0b0dfee129cb3335a26f6ce3cc45ee1187e8f0731758caa16792"}, - {file = "propcache-0.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb0a5be8d9aa213150e8d8148a42aca4984b285bcad1e69587dc4298edd929b"}, - {file = "propcache-0.5.4-cp314-cp314-win32.whl", hash = "sha256:30cc1cebaf9aef49db06357a50398323ae04d70460c0491837d026ab7d6452ea"}, - {file = "propcache-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a095db8e15a6020db149ecbed6461939fe74f6acaa3ae8b702a1fe8c38cd983"}, - {file = "propcache-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:45488d1a5f9ab5bd90aaa1ca20f50fe1922b8ffad71a2009d2adf41355897aac"}, - {file = "propcache-0.5.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:53eaa697c4d0422ff4cb714d00231b43352064d97b944033b30c1d57cc506ec0"}, - {file = "propcache-0.5.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:886b59c4d28ca97dd23b025fdfc50a0356be934efbbbca89ad26230067f86fe5"}, - {file = "propcache-0.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fa15757fea1dfcd5b7745cad9f4638929605531bd4018ab2adff7955f1a403d"}, - {file = "propcache-0.5.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f0093ac3e9daada202c2082439d414a625c57184727a46e112a3fb2a81cb788"}, - {file = "propcache-0.5.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3cd3a7edb6b95b9b33998135ebfa18d709da82290fb8f27c858970b5a12c8b56"}, - {file = "propcache-0.5.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c174bfd1c48a1b51a3078e95586dde718374bac79719ab3541ec9e74aec40574"}, - {file = "propcache-0.5.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a219f0ac59817a9114dd2aa57c13180f993e819ba658c7ddab4b66ed1ee0d370"}, - {file = "propcache-0.5.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17a7400cec0256f0a71ae71f9da398f9894c956ff6668a1c9d317b3367316320"}, - {file = "propcache-0.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:978f28401afbc76cdc3df9e1717b4229a06b626a1dcc75db4e1f2beb3884c3e9"}, - {file = "propcache-0.5.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4a1f4f5ffa55dce6307631f3cb2948e117e665966ea512e0d502b16c24f567e7"}, - {file = "propcache-0.5.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:213bb68d9ced5cf2bf717b1071bf2b09b4b04c426256f9fe6d054c60318424c4"}, - {file = "propcache-0.5.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:286867fb156488c251a3721766e380ac4495e4fd6b51aaa1403d89ce7f4359d9"}, - {file = "propcache-0.5.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:445ee3bfb46e85838387fb3c536a73cc0b994dc192b004e40e170adc54aa2a7e"}, - {file = "propcache-0.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48cb48c5346a97de792254af77715aa2529c2a1ebc5f586aa0aae44a02f1fe57"}, - {file = "propcache-0.5.4-cp314-cp314t-win32.whl", hash = "sha256:03b229037d25b801e7af53fd52b9fc49d9439b036fca1e087e02780631adfa97"}, - {file = "propcache-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1fc236528c457cd739c88abe823da851b7ab645d72792f88658114cc340c12"}, - {file = "propcache-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:135036c5cfc93864affb0f9af9a27e5d7a71cb7bd745e7b6dbfc2d56cc30e827"}, - {file = "propcache-0.5.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:45bf2e730ab8905d0527fe05a86500f406e64305c34cc81ebe64b4617cab9760"}, - {file = "propcache-0.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:31eb43ba2edc704ab2ec27815315dd8a19def0fb16215be4cfe8d32fe78ffd51"}, - {file = "propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:174507f82d3594622acb1dd2dafecf2d899d6d506335494e7107767bf05f3aae"}, - {file = "propcache-0.5.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e337653721d20ead710da33bf44487fbe8a0db8782714b60306481e9f95b51"}, - {file = "propcache-0.5.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d21d0d2c82bbfeb1677a9711f38df968f9837576102bb4add1bd449d28d88f1"}, - {file = "propcache-0.5.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccf4f7a79e26bb7efb06ecd50c177833b71df05cbc748701372325e6bcc17f6f"}, - {file = "propcache-0.5.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23278f808cd81d5ada7184a76606b925fb3389c60e1077b2cd7da7b1fcf0553c"}, - {file = "propcache-0.5.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e738ab81179510ce79b2eac9a6ecf47feffd9e76d1c72e403005dddb6e36c06c"}, - {file = "propcache-0.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a419ee85e654927baabda3929c03c0cc1112bf472ff0dfd6142f4e3a81ca4162"}, - {file = "propcache-0.5.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b61805357d966680acf68b3b6d49772631ed9df44ebece10ff1460e117a7da8a"}, - {file = "propcache-0.5.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:58134228927cee6c047d626c08e60a81be604a20578a12ce752cc5c9a84d4826"}, - {file = "propcache-0.5.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:350b272b2279f4135a64fc0c304a5d08e28a137c9573442c606152446638a831"}, - {file = "propcache-0.5.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:45bebbe252550fec975ba3b62bc6f931643cfd3b5464ef47619cf3fef154e01c"}, - {file = "propcache-0.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ada748108a43d29b7c328ba7db3755327cd94f028bcc1a7ee3f0addcfacd9c38"}, - {file = "propcache-0.5.4-cp315-cp315-win32.whl", hash = "sha256:ee19113bce2f3acd46432050688b70f61acd6857d75abb9ec96341b7e9ced123"}, - {file = "propcache-0.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ceb3e879afac028f93d272c957814695dc5569e4904262dbee92f6c41bd5e4a3"}, - {file = "propcache-0.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:c83acbce9f2b5e3f5f5eda9e53d2001fed22fcdfef81274a9e02d8fd53b70a30"}, - {file = "propcache-0.5.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:a5e8ef588c109725dc713ba69aadcac00a1ef90c2ce9c0a8c7075128f569f47f"}, - {file = "propcache-0.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4d86476a935c88963d9b8e1a9a0d38188790e9622169bfbafa173046846709d3"}, - {file = "propcache-0.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f5470694918830da62fac9e69133b53d23b736d7070e587b27a4a2be37e08e68"}, - {file = "propcache-0.5.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10ef33a68a61ce317e095fd2e202a592ea92392b90944a78c993f0d9a73ab06c"}, - {file = "propcache-0.5.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cacf3c9efd09df409dc33654dd077e1c245ba8fb747b0f0236ef41b7c49b589"}, - {file = "propcache-0.5.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:770e8209d018175fc0063936fa9583b6d27e88c5ad31543f3383d66080efdd62"}, - {file = "propcache-0.5.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03969626faf0783a592dfa17e28eac06018bd0b44dafae6943d53b92421a7f72"}, - {file = "propcache-0.5.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef3b928d9c984322b5c44e6964d8dbc653da87d2d8ee1647fa6da43072e650a9"}, - {file = "propcache-0.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7177c43eddf10a0893c4fec52ebb408fdcd7f7d63962caace9180d8f81b14ece"}, - {file = "propcache-0.5.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:420162a77f94eb1cf5ef7893f500016dabd548e73de956785a1dd899cc73006a"}, - {file = "propcache-0.5.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3eb2e820e8e2101407da93f17c57cbb7d225461955fc60105daaba14cd421ee2"}, - {file = "propcache-0.5.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:13e52b6e0bde97dee98ab66552dbff2931649c96f1ac432eac299fe689ec373b"}, - {file = "propcache-0.5.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12682126712ddc19b70ff819debbd279e58adf1f0c8f8f8138c18ade2044b284"}, - {file = "propcache-0.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3af0c8642b2da4815d86e631232ac8286e17644fad907c19508aa8e7cb4ba8ad"}, - {file = "propcache-0.5.4-cp315-cp315t-win32.whl", hash = "sha256:1df8d8561b21465c5dd56110a01caf897e026d065b4b84e98a488209094272ec"}, - {file = "propcache-0.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:02c0a34f16889cf800f10f0247a564d8ce6eeab6ffcd7c87198f769067eb8432"}, - {file = "propcache-0.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:dc4242ca653c9b30ab51c5f8193323e7bc0928f897ee9103201e59a43abcb72e"}, - {file = "propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468"}, - {file = "propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558"}, -] - [[package]] name = "pydantic" version = "2.14.1" @@ -1415,17 +1020,6 @@ files = [ [package.dependencies] pytest = ">=7,<10" -[[package]] -name = "ratelimit" -version = "2.2.1" -description = "API rate limit decorator" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "ratelimit-2.2.1.tar.gz", hash = "sha256:af8a9b64b821529aca09ebaf6d8d279100d766f19e90b5059ac6a718ca6dee42"}, -] - [[package]] name = "requests" version = "2.34.2" @@ -1448,21 +1042,6 @@ urllib3 = ">=1.26,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] -[[package]] -name = "requests-toolbelt" -version = "1.0.0" -description = "A utility belt for advanced users of python-requests" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -groups = ["main"] -files = [ - {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, - {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, -] - -[package.dependencies] -requests = ">=2.0.1,<3.0.0" - [[package]] name = "ruff" version = "0.17.1" @@ -1654,163 +1233,10 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] -[[package]] -name = "yarl" -version = "1.25.1" -description = "Yet another URL library" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "yarl-1.25.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:142c06c4d6a35ee3ec5da08499805e879cb3ca7c1fbfbecb0140fe72403818d6"}, - {file = "yarl-1.25.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:24ce942011a61953e7d313438038f4d32ff21387b775f58a957f7a07dd55ef95"}, - {file = "yarl-1.25.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e23c82b63cd7652fc24d33ed6cc17099d607aa3b4fc4ddc75e95062f3d82df4"}, - {file = "yarl-1.25.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee202350cf57abf0e9502a41601841019c25d3db7ff52d980aaf31446254059"}, - {file = "yarl-1.25.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5df89f769cc8ff94c3d7e7603386fba309d25ce5240132d26c15baa8d0e96c4c"}, - {file = "yarl-1.25.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83e9f4a25085bd4b7214701a0794ff1f50fc633ffb8bdfebf07abdd81c2db126"}, - {file = "yarl-1.25.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e636b64d24fd9c38053c5e389a1174c66361fa49dcfd220f4dd35b4abde7cb89"}, - {file = "yarl-1.25.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e5637ca8d0bd7fb72648a6c7934af4baaccb697657f7438c9d264fc2abb8b0b1"}, - {file = "yarl-1.25.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:683e362b8ba453080f7489c66f4ea794e751c35b72e7eab3575ef784c2fbc7fb"}, - {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:df23df54b5114a17c2d0ef192433e2e5a9f0c5178c32375e90b7cfc965f349d0"}, - {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f53dcd26694f148f738edc052b5a69234833e739f10f4c3287bdfd8ec0f7b326"}, - {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b8075fe90bc08e40b8b8a1874fab42ee4c7b56af05c5886e9cc841397f916908"}, - {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a8c2b841478068440d8b733005d13a5ef535b9928cbc05f17182d410f32ba449"}, - {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ca32926d7d77bcc8838425c4c95e040a3ace1cb7dfdae599013458dcda2607ca"}, - {file = "yarl-1.25.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:192a866877a49993949ef1975864ad8728bea28ee810f6abe1a0729c2b500426"}, - {file = "yarl-1.25.1-cp310-cp310-win_amd64.whl", hash = "sha256:3f4d48a6112712973e676bd792121fee470e432d749177162d9949d5c9460a1b"}, - {file = "yarl-1.25.1-cp310-cp310-win_arm64.whl", hash = "sha256:48796ea00a303961507dc6c8437c4b325a6fc3f95f7c36c71b91ea9a8150963c"}, - {file = "yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865"}, - {file = "yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d"}, - {file = "yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d"}, - {file = "yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420"}, - {file = "yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7"}, - {file = "yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3"}, - {file = "yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492"}, - {file = "yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2"}, - {file = "yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17"}, - {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de"}, - {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345"}, - {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40"}, - {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258"}, - {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1"}, - {file = "yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b"}, - {file = "yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e"}, - {file = "yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35"}, - {file = "yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4"}, - {file = "yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d"}, - {file = "yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6"}, - {file = "yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58"}, - {file = "yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad"}, - {file = "yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40"}, - {file = "yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c"}, - {file = "yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d"}, - {file = "yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54"}, - {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f"}, - {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc"}, - {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d"}, - {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1"}, - {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68"}, - {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707"}, - {file = "yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03"}, - {file = "yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85"}, - {file = "yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5"}, - {file = "yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26"}, - {file = "yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec"}, - {file = "yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc"}, - {file = "yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184"}, - {file = "yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651"}, - {file = "yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df"}, - {file = "yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab"}, - {file = "yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3"}, - {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a"}, - {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b"}, - {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a"}, - {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a"}, - {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98"}, - {file = "yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa"}, - {file = "yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9"}, - {file = "yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe"}, - {file = "yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e"}, - {file = "yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f"}, - {file = "yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac"}, - {file = "yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c"}, - {file = "yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c"}, - {file = "yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743"}, - {file = "yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f"}, - {file = "yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57"}, - {file = "yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453"}, - {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc"}, - {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f"}, - {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8"}, - {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868"}, - {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62"}, - {file = "yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233"}, - {file = "yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b"}, - {file = "yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414"}, - {file = "yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa"}, - {file = "yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287"}, - {file = "yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94"}, - {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9"}, - {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0"}, - {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa"}, - {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b"}, - {file = "yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6"}, - {file = "yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee"}, - {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0"}, - {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7"}, - {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b"}, - {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d"}, - {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3"}, - {file = "yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3"}, - {file = "yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371"}, - {file = "yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6"}, - {file = "yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc"}, - {file = "yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc"}, - {file = "yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b"}, - {file = "yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459"}, - {file = "yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d"}, - {file = "yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26"}, - {file = "yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273"}, - {file = "yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf"}, - {file = "yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57"}, - {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3"}, - {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2"}, - {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9"}, - {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f"}, - {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b"}, - {file = "yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c"}, - {file = "yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee"}, - {file = "yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d"}, - {file = "yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8"}, - {file = "yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4"}, - {file = "yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec"}, - {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487"}, - {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8"}, - {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75"}, - {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3"}, - {file = "yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225"}, - {file = "yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209"}, - {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3"}, - {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d"}, - {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c"}, - {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde"}, - {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64"}, - {file = "yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f"}, - {file = "yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd"}, - {file = "yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25"}, - {file = "yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3"}, - {file = "yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7"}, -] - -[package.dependencies] -idna = ">=2.0" -multidict = ">=4.0" -propcache = ">=0.2.1" - [extras] cli = ["click", "tabulate"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.16" -content-hash = "1a8bc9355021cbad4152bbe95ee07d757483b1e579ad2e0784b7b7f6f0917cdd" +content-hash = "94229947f8ace0de8b1b0d99dbfe4e638820930c65bba905a1f54bf7bcef4094" diff --git a/pyproject.toml b/pyproject.toml index 35394e5c..b8820739 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,11 +25,6 @@ requires-python = ">=3.10,<3.16" dependencies = [ "requests>=2.28.2,<3", "pydantic>=2.8,<3", - "ratelimit>=2.2.1,<3", - "gql[requests]>=3.6.0b2,<4", - # gql 3.6.0b2 mutates AST nodes that graphql-core 3.3.0 made immutable, so - # fetching the schema from the API fails on anything newer than this - "graphql-core==3.3.0a3", ] [project.optional-dependencies] @@ -60,6 +55,8 @@ pytest-cov = ">=6" pytest-describe = ">=2.2" types-requests = ">=2.28" types-tabulate = ">=0.9" +# To check the client's queries and mutations against the API schema +graphql-core = ">=3.2" # So the CLI and its tests run from a dev install without --all-extras click = ">=8.0,<9" tabulate = ">=0.9,<0.11" @@ -71,7 +68,6 @@ build-backend = "poetry.core.masonry.api" [tool.ruff] line-length = 88 target-version = "py310" -extend-exclude = ["schema"] [tool.ruff.lint] select = [ @@ -91,6 +87,8 @@ ignore = [ "D100", "D101", "D102", "D103", "D104", "D105", "D107", # No blank lines allowed after function docstring "D202", + # Docstrings may be noun phrases, like "The URL for a path" + "D401", # Line length is the formatter's job "E501", ] diff --git a/python_anvil/__init__.py b/python_anvil/__init__.py index 46e61c51..4752d96a 100644 --- a/python_anvil/__init__.py +++ b/python_anvil/__init__.py @@ -1,12 +1,26 @@ from importlib.metadata import PackageNotFoundError, version -from python_anvil import api -from python_anvil.models import FileCompatibleBaseModel - try: __version__ = version("python_anvil") -except PackageNotFoundError: +except PackageNotFoundError: # pragma: no cover __version__ = "(local)" -__all__ = ["FileCompatibleBaseModel", "api"] +from python_anvil.api import Anvil +from python_anvil.constants import VERSION_LATEST, VERSION_LATEST_PUBLISHED +from python_anvil.errors import AnvilError, AnvilRequestError +from python_anvil.models import FileCompatibleBaseModel +from python_anvil.upload import UploadWithOptions, prepare_graphql_file + + +__all__ = [ + "VERSION_LATEST", + "VERSION_LATEST_PUBLISHED", + "Anvil", + "AnvilError", + "AnvilRequestError", + "FileCompatibleBaseModel", + "UploadWithOptions", + "__version__", + "prepare_graphql_file", +] diff --git a/python_anvil/api.py b/python_anvil/api.py index 3e5edbbd..5e3cfb37 100644 --- a/python_anvil/api.py +++ b/python_anvil/api.py @@ -1,285 +1,468 @@ +"""The Anvil API client.""" + +from __future__ import annotations + +import json as json_lib import logging -from collections.abc import Callable -from typing import Any, AnyStr +import warnings +from collections.abc import Mapping +from typing import Any, TypeVar -from gql import gql -from graphql import DocumentNode +import requests +from pydantic import BaseModel -from .api_resources.mutations import ( +from python_anvil.api_resources.mutations import ( BaseQuery, CreateEtchPacket, ForgeSubmit, GenerateEtchSigningURL, ) -from .api_resources.payload import ( +from python_anvil.api_resources.payload import ( CreateEtchPacketPayload, FillPDFPayload, ForgeSubmitPayload, GeneratePDFPayload, ) -from .api_resources.requests import FullyQualifiedRequest, PlainRequest, RestRequest -from .http import GQLClient, HTTPClient +from python_anvil.constants import ( + BINARY_DATA_TYPES, + DATA_TYPE_BYTES, + DATA_TYPE_JSON, + DEFAULT_BASE_URL, + DEFAULT_REQUEST_LIMIT, + DEFAULT_REQUEST_LIMIT_MS, + DEFAULT_TIMEOUT, + VERSION_LATEST, + VERSION_LATEST_PUBLISHED, +) +from python_anvil.graphql import mutations, queries +from python_anvil.http import HTTPClient, process_response +from python_anvil.upload import ( + MultipartRequest, + UploadWithOptions, + prepare_graphql_file, + validate_upload_schema, +) logger = logging.getLogger(__name__) +# The most casts or welds the API returns per page +PAGE_LIMIT = 500 -def _get_return(res: dict, get_data: Callable[[dict], dict | list]): - """Process response and get data from path if provided.""" - _res = res - if "response" in res and "headers" in res: - _res = res["response"] - return {"response": get_data(_res), "headers": res["headers"]} - return get_data(_res) +PayloadModel = TypeVar("PayloadModel", bound=BaseModel) class Anvil: - """Main Anvil API class. + """The Anvil API client. - Handles all GraphQL and REST queries. + Handles all GraphQL and REST requests: authentication, rate limits, + retries and errors. Usage: - >> anvil = Anvil(api_key="my_key") - >> payload = {} - >> pdf_data = anvil.fill_pdf("the_template_id", payload) + >>> anvil = Anvil(api_key="my_key") + >>> pdf_bytes = anvil.fill_pdf("the_template_id", {"data": {...}}) + + Every method raises an `AnvilRequestError` when the API responds with an + error, and a `requests` exception when a request can't be made at all. + + :param api_key: Your API key, from your organization's settings. + :param access_token: An OAuth access token, instead of an API key. If both + are given, the access token is used. + :param base_url: Where to send requests. Defaults to production, + `https://app.useanvil.com`. REST requests go to `{base_url}/api/...` + and GraphQL requests to `{base_url}/graphql`. + :param user_agent: Sent as the User-Agent header. Defaults to + `python-anvil/`. + :param request_limit: How many requests to allow per `request_limit_ms` + until the first response from the API tells the client your actual + limits. Defaults to 1 per second. + :param request_limit_ms: See `request_limit`. + :param timeout: Seconds to wait for a connection, and then for each read, + before a request fails. A `(connect, read)` tuple is also accepted. + Defaults to 5 minutes; `None` waits forever. + :param session: A `requests.Session` to send requests with, e.g. one + configured with a proxy. Defaults to a session of the client's own. """ # Version number to use for latest versions (usually drafts) - VERSION_LATEST = -1 + VERSION_LATEST = VERSION_LATEST # Version number to use for the latest published version. # This is the default when a version is not provided. - VERSION_LATEST_PUBLISHED = -2 + VERSION_LATEST_PUBLISHED = VERSION_LATEST_PUBLISHED def __init__( self, api_key: str | None = None, - environment="dev", - endpoint_url=None, + *, + access_token: str | None = None, + base_url: str = DEFAULT_BASE_URL, + user_agent: str | None = None, + request_limit: int = DEFAULT_REQUEST_LIMIT, + request_limit_ms: int = DEFAULT_REQUEST_LIMIT_MS, + timeout: float | tuple[float, float] | None = DEFAULT_TIMEOUT, + session: requests.Session | None = None, + environment: str | None = None, + endpoint_url: str | None = None, ): - if not api_key: - raise ValueError("`api_key` must be a valid string") - - self.client = HTTPClient(api_key=api_key, environment=environment) - self.gql_client = GQLClient.get_client( - api_key=api_key, - environment=environment, - endpoint_url=endpoint_url, + if endpoint_url is not None: + raise TypeError( + "`endpoint_url` was removed in 6.0.0. Pass `base_url` instead, " + "e.g. base_url='https://staging.useanvil.com': GraphQL requests " + "go to {base_url}/graphql." + ) + if environment is not None: + warnings.warn( + "`environment` does nothing since 6.0.0 and will be removed: the " + "rate limits now come from the API's responses.", + DeprecationWarning, + stacklevel=2, + ) + if not (api_key or access_token): + raise ValueError("`api_key` or `access_token` is required") + + self.client = HTTPClient( + api_key, + access_token, + base_url=base_url, + user_agent=user_agent, + request_limit=request_limit, + request_limit_ms=request_limit_ms, + timeout=timeout, + session=session, ) - def query( + @property + def base_url(self) -> str: + return self.client.base_url + + # Class methods + + @staticmethod + def prepare_graphql_file( + path_or_file: Any, + *, + filename: str | None = None, + mimetype: str | None = None, + ignore_filename_validation: bool = False, + ) -> UploadWithOptions: + """Prepare a file for a GraphQL multipart upload. See `python_anvil.upload.prepare_graphql_file`.""" + return prepare_graphql_file( + path_or_file, + filename=filename, + mimetype=mimetype, + ignore_filename_validation=ignore_filename_validation, + ) + + # REST + + def request_rest( self, - query: str | DocumentNode, - variables: dict[str, Any] | None = None, - **kwargs, - ): - """Execute a GraphQL query. - - :param query: - :type query: Union[str, DocumentNode] - :param variables: - :type variables: Optional[Dict[str, Any]] - :param kwargs: - :return: + method: str, + path: str, + *, + json: Any = None, + params: Mapping[str, Any] | None = None, + data: Any = None, + headers: Mapping[str, str] | None = None, + data_type: str = DATA_TYPE_JSON, + timeout: float | tuple[float, float] | None = None, + ) -> Any: + """Send a REST request. For endpoints without a method of their own. + + :param method: The HTTP method, e.g. `"POST"`. + :param path: The path from the base URL, e.g. `/api/v1/fill/abc.pdf`. + A full URL on the base URL is accepted too. + :param json: A JSON body. + :param params: Query string parameters. + :param data: A raw body, e.g. bytes or a file object, instead of `json`. + :param headers: Extra headers. + :param data_type: What to return: `"json"` (the default) for the parsed + JSON, `"bytes"` for the raw body, or `"stream"` for the + `requests.Response` itself, to stream a large body with + `iter_content`. Close it when done, or use it as a context manager. + :param timeout: Overrides the client's timeout for this request. """ - # Remove `debug` for now. - kwargs.pop("debug", None) - if isinstance(query, str): - query = gql(query) + response = self.client.request( + method, + path, + params=params, + json=json, + data=data, + headers=headers, + stream=data_type == "stream", + timeout=timeout, + ) + return process_response(response, data_type) - return self.gql_client.execute(query, variable_values=variables, **kwargs) + def fill_pdf( + self, + template_id: str, + payload: dict | str | bytes | FillPDFPayload, + *, + version_number: int | None = None, + data_type: str = DATA_TYPE_BYTES, + params: Mapping[str, Any] | None = None, + ) -> Any: + """Fill a PDF template with your data. Returns the PDF. + + :param template_id: The eid of the PDF template (cast) to fill. + :param payload: The fill data: a dict, a JSON string or a `FillPDFPayload`. + Keys may be snake_case (`font_size`) or camelCase (`fontSize`). + :param version_number: The template version to fill. By default, the + latest published version. `Anvil.VERSION_LATEST` fills the most + recent version, published or not. + :param data_type: `"bytes"` (the default) or `"stream"`. See `request_rest`. + :param params: Extra query string parameters. + """ + data = _parse_payload(payload, FillPDFPayload) + _check_binary_data_type(data_type) + + query: dict[str, Any] = dict(params or {}) + if version_number is not None: + query["versionNumber"] = version_number + + return self.request_rest( + "POST", + f"/api/v1/fill/{template_id}.pdf", + json=data.model_dump(by_alias=True, exclude_none=True), + params=query or None, + data_type=data_type, + ) - def mutate( - self, query: str | BaseQuery, variables: dict[str, Any], **kwargs - ) -> dict[str, Any]: + def generate_pdf( + self, + payload: dict | str | bytes | GeneratePDFPayload, + *, + data_type: str = DATA_TYPE_BYTES, + params: Mapping[str, Any] | None = None, + ) -> Any: + """Generate a PDF from Markdown or HTML. Returns the PDF. + + :param payload: A dict, a JSON string or a `GeneratePDFPayload`. Markdown + by default; pass `type: "html"` with `data: {"html": ..., "css": ...}` + for HTML. + :param data_type: `"bytes"` (the default) or `"stream"`. See `request_rest`. + :param params: Extra query string parameters. """ - Execute a GraphQL mutation. - - NOTE: Any files attached provided in `variables` will be sent via the - multipart spec: - https://github.com/jaydenseric/graphql-multipart-request-spec - - :param query: - :type query: Union[str, BaseQuery] - :param variables: - :type variables: Dict[str, Any] - :param kwargs: - :return: + if not payload: + raise ValueError("`payload` must be a valid JSON string or a dict") + data = _parse_payload(payload, GeneratePDFPayload) + _check_binary_data_type(data_type) + + return self.request_rest( + "POST", + "/api/v1/generate-pdf", + json=data.model_dump(by_alias=True, exclude_none=True), + params=params, + data_type=data_type, + ) + + def download_documents( + self, document_group_eid: str, *, data_type: str = DATA_TYPE_BYTES + ) -> Any: + """Download the documents of an Etch packet's document group as a zip file. + + :param document_group_eid: The document group's eid, from + `create_etch_packet` or `get_etch_packet`. + :param data_type: `"bytes"` (the default) or `"stream"`. See `request_rest`. """ - # Remove `debug` for now. - kwargs.pop("debug", None) - if isinstance(query, str): - use_query = gql(query) - else: - mutation = query.get_mutation() - use_query = gql(mutation) + _check_binary_data_type(data_type) + return self.request_rest( + "GET", + f"/api/document-group/{document_group_eid}.zip", + data_type=data_type, + ) - return self.gql_client.execute(use_query, variable_values=variables, **kwargs) + # GraphQL - def request_rest(self, options: dict | None = None): - api = RestRequest(self.client, options=options) - return api + def request_graphql( + self, + query: str, + variables: Mapping[str, Any] | None = None, + *, + timeout: float | tuple[float, float] | None = None, + ) -> dict[str, Any]: + """Send a GraphQL query or mutation. For operations without a method of their own. - def request_fully_qualified(self, options: dict | None = None): - api = FullyQualifiedRequest(self.client, options=options) - return api + Returns the response's `data`. Files anywhere in `variables` are sent + as a multipart upload: see `prepare_graphql_file`. - def fill_pdf( - self, template_id: str, payload: dict | AnyStr | FillPDFPayload, **kwargs - ): - """Fill an existing template with provided payload data. - - Use the casts graphql query to get a list of available templates you - can use for this request. - - :param template_id: eid of an existing template/cast - :type template_id: str - :param payload: payload in the form of a dict or JSON data - :type payload: dict|str - :param kwargs.version_number: specific template version number to use. If - not provided, the latest _published_ version will be used. - :type kwargs.version_number: int + See the GraphQL reference for every query and mutation: + https://www.useanvil.com/docs/api/graphql/reference/ """ + operations = {"query": query, "variables": dict(variables or {})} + + # Checks for both file uploads and base64 uploads + if not validate_upload_schema(operations["variables"]): + raise ValueError( + "Invalid file schema detected: files must be under a `file` key" + ) + + multipart = MultipartRequest(operations) try: - if isinstance(payload, dict): - data = FillPDFPayload(**payload) - elif isinstance(payload, str): - data = FillPDFPayload.model_validate_json(payload) - elif isinstance(payload, FillPDFPayload): - data = payload + if multipart: + response = self.client.request( + "POST", "/graphql", files=multipart.build, timeout=timeout + ) else: - raise ValueError("`payload` must be a valid JSON string or a dict") - except KeyError as e: - logger.exception(e) - raise ValueError( - "`payload` validation failed. Please make sure all required " - "fields are set. " - ) from e - - version_number = kwargs.pop("version_number", None) - if version_number: - kwargs["params"] = {"versionNumber": version_number} - - api = RestRequest(client=self.client) - return api.post( - f"fill/{template_id}.pdf", - data.model_dump(by_alias=True, exclude_none=True) if data else {}, - **kwargs, - ) + response = self.client.request( + "POST", "/graphql", json=operations, timeout=timeout + ) + finally: + multipart.close() - def generate_pdf(self, payload: AnyStr | dict | GeneratePDFPayload, **kwargs): - if not payload: - raise ValueError("`payload` must be a valid JSON string or a dict") + body = process_response(response, DATA_TYPE_JSON) + if not isinstance(body, Mapping) or "data" not in body: + raise ValueError(f"Unexpected GraphQL response: {body!r}") + return body["data"] or {} - if isinstance(payload, dict): - data = GeneratePDFPayload(**payload) - elif isinstance(payload, str): - data = GeneratePDFPayload.model_validate_json(payload) - elif isinstance(payload, GeneratePDFPayload): - data = payload - else: - raise ValueError("`payload` must be a valid JSON string or a dict") + def query( + self, query: str, variables: Mapping[str, Any] | None = None + ) -> dict[str, Any]: + """Send a GraphQL query. The same as `request_graphql`.""" + return self.request_graphql(query, variables) - # Any data errors would come from here - api = RestRequest(client=self.client) - return api.post( - "generate-pdf", - data=data.model_dump(by_alias=True, exclude_none=True), - **kwargs, - ) + def mutate( + self, query: str | BaseQuery, variables: Mapping[str, Any] | None = None + ) -> dict[str, Any]: + """Send a GraphQL mutation, as a string or a mutation class. The same as `request_graphql`.""" + mutation = query if isinstance(query, str) else query.get_mutation() + return self.request_graphql(mutation, variables) - def get_cast( + def create_etch_packet( self, - eid: str, - fields: list[str] | None = None, - version_number: int | None = None, - cast_args: list[tuple[str, str]] | None = None, - **kwargs, + payload: dict | CreateEtchPacketPayload | CreateEtchPacket | None = None, + json: str | bytes | None = None, + *, + response_query: str | None = None, + mutation: str | None = None, ) -> dict[str, Any]: + """Create an Etch e-sign packet, and send it to the first signer unless it's a draft. + + Returns the response's `data`, with the packet under `createEtchPacket`. + + :param payload: A `CreateEtchPacket` builder, a `CreateEtchPacketPayload`, + or a dict of the mutation's variables (snake_case or camelCase). + Files to upload can be base64 dicts or prepared with + `prepare_graphql_file`. + :param json: The variables as a JSON string, instead of `payload`. + :param response_query: The fields to select on the created packet, + instead of the defaults. + :param mutation: Your own `createEtchPacket` mutation, with its response + fields, instead of the client's. `response_query` is then ignored. + """ + if payload is None and json is None: + raise TypeError("One of the arguments `payload` or `json` must exist") - if not fields: - # Use default fields - fields = ["eid", "title", "fieldInfo"] - - if not cast_args: - cast_args = [] + if json is not None: + payload = CreateEtchPacketPayload.model_validate_json(json) - cast_args.append(("eid", f'"{eid}"')) + if isinstance(payload, dict): + builder = CreateEtchPacket.create_from_dict(payload) + elif isinstance(payload, CreateEtchPacketPayload): + builder = CreateEtchPacket(payload=payload) + elif isinstance(payload, CreateEtchPacket): + builder = payload + else: + raise ValueError( + "`payload` must be a valid CreateEtchPacket instance or dict" + ) - # If `version_number` isn't provided, the API will default to the - # latest published version. - if version_number: - cast_args.append(("versionNumber", str(version_number))) + variables = builder.create_payload().model_dump( + by_alias=True, exclude_none=True + ) + return self.request_graphql( + mutation or builder.get_mutation(response_query), variables + ) - arg_str = "" - if len(cast_args): - joined_args = [(":".join(arg)) for arg in cast_args] - arg_str = f"({','.join(joined_args)})" + def get_etch_packet( + self, eid: str, *, response_query: str | None = None + ) -> dict[str, Any]: + """Get an Etch packet's details and status. Returns the packet. - res = self.query( - gql( - f"""{{ - cast {arg_str} {{ - {" ".join(fields)} - }} - }}""" - ), - **kwargs, + :param eid: The packet's eid. + :param response_query: The fields to select, instead of the defaults. + """ + data = self.request_graphql( + queries.etch_packet.generate_query(response_query), {"eid": eid} ) + return data["etchPacket"] - def get_data(r: dict) -> dict[str, Any]: - return r["cast"] + def generate_etch_signing_url(self, signer_eid: str, client_user_id: str) -> str: + """Generate the URL an embedded signer signs at. Returns the URL. - return _get_return(res, get_data=get_data) + The packet must have been sent, and the signer must be an `embedded` signer. - def get_casts( - self, fields: list[str] | None = None, show_all: bool = False, **kwargs - ) -> list[dict[str, Any]]: - """Retrieve all Cast objects for the current user across all organizations. - - :param fields: List of fields to retrieve for each cast object - :type fields: Optional[List[str]] - :param show_all: Boolean to show all Cast objects. - Defaults to showing only templates. - :type show_all: bool - :param kwargs: - :return: + :param signer_eid: The signer's eid, from the packet's `documentGroup.signers`. + :param client_user_id: Your ID for the user who will sign. """ - if not fields: - # Use default fields - fields = ["eid", "title", "fieldInfo"] + mutation = GenerateEtchSigningURL( + signer_eid=signer_eid, client_user_id=client_user_id + ) + data = self.request_graphql( + mutation.get_mutation(), mutation.create_payload().model_dump(by_alias=True) + ) + return data["generateEtchSignURL"] - cast_args = "" if show_all else "(isTemplate: true)" + generate_etch_sign_url = generate_etch_signing_url - res = self.query( - gql( - f"""{{ - currentUser {{ - organizations {{ - casts {cast_args} {{ - {" ".join(fields)} - }} - }} - }} - }}""" - ), - **kwargs, - ) + def forge_submit( + self, + payload: dict | ForgeSubmitPayload | None = None, + json: str | bytes | None = None, + *, + response_query: str | None = None, + mutation: str | None = None, + ) -> dict[str, Any]: + """Submit data to a webform (forge) in a workflow (weld). Returns the response's `data`. + + :param payload: A `ForgeSubmitPayload` or a dict of the mutation's + variables (snake_case or camelCase). `forge_eid` and `payload` are + required; `weld_data_eid` and `submission_eid` update a submission. + :param json: The variables as a JSON string, instead of `payload`. + :param response_query: The fields to select on the submission, instead + of the defaults. + :param mutation: Your own `forgeSubmit` mutation, instead of the client's. + """ + if payload is None and json is None: + raise TypeError("One of arguments `json` or `payload` are required") + + if json is not None: + payload = ForgeSubmitPayload.model_validate_json(json) - def get_data(r: dict): - orgs = r["currentUser"]["organizations"] - return [item for org in orgs for item in org["casts"]] + if isinstance(payload, dict): + builder = ForgeSubmit.create_from_dict(payload) + elif isinstance(payload, ForgeSubmitPayload): + builder = ForgeSubmit(payload=payload) + else: + raise ValueError( + "`payload` must be a valid ForgeSubmitPayload instance or dict" + ) - return _get_return(res, get_data=get_data) + variables = builder.create_payload().model_dump( + by_alias=True, exclude_none=True + ) + return self.request_graphql( + mutation or builder.get_mutation(response_query), variables + ) - def get_current_user(self, **kwargs): - """Retrieve current user data. + def remove_weld_data(self, eid: str, *, mutation: str | None = None) -> bool: + """Remove a workflow submission's data (a weld data). Returns whether it was removed. - :param kwargs: - :return: + :param eid: The weld data's eid, from `forge_submit`'s `weldData`. + :param mutation: Your own `removeWeldData` mutation, instead of the client's. """ - res = self.query( - gql( - """{ + data = self.request_graphql( + mutation or mutations.remove_weld_data.generate_mutation(), {"eid": eid} + ) + return bool(data["removeWeldData"]) + + # Convenience queries + + def get_current_user(self) -> dict[str, Any]: + """The user the API key belongs to, with their organizations.""" + data = self.request_graphql( + """{ currentUser { name email @@ -289,61 +472,112 @@ def get_current_user(self, **kwargs): eid name slug - casts { - eid - name - } } } }""" - ), - **kwargs, ) + return data["currentUser"] + + def get_cast( + self, + eid: str, + fields: list[str] | None = None, + version_number: int | None = None, + cast_args: list[tuple[str, str]] | None = None, + ) -> dict[str, Any]: + """A PDF template (cast). By default its `eid`, `title` and `fieldInfo`. + + :param eid: The cast's eid. + :param fields: The fields to select, instead of the defaults. + :param version_number: A specific version. By default, the latest + published version. + :param cast_args: Extra arguments for the `cast` query, as + `(name, value)` pairs with the value in GraphQL syntax. + """ + fields = fields or ["eid", "title", "fieldInfo"] + args = list(cast_args or []) + args.append(("eid", json_lib.dumps(eid))) + if version_number is not None: + args.append(("versionNumber", str(version_number))) + + arg_str = ", ".join(f"{name}: {value}" for name, value in args) + data = self.request_graphql( + f"""{{ + cast({arg_str}) {{ + {" ".join(fields)} + }} + }}""" + ) + return data["cast"] - return _get_return(res, get_data=lambda r: r["currentUser"]) + def get_casts( + self, fields: list[str] | None = None, show_all: bool = False + ) -> list[dict[str, Any]]: + """The PDF templates (casts) in every organization of the current user. - def get_welds(self, **kwargs) -> list | tuple[list, dict]: - res = self.query( - gql( - """{ - currentUser { - organizations { - welds { - eid - slug - name - forges { + Up to 500 per organization. + + :param fields: The fields to select, instead of `eid`, `title` and `fieldInfo`. + :param show_all: Include casts that aren't templates. + """ + fields = fields or ["eid", "title", "fieldInfo"] + is_template = "null" if show_all else "true" + + data = self.request_graphql( + f"""{{ + currentUser {{ + organizations {{ + casts(isTemplate: {is_template}, limit: {PAGE_LIMIT}) {{ + items {{ + {" ".join(fields)} + }} + }} + }} + }} + }}""" + ) + return [ + cast + for org in data["currentUser"]["organizations"] + for cast in org["casts"]["items"] + ] + + def get_welds(self) -> list[dict[str, Any]]: + """The workflows (welds) in every organization of the current user, with their webforms (forges). + + Up to 500 per organization. + """ + data = self.request_graphql( + f"""{{ + currentUser {{ + organizations {{ + welds(limit: {PAGE_LIMIT}) {{ + items {{ eid + slug name - } - } - } - } - }""" - ), - **kwargs, + forges {{ + eid + name + }} + }} + }} + }} + }} + }}""" ) - - def get_data(r: dict): - orgs = r["currentUser"]["organizations"] - return [item for org in orgs for item in org["welds"]] - - return _get_return(res, get_data=get_data) - - def get_weld(self, eid: str, **kwargs): - res = self.query( - gql( - """ - query WeldQuery( - #$organizationSlug: String!, - #$slug: String! - $eid: String! - ) { - weld( - #organizationSlug: $organizationSlug, - #slug: $slug - eid: $eid - ) { + return [ + weld + for org in data["currentUser"]["organizations"] + for weld in org["welds"]["items"] + ] + + def get_weld(self, eid: str) -> dict[str, Any]: + """A workflow (weld), with its webforms (forges).""" + data = self.request_graphql( + """ + query WeldQuery($eid: String!) { + weld(eid: $eid) { eid slug name @@ -353,94 +587,35 @@ def get_weld(self, eid: str, **kwargs): slug } } - }""" - ), - variables={"eid": eid}, - **kwargs, + }""", + {"eid": eid}, ) + return data["weld"] - def get_data(r: dict): - return r["weld"] - - return _get_return(res, get_data=get_data) - - def create_etch_packet( - self, - payload: dict - | CreateEtchPacketPayload - | CreateEtchPacket - | AnyStr - | None = None, - json=None, - **kwargs, - ): - """Create etch packet via a graphql mutation.""" - # Create an etch packet payload instance excluding signers and files - # (if any). We'll need to add those separately. below. - if not any([payload, json]): - raise TypeError("One of the arguments `payload` or `json` must exist") - - if json: - payload = CreateEtchPacketPayload.model_validate_json(json) - - if isinstance(payload, dict): - mutation = CreateEtchPacket.create_from_dict(payload) - elif isinstance(payload, CreateEtchPacketPayload): - mutation = CreateEtchPacket(payload=payload) - elif isinstance(payload, CreateEtchPacket): - mutation = payload - else: - raise ValueError( - "`payload` must be a valid CreateEtchPacket instance or dict" - ) - - payload = mutation.create_payload() - variables = payload.model_dump(by_alias=True, exclude_none=True) + def close(self) -> None: + """Close the client's HTTP session.""" + self.client.close() - return self.mutate(mutation, variables=variables, upload_files=True, **kwargs) + def __enter__(self) -> Anvil: + return self - def generate_etch_signing_url(self, signer_eid: str, client_user_id: str, **kwargs): - """Generate a signing URL for a given user.""" - mutation = GenerateEtchSigningURL( - signer_eid=signer_eid, - client_user_id=client_user_id, - ) - payload = mutation.create_payload() - return self.mutate( - mutation, variables=payload.model_dump(by_alias=True), **kwargs - ) + def __exit__(self, *exc_info: object) -> None: + self.close() - def download_documents(self, document_group_eid: str, **kwargs): - """Retrieve all completed documents in zip form.""" - api = PlainRequest(client=self.client) - return api.get(f"document-group/{document_group_eid}.zip", **kwargs) - def forge_submit( - self, - payload: dict[str, Any] | ForgeSubmitPayload | None = None, - json=None, - **kwargs, - ) -> dict[str, Any]: - """Create a Webform (forge) submission via a graphql mutation.""" - if not any([json, payload]): - raise TypeError("One of arguments `json` or `payload` are required") +def _parse_payload(payload: Any, model: type[PayloadModel]) -> PayloadModel: + """Validate a dict, JSON string or model instance as `model`.""" + if isinstance(payload, model): + return payload + if isinstance(payload, dict): + return model(**payload) + if isinstance(payload, (str, bytes)): + return model.model_validate_json(payload) + raise ValueError( + f"`payload` must be a dict, a JSON string or a {model.__name__}, not {type(payload).__name__}" + ) - if json: - payload = ForgeSubmitPayload.model_validate_json(json) - if isinstance(payload, dict): - mutation = ForgeSubmit.create_from_dict(payload) - elif isinstance(payload, ForgeSubmitPayload): - mutation = ForgeSubmit(payload=payload) - else: - raise ValueError( - "`payload` must be a valid ForgeSubmitPayload instance or dict" - ) - - return self.mutate( - mutation, - variables=mutation.create_payload().model_dump( - by_alias=True, exclude_none=True - ), - **kwargs, - ) +def _check_binary_data_type(data_type: str) -> None: + if data_type not in BINARY_DATA_TYPES: + raise ValueError(f"data_type must be one of: {'|'.join(BINARY_DATA_TYPES)}") diff --git a/python_anvil/api_resources/mutations/base.py b/python_anvil/api_resources/mutations/base.py index 08d92493..8c9f6de9 100644 --- a/python_anvil/api_resources/mutations/base.py +++ b/python_anvil/api_resources/mutations/base.py @@ -1,17 +1,15 @@ -class BaseQuery: - """Base class for any GraphQL queries/mutations.""" +from __future__ import annotations + +from typing import Any - mutation: str | None = None - mutation_res_query: str | None = None - def get_mutation(self): - if self.mutation and self.mutation_res_query: - return self.mutation.format(query=self.mutation_res_query) - return self.mutation +class BaseQuery: + """Base class for the classes that build a GraphQL mutation and its variables.""" + + def get_mutation(self, response_query: str | None = None) -> str: + """The mutation to send, selecting `response_query` or the default fields.""" + raise NotImplementedError - def create_payload(self): - if not self.mutation: - raise ValueError( - "`mutation` property must be set on the inheriting class level" - ) - raise NotImplementedError() + def create_payload(self) -> Any: + """The model whose dump is the mutation's variables.""" + raise NotImplementedError diff --git a/python_anvil/api_resources/mutations/create_etch_packet.py b/python_anvil/api_resources/mutations/create_etch_packet.py index f823e0eb..2fcf0ca5 100644 --- a/python_anvil/api_resources/mutations/create_etch_packet.py +++ b/python_anvil/api_resources/mutations/create_etch_packet.py @@ -12,81 +12,23 @@ DocumentUpload, EtchSigner, ) +from python_anvil.graphql.mutations.create_etch_packet import ( + DEFAULT_RESPONSE_QUERY, + generate_mutation, +) from python_anvil.utils import create_unique_id logger: Logger = logging.getLogger(__name__) -DEFAULT_RESPONSE_QUERY = """{ - eid - name - detailsURL - documentGroup { - eid - status - files - signers { - eid - aliasId - routingOrder - name - email - status - signActionType - } - } -}""" -# NOTE: Since the below will be used as a formatted string (this also applies -# to f-strings) any literal curly braces need to be doubled, else they'll be -# interpreted as string replacement tokens. -CREATE_ETCH_PACKET = """ -mutation CreateEtchPacket ( - $name: String, - $files: [EtchFile!], - $isDraft: Boolean, - $isTest: Boolean, - $mergePDFs: Boolean, - $signatureEmailSubject: String, - $signatureEmailBody: String, - $signatureProvider: String, - $signaturePageOptions: JSON, - $signers: [JSON!], - $webhookURL: String, - $replyToName: String, - $replyToEmail: String, - $data: JSON, - $enableEmails: JSON, - $createCastTemplatesFromUploads: Boolean, - $duplicateCasts: Boolean=false, - ) {{ - createEtchPacket ( - name: $name, - files: $files, - isDraft: $isDraft, - isTest: $isTest, - mergePDFs: $mergePDFs, - signatureEmailSubject: $signatureEmailSubject, - signatureEmailBody: $signatureEmailBody, - signatureProvider: $signatureProvider, - signaturePageOptions: $signaturePageOptions, - signers: $signers, - webhookURL: $webhookURL, - replyToName: $replyToName, - replyToEmail: $replyToEmail, - data: $data, - enableEmails: $enableEmails, - createCastTemplatesFromUploads: $createCastTemplatesFromUploads, - duplicateCasts: $duplicateCasts - ) - {query} - }} -""" +class CreateEtchPacket(BaseQuery): + """Builds a `createEtchPacket` mutation, adding signers and files one at a time. + Or pass the whole thing as `payload`. + """ -class CreateEtchPacket(BaseQuery): - mutation = CREATE_ETCH_PACKET - mutation_res_query = DEFAULT_RESPONSE_QUERY + default_response_query = DEFAULT_RESPONSE_QUERY def __init__( self, @@ -107,6 +49,8 @@ def __init__( enable_emails: bool | list[str] | None = None, create_cast_templates_from_uploads: bool | None = None, duplicate_casts: bool | None = None, + organization_eid: str | None = None, + allow_updates: bool | None = None, ): # `name` is required when `payload` is not present. if not payload and not name: @@ -132,6 +76,11 @@ def __init__( self.enable_emails = enable_emails self.create_cast_templates_from_uploads = create_cast_templates_from_uploads self.duplicate_casts = duplicate_casts + self.organization_eid = organization_eid + self.allow_updates = allow_updates + + def get_mutation(self, response_query: str | None = None) -> str: + return generate_mutation(response_query or self.default_response_query) @classmethod def create_from_dict(cls, payload: dict) -> "CreateEtchPacket": @@ -258,6 +207,8 @@ def create_payload(self) -> CreateEtchPacketPayload: enable_emails=self.enable_emails, create_cast_templates_from_uploads=self.create_cast_templates_from_uploads, duplicate_casts=self.duplicate_casts, + organization_eid=self.organization_eid, + allow_updates=self.allow_updates, ) return payload diff --git a/python_anvil/api_resources/mutations/forge_submit.py b/python_anvil/api_resources/mutations/forge_submit.py index ca4b5e73..58d3e7c6 100644 --- a/python_anvil/api_resources/mutations/forge_submit.py +++ b/python_anvil/api_resources/mutations/forge_submit.py @@ -3,70 +3,19 @@ from python_anvil.api_resources.mutations.base import BaseQuery from python_anvil.api_resources.mutations.helpers import get_payload_attrs from python_anvil.api_resources.payload import ForgeSubmitPayload +from python_anvil.graphql.mutations.forge_submit import ( + DEFAULT_RESPONSE_QUERY, + generate_mutation, +) -DEFAULT_RESPONSE_QUERY = """ -{ - eid - status - resolvedPayload - currentStep - completedAt - createdAt - updatedAt - signer { - name - email - status - routingOrder - } - weldData { - eid - status - isTest - isComplete - agents - } -} -""" - -# NOTE: Since the below will be used as a formatted string (this also applies -# to f-strings) any literal curly braces need to be doubled, else they'll be -# interpreted as string replacement tokens. -FORGE_SUBMIT = """ -mutation ForgeSubmit( - $forgeEid: String!, - $weldDataEid: String, - $submissionEid: String, - $payload: JSON!, - $currentStep: Int, - $complete: Boolean, - $isTest: Boolean, - $timezone: String, - $groupArrayId: String, - $groupArrayIndex: Int, - $webhookURL: String, -) {{ - forgeSubmit ( - forgeEid: $forgeEid, - weldDataEid: $weldDataEid, - submissionEid: $submissionEid, - payload: $payload, - currentStep: $currentStep, - complete: $complete, - isTest: $isTest, - timezone: $timezone, - groupArrayId: $groupArrayId, - groupArrayIndex: $groupArrayIndex, - webhookURL: $webhookURL - ) {query} -}} -""" +class ForgeSubmit(BaseQuery): + """Builds a `forgeSubmit` mutation and its variables.""" + default_response_query = DEFAULT_RESPONSE_QUERY -class ForgeSubmit(BaseQuery): - mutation = FORGE_SUBMIT - mutation_res_query = DEFAULT_RESPONSE_QUERY + def get_mutation(self, response_query: str | None = None) -> str: + return generate_mutation(response_query or self.default_response_query) def __init__( self, diff --git a/python_anvil/api_resources/mutations/generate_etch_signing_url.py b/python_anvil/api_resources/mutations/generate_etch_signing_url.py index 03833ead..e3a221e6 100644 --- a/python_anvil/api_resources/mutations/generate_etch_signing_url.py +++ b/python_anvil/api_resources/mutations/generate_etch_signing_url.py @@ -1,21 +1,21 @@ +from __future__ import annotations + from python_anvil.api_resources.mutations.base import BaseQuery from python_anvil.api_resources.payload import GenerateEtchSigningURLPayload +from python_anvil.graphql.mutations.generate_etch_sign_url import generate_mutation class GenerateEtchSigningURL(BaseQuery): - """Query class to handle retrieving a signing URL.""" - - mutation = """ - mutation ($signerEid: String!, $clientUserId: String!) { - generateEtchSignURL (signerEid: $signerEid, clientUserId: $clientUserId) - } - """ + """Builds a `generateEtchSignURL` mutation and its variables.""" def __init__(self, signer_eid: str, client_user_id: str): self.signer_eid = signer_eid self.client_user_id = client_user_id - def create_payload(self): + def get_mutation(self, response_query: str | None = None) -> str: + return generate_mutation() + + def create_payload(self) -> GenerateEtchSigningURLPayload: return GenerateEtchSigningURLPayload( signer_eid=self.signer_eid, client_user_id=self.client_user_id ) diff --git a/python_anvil/api_resources/payload.py b/python_anvil/api_resources/payload.py index c8a586a7..7df7cd5e 100644 --- a/python_anvil/api_resources/payload.py +++ b/python_anvil/api_resources/payload.py @@ -217,6 +217,8 @@ class CreateEtchPacketPayload(FileCompatibleBaseModel): enable_emails: bool | list[str] | None = None create_cast_templates_from_uploads: bool | None = None duplicate_casts: bool | None = None + organization_eid: str | None = None + allow_updates: bool | None = None class ForgeSubmitPayload(BaseModel): diff --git a/python_anvil/api_resources/requests.py b/python_anvil/api_resources/requests.py deleted file mode 100644 index a3255934..00000000 --- a/python_anvil/api_resources/requests.py +++ /dev/null @@ -1,182 +0,0 @@ -from typing import Any - -from python_anvil.constants import VALID_HOSTS -from python_anvil.http import HTTPClient - - -class AnvilRequest: - show_headers = False - _client: HTTPClient - - def get_url(self): - raise NotImplementedError - - def _request(self, method, url, **kwargs): - if not self._client: - raise AssertionError( - "Client has not been initialized. Please use the constructors " - "provided by the other request implementations." - ) - - if method.upper() not in ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]: - raise ValueError("Invalid HTTP method provided") - - full_url = "/".join([self.get_url(), url]) if len(url) > 0 else self.get_url() - - return self._client.request(method, full_url, **kwargs) - - def handle_error(self, response, status_code, headers): - extra = None - if self.show_headers: - extra = headers - - if hasattr(response, "decode"): - message = f"Error: {status_code}: {response.decode()} {extra}" - else: - message = f"Error: {status_code}: {response} {extra}" - - raise Exception(message) - - def process_response(self, response, status_code, headers, **kwargs): - res = response - if not 200 <= status_code < 300: - self.handle_error(response, status_code, headers) - - debug = kwargs.pop("debug", False) - - # Include headers alongside the response. - # This is useful for figuring out rate limits outside of this library's - # scope and to manage waiting. - include_headers = kwargs.pop("include_headers", False) - if debug or include_headers: - return {"response": res, "headers": headers} - - return res - - -class BaseAnvilHttpRequest(AnvilRequest): - def __init__(self, client, options=None): - self._client = client - self._options = options - - def get_url(self): - raise NotImplementedError - - def get(self, url, params=None, **kwargs): - retry = kwargs.pop("retry", True) - content, status_code, headers = self._request( - "GET", url, params=params, retry=retry - ) - return self.process_response(content, status_code, headers, **kwargs) - - def post(self, url, data=None, **kwargs): - retry = kwargs.pop("retry", True) - params = kwargs.pop("params", None) - content, status_code, headers = self._request( - "POST", url, json=data, retry=retry, params=params - ) - return self.process_response(content, status_code, headers, **kwargs) - - -class GraphqlRequest(AnvilRequest): - """Create a GraphQL request. - - .. deprecated :: 2.0.0 - Use `python_anvil.http.GQLClient` to make GraphQL queries and mutations. - """ - - API_HOST = "https://graphql.useanvil.com" - - def __init__(self, client: HTTPClient): - self._client = client - - def get_url(self): - return f"{self.API_HOST}" - - def post(self, query, variables=None, **kwargs): - return self.run_query("POST", query, variables=variables, **kwargs) - - def post_multipart(self, files=None, **kwargs): - return self.run_query("POST", None, files=files, is_multipart=True, **kwargs) - - def run_query( - self, method, query, variables=None, files=None, is_multipart=False, **kwargs - ): - if not query and not files: - raise AssertionError( - "Either `query` or `files` must be passed into this method." - ) - data: dict[str, Any] = {} - - if query: - data["query"] = query - - if files and is_multipart: - # Make sure `data` is nothing when we're doing a multipart request. - data = {} - elif variables: - data["variables"] = variables - - # Optional debug kwargs. - # At this point, only the CLI will pass this in as a - # "show me everything" sort of switch. - debug = kwargs.pop("debug", False) - include_headers = kwargs.pop("include_headers", False) - - content, status_code, headers = self._request( - method, - # URL blank here since graphql requests don't append to url - "", - # Queries need to be wrapped by curly braces(?) based on the - # current API implementation. - # The current library for graphql query generation doesn't do this(?) - json=data, - files=files, - parse_json=True, - **kwargs, - ) - - return self.process_response( - content, - status_code, - headers, - debug=debug, - include_headers=include_headers, - **kwargs, - ) - - -class RestRequest(BaseAnvilHttpRequest): - API_HOST = "https://app.useanvil.com" - API_BASE = "api" - API_VERSION = "v1" - - def get_url(self): - return f"{self.API_HOST}/{self.API_BASE}/{self.API_VERSION}" - - -class PlainRequest(BaseAnvilHttpRequest): - API_HOST = "https://app.useanvil.com" - API_BASE = "api" - - def get_url(self): - return f"{self.API_HOST}/{self.API_BASE}" - - -class FullyQualifiedRequest(BaseAnvilHttpRequest): - """A request class that validates URLs point to Anvil domains.""" - - def get_url(self): - return "" # Not used since we expect full URLs - - def _validate_url(self, url): - if not any(url.startswith(host) for host in VALID_HOSTS): - raise ValueError(f"URL must start with one of: {', '.join(VALID_HOSTS)}") - - def get(self, url, params=None, **kwargs): - self._validate_url(url) - return super().get(url, params, **kwargs) - - def post(self, url, data=None, **kwargs): - self._validate_url(url) - return super().post(url, data, **kwargs) diff --git a/python_anvil/auth.py b/python_anvil/auth.py new file mode 100644 index 00000000..26a0492f --- /dev/null +++ b/python_anvil/auth.py @@ -0,0 +1,60 @@ +"""Builds the Authorization header, the same way node-anvil does.""" + +from __future__ import annotations + +import base64 +import binascii +import json +import re +from typing import Any + + +# Three dot-separated base64url segments. The signature can be empty. +JWT_RE = re.compile(r"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$") + + +def build_auth_header( + api_key: str | None = None, access_token: str | None = None +) -> str: + """The Authorization header for an API key or an OAuth access token. + + An API key is sent with HTTP Basic auth, as `base64(api_key + ":")`. + Anvil's OAuth access tokens are JWTs, which the API wants as-is: + `Bearer `. Any other kind of token is base64 encoded, which + the API still accepts for backwards compatibility. + + If both are given, the access token is used. + """ + if access_token: + if is_jwt(access_token): + return f"Bearer {access_token}" + return f"Bearer {_base64(access_token)}" + if api_key: + return f"Basic {_base64(f'{api_key}:')}" + raise ValueError("`api_key` or `access_token` is required") + + +def is_jwt(token: Any) -> bool: + """Whether a token is a JWT: three base64url segments where the header and payload decode to JSON objects. + + This is a bit stricter than the API's check, so anything it says yes to, + the API will treat as a JWT too. + """ + if not isinstance(token, str) or not JWT_RE.match(token): + return False + + header, payload = (_decode_json_segment(s) for s in token.split(".")[:2]) + return bool(header and payload and isinstance(header.get("alg"), str)) + + +def _decode_json_segment(segment: str) -> dict[str, Any] | None: + try: + padded = segment + "=" * (-len(segment) % 4) + decoded = json.loads(base64.urlsafe_b64decode(padded)) + except (binascii.Error, ValueError, UnicodeDecodeError): + return None + return decoded if isinstance(decoded, dict) else None + + +def _base64(value: str) -> str: + return base64.b64encode(value.encode("ascii")).decode("ascii") diff --git a/python_anvil/cli.py b/python_anvil/cli.py index 7be199e9..646aafd4 100644 --- a/python_anvil/cli.py +++ b/python_anvil/cli.py @@ -1,8 +1,20 @@ +"""The `anvil` command line tool. Needs the `cli` extra: pip install 'python-anvil[cli]'.""" + +from __future__ import annotations + +import json +import logging import os +import sys from csv import DictReader -from logging import getLogger from time import sleep +from python_anvil import utils +from python_anvil.api import Anvil +from python_anvil.api_resources.payload import FillPDFPayload +from python_anvil.constants import DEFAULT_BASE_URL +from python_anvil.errors import AnvilRequestError + try: import click @@ -13,54 +25,58 @@ "pip install 'python-anvil[cli]'" ) from e -from python_anvil import utils -from .api import Anvil -from .api_resources.payload import FillPDFPayload +logger = logging.getLogger(__name__) -logger = getLogger(__name__) - - -def get_api_key(): +def get_api_key() -> str | None: return os.environ.get("ANVIL_API_KEY") -def contains_headers(res): - return isinstance(res, dict) and "headers" in res +def get_base_url() -> str: + return os.environ.get("ANVIL_BASE_URL") or DEFAULT_BASE_URL -def process_response(res): - return res["response"], res["headers"] +class AnvilGroup(click.Group): + """Reports API errors in one line, instead of a traceback.""" + def invoke(self, ctx: click.Context): # type: ignore[no-untyped-def] + try: + return super().invoke(ctx) + except AnvilRequestError as e: + click.echo(f"Error: {e}", err=True) + if ctx.obj and ctx.obj.get("debug"): + click.echo(json.dumps(e.errors, indent=2), err=True) + sys.exit(1) -@click.group() -@click.option("--debug/--no-debug", default=False) + +@click.group(cls=AnvilGroup) +@click.option("--debug/--no-debug", default=False, help="Log every request") @click.pass_context -def cli(ctx: click.Context, debug=False): +def cli(ctx: click.Context, debug: bool = False): + """Anvil API command line tool. + + Set $ANVIL_API_KEY to your API key. Set $ANVIL_BASE_URL to send requests + somewhere other than production. + """ ctx.ensure_object(dict) key = get_api_key() if not key: raise ValueError("$ANVIL_API_KEY must be defined in your environment variables") - anvil = Anvil(key) - ctx.obj["anvil"] = anvil + if debug: + logging.basicConfig(level=logging.DEBUG) + + ctx.obj["anvil"] = Anvil(key, base_url=get_base_url()) ctx.obj["debug"] = debug @cli.command("current-user", help="Show details about your API user") @click.pass_context def current_user(ctx): - anvil = ctx.obj["anvil"] - debug = ctx.obj["debug"] - res = anvil.get_current_user(debug=debug) - - if contains_headers(res): - res, headers = process_response(res) - if debug: - click.echo(headers) - + anvil: Anvil = ctx.obj["anvil"] + res = anvil.get_current_user() click.echo(f"User data: \n\n {res}") @@ -78,16 +94,10 @@ def current_user(ctx): @click.pass_context def generate_pdf(ctx, input_filename, out_filename): """Generate a PDF.""" - anvil = ctx.obj["anvil"] - debug = ctx.obj["debug"] - - with click.open_file(input_filename, "r") as infile: - res = anvil.generate_pdf(infile.read(), debug=debug) + anvil: Anvil = ctx.obj["anvil"] - if contains_headers(res): - res, headers = process_response(res) - if debug: - click.echo(headers) + with click.open_file(input_filename) as infile: + res = anvil.generate_pdf(infile.read()) with click.open_file(out_filename, "wb") as file: file.write(res) @@ -99,25 +109,22 @@ def generate_pdf(ctx, input_filename, out_filename): @click.pass_context def weld(ctx, eid, list_all): """Fetch weld info or list of welds.""" - anvil = ctx.obj["anvil"] - debug = ctx.obj["debug"] + anvil: Anvil = ctx.obj["anvil"] if list_all: - res = anvil.get_welds(debug=debug) - if contains_headers(res): - res, headers = process_response(res) - if debug: - click.echo(headers) - + res = anvil.get_welds() data = [(w["eid"], w.get("slug"), w.get("name"), w.get("forges")) for w in res] - click.echo(tabulate(data, tablefmt="pretty", headers=["eid", "slug", "title"])) + click.echo( + tabulate( + data, tablefmt="pretty", headers=["eid", "slug", "title", "forges"] + ) + ) return if not eid: - raise Exception("You need to pass in a weld eid") + raise click.UsageError("You need to pass in a weld eid") - res = anvil.get_weld(eid) - print(res) + click.echo(anvil.get_weld(eid)) @cli.command() @@ -131,47 +138,36 @@ def weld(ctx, eid, list_all): @click.option( "-a", "--all", "list_all", help="List all casts, even non-templates", is_flag=True ) -@click.option("--version_number", help="Get the specified version of this cast") +@click.option( + "--version_number", type=int, help="Get the specified version of this cast" +) @click.argument("eid", default="") @click.pass_context def cast(ctx, eid, version_number, list_all, list_templates): """Fetch Cast data given a Cast eid.""" - anvil = ctx.obj["anvil"] # type: Anvil - debug = ctx.obj["debug"] + anvil: Anvil = ctx.obj["anvil"] if not eid and not (list_templates or list_all): - raise AssertionError("Cast eid or --list/--all option required") + raise click.UsageError("Cast eid or --list/--all option required") if list_all or list_templates: - res = anvil.get_casts(show_all=list_all, debug=debug) - - if contains_headers(res): - res, headers = process_response(res) - if debug: - click.echo(headers) - - data = [[c["eid"], c["title"]] for c in res] + casts = anvil.get_casts(show_all=list_all) + data = [[c["eid"], c["title"]] for c in casts] click.echo(tabulate(data, headers=["eid", "title"])) return - if eid: - click.echo(f"Getting cast with eid '{eid}' \n") - _res = anvil.get_cast(eid, version_number=version_number, debug=debug) + click.echo(f"Getting cast with eid '{eid}' \n") + res = anvil.get_cast(eid, version_number=version_number) - if contains_headers(_res): - res, headers = process_response(res) - if debug: - click.echo(headers) - - def get_field_info(cc): - return tabulate(cc.get("fields", [])) + if not res: + click.echo(f"Cast with eid: {eid} not found") + return - if not _res: - click.echo(f"Cast with eid: {eid} not found") - return + def get_field_info(cc): + return tabulate(cc.get("fields", [])) - table_data = [[_res["eid"], _res["title"], get_field_info(_res["fieldInfo"])]] - click.echo(tabulate(table_data, tablefmt="pretty", headers=list(_res.keys()))) + table_data = [[res["eid"], res["title"], get_field_info(res["fieldInfo"])]] + click.echo(tabulate(table_data, tablefmt="pretty", headers=list(res.keys()))) @cli.command("fill-pdf") @@ -192,40 +188,33 @@ def get_field_info(cc): ) @click.pass_context def fill_pdf(ctx, template_id, out_filename, payload_csv): - """Fill PDF template with data.""" - anvil = ctx.obj["anvil"] - debug = ctx.obj["debug"] - - if all([template_id, out_filename, payload_csv]): - payloads: list[FillPDFPayload] = [] - with click.open_file(payload_csv, "r") as csv_file: - reader = DictReader(csv_file) - # NOTE: This is potentially problematic for larger datasets and/or - # very long csv files, but not sure if the use-case is there yet.. - # - # Once memory/execution times are a problem for this command, the - # `progressbar()` can be removed below and we could just work on - # each csv line individually without loading it all into memory - # as we are doing here (or with `list()`). But then that removes - # the nice progress bar, so..trade-offs! - for row in reader: - payloads.append(FillPDFPayload(data=dict(row))) - - with click.progressbar(payloads, label="Filling PDFs and saving") as ps: - indexed_files = utils.build_batch_filenames(out_filename) - for payload in ps: - res = anvil.fill_pdf(template_id, payload.model_dump(), debug=debug) - - if contains_headers(res): - res, headers = process_response(res) - if debug: - click.echo(headers) - - next_file = next(indexed_files) - click.echo(f"\nWriting {next_file}") - with click.open_file(next_file, "wb") as file: - file.write(res) - sleep(1) + """Fill PDF template with data, once per row of a CSV file.""" + anvil: Anvil = ctx.obj["anvil"] + + payloads: list[FillPDFPayload] = [] + with click.open_file(payload_csv) as csv_file: + reader = DictReader(csv_file) + # NOTE: This is potentially problematic for larger datasets and/or + # very long csv files, but not sure if the use-case is there yet.. + # + # Once memory/execution times are a problem for this command, the + # `progressbar()` can be removed below and we could just work on + # each csv line individually without loading it all into memory + # as we are doing here (or with `list()`). But then that removes + # the nice progress bar, so..trade-offs! + for row in reader: + payloads.append(FillPDFPayload(data=dict(row))) + + with click.progressbar(payloads, label="Filling PDFs and saving") as ps: + indexed_files = utils.build_batch_filenames(out_filename) + for payload in ps: + res = anvil.fill_pdf(template_id, payload) + + next_file = next(indexed_files) + click.echo(f"\nWriting {next_file}") + with click.open_file(next_file, "wb") as file: + file.write(res) + sleep(1) @cli.command("create-etch") @@ -248,21 +237,9 @@ def create_etch(ctx, payload): # You can also get data from STDIN > $ ANVIL_API_KEY=mykey anvil create-etch --payload - """ - anvil = ctx.obj["anvil"] - debug = ctx.obj["debug"] - res = anvil.create_etch_packet(json=payload.read(), debug=debug) - - if contains_headers(res): - res, headers = process_response(res) - if debug: - click.echo(headers) - - if "data" in res: - click.echo( - f"Etch packet created with id: {res['data']['createEtchPacket']['eid']}" - ) - else: - click.echo(res) + anvil: Anvil = ctx.obj["anvil"] + res = anvil.create_etch_packet(json=payload.read()) + click.echo(f"Etch packet created with id: {res['createEtchPacket']['eid']}") @cli.command("generate-etch-url", help="Generate an etch url for a signer") @@ -283,18 +260,10 @@ def create_etch(ctx, payload): ) @click.pass_context def generate_etch_url(ctx, signer_eid, client_user_id): - anvil = ctx.obj["anvil"] - debug = ctx.obj["debug"] - res = anvil.generate_etch_signing_url( - signer_eid=signer_eid, client_user_id=client_user_id, debug=debug + anvil: Anvil = ctx.obj["anvil"] + url = anvil.generate_etch_signing_url( + signer_eid=signer_eid, client_user_id=client_user_id ) - - if contains_headers(res): - res, headers = process_response(res) - if debug: - click.echo(headers) - - url = res.get("data", {}).get("generateEtchSignURL") click.echo(f"Signing URL is: {url}") @@ -317,24 +286,19 @@ def generate_etch_url(ctx, signer_eid, client_user_id): ) @click.pass_context def download_documents(ctx, document_group_eid, filename, stdout): - anvil = ctx.obj["anvil"] - debug = ctx.obj["debug"] - res = anvil.download_documents(document_group_eid, debug=debug) - - if contains_headers(res): - res, headers = process_response(res) - if debug: - click.echo(headers) - - if not stdout: - if not filename: - filename = f"{document_group_eid}.zip" - - with click.open_file(filename, "wb") as out_file: - out_file.write(res) - click.echo(f"Saved as '{click.format_filename(filename)}'") - else: + anvil: Anvil = ctx.obj["anvil"] + res = anvil.download_documents(document_group_eid) + + if stdout: click.echo(res) + return + + if not filename: + filename = f"{document_group_eid}.zip" + + with click.open_file(filename, "wb") as out_file: + out_file.write(res) + click.echo(f"Saved as '{click.format_filename(filename)}'") @cli.command("gql-query", help="Run a raw graphql query") @@ -349,20 +313,13 @@ def download_documents(ctx, document_group_eid, filename, stdout): "-v", "--variables", "variables", - help="The query variables. This is the 'variables' part of the JSON payload", + help="The query variables, as JSON. This is the 'variables' part of the JSON payload", ) @click.pass_context def gql_query(ctx, query, variables): - anvil = ctx.obj["anvil"] - debug = ctx.obj["debug"] - res = anvil.query(query, variables=variables, debug=debug) - - if contains_headers(res): - res, headers = process_response(res) - if debug: - click.echo(headers) - - click.echo(res) + anvil: Anvil = ctx.obj["anvil"] + res = anvil.query(query, variables=json.loads(variables) if variables else None) + click.echo(json.dumps(res, indent=2)) if __name__ == "__main__": # pragma: no cover diff --git a/python_anvil/constants.py b/python_anvil/constants.py index 92e68d32..b8f5a5d6 100644 --- a/python_anvil/constants.py +++ b/python_anvil/constants.py @@ -1,25 +1,29 @@ -"""Basic constants used in the library.""" +"""Constants used throughout the library.""" -GRAPHQL_ENDPOINT: str = "https://graphql.useanvil.com" -REST_ENDPOINT = "https://app.useanvil.com/api/v1" -ANVIL_HOST = "https://app.useanvil.com" +# Where requests go unless the client is given another `base_url` +DEFAULT_BASE_URL = "https://app.useanvil.com" -VALID_HOSTS = [ - ANVIL_HOST, - REST_ENDPOINT, - GRAPHQL_ENDPOINT, -] +# Version number to use for latest versions (usually drafts) +VERSION_LATEST = -1 +# Version number to use for the latest published version. +# This is the default when a version is not provided. +VERSION_LATEST_PUBLISHED = -2 -RETRIES_LIMIT = 5 -REQUESTS_LIMIT = { - "dev": { - "calls": 2, - "seconds": 1, - }, - "prod": { - "calls": 40, - "seconds": 1, - }, -} +# What a REST request's data comes back as +DATA_TYPE_JSON = "json" +DATA_TYPE_BYTES = "bytes" +DATA_TYPE_STREAM = "stream" +DATA_TYPES = (DATA_TYPE_JSON, DATA_TYPE_BYTES, DATA_TYPE_STREAM) +BINARY_DATA_TYPES = (DATA_TYPE_BYTES, DATA_TYPE_STREAM) -RATELIMIT_ENV = "dev" +# Until the first response says what the account's rate limit is, the client +# allows this many requests per interval +DEFAULT_REQUEST_LIMIT = 1 +DEFAULT_REQUEST_LIMIT_MS = 1000 + +# Added to every wait for the rate limit, and to a 429's Retry-After, to allow +# for requests that were sent further apart than they arrive +RETRY_BUFFER_MS = 50 + +# Seconds to wait for a connection, and then for each read, before giving up +DEFAULT_TIMEOUT = 300 diff --git a/python_anvil/errors.py b/python_anvil/errors.py new file mode 100644 index 00000000..96a1f3fc --- /dev/null +++ b/python_anvil/errors.py @@ -0,0 +1,126 @@ +"""The errors the client raises, and how API error responses become them. + +REST and GraphQL error responses come in several shapes. They are all +normalized into a list of dicts that each have a `message`, a `name`, or both, +and which keep whatever else the API said, like a validation error's `fields`. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + import requests + + +class AnvilError(Exception): + """Base class for every error this library raises on purpose.""" + + +class AnvilRequestError(AnvilError): + """The API responded, but with an error. + + :ivar errors: The normalized errors. Each has a `message`, a `name`, or + both. GraphQL errors also have `path` and `locations`; REST validation + errors have `fields`, each with a `message` and `property`. + :ivar status_code: The HTTP status code. GraphQL errors usually come + with a 200. + :ivar headers: The response headers, e.g. for the rate limit headers. + :ivar data: Any data the API returned alongside the errors. A GraphQL + response can be partially successful. + :ivar response: The `requests.Response`, if the error came from one. + """ + + def __init__( + self, + errors: list[dict[str, Any]], + *, + status_code: int, + headers: Mapping[str, str] | None = None, + data: Any = None, + response: requests.Response | None = None, + ): + self.errors = errors + self.status_code = status_code + self.headers: Mapping[str, str] = headers or {} + self.data = data + self.response = response + super().__init__(f"{status_code}: {'; '.join(format_error(e) for e in errors)}") + + @property + def message(self) -> str: + """The first error's message.""" + return format_error(self.errors[0]) if self.errors else "Unknown Error" + + +def format_error(error: Mapping[str, Any]) -> str: + """One line describing a normalized error, including any field details.""" + parts = [str(part) for part in (error.get("name"), error.get("message")) if part] + description = ": ".join(parts) if parts else "Unknown Error" + + fields = error.get("fields") + if isinstance(fields, list) and fields: + details = ", ".join( + f"{field.get('property')}: {field.get('message')}" + if field.get("property") + else str(field.get("message")) + for field in fields + if isinstance(field, Mapping) + ) + if details: + description = f"{description} ({details})" + + return description + + +def looks_like_json_error(json: Any) -> bool: + """Whether a JSON response body is an error, whatever its status code.""" + return isinstance(json, Mapping) and bool( + json.get("errors") or json.get("message") or json.get("name") + ) + + +def normalize_json_errors( + json: Any, status_text: str = "Unknown Error" +) -> list[dict[str, Any]]: + """Normalize a JSON error response into a list of errors.""" + if isinstance(json, Mapping): + # Normal, GraphQL way + errors = json.get("errors") + if isinstance(errors, list) and errors: + return [ + dict(error) if isinstance(error, Mapping) else {"message": str(error)} + for error in errors + ] + + # Alternative way from some REST calls: + # { + # "name": "AssertionError", + # "message": "PDF did not generate properly from given HTML!" + # } + # + # OR + # + # { + # "name": "ValidationError", + # "fields":[{ "message": "Required", "property": "data" }] + # } + if json.get("message") or json.get("name"): + return [dict(json)] + + # Hmm, ok. Default way + status_text = status_text or "Unknown Error" + return [{"name": status_text, "message": status_text}] + + +def normalize_exception( + error: BaseException | None, status_text: str = "Unknown Error" +) -> list[dict[str, Any]]: + """Normalize an exception, like a JSON parsing error, into a list of errors.""" + if error is not None: + return [{"name": type(error).__name__, "message": str(error)}] + + status_text = status_text or "Unknown Error" + return [{"name": status_text, "message": status_text}] diff --git a/python_anvil/exceptions.py b/python_anvil/exceptions.py index 36db13fe..d058183e 100644 --- a/python_anvil/exceptions.py +++ b/python_anvil/exceptions.py @@ -1,6 +1,9 @@ -class AnvilException(BaseException): - pass +"""The exception names from before 6.0.0. New code should import from `python_anvil.errors`.""" +from python_anvil.errors import AnvilError, AnvilRequestError -class AnvilRequestException(AnvilException): - pass + +AnvilException = AnvilError +AnvilRequestException = AnvilRequestError + +__all__ = ["AnvilException", "AnvilRequestException"] diff --git a/python_anvil/graphql/__init__.py b/python_anvil/graphql/__init__.py new file mode 100644 index 00000000..d43b0f10 --- /dev/null +++ b/python_anvil/graphql/__init__.py @@ -0,0 +1,11 @@ +"""The client's GraphQL operations: the queries and mutations it sends. + +These match node-anvil's `src/graphql/` exactly, variable names and default +response fields included. Pass your own response query to select other +fields, or your own whole operation. +""" + +from python_anvil.graphql import mutations, queries + + +__all__ = ["mutations", "queries"] diff --git a/python_anvil/graphql/mutations/__init__.py b/python_anvil/graphql/mutations/__init__.py new file mode 100644 index 00000000..13a96725 --- /dev/null +++ b/python_anvil/graphql/mutations/__init__.py @@ -0,0 +1,14 @@ +from python_anvil.graphql.mutations import ( + create_etch_packet, + forge_submit, + generate_etch_sign_url, + remove_weld_data, +) + + +__all__ = [ + "create_etch_packet", + "forge_submit", + "generate_etch_sign_url", + "remove_weld_data", +] diff --git a/python_anvil/graphql/mutations/create_etch_packet.py b/python_anvil/graphql/mutations/create_etch_packet.py new file mode 100644 index 00000000..9aeb0a29 --- /dev/null +++ b/python_anvil/graphql/mutations/create_etch_packet.py @@ -0,0 +1,75 @@ +DEFAULT_RESPONSE_QUERY = """{ + eid + name + status + isTest + allowUpdates + numberRemainingSigners + detailsURL + webhookURL + completedAt + archivedAt + createdAt + updatedAt + documentGroup { + eid + status + files + signers { + eid + aliasId + routingOrder + name + email + status + signActionType + } + } +}""" + + +def generate_mutation(response_query: str | None = None) -> str: + return f""" + mutation CreateEtchPacket( + $name: String, + $organizationEid: String, + $files: [EtchFile!], + $isDraft: Boolean, + $isTest: Boolean, + $signatureEmailSubject: String, + $signatureEmailBody: String, + $signatureProvider: String, + $signaturePageOptions: JSON, + $signers: [JSON!], + $data: JSON, + $webhookURL: String, + $replyToName: String, + $replyToEmail: String, + $enableEmails: JSON, + $createCastTemplatesFromUploads: Boolean, + $duplicateCasts: Boolean, + $mergePDFs: Boolean, + $allowUpdates: Boolean + ) {{ + createEtchPacket( + name: $name, + organizationEid: $organizationEid, + files: $files, + isDraft: $isDraft, + isTest: $isTest, + signatureEmailSubject: $signatureEmailSubject, + signatureEmailBody: $signatureEmailBody, + signatureProvider: $signatureProvider, + signaturePageOptions: $signaturePageOptions, + signers: $signers, + data: $data, + webhookURL: $webhookURL, + replyToName: $replyToName, + replyToEmail: $replyToEmail, + enableEmails: $enableEmails, + createCastTemplatesFromUploads: $createCastTemplatesFromUploads, + duplicateCasts: $duplicateCasts, + mergePDFs: $mergePDFs, + allowUpdates: $allowUpdates + ) {response_query or DEFAULT_RESPONSE_QUERY} + }}""" diff --git a/python_anvil/graphql/mutations/forge_submit.py b/python_anvil/graphql/mutations/forge_submit.py new file mode 100644 index 00000000..9ee8a60f --- /dev/null +++ b/python_anvil/graphql/mutations/forge_submit.py @@ -0,0 +1,56 @@ +DEFAULT_RESPONSE_QUERY = """{ + id + eid + status + continueURL + payloadValue + currentStep + completedAt + createdAt + updatedAt + signer { + name + email + status + routingOrder + } + weldData { + eid + isTest + isComplete + agents + } +}""" + + +def generate_mutation(response_query: str | None = None) -> str: + return f""" + mutation ForgeSubmit( + $forgeEid: String!, + $weldDataEid: String, + $submissionEid: String, + $payload: JSON!, + $enforcePayloadValidOnCreate: Boolean, + $currentStep: Int, + $complete: Boolean, + $isTest: Boolean, + $timezone: String, + $webhookURL: String, + $groupArrayId: String, + $groupArrayIndex: Int + ) {{ + forgeSubmit( + forgeEid: $forgeEid, + weldDataEid: $weldDataEid, + submissionEid: $submissionEid, + payload: $payload, + enforcePayloadValidOnCreate: $enforcePayloadValidOnCreate, + currentStep: $currentStep, + complete: $complete, + isTest: $isTest, + timezone: $timezone, + webhookURL: $webhookURL, + groupArrayId: $groupArrayId, + groupArrayIndex: $groupArrayIndex + ) {response_query or DEFAULT_RESPONSE_QUERY} + }}""" diff --git a/python_anvil/graphql/mutations/generate_etch_sign_url.py b/python_anvil/graphql/mutations/generate_etch_sign_url.py new file mode 100644 index 00000000..f914b294 --- /dev/null +++ b/python_anvil/graphql/mutations/generate_etch_sign_url.py @@ -0,0 +1,12 @@ +def generate_mutation() -> str: + return """ + mutation GenerateEtchSignURL ( + $signerEid: String!, + $clientUserId: String!, + ) { + generateEtchSignURL ( + signerEid: $signerEid, + clientUserId: $clientUserId + ) + } +""" diff --git a/python_anvil/graphql/mutations/remove_weld_data.py b/python_anvil/graphql/mutations/remove_weld_data.py new file mode 100644 index 00000000..8843841c --- /dev/null +++ b/python_anvil/graphql/mutations/remove_weld_data.py @@ -0,0 +1,9 @@ +def generate_mutation() -> str: + return """ + mutation RemoveWeldData ( + $eid: String!, + ) { + removeWeldData ( + eid: $eid, + ) + }""" diff --git a/python_anvil/graphql/queries/__init__.py b/python_anvil/graphql/queries/__init__.py new file mode 100644 index 00000000..84be283c --- /dev/null +++ b/python_anvil/graphql/queries/__init__.py @@ -0,0 +1,4 @@ +from python_anvil.graphql.queries import etch_packet + + +__all__ = ["etch_packet"] diff --git a/python_anvil/graphql/queries/etch_packet.py b/python_anvil/graphql/queries/etch_packet.py new file mode 100644 index 00000000..a8918f9b --- /dev/null +++ b/python_anvil/graphql/queries/etch_packet.py @@ -0,0 +1,41 @@ +DEFAULT_RESPONSE_QUERY = """{ + id + eid + name + status + isTest + numberRemainingSigners + webhookURL + detailsURL + completedAt + archivedAt + createdAt + updatedAt + documentGroup { + eid + status + files + signers { + id + eid + aliasId + routingOrder + name + email + status + signActionType + } + } +}""" + + +def generate_query(response_query: str | None = None) -> str: + return f""" + query EtchPacket ( + $eid: String!, + ) {{ + etchPacket ( + eid: $eid, + ) {response_query or DEFAULT_RESPONSE_QUERY} + }} +""" diff --git a/python_anvil/http.py b/python_anvil/http.py index 85da58bc..5b0031f0 100644 --- a/python_anvil/http.py +++ b/python_anvil/http.py @@ -1,224 +1,184 @@ -import os -from base64 import b64encode -from logging import getLogger +"""Sends the client's requests: auth, rate limiting, retries and error handling.""" -# import json -import requests -from gql import Client -from gql.dsl import DSLSchema -from gql.transport.requests import RequestsHTTPTransport -from ratelimit import limits, sleep_and_retry -from ratelimit.exception import RateLimitException -from requests.auth import HTTPBasicAuth +from __future__ import annotations -from python_anvil.exceptions import AnvilRequestException +import logging +from collections.abc import Callable, Mapping +from typing import Any +from urllib.parse import urlsplit -from .constants import GRAPHQL_ENDPOINT, RATELIMIT_ENV, REQUESTS_LIMIT, RETRIES_LIMIT +import requests +from python_anvil.auth import build_auth_header +from python_anvil.constants import ( + DATA_TYPE_BYTES, + DATA_TYPE_JSON, + DATA_TYPE_STREAM, + DATA_TYPES, + DEFAULT_BASE_URL, + DEFAULT_REQUEST_LIMIT, + DEFAULT_REQUEST_LIMIT_MS, + DEFAULT_TIMEOUT, +) +from python_anvil.errors import ( + AnvilRequestError, + looks_like_json_error, + normalize_exception, + normalize_json_errors, +) +from python_anvil.rate_limit import RateLimiter -logger = getLogger(__name__) +logger = logging.getLogger(__name__) -def _handle_request_error(e: Exception): - raise e +def default_user_agent() -> str: + from python_anvil import __version__ -def get_local_schema(raise_on_error=False) -> str | None: - """ - Retrieve local GraphQL schema. + return f"python-anvil/{__version__}" - :param raise_on_error: - :return: - """ - try: - file_dir = os.path.dirname(os.path.realpath(__file__)) - file_path = os.path.join(file_dir, "..", "schema", "anvil_schema.graphql") - with open(file_path, encoding="utf-8") as file: - schema = file.read() - except Exception: - logger.warning( - "Unable to find local schema. Will not use schema for local " - "validation. Use `fetch_schema_from_transport=True` to allow " - "fetching the remote schema." - ) - if raise_on_error: - raise - schema = None - - return schema - - -def get_gql_ds(client: Client) -> DSLSchema: - if not client.schema: - raise ValueError("Client does not have a valid GraphQL schema.") - return DSLSchema(client.schema) - - -# FIXME: when gql 3.6.0 is stable, we can use this to paper over pre-graphql -# handler errors. https://github.com/graphql-python/gql/releases/tag/v3.6.0b1 -# def json_deserialize (response_text): -# try: -# return json.loads(response_text) -# except Exception as e: -# return {"errors": [{"message": response_text}]} - - -class GQLClient: - """GraphQL client factory class.""" - - @staticmethod - def get_client( - api_key: str, - environment: str = "dev", - endpoint_url: str | None = None, - fetch_schema_from_transport: bool = False, - force_local_schema: bool = False, - ) -> Client: - auth = HTTPBasicAuth(username=api_key, password="") - endpoint_url = endpoint_url or GRAPHQL_ENDPOINT - transport = RequestsHTTPTransport( - retries=RETRIES_LIMIT, - auth=auth, - url=endpoint_url, - verify=True, - # FIXME: when gql 3.6.0 is stable... see note above - # json_deserialize=json_deserialize, - ) - schema = None - if force_local_schema or not fetch_schema_from_transport: - schema = get_local_schema(raise_on_error=False) +class HTTPClient: + """Sends requests to one Anvil environment with one set of credentials. - return Client( - schema=schema, - transport=transport, - fetch_schema_from_transport=fetch_schema_from_transport, - ) + Every request gets the Authorization and User-Agent headers, waits for the + rate limit, and is retried after a 429. See `RateLimiter`. + """ + def __init__( + self, + api_key: str | None = None, + access_token: str | None = None, + *, + base_url: str = DEFAULT_BASE_URL, + user_agent: str | None = None, + request_limit: int = DEFAULT_REQUEST_LIMIT, + request_limit_ms: int = DEFAULT_REQUEST_LIMIT_MS, + timeout: float | tuple[float, float] | None = DEFAULT_TIMEOUT, + session: requests.Session | None = None, + ): + self.base_url = normalize_base_url(base_url) + self.auth_header = build_auth_header(api_key, access_token) + self.user_agent = user_agent or default_user_agent() + self.timeout = timeout + self.session = session or requests.Session() + self.rate_limiter = RateLimiter(request_limit, request_limit_ms) + + def url(self, path_or_url: str) -> str: + """The full URL for a path like `/api/v1/fill/abc.pdf`. A full URL on the base URL is used as-is.""" + if path_or_url.startswith(self.base_url): + return path_or_url + return f"{self.base_url}/{path_or_url.lstrip('/')}" -class HTTPClient: - def __init__(self, api_key=None, environment="dev"): - self._session = requests.Session() - self.api_key = api_key - global RATELIMIT_ENV - RATELIMIT_ENV = environment - - def get_auth(self, encode=False) -> str: - # TODO: Handle OAuth + API_KEY - if not self.api_key: - raise AttributeError("You must have an API key") - - # By default, the `requests` package will base64encode things with - # the `HTTPBasicAuth` method, so no need to handle that here, but the - # option is here if you _really_ want it. - if encode: - return b64encode(f"{self.api_key}:".encode()).decode() - - return self.api_key - - @sleep_and_retry - @limits( - calls=REQUESTS_LIMIT[RATELIMIT_ENV]["calls"], - period=REQUESTS_LIMIT[RATELIMIT_ENV]["seconds"], - ) - def do_request( + def request( self, - method, - url, - headers=None, - data=None, - auth=None, - params=None, - retry=True, - files=None, - **kwargs, + method: str, + path_or_url: str, + *, + params: Mapping[str, Any] | None = None, + json: Any = None, + data: Any = None, + files: Callable[[], Any] | None = None, + headers: Mapping[str, str] | None = None, + stream: bool = False, + timeout: float | tuple[float, float] | None = None, ) -> requests.Response: - for _ in range(5): - # Retry a max of 5 times in case of hitting any rate limit errors - res = self._session.request( - method, + """Send a request, within the rate limit and retried after any 429. + + The response is returned as the server sent it, errors and all. Use + `process_response` to turn it into data or an `AnvilRequestError`. + + :param files: A function that returns the multipart parts to send, so + they can be built again for a retry. + """ + url = self.url(path_or_url) + all_headers = { + "User-Agent": self.user_agent, + "Authorization": self.auth_header, + **(headers or {}), + } + timeout = self.timeout if timeout is None else timeout + + def send() -> requests.Response: + return self.session.request( + method.upper(), url, - headers=headers, - data=data, - auth=auth, params=params, - files=files, - **kwargs, + json=json, + data=data, + files=files() if files else None, + headers=all_headers, + stream=stream, + timeout=timeout, ) - if res.status_code == 429: - time_to_wait = int(res.headers.get("Retry-After", 1)) - if retry: - logger.warning( - "Rate-limited: request not accepted. Retrying in %i second%s.", - time_to_wait, - "s" if time_to_wait > 1 else "", - ) + return self.rate_limiter.run(send) - # This exception will raise up to the `sleep_and_retry` decorator - # which will handle waiting for `time_to_wait` seconds. - raise RateLimitException("Retrying", period_remaining=time_to_wait) + def close(self) -> None: + self.session.close() - raise AnvilRequestException( - f"Rate limit exceeded. Retry after {time_to_wait} seconds." - ) - break +def process_response( + response: requests.Response, data_type: str = DATA_TYPE_JSON +) -> Any: + """The data in a response, or an `AnvilRequestError` if it is an error. - return res + Whatever was asked for, a JSON response is parsed and checked for errors: + the API answers every request with JSON when something is wrong. + """ + if data_type not in DATA_TYPES: + raise ValueError(f"data_type must be one of: {'|'.join(DATA_TYPES)}") - def request( - self, - method, - url, - headers=None, - data=None, - auth=None, - params=None, - retry=True, - files=None, - **kwargs, - ): - """Make an HTTP request. - - :param method: HTTP method to use - :param url: URL to make the request on. - :param headers: - :param data: - :param auth: - :param params: - :param files: - :param retry: Whether to retry on any rate-limited requests - :param kwargs: - :return: - """ - parse_json = kwargs.pop("parse_json", False) - if self.api_key and not auth: - auth = HTTPBasicAuth(self.get_auth(), "") + status_code = response.status_code + content_type = response.headers.get("content-type", "") + json_body: Any = None + parse_error: Exception | None = None + is_json = "application/json" in content_type.lower() + if is_json: try: - res = self.do_request( - method, - url, - headers=headers, - data=data, - auth=auth, - params=params, - retry=retry, - files=files, - **kwargs, + json_body = response.json() + except ValueError as e: + parse_error = e + logger.debug( + "Problem parsing the JSON response for status %s: %s", status_code, e ) - if parse_json and res.headers.get("Content-Type") == "application/json": - content = res.json() - else: - # This actually reads the content and can potentially cause issues - # depending on the content. - # The structure of this method is very similar to Stripe's requests - # HTTP client: https://github.com/stripe/stripe-python/blob/afa872c538bee0a1e14c8e131df52dd3c24ff05a/stripe/http_client.py#L304-L308 - content = res.content - status_code = res.status_code - except Exception as e: - _handle_request_error(e) - - return content, status_code, res.headers + if parse_error or looks_like_json_error(json_body) or status_code >= 300: + if parse_error: + errors = normalize_exception(parse_error) + else: + errors = normalize_json_errors( + json_body, response.reason or "Unknown Error" + ) + # A GraphQL response can have data alongside its errors + data = json_body.get("data") if isinstance(json_body, Mapping) else None + response.close() + raise AnvilRequestError( + errors, + status_code=status_code, + headers=response.headers, + data=data, + response=response, + ) + + if is_json or data_type == DATA_TYPE_JSON: + return json_body if is_json else response.json() + if data_type == DATA_TYPE_BYTES: + return response.content + if data_type == DATA_TYPE_STREAM: + return response + raise ValueError( + f"data_type must be one of: {'|'.join(DATA_TYPES)}" + ) # pragma: no cover + + +def normalize_base_url(base_url: str) -> str: + if not isinstance(base_url, str) or not base_url: + raise ValueError("`base_url` must be a URL like https://app.useanvil.com") + parts = urlsplit(base_url) + if parts.scheme not in ("http", "https") or not parts.netloc: + raise ValueError( + f"`base_url` must be a URL like https://app.useanvil.com, not {base_url!r}" + ) + return base_url.rstrip("/") diff --git a/python_anvil/rate_limit.py b/python_anvil/rate_limit.py new file mode 100644 index 00000000..97652fb9 --- /dev/null +++ b/python_anvil/rate_limit.py @@ -0,0 +1,183 @@ +"""Keeps requests within the API's rate limit, and retries those that weren't. + +The API counts requests over a sliding window: at most `X-RateLimit-Limit` +requests in any `X-RateLimit-Interval-Ms`. The limiter counts the same way, +so it only holds a request back when the limit has been reached, and never +delays requests that are within it. + +Every response carries those headers, so the client's first request goes out +alone, and the limits in its response become the limits for every request +after it. Requests made in the meantime wait for that first response. + +A request that still gets a `429 Too Many Requests` is retried after the +`Retry-After` in the response, plus a small buffer. +""" + +from __future__ import annotations + +import logging +import threading +import time +from collections import deque +from collections.abc import Callable, Mapping +from typing import Any + +import requests + +from python_anvil.constants import ( + DEFAULT_REQUEST_LIMIT, + DEFAULT_REQUEST_LIMIT_MS, + RETRY_BUFFER_MS, +) + + +logger = logging.getLogger(__name__) + +LIMIT_HEADER = "x-ratelimit-limit" +INTERVAL_HEADER = "x-ratelimit-interval-ms" +RETRY_AFTER_HEADER = "retry-after" + + +class RateLimiter: + """A thread-safe sliding window rate limiter that learns its limits from the API.""" + + def __init__( + self, + limit: Any = DEFAULT_REQUEST_LIMIT, + interval_ms: Any = DEFAULT_REQUEST_LIMIT_MS, + ): + self._condition = threading.Condition() + # Fall back to 1 request per second if the starting limits are invalid + self.limit: int = DEFAULT_REQUEST_LIMIT + self.interval_ms: int = DEFAULT_REQUEST_LIMIT_MS + self.set_limits(limit, interval_ms) + + # When the requests in the current window were sent, oldest first + self._request_times: deque[float] = deque() + # Whether the limits have been set from an API response + self.has_set_from_response = False + # Whether a request is out whose response will set the limits + self._setting_in_progress = False + + def set_limits(self, limit: Any, interval_ms: Any) -> bool: + """Set the limits, if both are positive numbers. Returns whether they were.""" + limit, interval_ms = _positive_int(limit), _positive_int(interval_ms) + if limit is None or interval_ms is None: + return False + + with self._condition: + # Requests already sent still count against the new limits + self.limit = limit + self.interval_ms = interval_ms + self._condition.notify_all() + return True + + def update_from_headers(self, headers: Mapping[str, Any]) -> None: + """Set the limits from a response's rate limit headers, the first time only.""" + with self._condition: + if self.has_set_from_response: + return + self.has_set_from_response = True + self._setting_in_progress = False + self._condition.notify_all() + + if not self.set_limits( + _header(headers, LIMIT_HEADER), _header(headers, INTERVAL_HEADER) + ): + logger.debug( + "The response had no valid rate limit headers; keeping %s requests " + "per %sms", + self.limit, + self.interval_ms, + ) + + def run(self, send: Callable[[], requests.Response]) -> requests.Response: + """Call `send` when the rate limit allows, retrying it after any 429. + + `send` is called again for each retry, so it must be able to build its + request again. + """ + while True: + self._wait_for_turn() + try: + response = send() + except BaseException: + # There won't be a response to set the limits from, so let + # the requests waiting for one go ahead + self._release_setup_gate() + raise + + if not self.has_set_from_response: + self.update_from_headers(response.headers) + + if response.status_code != 429: + return response + + delay = retry_delay_seconds(_header(response.headers, RETRY_AFTER_HEADER)) + # Nothing will read the body, so free the connection for the retry + response.close() + logger.warning( + "Rate limited: the request was not accepted. Retrying in %.2f seconds.", + delay, + ) + time.sleep(delay) + + def _wait_for_turn(self) -> None: + with self._condition: + # Until the first response has set the limits, only the request + # whose response will set them may go + while self._setting_in_progress and not self.has_set_from_response: + self._condition.wait() + if not self.has_set_from_response: + self._setting_in_progress = True + + # Wait until another request would keep us within `limit` requests + # in any `interval_ms` + while True: + # Not time.time(), so that changes to the system clock can't hold requests up + now = time.monotonic() + # Requests sent before this no longer count. The buffer allows for + # requests that were sent further apart than they arrive. + window_start = now - (self.interval_ms + RETRY_BUFFER_MS) / 1000 + while self._request_times and self._request_times[0] <= window_start: + self._request_times.popleft() + + if len(self._request_times) < self.limit: + self._request_times.append(now) + return + + # Wait for the oldest request to leave the window, then check again + self._condition.wait(timeout=self._request_times[0] - window_start) + + def _release_setup_gate(self) -> None: + with self._condition: + if not self.has_set_from_response: + self._setting_in_progress = False + self._condition.notify_all() + + +def retry_delay_seconds(retry_after: Any) -> float: + """How long to wait before retrying a 429: its Retry-After seconds plus the buffer.""" + try: + seconds = abs(float(retry_after)) + except (TypeError, ValueError): + seconds = 0.0 + return seconds + RETRY_BUFFER_MS / 1000 + + +def _header(headers: Mapping[str, Any], name: str) -> Any: + # requests gives case-insensitive headers, but a plain dict might not be + value = headers.get(name) + if value is None: + for key, val in headers.items(): + if key.lower() == name: + return val + return value + + +def _positive_int(value: Any) -> int | None: + try: + number = int(value) + except (TypeError, ValueError): + return None + return number if number > 0 else None diff --git a/python_anvil/upload.py b/python_anvil/upload.py new file mode 100644 index 00000000..ed5de1c6 --- /dev/null +++ b/python_anvil/upload.py @@ -0,0 +1,265 @@ +"""File uploads in GraphQL requests, per the GraphQL multipart request spec. + +https://github.com/jaydenseric/graphql-multipart-request-spec + +A file can be anywhere in a request's variables. `extract_files` finds them, +replaces each with `None`, and records where it was, which `build_multipart` +turns into the `operations`, `map` and file parts of the request. +""" + +from __future__ import annotations + +import io +import json +import os +from collections.abc import Mapping +from mimetypes import guess_type +from pathlib import Path +from typing import Any + + +FileInput = str | os.PathLike | bytes | bytearray | io.IOBase + +# The multipart part a file is sent as: the filename, the file contents (bytes +# or a file object) and the mimetype, in the shape `requests` wants +FilePart = tuple[str, Any, str | None] + +FILENAME_IGNORE_MESSAGE = ( + "If you think you can ignore this, pass `ignore_filename_validation=True`." +) +# Without a name, the part would have no filename and the API would treat it as text +DEFAULT_FILENAME = "blob" + + +class UploadWithOptions: + """A file to upload, with the filename and mimetype to send it with. + + Make these with `prepare_graphql_file`. + """ + + def __init__( + self, + file: FileInput, + filename: str | None = None, + mimetype: str | None = None, + ): + self.file = file + self.filename = filename + self.mimetype = mimetype + + @property + def options(self) -> dict[str, str]: + return { + key: value + for key, value in (("filename", self.filename), ("mimetype", self.mimetype)) + if value + } + + def __repr__(self) -> str: + return f"UploadWithOptions({self.file!r}, {self.options!r})" + + +def prepare_graphql_file( + path_or_file: FileInput, + *, + filename: str | None = None, + mimetype: str | None = None, + ignore_filename_validation: bool = False, +) -> UploadWithOptions: + """Prepare a file for a GraphQL multipart upload, e.g. in `create_etch_packet`. + + :param path_or_file: A path to a file, the file's `bytes`, or an open + binary file object. A path is opened when the request is sent, and + opened again if the request is retried; a file object is read from + its current position, and rewound if the request is retried. + :param filename: The filename to send. Required for `bytes` and for file + objects that have no `name`, like a `BytesIO`. A path and a file + opened from one are sent with their own names unless this is given. + :param mimetype: The Content-Type to send the file with. Defaults to one + guessed from the filename. + :param ignore_filename_validation: Send a file without a filename, as + "blob". An escape hatch for things we didn't anticipate. + """ + if not isinstance(path_or_file, (str, os.PathLike)) and not ( + filename or ignore_filename_validation or _own_filename(path_or_file) + ): + raise ValueError( + f"When passing {_describe(path_or_file)} to prepare_graphql_file, " + f"`filename` must be provided. {FILENAME_IGNORE_MESSAGE}" + ) + + return UploadWithOptions(path_or_file, filename=filename, mimetype=mimetype) + + +def is_file(value: Any) -> bool: + """Whether a value in a request's variables is a file to upload. + + Paths given as plain strings aren't, since they can't be told apart from + other strings: pass those through `prepare_graphql_file`. + """ + return isinstance( + value, (UploadWithOptions, bytes, bytearray, io.IOBase, os.PathLike) + ) + + +def extract_files(value: Any) -> tuple[Any, list[Any], dict[int, list[str]]]: + """Pull the files out of a request's variables. + + Given the whole operation, `{"query": ..., "variables": ...}`, returns a + copy with every file replaced by `None`, the files found, and where each + was found: a map of a file's index to the dotted paths it was at, e.g. + `variables.files.0.file`. + """ + files: list[Any] = [] + paths: dict[int, list[str]] = {} + + def walk(node: Any, path: str) -> Any: + if is_file(node): + # The same file in two places is sent once, with two paths + for index, file in enumerate(files): + if file is node: + paths[index].append(path) + return None + files.append(node) + paths[len(files) - 1] = [path] + return None + if isinstance(node, Mapping): + return {key: walk(child, _join(path, key)) for key, child in node.items()} + if isinstance(node, (list, tuple)): + return [walk(child, _join(path, i)) for i, child in enumerate(node)] + return node + + clone = walk(value, "") + return clone, files, paths + + +def _join(path: str, key: Any) -> str: + return f"{path}.{key}" if path else str(key) + + +def validate_upload_schema( + value: Any, parent: Any = None, key: str | None = None +) -> bool: + """Whether every file in the variables is where the API expects one: under a `file` key. + + Also checks base64 uploads, `{"data": ..., "filename": ..., "mimetype": ...}`. + """ + if value is None: + return True + + if key != "file": + if isinstance(value, (list, tuple)): + return all(validate_upload_schema(child, value) for child in value) + if isinstance(value, Mapping): + return all( + validate_upload_schema(child, value, child_key) + for child_key, child in value.items() + ) + return not is_file(value) + + # OK, the key is 'file' + + # All flavors should be nested, and not top-level + if not (isinstance(parent, Mapping) and parent.get("file") is value): + return False + + # Base64 Upload + if isinstance(value, Mapping): + if value.get("data"): + # Must be a string and also have the provided keys + return isinstance(value["data"], str) and all( + value.get(required) for required in ("filename", "mimetype") + ) + # Another form of the Upload scalar, like {"url": ...} + return True + + return is_file(value) + + +class MultipartRequest: + """The parts of a multipart GraphQL request, built fresh for each attempt. + + Files given as paths are opened when the parts are built, so a retry sends + them in full again. File objects are rewound for a retry when they can be; + one that can't be read again fails rather than sending an empty file. + """ + + def __init__(self, operations: Mapping[str, Any]): + clone, self._files, paths = extract_files(dict(operations)) + self.operations_json = json.dumps(clone) + self.map_json = json.dumps({str(i + 1): found for i, found in paths.items()}) + self.attempts = 0 + self._opened: list[io.IOBase] = [] + + def __bool__(self) -> bool: + return bool(self._files) + + def build(self) -> list[tuple[str, Any]]: + """The `files` argument for `requests`: the operations, the map, then each file.""" + self.close() + self.attempts += 1 + + parts: list[tuple[str, Any]] = [ + ("operations", (None, self.operations_json, "application/json")), + ("map", (None, self.map_json, "application/json")), + ] + for index, file in enumerate(self._files): + parts.append((str(index + 1), self._part(file))) + return parts + + def close(self) -> None: + """Close the files this opened for the last attempt.""" + for handle in self._opened: + handle.close() + self._opened = [] + + def _part(self, file: Any) -> FilePart: + filename: str | None = None + mimetype: str | None = None + if isinstance(file, UploadWithOptions): + filename, mimetype = file.filename, file.mimetype + file = file.file + + if isinstance(file, (str, os.PathLike)): + path = Path(file) + filename = filename or path.name + handle = path.open("rb") + self._opened.append(handle) + content: Any = handle + elif isinstance(file, (bytes, bytearray)): + content = bytes(file) + elif isinstance(file, io.IOBase) or hasattr(file, "read"): + filename = filename or _own_filename(file) + if self.attempts > 1: + if file.seekable(): + file.seek(0) + else: + raise ValueError( + f"Can't send {filename or 'a file'} again because its " + "stream has already been read. To upload a file in a " + "request that may be retried, pass a path, a seekable " + "file object or bytes." + ) + content = file + else: + raise TypeError(f"Can't upload {_describe(file)}") + + filename = filename or DEFAULT_FILENAME + mimetype = mimetype or guess_type(filename)[0] + return filename, content, mimetype + + +def _own_filename(file: Any) -> str | None: + """The name a file object carries, if it has one, like an open file's path.""" + name = getattr(file, "name", None) + if isinstance(name, (bytes, bytearray)): + name = name.decode("utf-8", "replace") + if isinstance(name, str) and name: + return os.path.basename(name) + return None + + +def _describe(value: Any) -> str: + if isinstance(value, (bytes, bytearray)): + return "bytes" + return f"a {type(value).__name__}" diff --git a/schema/anvil_schema.graphql b/schema/anvil_schema.graphql index df5a8b84..1ae95b07 100644 --- a/schema/anvil_schema.graphql +++ b/schema/anvil_schema.graphql @@ -1,1276 +1,1745 @@ -type GenerateEmbedURLResponse { - """ - The EID for the token associated with the request you just made. Can be used later to expire the session that was created using it. - """ - requestTokenEid: String! +type Amount { + """Monetary amount value""" + amount: Float! + + """Currency code (e.g., USD)""" + currency: String! +} +type BillingPeriod { """ - The URL to send your embedded User to. They will be logged in and then redirected to the appropriate page for their embedded access. - You can direct them back to this URL if they need to access their embedded experience again as long as their session has not expired. + End date (exclusive) of the billing period in ISO 8601 format; from Metronome this is ending_before (the first instant outside the billing period) """ - url: String! + end: String! + + """Start date (inclusive) of the billing period in ISO 8601 format""" + start: String! } -"""Datetime value represented in `ISO8601`.""" -scalar Date +type BillingSubscription { + """Arbitrary subscription attributes""" + attributes: JSON -type File { - src: String! - key: String! - name: String! - mimetype: String! - encoding: String! -} + """monthly | annual | null""" + billingInterval: String -type Invoice { - startDate: Date - endDate: Date - totalAmount: Float -} + """Subscription identifier""" + id: ID! -type InvoiceUnitItem { - unitType: String - baseUnits: Int - usedUnits: Int - unitAmount: Float - totalAmount: Float -} + """Subscription name""" + name: String! -type InvoiceBucketItem { - unitType: String - includedUnits: Int - usedUnits: Int + """Amount for the subscription""" + price: Amount + + """active | available | expired""" + status: String! } """ -The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +A `Cast` holds configuration for a single PDF template. It defines the +location of boxes on PDF pages, and the type of each box (e.g. date, phone +number, etc.). + +`Cast` objects are used by several API features: + +* The [PDF filling endpoint](/docs/api/fill-pdf) +* Etch signature packets: see the [Etch e-sign guide](/docs/api/e-signatures) for more info +* Workflows: see the [Workflow API guide](/docs/api/workflows) for more info """ -scalar JSON +type Cast { + """Allowed `Cast` field alias IDs""" + allowedAliasIds: [String] + archivedAt: Date + config( + """Only retrieve the specific keys provided.""" + onlyKeys: [String] = null + ): JSON + createdAt: Date -type Mutation { """ - Create a `Cast` with the specified properties. This will require you to publish the `Cast` - via the [`publishCast`]({{Mutations.publishCast}}) mutation before it will go live. + The `eid` is the Cast's object identifier. It is also the PDF template ID. """ - createCast(organizationEid: String, title: String, file: Upload!, isTemplate: Boolean = true, allowedAliasIds: [String]): Cast + eid: String """ - Create an e-signature packet. See the - [Etch e-sign guide](/docs/api/e-signatures) for usage information. + Example data payload to fill this PDF. Shown on the "API Info" tab for this template """ - createEtchPacket( - name: String - organizationEid: String - files: [EtchFile!] - isDraft: Boolean - isTest: Boolean + exampleData: JSON - """Customizes the email subject on signer emails""" - signatureEmailSubject: String + """ + A digestable array of objects with all the details about all fields in this PDF, sorted by page, top to bottom, left to right. + """ + fieldInfo: JSON + hasBeenPublished: Boolean + hasUnpublishedChanges: Boolean + isLatestVersion: Boolean + isPublishedVersion: Boolean - """Customizes the email body on signer emails""" - signatureEmailBody: String - signatureProvider: String - signaturePageOptions: JSON - signers: [JSON!] - data: JSON - webhookURL: String + """ + `true` when this `Cast` was created as a template. Template casts show up in the `PDF Templates` page on your dashboard. + """ + isTemplate: Boolean + latestDraftVersionNumber: Int - """ - Overrides the replyTo header on emails sent to signers. - By default, this will be your organization name. - """ - replyToName: String + """ + Basic information about the latest published version (title and publisher name) + """ + latestPublishedVersionInfo: JSON - """ - Overrides the replyTo header on emails sent to signers. - By default, this will be the support email listed in your organization settings. - """ - replyToEmail: String + """The file name""" + name: String + organization: Organization - """ - Enable all, none, or specific emails for this packet. When enabled, an - email type will adhere to the organization settings for that email type. - For example, when `signerViewAdmin` is enabled, an email will only be - sent to organization members specified in the organization settings. + """ + If this `Cast` was originally copied from another `Cast`, this will be the original `Cast`. + """ + parentCast: Cast + publishedAt: Date - Possible values: `true`, `false`, or an array of strings with any of - the following `'signerViewAdmin'`, `'signerCompleteAdmin'`, or - `'etchCompleteAdmin'`. + """Current published version number of this Cast""" + publishedNumber: Int + publishedVersionId: Int + publishedVersionNumber: Int + publishedVersions( + """The requested number of rows per page (max: 500)""" + limit: Int = 10 - The default is `false`. - """ - enableEmails: JSON + """The requested page number""" + offset: Int = 1 + ): CastVersionPage - """Set to `true` to save uploaded PDFs to your PDF templates.""" - createCastTemplatesFromUploads: Boolean - duplicateCasts: Boolean = false + """The title shown in the UI""" + title: String - """ - When enabled, merges all PDFs before users sign. All signers will sign - one (potentially large) PDF. - """ - mergePDFs: Boolean - ): EtchPacket + """The file's mimetype. e.g. `application/pdf`""" + type: String + updatedAt: Date """ - Create a `Forge` with the specified properties. This will require you to publish the `Weld` - via the [`publishWeld`]({{Mutations.publishWeld}}) mutation before it will go live with the Workflow. - """ - createForge(weldEid: String!, name: String!, slug: String!, config: JSON, castEid: String, castFieldIds: JSON): Forge - createSubmission(forgeEid: String!, weldDataEid: String!): Submission - createWebhook(organizationEid: String, organizationSlug: String, url: String): Organization - + An array of files uploaded by users during the dynamic template creation process. """ - Create a Webhook Action to subscribe to events on objects. - - Here is a list of all the actions and the object types that are supported by them: - - [`etchPacketComplete`](/docs/api/webhooks/#etchpacketcomplete) => [`EtchPacket`]({{Types.EtchPacket}}) - - [`forgeComplete`](/docs/api/webhooks/#forgecomplete) => [`Forge`]({{Types.Forge}}), [`Submission`]({{Types.Submission}}), [`WeldData`]({{Types.WeldData}}), [`Weld`]({{Types.Weld}}) - - [`signerUpdateStatus`](/docs/api/webhooks/#signerupdatestatus) => [`Signer`]({{Types.Signer}}), [`EtchPacket`]({{Types.EtchPacket}}), [`WeldData`]({{Types.WeldData}}), [`Weld`]({{Types.Weld}}) - - [`signerComplete`](/docs/api/webhooks/#signercomplete) => [`Signer`]({{Types.Signer}}), [`EtchPacket`]({{Types.EtchPacket}}), [`WeldData`]({{Types.WeldData}}), [`Weld`]({{Types.Weld}}) - - [`weldCreate`](/docs/api/webhooks/#weldcreate) => [`Organization`]({{Types.Organization}}) - - [`weldComplete`](/docs/api/webhooks/#weldcomplete) => [`WeldData`]({{Types.WeldData}}), [`Weld`]({{Types.Weld}}) + userUploads: [UserUpload] + versionId: Int - See the [Webhook Action documentation](/docs/api/webhooks/#webhook-actions) for usage information. - """ - createWebhookAction( - """ - The action you'd like to subscribe to on the object(s). - You can also use `*` to monitor all actions. - """ - action: String! + """Current version number of this Cast""" + versionNumber: Int + versions( + """The requested number of rows per page (max: 500)""" + limit: Int = 10 - """ - The type of object that you'd like to monitor for events. - You can also use `*` to monitor all object types. - """ - objectType: String! + """The requested page number""" + offset: Int = 1 + ): CastVersionPage +} - """ - The `eid` of the specific object you'd like to monitor. - You can also use `*` to monitor all objects of the specified type(s). - """ - objectEid: String! - config: WebhookActionConfigInput +type CastPage { + """The actual records for the requested page""" + items: [Cast] - """ - The `eid` of a pre-existing [`Webhook`]({{Types.Webhook}}) that you'd like to use for this Webhook Action. - A new `Webhook` can be created as well. - """ - webhookEid: String + """The requested page number""" + page: Int - """ - If you want to create a new [`Webhook`]({{Types.Webhook}}) on-the-fly, you must specify the Organization `eid` here. - """ - organizationEid: String + """Total number of pages of results""" + pageCount: Int - """ - If you want to create a new [`Webhook`]({{Types.Webhook}}) on-the-fly, you must specify the URL here. - """ - url: String - ): WebhookAction + """The requested number of rows per page""" + pageSize: Int """ - Create a `Weld` with the specified properties. This will require you to publish the `Weld` - via the [`publishWeld`]({{Mutations.publishWeld}}) mutation before it will go live. + Total number of rows found for the query before pagination. Capped when the query was given a `countLimit`. """ - createWeld( - organizationEid: String! - name: String - slug: String - visibility: String - draftStep: String - config: JSON - castEid: String - files: [WeldFile] - - """Set to `true` to save uploaded PDFs to your PDF templates.""" - createCastTemplatesFromUploads: Boolean - ): Weld - createWeldData(weldEid: String!, weldDataGroupEid: String, isTest: Boolean = false, webhookURL: String): WeldData - destroySubmission(eid: String!): Boolean - disconnectDocusign(organizationSlug: String!): Organization - duplicateCast(eid: String!, organizationEid: String!, name: String, title: String): Cast - duplicateWeld(eid: String!, organizationEid: String!, visibility: String, name: String, slug: String): Weld + rowCount: Int """ - Expires the `UserToken` associated with the provided `requestTokenEid`. Any authentication attempts - made using that token will be rejected. + False when `rowCount` stopped at the requested `countLimit`, meaning there are at least that many rows. True whenever the count ran to completion. """ - expireSessionToken( - """ - The EID of the request token that was returned from the [`generateEmbedURL`]({{Mutations.generateEmbedURL}}) mutation. - Used to determine which session token to invalidate. - """ - requestTokenEid: String! - ): Boolean + rowCountIsExact: Boolean +} - """ - Expires all valid `SignerTokens` for a specific `Signer`. Signers who - visit links with expired tokens will be shown a "Token Expired" error, or - will be [redirected to your - service](/docs/api/e-signatures#handling-signing-errors) if there is a - `redirectURL` set on the signer. +"""A mapping from an old redirect path to its canonical destination""" +type CastRedirectMapping { + """The Cast EID this redirect belongs to""" + castEid: String! - If the `Signer` has already completed signing, this mutation will throw an - error. If you would like the `Signer` to be able to sign again, email - signers will need to be notified via `sendEtchPacket`, and embedded - signers will need a new signing link generated by `generateEtchSignURL`. - """ - expireSignerTokens(signerEid: String!): Signer + """The old redirect path (e.g., /forms/old-tag/old-slug/)""" + from: String! """ - Submits data to a Workflow's webform (a `Forge`). You can use this - mutation to start a Workflow with data from your system and submit data to - an _existing_ `Submission`. - - See the [Workflow API guide](/docs/api/workflows) for more information on - usage scenarios. + The canonical destination path (e.g., /forms/current-tag/current-slug/) """ - forgeSubmit( - forgeEid: String! - weldDataEid: String - submissionEid: String + to: String! +} - """ - A `{ key: value }` object where `key` is the id or field alias of the - webform (`Forge`) field, and value is the data you'd like to send. - - Data is additive: you only need to submit data you wish to update. - """ - payload: JSON! - enforcePayloadValidOnCreate: Boolean = true - currentStep: Int - complete: Boolean - isTest: Boolean = false - timezone: String - webhookURL: String - groupArrayId: String - groupArrayIndex: Int - ): Submission +type CastTag { + cast: Cast + createdAt: Date + id: Int! + tag: Tag + updatedAt: Date +} - """ - Generate a URL for embedding the PDF template editor, the Etch packet - builder, or the Workflow builder in your app. Embedding editors is an - enterprise feature. Please contact sales@useanvil.com if you are interested - in editor embedding. - """ - generateEmbedURL( - """ - The type of object you want to create an embedded URL for. Allowed values are `edit-etch-packet`, `edit-pdf-template`, `edit-weld`, `edit-workflow` - """ - type: String! +type CastVersion { + config: JSON + createdAt: Date - """The EID of the object you want to give embedded access to.""" - eid: String! + """The `eid` is the object identifier.""" + eid: String! + id: Int! + location: String + name: String + number: Int + publishUpdatedAt: Date + publishedAt: Date + publishedDescription: String + publishedNumber: Int + publishedTitle: String + publishingUser: User + title: String + type: String + updatedAt: Date + updatedColumns: [String] + user: User +} - """ - An ISO compliant date-time string such as `2023-08-17T13:22:17+00:00` that indicates when the embedded access will be valid until. +type CastVersionPage { + """The actual records for the requested page""" + items: [CastVersion] - Must provide either `validUntil` or `validForSeconds` arguments. - """ - validUntil: String + """The requested page number""" + page: Int - """ - The number of seconds from now that the embedded access will be valid for. + """Total number of pages of results""" + pageCount: Int - Must provide either `validUntil` or `validForSeconds` arguments. - """ - validForSeconds: Int + """The requested number of rows per page""" + pageSize: Int - """ - Any information that you would like to associate with this embedded URL request - """ - metadata: JSON - ): GenerateEmbedURLResponse + """ + Total number of rows found for the query before pagination. Capped when the query was given a `countLimit`. + """ + rowCount: Int """ - Generate a signing URL for embedded signers. See the - [e-sign API guide on embedding signers](/docs/api/e-signatures#controlling-the-signature-process-with-embedded-signers) - for more info. + False when `rowCount` stopped at the requested `countLimit`, meaning there are at least that many rows. True whenever the count ran to completion. """ - generateEtchSignURL( - signerEid: String! + rowCountIsExact: Boolean +} - """ - Your id for the person signing: a way to trace this signer back to a user in your system. - """ - clientUserId: String! - ): String +"""Datetime value represented in `ISO8601`.""" +scalar Date +""" +A collection of all the final files generated with data from a `WeldData` or +`EtchPacket`. When your users download a zip file of PDFs, that final +collection of filled, generated, uploaded, and signed files is specified by +the `DocumentGroup`. A `DocumentGroup` will be connected to either a +`WeldData` or a `EtchPacket`, never both. A `DocumentGroup` will only +exist on these objects if it has been sent out for signature. +""" +type DocumentGroup { """ - Publish a `Cast` by updating the `Cast` with all un-published changes. + Timestamp of when all Signers have finished signing or when all forms have been filled when no signers. + """ + completedAt: Date + createdAt: Date + currentRoutingStep: Int - This is required to have any changes from an [`updateCast`]({{Mutations.updateCast}}) take effect. + """The URL to download all documents as a zip file""" + downloadZipURL: String + + """The `eid` is the object identifier.""" + eid: String + + """ + The parent `EtchPacket`. This will only exist for DocumentGroups created as part of an Etch signature packet. """ - publishCast(eid: String, title: String, description: String): Cast + etchPacket: EtchPacket + + """A file listing of all files in the `DocumentGroup`""" + files: [JSON] + finishPageConfig: JSON + orgLogo: JSON + + """Possible values: `etch` or `docusign`""" + provider: String + providerConfig: JSON + signaturePageConfig: JSON + signers: [Signer] """ - Publish a `Weld` by updating the `Weld` with all un-published changes. + Possible values: `sent`, `delivered`, `partial`, `declined`, `voided`, `completed` + """ + status: String + stylesheetURL: String + updatedAt: Date - This is required to have any changes from an [`updateWeld`]({{Mutations.updateWeld}}) take effect. """ - publishWeld( - eid: String - title: String - description: String + The parent `WeldData`. This will only exist for DocumentGroups created by a Workflow. + """ + weldData: WeldData +} - """ - A boolean indicating whether you'd like to migrate all open workflow submissions to the - new published version, or leave them pegged to the version under which they were created. - """ - migrateOpenWeldDatas: Boolean = true - ): Weld - removeEtchPacket(eid: String!): Boolean - removeWebhook(eid: String!): Organization +type DocumentGroupPage { + """The actual records for the requested page""" + items: [DocumentGroup] - """Remove a Webhook Action and stop receiving Webhook calls for it.""" - removeWebhookAction( - """The EID of the Webhook Action you want to remove.""" - eid: String! - ): Boolean - removeWeldData(eid: String!): Boolean - retryWebhookLog(eid: String!): WebhookLog + """The requested page number""" + page: Int - """ - This will move a `draft` `EtchPacket` into sent mode (`status` == - `sent`). If the first signer is an email signer, this will kick off an - email, with a link to sign their portion of the packet. + """Total number of pages of results""" + pageCount: Int - You can call this mutation after a packet is sent to "resend" signature - emails to your email signers. For example, you can send a new email via - `sendEtchPacket` if they lose the email, or if they have an email with an - expired token. This mutation will always email the first signer who has not - yet signed. + """The requested number of rows per page""" + pageSize: Int - When your signers are set up as `embedded` signers, they will _not_ be - emailed. You must generate signing URLs via the - [`generateEtchSignURL`]({{Mutations.generateEtchSignURL}}) mutation. See - [our e-signature guide on embedded signers](/docs/api/e-signatures#controlling-the-signature-process-with-embedded-signers) - for more info. """ - sendEtchPacket(eid: String!): EtchPacket - skipSigner(signerEid: String!): Signer + Total number of rows found for the query before pagination. Capped when the query was given a `countLimit`. + """ + rowCount: Int """ - Update a `Cast` with new properties. This will create a new `CastVersion` and: - 1. If this being used as a Template, it will require you to publish the `Cast` via - the [`publishCast`]({{Mutations.publishCast}}) mutation - before it will go live. - 2. If the `Cast` is part of a Workflow, it will require you to publish the `Weld` via - the [`publishWeld`]({{Mutations.publishWeld}}) mutation + False when `rowCount` stopped at the requested `countLimit`, meaning there are at least that many rows. True whenever the count ran to completion. """ - updateCast( - eid: String! - name: String - title: String - isTemplate: Boolean - config: JSON - configFile: Upload - file: Upload - isArchived: Boolean + rowCountIsExact: Boolean +} - """ - Used to ensure that the Cast is still at the version that you believed it is. - Helps to prevent performing an update without being aware that an update has already - occurred in the meantime. - """ - versionNumber: Int - allowedAliasIds: [String] - ): Cast - updateEtchFree(etchTemplateEid: String!, token: String!, isEditing: Boolean, name: String, ownerName: String, ownerEmail: String, signers: JSON, fields: [JSON], castFields: [JSON]): EtchTemplate - updateEtchPacket(eid: String!, token: String, isArchived: Boolean, name: String, webhookURL: String, payload: JSON): EtchPacket - updateEtchTemplate(eid: String!, isRepeatable: Boolean, name: String, config: JSON): EtchTemplate +""" +An object used by `createEtchPacket` to specify a file. See the +[Etch e-sign guide](/docs/api/e-signatures) for more info. +""" +input EtchFile { + base64File: Upload + castEid: String + fields: [JSON!] + file: Upload + + """Filename set when creating the `EtchPacket`.""" + filename: String + fontSize: Int + + """An id string set when creating the `EtchPacket`.""" + id: String! """ - Update a `Forge` with new properties. This will create a new `ForgeVersion` and - will require you to publish the `Weld` via the [`publishWeld`]({{Mutations.publishWeld}}) mutation - before it will go live. + For generated PDFs, set to false to disable the "Generated at XXX" timestamp at the bottom of the document. """ - updateForge( - eid: String! - name: String - slug: String - config: JSON - configFile: Upload - isArchived: Boolean - isRequired: Boolean - title: String - organizationRole: String - unauthenticatedAuthType: String - - """ - Used to ensure that the Forge is still at the version that you believed it is. - Helps to prevent performing an update without being aware that an update has already - occurred in the meantime. - """ - versionNumber: Int - ): Forge - updateOrganization(organizationSlug: String!, name: String, logo: Upload, billingEmail: String, supportEmail: String, slug: String, defaultSourceId: String, signatureProvider: String, config: JSON, usePDFFillingAPI: Boolean, weldCompleteEmailRecipients: JSON, signerViewEmailRecipients: JSON, signerCompleteEmailRecipients: JSON, etchCompleteEmailRecipients: JSON, weldCompleteEmailEnableForTest: Boolean): Organization - updateOrganizationUser(role: String!, externalId: String, organizationEid: String!, userEid: String!): Organization + includeTimestamp: Boolean """ - Update a `Signer`'s name and email address. A signer's name and email can - be updated as long as the signer has not completed signing or been skipped. + For generated PDFs, you can specify a logo to be rendered at the top of the document. + """ + logo: JSON - Name and email for a signer can be update updated after a previous signer - has already signed. Documents will be updated with the new signer - information. Only name and email fields that have been attached to a signer - will be updated. An invisible signature will be added to each updated - document indicating what changed and who changed it. + """Holds HTML/CSS to generate PDF""" + markup: JSON - This mutation can be used on signers in `EtchPackets` and in Workflows - (attached to `WeldData` objects). - """ - updateSigner( - """The eid of the `Signer` to update.""" - eid: String! + """Holds page customizations to generate PDF""" + page: JSON + pageSizes: [JSON!] + textColor: String + title: String +} - """The signer's new name.""" - name: String +""" +Represents a single action taken by a user during the signing process. For +example, an `EtchLog` object will be created when a user signs, downloads +a file, voids the packet, etc. - """The signer's new email address.""" - email: String - ): Signer - updateSubmission(eid: String!, isExcluded: Boolean): Submission - updateWebhook(eid: String!, url: String): Organization +All actions with a short description: - """ - Update a `Weld` with new properties. This will create a new `WeldVersion` and - will require you to publish the `Weld` via the [`publishWeld`]({{Mutations.publishWeld}}) mutation - before it will go live. - """ - updateWeld( - eid: String! - name: String - slug: String - visibility: String - config: JSON - configFile: Upload - isArchived: Boolean - expiresAt: String - draftStep: String - entryForgeId: Int - entryButtonText: String - entryButtonCopyLink: Boolean - signatureEmailSubject: JSON - signatureEmailBody: JSON - dataDisplayTitle: JSON - signatureMode: String - signatureProvider: String - lockedTitleNew: String - lockedDescriptionNew: String - lockedTitleExisting: String - lockedDescriptionExisting: String - expireAfterDaysComplete: Int - expireAfterDaysStart: Int - planEid: String +```md +accessed: Viewed +acknowledged: Reviewed & acknowledged +formCompleted: Filled & completed +archived: Archived +completed: Completed +created: Created +download-csv-all: Downloaded CSV +download-csv-individual-file: Downloaded CSV file +download-file-individual: Downloaded file +download-files-zip: Downloaded zip +emailed-completed: Emailed +emailed-by-user: Emailed sign link +emailed: Emailed +locked: Locked +pin-code-changed: Changed PIN +pin-code-failed: Access failed +pin-code-set: Set PIN +pin-code-unset: Unset PIN +pin-code-verified: Access verified +reassigned-signer: Updated signer details +external-update-signer: Updated signer details +signed: Signed +unarchived: Unarchived +unlocked: Unlocked +voided: Voided +saved-form: Saved form +upload-file-accessed: Accessed file +upload-file: Uploaded +``` +""" +type EtchLog { + """The action taken.""" + action: String + createdAt: Date - """ - Used to ensure that the Weld is still at the version that you believed it is. - Helps to prevent performing an update without being aware that an update has already - occurred in the meantime. - """ - versionNumber: Int - weldCompleteEmailRecipients: JSON - weldCompleteEmailEnableForTest: Boolean - mergePDFs: Boolean - ): Weld - updateWeldData(eid: String!, isTest: Boolean, isArchived: Boolean, isExpired: Boolean, pin: String, webhookURL: String): WeldData - voidDocumentGroup(weldDataEid: String, eid: String, voidedReason: String!): DocumentGroup -} + """The `eid` is the object identifier.""" + eid: String -type PaymentMethod { - id: String! - type: String - last4: String - providerName: String - status: String - expMonth: Int - expYear: Int - isDefault: Boolean + """String id, often a filename, of any file related to the action taken.""" + fileId: String + + """Location of the user taking the action.""" + formattedLocation: String + id: Int! + + """Any action-specific information.""" + metadata: JSON + + """The Signer who took the action, if applicable.""" + signer: Signer + + """The Anvil `User` who took this action, if applicable.""" + user: User + + """The location of the user who took the action.""" + userLocation: JSON } -type Query { +""" +An `EtchPacket` represents a signature packet. `EtchPackets` packets allow you to gather signatures from multiple signers on a collection of PDFs filled with your data (see `payload`), and uploads such as images. See the [e-signature guide](/docs/api/e-signatures) for usage. +""" +type EtchPacket { """ - Fetch a single `Cast`. A `Cast` is a PDF Template. See our - [terminology guide](/docs/api/getting-started#terminology) for more info. + When enabled, the EtchPacket payload can be updated. Requires a plan that allows EtchPacket updates. """ - cast( - eid: String! + allowUpdates: Boolean + archivedAt: Date + completedAt: Date - """ - Optional argument to request a specific version of the `Cast`. If not provided, - the last ***published*** version will be returned, unless it has not been published - in which case the latest ***draft*** version will be returned. + """`true` when `payload` contains data that will fill PDFs""" + containsFillData: Boolean + createdAt: Date - Providing `-1` is a special value that will return the latest - draft version of the `Cast`. - """ - versionNumber: Int - ): Cast + """ + URL to this `EtchPacket` in the dashboard. Any users in your organization can view this URL. + """ + detailsURL: String """ - Fetches the currently logged in user. When used in an API context, this will - return your API user. The `currentUser` query is useful to fetch objects - in your organization. e.g. your `Organization`, its `Weld`s, etc. + A `DocumentGroup` contains the documents after they have been filled and sent out for signature. The `DocumentGroup` will only exist here when `status != 'draft'`, i.e when documents have been sent out for signature. See the `DocumentGroup`'s `status` for lifecycle information. """ - currentUser: User + documentGroup: DocumentGroup + + """The `eid` is the object identifier.""" + eid: String + embeddedAt: Date """ - Fetch a single `EtchPacket`. An `EtchPacket` represents a signature - packet containing PDFs, signers, signed documents. This is the result of - `createEtchPacket`. See the - [e-signature API guide](/docs/api/e-signatures) for more info. + An array of actions during the signing process. Details actions when files are signed, downloaded, etc. """ - etchPacket(eid: String!): EtchPacket + etchLogEvents: [EtchLog] """ - Fetch a single `Forge` by specifying _either_ (but not both) of the following argument sets: - 1. Just the `eid` for the Forge if you know it. - 2. Both the `organizationSlug` and `eidOrSlug`. + The `EtchTemplate` holds configuration for signers and files in the packet. Each `EtchPacket` will have an `EtchTemplate`. + """ + etchTemplate: EtchTemplate + id: Int! + isFree: Boolean @deprecated(reason: "Use etchTemplate.isFree instead") - A `Forge` holds configuration for a single webform. A `Forge` defines a - webform's fields, the page fields are on, their types, webform logic, etc. - Each Forge will be a member of only one `Weld` (Workflow). See our - [terminology guide](/docs/api/getting-started#terminology) for more info. + """ + `true` when it is a test packet. You will not be charged for test packets. Filled and signed files will be watermarked. + """ + isTest: Boolean - A better way to fetch `Forge`s is via the `weld` query. - For example: + """The name shown in the UI.""" + name: String + numberRemainingSigners: Int + organization: Organization + + """Data specified to fill PDFs in the packet.""" + payload: JSON + sentAt: Date - ```graphql - weld (eid: weldEid) { - forges { - eid - slug - name - } - } - ``` """ - forge( - organizationSlug: String - eidOrSlug: String + Possible values: `draft`, `sent`, `delivered`, `partial`, `declined`, `voided`, `completed`. + """ + status: String + updatedAt: Date - """ - If you are authenticated and know the EID, you can use this argument. - Be sure to omit the `organizationSlug` and `eidOrSlug` arguments if passing `eid`. - """ - eid: String + """Uploaded files like images included in the packet.""" + userUploads: [UserUpload] - """ - If you want to recieve the Forge and its related, versioned items in the version associated with - a specific [`WeldData`]({{Types.WeldData}}) then pass that `WeldData` EID here. - """ - weldDataEid: String + """ + An array of webhook notification calls to your server related to this `EtchPacket`. Get the status codes, response times, and retry information from each `WebhookLog`. + """ + webhookLogs: [WebhookLog] - """ - If you want to recieve the Forge and its related, versioned items in the version associated with - a specific [`Submission`]({{Types.Submission}}) then pass that `Submission` EID here. - """ - submissionEid: String + """ + The specific webhook notification URL for this `EtchPacket`. We will POST to this URL when users take various actions. + """ + webhookURL: String +} - """ - Optional argument to request a specific version of the `Forge`. If not provided, - the last ***published*** version will be returned, unless it has not been published - in which case the latest ***draft*** version will be returned. +type EtchPacketPage { + """The actual records for the requested page""" + items: [EtchPacket] - All `Welds` and `Casts` that are related to this `Forge` will be returned - in their state "as-of" the version requested for the `Forge`. + """The requested page number""" + page: Int - Providing `-1` is a special value that will return the latest - draft version of the `Forge`, but also the latest draft version of any related - `Weld` and `Casts`. - """ - versionNumber: Int - ): Forge + """Total number of pages of results""" + pageCount: Int + + """The requested number of rows per page""" + pageSize: Int """ - Fetch a single `Organization`. You obviously will only be able to access - organizations you are a part of. + Total number of rows found for the query before pagination. Capped when the query was given a `countLimit`. + """ + rowCount: Int - A better way to fetch this information is via the `currentUser` query. - For example: + """ + False when `rowCount` stopped at the requested `countLimit`, meaning there are at least that many rows. True whenever the count ran to completion. + """ + rowCountIsExact: Boolean +} - ```graphql - currentUser { - organizations { - eid - slug - name - } - } - ``` - """ - organization(organizationSlug: String!): Organization - - """ - Fetches a single `Signer`. - - An alternate way to fetch signers is via a `DocumentGroup` object. For - example, use the `etchPacket` or `weldData` query, then dig down to the - signers in your query. - """ - signer(eid: String!): Signer - - """ - Fetches a single `Submission`. A `Submission` holds data submitted to a - webform. See our - [terminology guide](/docs/api/getting-started#terminology) for more info. +""" +An `EtchTemplate` holds configuration for a signature packet. You should not need to interact directly with an `EtchTemplate` very much. See the [e-signature guide](/docs/api/e-signatures) for more info on sending documents out for signatures. +""" +type EtchTemplate { + """All `Cast` objects (fillable pdfs) included in this template.""" + casts: [Cast] + config: JSON + createdAt: Date - A better way to fetch submissions is via the `weldData` query. For example: + """The `eid` is the object identifier.""" + eid: String - ```graphql - weldData (eid: weldDataEid) { - displayTitle - submissions { - eid - resolvedPayload - } - } - ``` """ - submission(organizationSlug: String!, forgeEidOrSlug: String!, eid: String!, forceCreate: Boolean, timezone: String): Submission - + An array of `EtchPackets` created from this template. At this time, this will be a 1-to-1 relationship. """ - Fetch a single `Weld`. This query is a good way to fetch all of a - Workflow's submissions (`WeldData`s). See the `Weld`'s `weldDatas` - resolver. - - Specify either the `Weld`'s `eid` _or_ both the `Weld`'s `slug` and - the `Weld`'s `organizationSlug`. + etchPackets: [EtchPacket] + id: Int! + isFree: Boolean + isRepeatable: Boolean + name: String + organization: Organization - See the [Workflow API guide](/docs/api/workflows) for more information on - query usage. """ - weld( - eid: String - slug: String - organizationSlug: String - - """ - Optional argument to request a specific version of the `Weld`. If not provided, - the last ***published*** version will be returned, unless it has not been published - in which case the latest ***draft*** version will be returned. - - All `Forges` and `Casts` that are related to this `Weld` will be returned - in their state "as-of" the version requested for the `Weld`. - - Providing `-1` is a special value that will return the latest - draft version of the `Weld`, but also the latest draft version of any related - `Forges` and `Casts`. - """ - versionNumber: Int - ): Weld - + Signer configurations including the inferred signatureExperience. Useful for inspecting signer state on draft packets before sending. """ - Fetch a single `WeldData` (Workflow submission). If you need to fetch all - `WeldData`s for a given `Weld`, see the `weld` query. + signers: [JSON] + updatedAt: Date + userUploads: [UserUpload] +} - See the [Workflow API guide](/docs/api/workflows) for more information on - query usage. - """ - weldData(eid: String!): WeldData +type File { + encoding: String! + key: String! + mimetype: String! + name: String! + src: String! } """ -The `Upload` scalar type represents a file upload. This can be either -an object with `data`, `mimetype`, and `filename` keys, or multipart -request with a structure described by the -[GraphQL multipart requests specification](https://github.com/jaydenseric/graphql-multipart-request-spec). - -While sending up an object structure described in the example is considerably -simpler than the multipart spec, we only support request payloads of up to 1MB. -Content-Type `application/json` requests with sizes over the 1MB size will -result in a `419 Request Too Large` response. - -If your request is larger than 1MB, use a multipart request. A very basic -multipart GraphQL + `Upload` example using `curl`: - -```sh -curl https://graphql.useanvil.com \ -F operations='[{ "query": "CreateCast ($file: Upload!) { createCast(file: $file) { id } }", "variables": { "file": null } }]' \ - -F map='{ "0": ["0.variables.file"] }' \ - -F 0=@my-file.pdf -``` - -Our [Node API Client](https://github.com/anvilco/node-anvil) uses multipart -uploads by default. +A `Forge` holds configuration for a single webform. A `Forge` defines a +webform's fields, the page fields are on, their types, webform logic, etc. +Each Forge will be a member of only one `Weld` (Workflow). +See our [Workflow API guide](/docs/api/workflows) for more info. """ -scalar Upload +type Forge { + archivedAt: Date + config(onlyKeys: [String] = null): JSON + createdAt: Date + + """The `eid` is the Forge/Webform's object identifier.""" + eid: String -""" -Configuration options for a [`Webhook Action`]({{Types.WebhookAction}}). -""" -input WebhookActionConfigInput { """ - Whether to encrypt the data payloads delivered by the [`Webhook`]({{Types.Webhook}}) call. Defaults to `false`. + Example payload to fill this webform. Shown on the "API information" page for this webform's Workflow. """ - encryptData: Boolean = false -} - -""" -A `Cast` holds configuration for a single PDF template. It defines the -location of boxes on PDF pages, and the type of each box (e.g. date, phone -number, etc.). - -`Cast` objects are used by several API features: - -* The [PDF filling endpoint](/docs/api/fill-pdf) -* Etch signature packets: see the [Etch e-sign guide](/docs/api/e-signatures) for more info -* Workflows: see the [Workflow API guide](/docs/api/workflows) for more info -""" -type Cast { - """Current version number of this Cast""" - versionNumber: Int - versionId: Int - latestDraftVersionNumber: Int + examplePayload: JSON - """Current published version number of this Cast""" - publishedNumber: Int - publishedAt: Date - hasUnpublishedChanges: Boolean + """ + A list of objects describing all the fields in this webform. The response is a more digestable way to get all the fields than digging in `config`. + """ + fieldInfo: JSON hasBeenPublished: Boolean + hasUnpublishedChanges: Boolean id: Int! - eid: String - - """The file's mimetype. e.g. `application/pdf`""" - type: String + isLatestVersion: Boolean + isPublishedVersion: Boolean - """The file name""" + """Is this forge required to complete the Workflow?""" + isRequired: Boolean + latestDraftVersionNumber: Int name: String + organization: Organization + publishedAt: Date - """The title shown in the UI""" - title: String + """Current published version number of this Forge""" + publishedNumber: Int - """ - `true` when this `Cast` was created as a template. Template casts show up in the `PDF Templates` page on your dashboard. - """ - isTemplate: Boolean + """The slug of the currently published version""" + publishedSlug: String + publishedVersionId: Int + publishedVersionNumber: Int + publishedVersions( + """The requested number of rows per page (max: 500)""" + limit: Int = 10 + + """The requested page number""" + offset: Int = 1 + ): ForgeVersionPage """ - Example data payload to fill this PDF. Shown on the "API Info" tab for this template + The forge `config` with cascade-resolved flags merged in (e.g. `optionalByDefault`, resolved forge → weld → org). Read this to render/validate the form; raw `config` is for editing the stored config. """ - exampleData: JSON + resolvedConfig: JSON + slug: String + testForgeCompleteWebhookPayload: JSON + updatedAt: Date + userUploads: [UserUpload] + versionId: Int - """Allowed `Cast` field alias IDs""" - allowedAliasIds: [String] + """Current version number of this Forge""" + versionNumber: Int + versions( + """The requested number of rows per page (max: 500)""" + limit: Int = 10 - """ - A digestable array of objects with all the details about all fields in this PDF, sorted by page, top to bottom, left to right. Shown on the "API Info" tab for this template - """ - fieldInfo: JSON - config( - """Only retrieve the specific keys provided.""" - onlyKeys: [String] = null - ): JSON - organization: Organization + """The requested page number""" + offset: Int = 1 + ): ForgeVersionPage + weld: Weld +} - """ - If this `Cast` was originally copied from another `Cast`, this will be the original `Cast`. - """ - parentCast: Cast +type ForgeMap { + config: JSON createdAt: Date + + """The `eid` is the object identifier.""" + eid: String + forgeA: Forge + forgeB: Forge + id: Int! updatedAt: Date - archivedAt: Date } -type CastPage { - """Total number of rows found for the query before pagination""" - rowCount: Int - - """Total number of pages of results""" - pageCount: Int +type ForgePage { + """The actual records for the requested page""" + items: [Forge] """The requested page number""" page: Int + """Total number of pages of results""" + pageCount: Int + """The requested number of rows per page""" pageSize: Int - """The actual records for the requested page""" - items: [Cast] + """ + Total number of rows found for the query before pagination. Capped when the query was given a `countLimit`. + """ + rowCount: Int + + """ + False when `rowCount` stopped at the requested `countLimit`, meaning there are at least that many rows. True whenever the count ran to completion. + """ + rowCountIsExact: Boolean } -type CastVersion { - updatedColumns: [String] - name: String - type: String - title: String +type ForgeVersion { config: JSON + createdAt: Date + + """The `eid` is the object identifier.""" eid: String! + id: Int! + name: String + number: Int + publishUpdatedAt: Date + publishedAt: Date + publishedDescription: String publishedNumber: Int publishedTitle: String - publishedDescription: String - publishedAt: Date - publishUpdatedAt: Date publishingUser: User + slug: String + updatedAt: Date + updatedColumns: [String] + user: User } -type CastVersionPage { - """Total number of rows found for the query before pagination""" - rowCount: Int - - """Total number of pages of results""" - pageCount: Int +type ForgeVersionPage { + """The actual records for the requested page""" + items: [ForgeVersion] """The requested page number""" page: Int + """Total number of pages of results""" + pageCount: Int + """The requested number of rows per page""" pageSize: Int - """The actual records for the requested page""" - items: [CastVersion] + """ + Total number of rows found for the query before pagination. Capped when the query was given a `countLimit`. + """ + rowCount: Int + + """ + False when `rowCount` stopped at the requested `countLimit`, meaning there are at least that many rows. True whenever the count ran to completion. + """ + rowCountIsExact: Boolean +} + +type GenerateEmbedURLResponse { + """ + The EID for the token associated with the request you just made. Can be used later to expire the session that was created using it. + """ + requestTokenEid: String! + + """ + The URL to send your embedded User to. They will be logged in and then redirected to the appropriate page for their embedded access. + You can direct them back to this URL if they need to access their embedded experience again as long as their session has not expired. + """ + url: String! +} + +input GenerateExpressionDataInput { + """ + A `Boolean` indicating whether the resulting expression shoudl resolve to a boolean value. + """ + isBoolean: Boolean = false + + """ + A human-readable statement representing the expression you are trying to create. References to fields must be wrapped in double-brackets like so: `{{:}}` + """ + userStatement: String! } """ -A collection of all the final files generated with data from a `WeldData` or -`EtchPacket`. When your users download a zip file of PDFs, that final -collection of filled, generated, uploaded, and signed files is specified by -the `DocumentGroup`. A `DocumentGroup` will be connected to either a -`WeldData` or a `EtchPacket`, never both. A `DocumentGroup` will only -exist on these objects if it has been sent out for signature. +A third-party integration connection (e.g. HubSpot) for an organization. """ -type DocumentGroup { - id: Int! - eid: String +type Integration { + """ + Whether the organization has an active connection for this integration. + """ + isConnected: Boolean! + + """ + Available object types, normalized into the `{ k, v }` shape expected by Dropdown. + """ + objectTypes: JSON +} + +type Invoice { + endDate: Date + id: String + invoiceUrl: String + startDate: Date + status: String + totalAmount: Float +} + +type InvoiceBucketItem { + includedUnits: Int + unitType: String + usedUnits: Int +} + +type InvoiceUnitItem { + baseUnits: Int + totalAmount: Float + unitAmount: Float + unitType: String + usedUnits: Int +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON + +type MetricUsage { + """Usage that will be billed (usage not covered by credits or prepaid)""" + billableUsage: Int! + + """Cost for billable usage (billableUsage * unitPrice)""" + cost: Amount! + + """Initial number of free credits granted for this metric""" + creditsInitial: Int + + """Number of free credits remaining for this metric (null if no credits)""" + creditsRemaining: Int + + """Metric key (e.g., pdfApiCalls, workflowSubmissions)""" + key: ID! + + """Display label for the metric""" + label: String! + + """Initial number of prepaid units from bulk purchases""" + prepaidInitial: Int + + """ + Number of prepaid units remaining from bulk purchases (null if no prepaid) + """ + prepaidRemaining: Int + + """Unit label (e.g., calls, submissions, packets)""" + unit: String! + + """Price per unit""" + unitPrice: Amount! +} + +type Mutation { + """ + Create a `Cast` with the specified properties. This will require you to publish the `Cast` + via the [`publishCast`]({{Mutations.publishCast}}) mutation before it will go live. + """ + createCast( + """ + Uses AI to help analyze the document's fields. Will implicitly set `detectFields` to `true`. + """ + advancedDetectFields: Boolean = false + allowedAliasIds: [String] + + """ + Uses AI to help detect the document's fields. Will implicitly set `detectFields` to `true`. + """ + detectBoxesAdvanced: Boolean = false + detectFields: Boolean = true + file: Upload! + isTemplate: Boolean = true + organizationEid: String + + """ + The schema input, interpreted according to `schemaSource`: + + - `schemaSource: "json"` -- `schema` *is* your aliasIds, passed + inline. This is the canonical way to supply your own custom field + aliases. Accepts either the object form or the array shorthand. For + example: + ``` + schemaSource: "json" + schema: { employeeName: { description: "The employee's full name" } } + ``` + or + ``` + schemaSource: "json" + schema: ["employeeName", "employeeId"] + ``` + + - If `schemaSource` is an integration (currently only `"hubspot"`), + `schema` tells us which object type(s) on that integration to build + `aliasIds` from. We look up each object type's properties from the + integration and normalize them into the `aliasIds` shape for you. + How each aliasId is named depends on how many object types you pass: + - One object type: the bare property name. For example, + `schema: { objectTypes: ["contacts"] }` gives aliasIds like + `firstname` and `email`. + - More than one: `${objectType}.${propertyName}`, so properties two + object types share stay separate. For example, + `schema: { objectTypes: ["contacts", "companies"] }` gives + aliasIds like `contacts.email`, `contacts.phone`, and + `companies.phone`. + + Detected fields that match a property are tagged with its aliasId, + so these are also the keys to use in `data` when filling the Cast. + + Requires `advancedDetectFields: true`. + """ + schema: JSON + + """ + The source of the input schema (e.g. "json", "hubspot") used to create this cast. Pair it with `schema` (see below). + """ + schemaSource: String + title: String + ): Cast + + """ + Create an e-signature packet. See the + [Etch e-sign guide](/docs/api/e-signatures) for usage information. + """ + createEtchPacket( + """ + Uses AI to label uploaded documents, discover signers, and connect signers to fields. + """ + advancedCreate: Boolean = false + + """ + When enabled, the EtchPacket payload can be updated. Requires a plan that allows EtchPacket updates. + """ + allowUpdates: Boolean = false + + """Set to `true` to save uploaded PDFs to your PDF templates.""" + createCastTemplatesFromUploads: Boolean + + """ + Specifies data to fill documents in the `files` array. See the guide for details. + """ + data: JSON + + """ + Uses AI to help detect fields on uploaded document. Will implicitly set `detectFields` to `true`. + """ + detectBoxesAdvanced: Boolean = false + + """ + Set to `true` to duplicate all referenced PDF templates on packet + creation. Setting to true is useful if you would like to edit the PDFs + in the UI after packet creation. + """ + duplicateCasts: Boolean = true + + """ + Enable all, none, or specific emails for this packet. When enabled, an + email type will adhere to the organization settings for that email type. + For example, when `signerViewAdmin` is enabled, an email will only be + sent to organization members specified in the organization settings. + + Possible values: `true`, `false`, or an array of strings with any of + the following `'signerViewAdmin'`, `'signerCompleteAdmin'`, or + `'etchCompleteAdmin'`. + + The default is `false`. + """ + enableEmails: JSON + + """ + When enabled, the signature certificate is withheld from what signers + download (finish page, per-file download, and the combined zip). It is + still generated, stored, and available to your organization's users. + """ + excludeCertificateFromSignerDownloads: Boolean + + """ + Specifies files that will be in the resulting packet. See the guide for details. + """ + files: [EtchFile!] + + """ + Allows customization and whitelabeling of the signer finish page. See the guide for details. + """ + finishPageOptions: JSON + + """ + When set to `false`, the packet will immediately be sent to the first + signer. When set to `true`, the resulting `EtchPacket` will not be + immediately sent. You will need to send the packet via the UI or + the [`sendEtchPacket`](#operation-sendetchpacket-Mutations) mutation. + """ + isDraft: Boolean + + """ + When `true`, the resulting packet will be in test mode. You will not + be charged for test packets. Test packets will not use valid signatures. + Test packets only email verified members of your organization; sending + one to any other email signer is refused. + """ + isTest: Boolean + + """ + When enabled, merges all PDFs before users sign. All signers will sign + one (potentially large) PDF. + """ + mergePDFs: Boolean + name: String + organizationEid: String + + """ + Overrides the replyTo header on emails sent to signers. + By default, this will be the support email listed in your organization settings. + """ + replyToEmail: String + + """ + Overrides the replyTo header on emails sent to signers. + By default, this will be your organization name. + """ + replyToName: String + + """ + When `true`, every signer on the packet must have a signature or + signature initial field assigned; sending a packet with a signer missing + one throws an error. When omitted, falls back to the organization's + `requireSignatures` config, then defaults to `false`. Pass `false` + to explicitly override an organization that has the setting enabled. + """ + requireSignatures: Boolean + + """Customizes the email body on signer emails""" + signatureEmailBody: String + + """Customizes the email subject on signer emails""" + signatureEmailSubject: String + + """ + Allows customization and whitelabeling of the signature page. See the guide for details. + """ + signaturePageOptions: JSON + signatureProvider: String + + """ + BCC recipients for DocuSign envelopes. Only relevant when `signatureProvider` is set to `'docusign'`. + + Each recipient will receive a copy of the completed signature packet via email. + + Required fields: + - `name`: Recipient name + - `email`: Recipient email address + - `type`: Must be `'carbonCopy'` + + Optional fields: + - `disableInTestMode`: When `true`, removes recipient when packet is in test mode + - `roleName`: Role name for the recipient + - `accessCode`: Access code for the recipient + - `templateAccessCodeRequired`: Whether access code is required + """ + signatureRecipients: [JSON!] + + """ + Specifies signers who will sign documents in the `files` array. See the guide for details. + + When `signatureProvider` is set to `'docusign'`, additional DocuSign-specific fields are available: + - `requireIdCheck`: Boolean or string ('kb', 'sms', 'smskb', 'idsms') to require ID verification + - `requireIdCheckInTest`: Boolean to require ID verification even in test mode + - `idCheckPhone`: Phone number for ID verification (required when requireIdCheck is 'sms', 'smskb', or 'idsms') + - `accessCode`: Access code for the signer + """ + signers: [JSON!] + + """ + Allows you to set a webhook URL called specifically for actions on the resulting `EtchPacket` + """ + webhookURL: String + ): EtchPacket + + """ + Create a `Forge` with the specified properties. This will require you to publish the `Weld` + via the [`publishWeld`]({{Mutations.publishWeld}}) mutation before it will go live with the Workflow. + """ + createForge(castEid: String, castFieldIds: JSON, config: JSON, name: String!, slug: String!, weldEid: String!): Forge + createSubmission(forgeEid: String!, weldDataEid: String!): Submission + createWebhook(organizationEid: String, organizationSlug: String, url: String): Organization + + """ + Create a Webhook Action to subscribe to events on objects. + + Here is a list of all the actions and the object types that are supported by them: + - [`documentGroupCreate`](/docs/api/webhooks/#documentgroupcreate) => [`DocumentGroup`]({{Types.DocumentGroup}}) + - [`etchPacketComplete`](/docs/api/webhooks/#etchpacketcomplete) => [`EtchPacket`]({{Types.EtchPacket}}) + - [`forgeComplete`](/docs/api/webhooks/#forgecomplete) => [`Forge`]({{Types.Forge}}), [`Submission`]({{Types.Submission}}), [`WeldData`]({{Types.WeldData}}), [`Weld`]({{Types.Weld}}) + - [`signerUpdateStatus`](/docs/api/webhooks/#signerupdatestatus) => [`Signer`]({{Types.Signer}}), [`EtchPacket`]({{Types.EtchPacket}}), [`WeldData`]({{Types.WeldData}}), [`Weld`]({{Types.Weld}}) + - [`signerComplete`](/docs/api/webhooks/#signercomplete) => [`Signer`]({{Types.Signer}}), [`EtchPacket`]({{Types.EtchPacket}}), [`WeldData`]({{Types.WeldData}}), [`Weld`]({{Types.Weld}}) + - [`weldCreate`](/docs/api/webhooks/#weldcreate) => [`Organization`]({{Types.Organization}}) + - [`weldComplete`](/docs/api/webhooks/#weldcomplete) => [`WeldData`]({{Types.WeldData}}), [`Weld`]({{Types.Weld}}) + + See the [Webhook Action documentation](/docs/api/webhooks/#webhook-actions) for usage information. + """ + createWebhookAction( + """ + The action you'd like to subscribe to on the object(s). + You can also use `*` to monitor all actions. + """ + action: String! + config: WebhookActionConfigInput + + """ + The `eid` of the specific object you'd like to monitor. + You can also use `*` to monitor all objects of the specified type(s). + """ + objectEid: String! + + """ + The type of object that you'd like to monitor for events. + You can also use `*` to monitor all object types. + """ + objectType: String! + + """ + If you want to create a new [`Webhook`]({{Types.Webhook}}) on-the-fly, you must specify the Organization `eid` here. + """ + organizationEid: String + + """ + If you want to create a new [`Webhook`]({{Types.Webhook}}) on-the-fly, you must specify the URL here. + """ + url: String + + """ + The `eid` of a pre-existing [`Webhook`]({{Types.Webhook}}) that you'd like to use for this Webhook Action. + A new `Webhook` can be created as well. + """ + webhookEid: String + ): WebhookAction + + """ + Create a `Weld` with the specified properties. This will require you to publish the `Weld` + via the [`publishWeld`]({{Mutations.publishWeld}}) mutation before it will go live. + """ + createWeld( + """ + Uses AI to generate webform pages, create signers, and connect webform fields to supplied documents. + """ + advancedCreate: Boolean = false + + """Uses AI to help detect and analyze uploaded documents' fields.""" + advancedDetectFields: Boolean = false + castEid: String + config: JSON + + """Set to `true` to save uploaded PDFs to your PDF templates.""" + createCastTemplatesFromUploads: Boolean + + """Uses AI to help detect fields on uploaded documents.""" + detectBoxesAdvanced: Boolean = false + draftStep: String + files: [WeldFile] + name: String + organizationEid: String + slug: String + visibility: String + ): Weld + createWeldData(isTest: Boolean = false, webhookURL: String, weldDataGroupEid: String, weldEid: String!): WeldData + destroySubmission(eid: String!): Boolean + disconnectDocusign(organizationSlug: String!): Organization + duplicateCast(eid: String!, name: String, organizationEid: String, title: String): Cast + duplicateWeld(eid: String!, name: String, organizationEid: String!, slug: String, visibility: String): Weld + + """ + Expires the `UserToken` associated with the provided `requestTokenEid`. Any authentication attempts + made using that token will be rejected. + """ + expireSessionToken( + """ + The EID of the request token that was returned from the [`generateEmbedURL`]({{Mutations.generateEmbedURL}}) mutation. + Used to determine which session token to invalidate. + """ + requestTokenEid: String! + ): Boolean + + """ + Expires all valid `SignerTokens` for a specific `Signer`. Signers who + visit links with expired tokens will be shown a "Token Expired" error, or + will be [redirected to your + service](/docs/api/e-signatures#handling-signing-errors) if there is a + `redirectURL` set on the signer. + + If the `Signer` has already completed signing, this mutation will throw an + error. If you would like the `Signer` to be able to sign again, email + signers will need to be notified via `sendEtchPacket`, and embedded + signers will need a new signing link generated by `generateEtchSignURL`. + """ + expireSignerTokens(signerEid: String!): Signer + + """ + Submits data to a Workflow's webform (a `Forge`). You can use this + mutation to start a Workflow with data from your system and submit data to + an _existing_ `Submission`. + + See the [Workflow API guide](/docs/api/workflows) for more information on + usage scenarios. + """ + forgeSubmit( + complete: Boolean + currentStep: Int + enforcePayloadValidOnCreate: Boolean = true + forgeEid: String! + groupArrayId: String + groupArrayIndex: Int + isTest: Boolean = false + + """ + A `{ key: value }` object where `key` is the id or field alias of the + webform (`Forge`) field, and value is the data you'd like to send. + + Data is additive: you only need to submit data you wish to update. + """ + payload: JSON! + submissionEid: String + timezone: String + webhookURL: String + weldDataEid: String + ): Submission """ - Possible values: `sent`, `delivered`, `partial`, `declined`, `voided`, `completed` + Generate a URL for embedding the PDF template editor, the Etch packet + builder, or the Workflow builder in your app. Embedding editors is an + enterprise feature. Please contact sales@useanvil.com if you are interested + in editor embedding. """ - status: String + generateEmbedURL( + """The EID of the object you want to give embedded access to.""" + eid: String! - """Possible values: `etch` or `docusign`""" - provider: String - currentRoutingStep: Int + """ + Any information that you would like to associate with this embedded URL request + """ + metadata: JSON - """A file listing of all files in the `DocumentGroup`""" - files: [JSON] + """ + Options to control many parts of a token user's experience. + Currently only supported when `type` is `edit-pdf-template`. + """ + options: JSON + + """ + For `type: edit-etch-packet` embeds only. When `true` (default), + every signer must have at least one signature or initial field; + signer-experience inference is not surfaced to the packet builder. + When `false`, signers without signature fields are allowed (the + packet builder UI shows a pre-send review of each signer's + inferred experience). Has no effect for other embed types. + """ + requireSignatures: Boolean = true + + """ + The type of object you want to create an embedded URL for. Allowed values are `edit-etch-packet`, `edit-pdf-template`, `edit-weld`, `submit-webform`, `edit-workflow` + """ + type: String! + + """ + The number of seconds from now that the embedded access will be valid for. + + Must provide either `validUntil` or `validForSeconds` arguments. + """ + validForSeconds: Int + + """ + An ISO compliant date-time string such as `2030-01-01T00:00:00+00:00` that indicates when the embedded access will be valid until. + + Must provide either `validUntil` or `validForSeconds` arguments. + """ + validUntil: String + ): GenerateEmbedURLResponse """ - The parent `WeldData`. This will only exist for DocumentGroups created by a Workflow. + Generate a signing URL for embedded signers. See the + [e-sign API guide on embedding signers](/docs/api/e-signatures#controlling-the-signature-process-with-embedded-signers) + for more info. """ - weldData: WeldData + generateEtchSignURL( + """ + Your id for the person signing: a way to trace this signer back to a user in your system. + """ + clientUserId: String! + signerEid: String! + ): String + mergeWelds(autoPublish: Boolean, autoPublishDescription: String, autoPublishTitle: String, deduplicateWebformFields: Boolean!, name: String!, organizationEid: String!, slug: String!, weldEids: [String!]): Weld """ - The parent `EtchPacket`. This will only exist for DocumentGroups created as part of an Etch signature packet. + Notify a signer that it is their turn to sign. You can think of this as a + "resend email" action for signers. This mutation will email signers with + `signerType: 'email'` asking them to sign their portion of the signature + packet. It will have no effect on signers with + `signerType: 'embedded'` signers, it will error if the signer has already + signed, or if it is not yet the signer's turn to sign. """ - etchPacket: EtchPacket - signers: [Signer] - providerConfig: JSON - finishPageConfig: JSON - signaturePageConfig: JSON - - """The URL to download all documents as a zip file""" - downloadZipURL: String - createdAt: Date - updatedAt: Date + notifySigner(signerEid: String!): Signer """ - Timestamp of when all Signers have finished signing or when all forms have been filled when no signers. + Publish a `Cast` by updating the `Cast` with all un-published changes. + + This is required to have any changes from an [`updateCast`]({{Mutations.updateCast}}) take effect. """ - completedAt: Date -} - -type DocumentGroupPage { - """Total number of rows found for the query before pagination""" - rowCount: Int - - """Total number of pages of results""" - pageCount: Int - - """The requested page number""" - page: Int - - """The requested number of rows per page""" - pageSize: Int - - """The actual records for the requested page""" - items: [DocumentGroup] -} - -""" -Represents a single action taken by a user during the signing process. For -example, an `EtchLog` object will be created when a user signs, downloads -a file, voids the packet, etc. - -All actions with a short description: + publishCast( + description: String -```md -accessed: Viewed -archived: Archived -completed: Signed & completed -created: Created -download-csv-all: Downloaded CSV -download-csv-individual-file: Downloaded CSV file -download-file-individual: Downloaded file -download-files-zip: Downloaded zip -emailed-completed: Emailed -emailed-by-user: Emailed sign link -emailed: Emailed -locked: Locked -pin-code-changed: Changed PIN -pin-code-failed: Access failed -pin-code-set: Set PIN -pin-code-unset: Unset PIN -pin-code-verified: Access verified -reassigned-signer: Updated signer details -saved-form: Saved form -signed: Signed -unarchived: Unarchived -unlocked: Unlocked -upload-file-accessed: Accessed file -upload-file: Uploaded -voided: Voided -``` -""" -type EtchLog { - id: Int! - eid: String + """The object's `eid`""" + eid: String! - """The action taken.""" - action: String + """ + A title for this version. This will be displayed in the version history. + """ + title: String! + ): Cast - """Location of the user taking the action.""" - formattedLocation: String + """ + Publish a `Weld` by updating the `Weld` with all un-published changes. + + This is required to have any changes from an [`updateWeld`]({{Mutations.updateWeld}}) take effect. + """ + publishWeld( + description: String - """String id, often a filename, of any file related to the action taken.""" - fileId: String + """The object's `eid`""" + eid: String! - """Any action-specific information.""" - metadata: JSON + """ + A boolean indicating whether you'd like to migrate all open workflow submissions to the + new published version, or leave them pegged to the version under which they were created. + """ + migrateOpenWeldDatas: Boolean = true - """The Anvil `User` who took this action, if applicable.""" - user: User + """ + A title for this version. This will be displayed in the version history. + """ + title: String! + ): Weld + removeEtchPacket(eid: String!): Boolean + removeWebhook(eid: String!): Organization - """The Signer who took the action, if applicable.""" - signer: Signer - createdAt: Date -} + """Remove a Webhook Action and stop receiving Webhook calls for it.""" + removeWebhookAction( + """The EID of the Webhook Action you want to remove.""" + eid: String! + ): Boolean + removeWeldData(eid: String!): Boolean + retryWebhookLog(eid: String!): WebhookLog -""" -An `EtchPacket` represents a signature packet. `EtchPackets` packets allow you to gather signatures from multiple signers on a collection of PDFs filled with your data (see `payload`), and uploads such as images. See the [e-signature guide](/docs/api/e-signatures) for usage. -""" -type EtchPacket { - id: Int! - eid: String + """ + This will move a `draft` `EtchPacket` into sent mode (`status` == + `sent`). If the first signer is an email signer, this will kick off an + email, with a link to sign their portion of the packet. + + You can call this mutation after a packet is sent to "resend" signature + emails to your email signers. For example, you can send a new email via + `sendEtchPacket` if they lose the email, or if they have an email with an + expired token. This mutation will always email the first signer who has not + yet signed. + + When your signers are set up as `embedded` signers, they will _not_ be + emailed. You must generate signing URLs via the + [`generateEtchSignURL`]({{Mutations.generateEtchSignURL}}) mutation. See + [our e-signature guide on embedded signers](/docs/api/e-signatures#controlling-the-signature-process-with-embedded-signers) + for more info. + """ + sendEtchPacket(eid: String!): EtchPacket + skipSigner(clearNameAndEmail: Boolean = true, signerEid: String!): Signer - """The name shown in the UI.""" - name: String + """ + Mint a short-lived Nango Connect session token for the given integration + (e.g. "hubspot"), scoped to the current organization. The frontend uses + this token to open Nango's hosted Connect UI popup. + """ + startIntegrationConnectSession(integration: String, organizationEid: String): String """ - Possible values: `draft`, `sent`, `delivered`, `partial`, `declined`, `voided`, `completed`. + Mint a short-lived Nango Connect session token for repairing an existing, + broken connection for the given integration (e.g. "hubspot"), scoped to + the current organization. The frontend uses this token to open Nango's + hosted Connect UI popup. """ - status: String + startIntegrationReconnectSession(integration: String, organizationEid: String): String """ - `true` when it is a test packet. You will not be charged for test packets. Filled and signed files will be watermarked. + Update a `Cast` with new properties. This will create a new `CastVersion` and: + 1. If this being used as a Template, it will require you to publish the `Cast` via + the [`publishCast`]({{Mutations.publishCast}}) mutation + before it will go live. + 2. If the `Cast` is part of a Workflow, it will require you to publish the `Weld` via + the [`publishWeld`]({{Mutations.publishWeld}}) mutation """ - isTest: Boolean + updateCast( + allowedAliasIds: [String] + config: JSON + configFile: Upload + eid: String! + feedbackMessage: String + feedbackRating: Int + file: Upload + isArchived: Boolean + isTemplate: Boolean + name: String + organizationEid: String + title: String - """`true` when `payload` contains data that will fill PDFs""" - containsFillData: Boolean + """ + Used to ensure that the Cast is still at the version that you believed it is. + Helps to prevent performing an update without being aware that an update has already + occurred in the meantime. + """ + versionNumber: Int + ): Cast + updateEtchFree(castFields: [JSON], etchTemplateEid: String!, fields: [JSON], isEditing: Boolean, name: String, ownerEmail: String, ownerName: String, signers: JSON, token: String!): EtchTemplate + updateEtchPacket( + eid: String! + isArchived: Boolean - """Data specified to fill PDFs in the packet.""" - payload: JSON - numberRemainingSigners: Int + """ + If `true` we treat the provided `payload` as a partial update to be merged with the previous payload. + Any values in the `payload` that are set to `null` will be removed from the resulting merged payload. + """ + mergePayloads: Boolean = false + name: String + payload: JSON + token: String + webhookURL: String + ): EtchPacket + updateEtchTemplate(config: JSON, eid: String!, isRepeatable: Boolean, name: String): EtchTemplate """ - URL to this `EtchPacket` in the dashboard. Any users in your organization can view this URL. + Update a `Forge` with new properties. This will create a new `ForgeVersion` and + will require you to publish the `Weld` via the [`publishWeld`]({{Mutations.publishWeld}}) mutation + before it will go live. """ - detailsURL: String + updateForge( + config: JSON + configFile: Upload + eid: String! + isArchived: Boolean + isRequired: Boolean + name: String + organizationRole: String + slug: String + title: String + unauthenticatedAuthType: String + + """ + Used to ensure that the Forge is still at the version that you believed it is. + Helps to prevent performing an update without being aware that an update has already + occurred in the meantime. + """ + versionNumber: Int + ): Forge + updateOrganization(billingEmail: String, config: JSON, defaultSourceId: String, etchCompleteEmailRecipients: JSON, logo: Upload, name: String, organizationEid: String, organizationSlug: String, signatureProvider: String, signerCompleteEmailRecipients: JSON, signerOptions: JSON, signerViewEmailRecipients: JSON, slug: String, stylesheetURL: String, supportEmail: String, weldCompleteEmailEnableForTest: Boolean, weldCompleteEmailRecipients: JSON): Organization + updateOrganizationUser(externalId: String, organizationEid: String!, role: String!, userEid: String!): Organization """ - The specific webhook notification URL for this `EtchPacket`. We will POST to this URL when users take various actions. + Update a `Signer`'s name and email address. A signer's name and email can + be updated as long as the signer has not completed signing or been skipped. + + Name and email for a signer can be updated after a previous signer + has already signed. Documents will be updated with the new signer + information. Only name and email fields that have been attached to a signer + will be updated. An invisible signature will be added to each updated + document indicating what changed and who changed it. + + This mutation can be used on signers in `EtchPackets` and in Workflows + (attached to `WeldData` objects). """ - webhookURL: String - organization: Organization + updateSigner( + """ + If this is an Etch signer and they were created as `pending`, set this to `true` to activate them. + """ + activateIfPending: Boolean = false - """ - A `DocumentGroup` contains the documents after they have been filled and sent out for signature. The `DocumentGroup` will only exist here when `status != 'draft'`, i.e when documents have been sent out for signature. See the `DocumentGroup`'s `status` for lifecycle information. - """ - documentGroup: DocumentGroup + """ + Optionally provide a new or update an existing user-specified id for the `Signer`. This is specified as `id` in the [`createEtchPacket`]({{Mutations.createEtchPacket}}) + mutation. Note: This will only be updated if the `name` and/or the `email` are also changed. + """ + aliasId: String - """ - The `EtchTemplate` holds configuration for signers and files in the packet. Each `EtchPacket` will have an `EtchTemplate`. - """ - etchTemplate: EtchTemplate + """The eid of the `Signer` to update.""" + eid: String! - """Uploaded files like images included in the packet.""" - userUploads: [UserUpload] + """The signer's new email address.""" + email: String - """ - An array of actions during the signing process. Details actions when files are signed, downloaded, etc. - """ - etchLogEvents: [EtchLog] + """The signer's new name.""" + name: String + ): Signer + updateSubmission(eid: String!, isExcluded: Boolean): Submission + updateWebhook(eid: String!, url: String): Organization """ - An array of webhook notification calls to your server related to this `EtchPacket`. Get the status codes, response times, and retry information from each `WebhookLog`. + Update a `Weld` with new properties. This will create a new `WeldVersion` and + will require you to publish the `Weld` via the [`publishWeld`]({{Mutations.publishWeld}}) mutation + before it will go live. """ - webhookLogs: [WebhookLog] - createdAt: Date - updatedAt: Date - archivedAt: Date - completedAt: Date -} - -type EtchPacketPage { - """Total number of rows found for the query before pagination""" - rowCount: Int - - """Total number of pages of results""" - pageCount: Int - - """The requested page number""" - page: Int - - """The requested number of rows per page""" - pageSize: Int + updateWeld( + config: JSON + configFile: Upload + dataDisplayTitle: JSON + draftStep: String + eid: String! + entryButtonCopyLink: Boolean + entryButtonText: String + entryForgeId: Int + excludeCertificateFromSignerDownloads: Boolean + expireAfterDaysComplete: Int + expireAfterDaysStart: Int + expiresAt: String + isArchived: Boolean + lockedDescriptionExisting: String + lockedDescriptionNew: String + lockedTitleExisting: String + lockedTitleNew: String + mergePDFs: Boolean + name: String + organizationEid: String + signatureEmailBody: JSON + signatureEmailSubject: JSON + signatureMode: String + signatureProvider: String + signatureStepsMode: String + slug: String + steps: JSON - """The actual records for the requested page""" - items: [EtchPacket] + """ + Used to ensure that the Weld is still at the version that you believed it is. + Helps to prevent performing an update without being aware that an update has already + occurred in the meantime. + """ + versionNumber: Int + visibility: String + weldCompleteEmailEnableForTest: Boolean + weldCompleteEmailRecipients: JSON + ): Weld + updateWeldData(eid: String!, isArchived: Boolean, isExpired: Boolean, isTest: Boolean, pin: String, webhookURL: String): WeldData + voidDocumentGroup( + """The DocumentGroup's `eid`""" + eid: String + voidedReason: String! + weldDataEid: String + ): DocumentGroup } -""" -An `EtchTemplate` holds configuration for a signature packet. You should not need to interact directly with an `EtchTemplate` very much. See the [e-signature guide](/docs/api/e-signatures) for more info on sending documents out for signatures. -""" -type EtchTemplate { +type ObjectRelationship { id: Int! - eid: String - name: String - isFree: Boolean - isRepeatable: Boolean - config: JSON - organization: Organization + relatedObjectEid: String! + relatedObjectType: String + relation: String + sourceObjectEid: String! + sourceObjectType: String +} - """All `Cast` objects (fillable pdfs) included in this template.""" - casts: [Cast] +"""An entity that encapsulates a set of Workflows, templates, users, etc.""" +type Organization { + adminUsers: [User] + availableSignatureProviderTypes: [String] + + """The email used for all billing related correspondence.""" + billingEmail: String """ - An array of `EtchPackets` created from this template. At this time, this will be a 1-to-1 relationship. + Returns a paginated Page that includes items (array of Casts), rowCount, pageCount, page, and pageSize. """ - etchPackets: [EtchPacket] - userUploads: [UserUpload] - createdAt: Date - updatedAt: Date -} - -""" -A `Forge` holds configuration for a single webform. A `Forge` defines a -webform's fields, the page fields are on, their types, webform logic, etc. -Each Forge will be a member of only one `Weld` (Workflow). - -See our [Workflow API guide](/docs/api/workflows) for more info. -""" -type Forge { - """Current version number of this Forge""" - versionNumber: Int - versionId: Int - latestDraftVersionNumber: Int + casts( + """ + Stop counting matching rows once this many are found (max: 10000). `rowCount` is capped at this value and `rowCountIsExact` becomes false. Omit for an exact total. + """ + countLimit: Int - """Current published version number of this Forge""" - publishedNumber: Int - publishedAt: Date - hasUnpublishedChanges: Boolean - hasBeenPublished: Boolean - id: Int! - eid: String - name: String - slug: String + """Set to `true` to search only within archived casts""" + isArchived: Boolean = false - """Is this forge required to complete the Workflow?""" - isRequired: Boolean - config(onlyKeys: [String] = null): JSON + """Set to `true` to search only within draft casts""" + isDraft: Boolean = null - """ - A list of objects describing all the fields in this webform. The response is a more digestable way to get all the fields than digging in `config`. - """ - fieldInfo: JSON - weld: Weld - organization: Organization - userUploads: [UserUpload] - createdAt: Date - updatedAt: Date - archivedAt: Date + """Set to `true` to search only within published casts""" + isPublished: Boolean = null - """ - Example payload to fill this webform. Shown on the "API information" page for this webform's Workflow. - """ - examplePayload: JSON -} + """ + Defaults to `true`, returning only template casts. Set to `false` for only non-template casts, or `null` for both. + """ + isTemplate: Boolean = true -type ForgePage { - """Total number of rows found for the query before pagination""" - rowCount: Int + """The requested number of rows per page (max: 500)""" + limit: Int = 10 - """Total number of pages of results""" - pageCount: Int + """The requested page number""" + offset: Int = 1 - """The requested page number""" - page: Int + """Order by a specific field""" + orderBy: String = "name" - """The requested number of rows per page""" - pageSize: Int + """Order in ascending or descending order""" + orderDirection: String = "ASC" - """The actual records for the requested page""" - items: [Forge] -} + """Search based on the title of the Cast""" + query: String = null -type ForgeMap { - id: Int! - eid: String - forgeA: Forge - forgeB: Forge + """Filter casts that have at least one tag with a name from this array""" + tagNames: [String] = null + ): CastPage + childOrganizations: [Organization] config: JSON createdAt: Date - updatedAt: Date -} - -type ForgeVersion { - updatedColumns: [String] - name: String - slug: String - config: JSON - eid: String! - publishedNumber: Int - publishedTitle: String - publishedDescription: String - publishedAt: Date - publishUpdatedAt: Date - publishingUser: User -} -type ForgeVersionPage { - """Total number of rows found for the query before pagination""" - rowCount: Int + """The `eid` is the object identifier.""" + eid: String + etchCompleteStats: JSON + etchPackets( + """ + Stop counting matching rows once this many are found (max: 10000). `rowCount` is capped at this value and `rowCountIsExact` becomes false. Omit for an exact total. + """ + countLimit: Int + dateEnd: String = null + dateFilterBy: String = null + dateStart: String = null - """Total number of pages of results""" - pageCount: Int + """ + Set to `true` to return only archived packets, or `false` to return only active packets. When omitted, archived packets are included only if `status` contains `archived`. + """ + isArchived: Boolean + isTest: Boolean = false - """The requested page number""" - page: Int + """The requested number of rows per page (max: 500)""" + limit: Int = 5 - """The requested number of rows per page""" - pageSize: Int + """The requested page number""" + offset: Int = 1 + orderBy: String = "name" + orderDirection: String = "ASC" + query: String = null + status: [String] + ): EtchPacketPage - """The actual records for the requested page""" - items: [ForgeVersion] -} + """ + Etch templates in this organization, oldest first. Page through them with `limit` and `offset`. + """ + etchTemplates( + """The requested number of rows per page (max: 500)""" + limit: Int = 500 -"""An entity that encapsulates a set of Workflows, templates, users, etc.""" -type Organization { + """The requested page number""" + offset: Int = 1 + ): [EtchTemplate] + forgeMaps: [ForgeMap] + forges(isArchived: Boolean = false): [Forge] id: Int! - eid: String - name: String + isPersonal: Boolean + + """Is this Organization set up for SSO-based authentication or not.""" + isSso: Boolean + isSubscribed: Boolean logo: JSON """ URL to your logo. You can update this on your organization settings page. """ logoURL: String + name: String + organizationUserInvites: [OrganizationUserInvite] + organizationUsers: [OrganizationUser] parentOrganization: Organization + paymentMethods: [PaymentMethod] + relatedOrganizations(actions: [String]): [Organization] - """The email used for all billing related correspondence.""" - billingEmail: String + """Number of user slots remaining for this organization.""" + remainingUsers: Int + resolvedConfig: JSON + signatureProviderType: String + slug: String + + """ + Your organization's white-label CSS stylesheet. You can update this on your organization settings page. + """ + stylesheetURL: String + subscriptionActivity: JSON """ Your organization's support email address. Used as the reply-to address for emails sent to your users by Anvil. """ supportEmail: String - slug: String - users: [User] - adminUsers: [User] - organizationUsers: [OrganizationUser] - organizationUserInvites: [OrganizationUserInvite] - isPersonal: Boolean - isInternal: Boolean - isSubscribed: Boolean - useTestSignatureProvider: Boolean - config: JSON - createdAt: Date updatedAt: Date + useTestSignatureProvider: Boolean + users: [User] + webhook: Webhook + webhookActions: [WebhookAction] + webhookLogs( + """The requested number of rows per page (max: 500)""" + limit: Int = 10 + + """The requested page number""" + offset: Int = 1 + ): WebhookLogPage + weldCompleteStats: JSON + weldCompleteWebhookStats: JSON + weldGroups: [WeldGroup] + + """ + Returns a paginated Page that includes items (array of Welds), rowCount, pageCount, page, and pageSize. + """ welds( - """Filter based on the visibility of the Weld""" - visibility: String = "live" + """ + Stop counting matching rows once this many are found (max: 10000). `rowCount` is capped at this value and `rowCountIsExact` becomes false. Omit for an exact total. + """ + countLimit: Int + + """Search based on the date the Weld was created""" + dateEnd: String = null + + """Filter by a specific date""" + dateFilterBy: String = null + + """Search based on the date the Weld was created""" + dateStart: String = null """Set to `true` to search only within archived Welds""" isArchived: Boolean = false - ): [Weld] - etchTemplates: [EtchTemplate] - etchPackets( - status: [String] - isTest: Boolean = false - isArchived: Boolean = false - """The requested number of rows per page""" - limit: Int = 5 + """Set to `true` to search only within locked Welds""" + isClosed: Boolean = null - """The requested page number""" - offset: Int = 1 - ): EtchPacketPage - weldGroups: [WeldGroup] - forges(isArchived: Boolean = false): [Forge] - forgeMaps: [ForgeMap] - casts(isArchived: Boolean = false, isTemplate: Boolean = null): [Cast] - paymentMethods: [PaymentMethod] - webhook: Webhook - webhookActions: [WebhookAction] - webhookLogs( - """The requested number of rows per page""" + """Set to `true` to search only within Welds with unpublished changes""" + isDraft: Boolean = null + + """The requested number of rows per page (max: 500)""" limit: Int = 10 + """Search based on the title of the Weld""" + name: String = null + """The requested page number""" offset: Int = 1 - ): WebhookLogPage - childOrganizations: [Organization] - subscribedPlanFeatures: JSON - subscriptionActivity: JSON - currentInvoice: Invoice - totalUsedUnits: Int - weldCompleteWebhookStats: JSON - weldCompleteStats: JSON - etchCompleteStats: JSON - remainingSubmissions: JSON - remainingEtchCompletions: JSON - signatureProviderType: String - availableSignatureProviderTypes: [String] + + """Order by a specific field""" + orderBy: String = "name" + + """Order in ascending or descending order""" + orderDirection: String = "ASC" + + """Search based on the title of the Weld""" + query: String = null + + """Filter based on the visibility of the Weld""" + visibility: String + ): WeldPage } type OrganizationPage { - """Total number of rows found for the query before pagination""" - rowCount: Int - - """Total number of pages of results""" - pageCount: Int + """The actual records for the requested page""" + items: [Organization] """The requested page number""" page: Int + """Total number of pages of results""" + pageCount: Int + """The requested number of rows per page""" pageSize: Int - """The actual records for the requested page""" - items: [Organization] -} - -type OrganizationUser { - id: Int! + """ + Total number of rows found for the query before pagination. Capped when the query was given a `countLimit`. + """ + rowCount: Int """ - Role of this user within your org. Possible values: viewer, editor, billingAdmin, admin, tokenized, api + False when `rowCount` stopped at the requested `countLimit`, meaning there are at least that many rows. True whenever the count ran to completion. """ - role: String + rowCountIsExact: Boolean +} + +type OrganizationUser { + createdAt: Date """ A place to store your external system's unique identifier for this User. """ externalId: String - user: User + id: Int! organization: Organization - createdAt: Date + + """ + Role of this user within your org. Possible values: viewer, editor, billingAdmin, admin, tokenized, api + """ + role: String updatedAt: Date + user: User } """ @@ -1278,25 +1747,228 @@ An invite sent for a user to join your organization. Once accepted, an `OrganizationUser` will be created. """ type OrganizationUserInvite { - id: Int! + acceptedAt: Date + createdAt: Date + + """The `eid` is the object identifier.""" eid: String + email: String + + """ + A place to store your external system's unique identifier for this User. + """ + externalId: String + id: Int! + name: String + organization: Organization + revokedAt: Date + + """ + Role of this user within your org. Possible values: viewer, editor, billingAdmin, admin, tokenized, api + """ + role: String + updatedAt: Date +} + +type PaymentMethod { + expMonth: Int + expYear: Int + id: String! + isDefault: Boolean + last4: String + providerName: String + status: String + type: String +} + +type Query { + """ + Fetch a single `Cast`. A `Cast` is a PDF Template. See our + [terminology guide](/docs/api/getting-started#terminology) for more info. + """ + cast( + """The Cast's `eid` (which is the same as the PDF template ID)""" + eid: String + + """ + Optional argument to request a specific version of the `Cast`. If not provided, + the last ***published*** version will be returned, unless it has not been published + in which case the latest ***draft*** version will be returned. + + Providing `-1` is a special value that will return the latest + draft version of the `Cast`. + """ + versionNumber: Int + ): Cast + + """ + Fetches the currently logged in user. When used in an API context, this will + return your API user. The `currentUser` query is useful to fetch objects + in your organization. e.g. your `Organization`, its `Weld`s, etc. + """ + currentUser: User + + """ + Fetch a single `EtchPacket`. An `EtchPacket` represents a signature + packet containing PDFs, signers, signed documents. This is the result of + `createEtchPacket`. See the + [e-signature API guide](/docs/api/e-signatures) for more info. + """ + etchPacket(eid: String!): EtchPacket + + """ + Fetch a single `Forge` by specifying _either_ (but not both) of the following argument sets: + 1. Just the `eid` for the Forge if you know it. + 2. Both the `organizationSlug` and `eidOrSlug`. + + A `Forge` holds configuration for a single webform. A `Forge` defines a + webform's fields, the page fields are on, their types, webform logic, etc. + Each Forge will be a member of only one `Weld` (Workflow). See our + [terminology guide](/docs/api/getting-started#terminology) for more info. + + A better way to fetch `Forge`s is via the `weld` query. + For example: + + ```graphql + weld (eid: weldEid) { + forges { + eid + slug + name + } + } + ``` + """ + forge( + """ + If you are authenticated and know the EID, you can use this argument. + Be sure to omit the `organizationSlug` and `eidOrSlug` arguments if passing `eid`. + """ + eid: String + eidOrSlug: String + organizationSlug: String + + """ + If you want to receive the Forge and its related, versioned items in the version associated with + a specific [`Submission`]({{Types.Submission}}) then pass that `Submission` EID here. + """ + submissionEid: String + + """ + Optional argument to request a specific version of the `Forge`. If not provided, + the last ***published*** version will be returned, unless it has not been published + in which case the latest ***draft*** version will be returned. + + All `Welds` and `Casts` that are related to this `Forge` will be returned + in their state "as-of" the version requested for the `Forge`. + + Providing `-1` is a special value that will return the latest + draft version of the `Forge`, but also the latest draft version of any related + `Weld` and `Casts`. + """ + versionNumber: Int + + """ + If you want to receive the Forge and its related, versioned items in the version associated with + a specific [`WeldData`]({{Types.WeldData}}) then pass that `WeldData` EID here. + """ + weldDataEid: String + ): Forge + + """ + A connected (or connectable) third-party integration for the current + organization, e.g. "hubspot". Request only the fields you need — + `objectTypes` lazily calls out to the integration only when selected. + """ + integration(organizationEid: String, type: String): Integration + + """ + Fetch a single `Organization`. You obviously will only be able to access + organizations you are a part of. + + A better way to fetch this information is via the `currentUser` query. + For example: + + ```graphql + currentUser { + organizations { + eid + slug + name + } + } + ``` + """ + organization(organizationEid: String, organizationSlug: String): Organization + + """ + Fetches a single `Signer`. + + An alternate way to fetch signers is via a `DocumentGroup` object. For + example, use the `etchPacket` or `weldData` query, then dig down to the + signers in your query. + """ + signer(eid: String!): Signer + + """ + Fetches a single `Submission`. A `Submission` holds data submitted to a + webform. See our + [terminology guide](/docs/api/getting-started#terminology) for more info. + + A better way to fetch submissions is via the `weldData` query. For example: + + ```graphql + weldData (eid: weldDataEid) { + displayTitle + submissions { + eid + resolvedPayload + } + } + ``` + """ + submission(eid: String!, forceCreate: Boolean, forgeEidOrSlug: String!, organizationSlug: String!, timezone: String): Submission """ - Role of this user within your org. Possible values: viewer, editor, billingAdmin, admin, tokenized, api + Fetch a single `Weld`. This query is a good way to fetch all of a + Workflow's submissions (`WeldData`s). See the `Weld`'s `weldDatas` + resolver. + + Specify either the `Weld`'s `eid` _or_ both the `Weld`'s `slug` and + the `Weld`'s `organizationSlug`. + + See the [Workflow API guide](/docs/api/workflows) for more information on + query usage. """ - role: String + weld( + """The Workflow / Weld's `eid`""" + eid: String + organizationSlug: String + slug: String + + """ + Optional argument to request a specific version of the `Weld`. If not provided, + the last ***published*** version will be returned, unless it has not been published + in which case the latest ***draft*** version will be returned. + + All `Forges` and `Casts` that are related to this `Weld` will be returned + in their state "as-of" the version requested for the `Weld`. + + Providing `-1` is a special value that will return the latest + draft version of the `Weld`, but also the latest draft version of any related + `Forges` and `Casts`. + """ + versionNumber: Int + ): Weld """ - A place to store your external system's unique identifier for this User. + Fetch a single `WeldData` (Workflow submission). If you need to fetch all + `WeldData`s for a given `Weld`, see the `weld` query. + + See the [Workflow API guide](/docs/api/workflows) for more information on + query usage. """ - externalId: String - name: String - email: String - organization: Organization - acceptedAt: Date - revokedAt: Date - createdAt: Date - updatedAt: Date + weldData(eid: String!): WeldData } """ @@ -1304,21 +1976,35 @@ Represents a single signer. See `Signer.status` to indicate where a signer is in the signing process. """ type Signer { - id: Int! - eid: String - """The `Signer`'s user-specified id set on packet creation.""" aliasId: String + allowDefaultTranslations: Boolean """ - Possible values: `sent`, `viewed`, `delivered`, `voided`, `autoresponded`, `faxpending`, `created`, `skipped`, `declined`, `idCheckFailed`, `completed` + Your id for the person signing: a way to trace this signer back to a user in your system. """ - status: String + clientUserId: String + + """Timestamp when the `Signer` has finished signing.""" + completedAt: Date + createdAt: Date + + """ + A `DocumentGroup` references all signers and the documents after they have been filled and sent out for signature. See the `DocumentGroup`'s `status` for lifecycle information. + """ + documentGroup: DocumentGroup + + """The `eid` is the object identifier.""" + eid: String + email: String + + """The URL the user will be redirected to when they finish signing.""" + finishURL: String + id: Int! + name: String """Possible values: `etch` or `docusign`.""" provider: String - name: String - email: String """ Numeric step in the signing process where this user signs. Signers are routed in sorted order. @@ -1330,50 +2016,54 @@ type Signer { """ signActionType: String - """A Anvil `User` associated with this signer.""" - user: User - - """ - An associated `Submission`. This wil exist for signers who sign at the end of a Workflow. - """ - submission: Submission - """ - A `DocumentGroup` references all signers and the documents after they have been filled and sent out for signature. See the `DocumentGroup`'s `status` for lifecycle information. + The inferred signing experience for this signer. Possible values: `signBeforeReviewing`, `signWhileReviewing`, `formCompletionOnly`, `acknowledgeOnly`. """ - documentGroup: DocumentGroup + signatureExperience: String """ Get all `SignerTokens` this signer has available. These can tell you when a signer's token will be expiring, which token was used for signing, etc. """ signerTokens: [SignerToken] - createdAt: Date - updatedAt: Date - """Timestamp when the `Signer` has finished signing.""" - completedAt: Date + """ + Possible values: `sent`, `viewed`, `delivered`, `voided`, `autoresponded`, `faxpending`, `created`, `skipped`, `pending`, `ignored`, `declined`, `idCheckFailed`, `completed` + """ + status: String """ - Your id for the person signing: a way to trace this signer back to a user in your system. + An associated `Submission`. This wil exist for signers who sign at the end of a Workflow. """ - clientUserId: String + submission: Submission + supportEmail: String + updatedAt: Date + + """A Anvil `User` associated with this signer.""" + user: User } type SignerPage { - """Total number of rows found for the query before pagination""" - rowCount: Int - - """Total number of pages of results""" - pageCount: Int + """The actual records for the requested page""" + items: [Signer] """The requested page number""" page: Int + """Total number of pages of results""" + pageCount: Int + """The requested number of rows per page""" pageSize: Int - """The actual records for the requested page""" - items: [Signer] + """ + Total number of rows found for the query before pagination. Capped when the query was given a `countLimit`. + """ + rowCount: Int + + """ + False when `rowCount` stopped at the requested `countLimit`, meaning there are at least that many rows. True whenever the count ran to completion. + """ + rowCountIsExact: Boolean } """ @@ -1385,31 +2075,38 @@ idea of what tokens the signer has available, whether they are valid, and their expiration dates (`validUntil`). """ type SignerToken { - id: Int! - eid: String + """Date when the token was created""" + createdAt: Date - """ - Type of token issued. Tells you how the token was issued. Values: `api`, `sign-now`, `notify-signer`, `view` - """ - type: String + """The `eid` is the object identifier.""" + eid: String - """Date of token expiration.""" - validUntil: Date + """Extra data for the token.""" + extra: JSON - """`true` when this token is valid and can be used to sign.""" - valid: Boolean + """`true` when this token was used to sign the `Signer`'s documents.""" + hasSigned: Boolean + id: Int! """ Date when the token was invalidated if explicitly invalidated. If invalid due to expiration, use `validUntil`. """ invalidatedAt: Date - """`true` when this token was used to sign the `Signer`'s documents.""" - hasSigned: Boolean - """Date when the token was used to sign documents.""" signedAt: Date signer: Signer + + """ + Type of token issued. Tells you how the token was issued. Values: `api`, `sign-now`, `notify-signer`, `view` + """ + type: String + + """`true` when this token is valid and can be used to sign.""" + valid: Boolean + + """Date of token expiration.""" + validUntil: Date } """ @@ -1419,108 +2116,161 @@ submission on a particular `Forge`. See our [Workflow API guide](/docs/api/workflows) for more info. """ type Submission { - id: Int! - eid: String + completedAt: Date + completionPercentage: Float - """ - Possible values: `created`, `in-progress`, `waiting-to-sign`, `user-signed-and-waiting`, `users-turn-to-sign-ui`, `users-turn-to-sign-email`, `someone-else-signed-and-waiting`, `completed` - """ - status: String + """URL to the next step in this webform.""" + continueURL: String + createdAt: Date + currentStep: Int - """ - The payload resolved with field aliases and field information. If you need to fetch the submission payload, use this. - """ - resolvedPayload: JSON + """The `eid` is the object identifier.""" + eid: String + forge: Forge + id: Int! + isExcluded: Boolean """The raw payload""" payload: JSON """The raw payload without types""" payloadValue: JSON - currentStep: Int - totalSteps: Int + requestMeta: JSON - """URL to the next step in this webform.""" - continueURL: String - weldData: WeldData - forge: Forge - user: User + """ + The payload resolved with field aliases and field information. If you need to fetch the submission payload, use this. + """ + resolvedPayload: JSON + reviewData(targetLanguage: String): JSON signer: Signer - reviewData: JSON - completionPercentage: Float - isExcluded: Boolean + + """ + Possible values: `created`, `in-progress`, `waiting-to-sign`, `user-signed-and-waiting`, `users-turn-to-sign-ui`, `users-turn-to-sign-email`, `someone-else-signed-and-waiting`, `completed` + """ + status: String + totalSteps: Int touchedByUser: Boolean - requestMeta: JSON - createdAt: Date updatedAt: Date - completedAt: Date + user: User + weldData: WeldData } type SubmissionPage { - """Total number of rows found for the query before pagination""" - rowCount: Int - - """Total number of pages of results""" - pageCount: Int + """The actual records for the requested page""" + items: [Submission] """The requested page number""" page: Int + """Total number of pages of results""" + pageCount: Int + """The requested number of rows per page""" pageSize: Int - """The actual records for the requested page""" - items: [Submission] + """ + Total number of rows found for the query before pagination. Capped when the query was given a `countLimit`. + """ + rowCount: Int + + """ + False when `rowCount` stopped at the requested `countLimit`, meaning there are at least that many rows. True whenever the count ran to completion. + """ + rowCountIsExact: Boolean } -type User { +type Tag { + """ + Config for the tag, e.g. { color: "red", description: "This is a red tag" } + """ + config: JSON + createdAt: Date + + """The `eid` is the object identifier.""" + eid: String id: Int! + + """ + The other tables this tag can be applied to, e.g. cast, weld_data, etch_packet, weld, etc. + """ + intendedFor: String + + """The name of the tag""" + name: String + organization: Organization + updatedAt: Date +} + +"The `Upload` scalar type represents a file upload. This can be one of:\n\n1. An object with `data`, `mimetype`, and `filename` keys (base64 buffer)\n2. An object with `url` and optionally `filename` keys (URL download)\n3. A multipart request with a structure described by the\n [GraphQL multipart requests specification](https://github.com/jaydenseric/graphql-multipart-request-spec)\n\nWhile sending up an object structure described in the example is considerably\nsimpler than the multipart spec, we only support request payloads of up to 1MB.\nContent-Type `application/json` requests with sizes over the 1MB size will\nresult in a `419 Request Too Large` response.\n\nIf your request is larger than 1MB, use a multipart request. A very basic\nmultipart GraphQL + `Upload` example using `curl`:\n\n```sh\ncurl https://graphql.useanvil.com \\ -F operations='[{ \"query\": \"CreateCast ($file: Upload!) { createCast(file: $file) { id } }\", \"variables\": { \"file\": null } }]' \\\n -F map='{ \"0\": [\"0.variables.file\"] }' \\\n -F 0=@my-file.pdf\n```\n\nOur [Node API Client](https://github.com/anvilco/node-anvil) uses multipart\nuploads by default.\n" +scalar Upload + +type User { + createdAt: Date + + """The `eid` is the object identifier.""" eid: String email: String - name: String + extra: JSON firstName: String lastName: String + name: String + onboarding: JSON + organizationUsers: [OrganizationUser] + organizations: [Organization] + preferences: UserPreferences! """What type of role the User has. Either `user` or `api`.""" role: String - verifiedEmail: Boolean - preferences: UserPreferences! - organizations: [Organization] - organizationUsers: [OrganizationUser] - createdAt: Date updatedAt: Date - extra: JSON - numSentEtchPackets: Int + verifiedEmail: Boolean } type UserPage { - """Total number of rows found for the query before pagination""" - rowCount: Int - - """Total number of pages of results""" - pageCount: Int + """The actual records for the requested page""" + items: [User] """The requested page number""" page: Int + """Total number of pages of results""" + pageCount: Int + """The requested number of rows per page""" pageSize: Int - """The actual records for the requested page""" - items: [User] + """ + Total number of rows found for the query before pagination. Capped when the query was given a `countLimit`. + """ + rowCount: Int + + """ + False when `rowCount` stopped at the requested `countLimit`, meaning there are at least that many rows. True whenever the count ran to completion. + """ + rowCountIsExact: Boolean +} + +type UserPreferences { + acceptedSignature: Boolean + initials: String + mcpOrganizationEid: String + name: String + require2FA: Boolean } type UserUpload { - id: Int! + cast: Cast + createdAt: Date + + """The `eid` is the object identifier.""" eid: String - location: String - user: User etchPacket: EtchPacket - weldData: WeldData forge: Forge + id: Int! + location: String metadata: JSON - createdAt: Date updatedAt: Date + user: User + weldData: WeldData } """ @@ -1530,27 +2280,31 @@ represented by this object. See the [webhooks guide](/docs/api/webhooks) for more information. """ type Webhook { - id: Int! + createdAt: Date + creationUser: User + + """The `eid` is the object identifier.""" eid: String + id: Int! + inactiveAt: Date + inactiveReason: String + organization: Organization + + """Prossible values: `active` or `inactive`.""" + status: String + + """Use this token to make sure the request is from Anvil.""" + token: String """ - Webhook URLs that accept notifications for any object will be `prod`. Per-object webhook URLs will have type `adhoc`. + Webhook URLs that accept notifications for any object will be `prod`. Per-object webhook URLs will have type `adhoc`. Webhook URLs that accept notifications for a specific object will have type `webhookAction`. """ type: String - - """Prossible values: `active` or `inactive`.""" - status: String + updatedAt: Date """The URL Anvil will POST to.""" url: String - - """Use this token to make sure the request is from Anvil.""" - token: String webhookActions: [WebhookAction] - creationUser: User - organization: Organization - createdAt: Date - updatedAt: Date } """ @@ -1558,15 +2312,30 @@ A model that represents a subscription to actions on other objects in our system that would trigger a [`Webhook`]({{Types.Webhook}}) call. """ type WebhookAction { - eid: String! - webhook: Webhook! action: String! - objectEid: String - objectType: String config: JSON createdAt: Date! - updatedAt: Date! creationUser: User! + + """The `eid` is the object identifier.""" + eid: String! + objectEid: String + objectType: String + updatedAt: Date! + webhook: Webhook! +} + +""" +Configuration options for a [`Webhook Action`]({{Types.WebhookAction}}). +""" +input WebhookActionConfigInput { + """Whether actually make the webhook call on Staging""" + doOnStaging: Boolean + + """ + Whether to encrypt the data payloads delivered by the [`Webhook`]({{Types.Webhook}}) call. Defaults to `false`. + """ + encryptData: Boolean = false } """ @@ -1574,11 +2343,13 @@ A `WebhookLog` represents a single webhook call to your server. See the [webhooks guide](/docs/api/webhooks) for more information. """ type WebhookLog { - id: Int! - eid: String - """The webhook action type, e.g. `signerComplete`.""" action: String + createdAt: Date + + """The `eid` is the object identifier.""" + eid: String + id: Int! """ `true` when this call was considered an error. It will be an error when the status code is >= 400, or the request timed out. @@ -1588,18 +2359,9 @@ type WebhookLog { """`true` when this call was a retry of a previous call.""" isRetry: Boolean - """The status code returned by your server for this call.""" - statusCode: Int - """Time it took to complete the webhook request.""" millisecondsToFinish: Int objectEid: String - rootObjectType: String - - """ - Dashboard URL to the object that triggered the webhook event. e.g. it will link to a Workflow submission or an etch packet. - """ - rootObjectURL: String objectMetadata: JSON organization: Organization @@ -1607,24 +2369,42 @@ type WebhookLog { If this call was a retry, `originalWebhookLog` will be `WebhookLog` that was retried. """ originalWebhookLog: WebhookLog - createdAt: Date + + """The decrypted webhook payload data.""" + payload: JSON + rootObjectType: String + + """ + Dashboard URL to the object that triggered the webhook event. e.g. it will link to a Workflow submission or an etch packet. + """ + rootObjectURL: String + + """The status code returned by your server for this call.""" + statusCode: Int } type WebhookLogPage { - """Total number of rows found for the query before pagination""" - rowCount: Int - - """Total number of pages of results""" - pageCount: Int + """The actual records for the requested page""" + items: [WebhookLog] """The requested page number""" page: Int + """Total number of pages of results""" + pageCount: Int + """The requested number of rows per page""" pageSize: Int - """The actual records for the requested page""" - items: [WebhookLog] + """ + Total number of rows found for the query before pagination. Capped when the query was given a `countLimit`. + """ + rowCount: Int + + """ + False when `rowCount` stopped at the requested `countLimit`, meaning there are at least that many rows. True whenever the count ran to completion. + """ + rowCountIsExact: Boolean } """ @@ -1634,21 +2414,10 @@ A Weld is a Workflow. It holds the configuration that combines one or more See our [Workflow API guide](/docs/api/workflows) for more info. """ type Weld { - """Current version number of this Weld""" - versionNumber: Int - versionId: Int - latestDraftVersionNumber: Int - - """Current published version number of this Weld""" - publishedNumber: Int - publishedAt: Date - hasUnpublishedChanges: Boolean - hasBeenPublished: Boolean - id: Int! - eid: String - slug: String - name: String - visibility: String + allowDefaultTranslations: Boolean + archivedAt: Date + availableSignatureProviderTypes: [String] + casts: [Cast] """Config object specifying files output, signers, etc.""" config( @@ -1657,54 +2426,94 @@ type Weld { """ onlyKeys: [String] = null ): JSON - organization: Organization - hasSigners: Boolean + createdAt: Date + draftStep: String + + """The `eid` is the Weld/Workflow's object identifier.""" + eid: String + expiresAt: Date forges(isArchived: Boolean = false): [Forge] - casts: [Cast] - weldGroups: [WeldGroup] + hasBeenPublished: Boolean + hasSigners: Boolean + hasUnpublishedChanges: Boolean + hasWeldDatas: Boolean + id: Int! + isLatestVersion: Boolean + isPublishedVersion: Boolean + isTest: Boolean + latestDraftVersionNumber: Int + name: String + organization: Organization + publishedAt: Date - """Fetch all the `Weld`'s `WeldData`s (Workflow submissions)""" - weldDatas( - touchedByUser: Boolean = true - ignoreTest: Boolean = true - isTest: Boolean = false - isArchived: Boolean = false - statuses: [String] - fromDate: Date - toDate: Date - simpleSearchTerm: String - eids: [String] + """Current published version number of this Weld""" + publishedNumber: Int - """The requested number of rows per page""" - limit: Int = 5 + """The slug of the currently published version""" + publishedSlug: String + publishedVersionId: Int + publishedVersionNumber: Int + publishedVersions( + """The requested number of rows per page (max: 500)""" + limit: Int = 10 """The requested page number""" offset: Int = 1 - ): WeldDataPage + ): WeldVersionPage signatureProviderType: String - availableSignatureProviderTypes: [String] - remainingSubmissions: JSON - createdAt: Date + slug: String + styleConfig: JSON + stylesheetURL: String + + """Total number of submissions (WeldDatas) started by this Weld""" + submissionsCount: Int! + testSignerCompleteWebhookPayload: JSON + testWeldCompleteWebhookPayload: JSON + title: String + + """Total size in bytes of all PDF casts that would be merged""" + totalOutputFileSize: Int updatedAt: Date - archivedAt: Date - expiresAt: Date -} + versionId: Int -type WeldPage { - """Total number of rows found for the query before pagination""" - rowCount: Int + """Current version number of this Weld""" + versionNumber: Int + versions( + """The requested number of rows per page (max: 500)""" + limit: Int = 10 - """Total number of pages of results""" - pageCount: Int + """The requested page number""" + offset: Int = 1 + ): WeldVersionPage + visibility: String + weldDataCounts: JSON - """The requested page number""" - page: Int + """Fetch all the `Weld`'s `WeldData`s (Workflow submissions)""" + weldDatas( + """ + Stop counting matching rows once this many are found (max: 10000). `rowCount` is capped at this value and `rowCountIsExact` becomes false. Omit for an exact total. + """ + countLimit: Int + eids: [String] + fromDate: Date + ignoreTest: Boolean = true + isArchived: Boolean = false + isTest: Boolean = false - """The requested number of rows per page""" - pageSize: Int + """The requested number of rows per page (max: 500)""" + limit: Int = 5 - """The actual records for the requested page""" - items: [Weld] + """The requested page number""" + offset: Int = 1 + orderBy: String + orderDirection: String + query: String + simpleSearchTerm: String + statuses: [String] + toDate: Date + touchedByUser: Boolean = true + ): WeldDataPage + weldGroups: [WeldGroup] } """ @@ -1715,80 +2524,96 @@ in the related `Weld` (workflow). See our [workflow API guide](/docs/api/workflows) for more info. """ type WeldData { - id: Int! - eid: String + agents: JSON + archivedAt: Date - """The title displayed in the UI.""" - displayTitle: String + """ + How complete is this workflow submission. Will be a value between 0 and 1. + """ + completionPercentage: Float """ - Possible values: `created`, `in-progress`, `ready-to-sign`, `awaiting-signatures`, `declined`, `completed` + URL to the next form to be filled by the user. Use this after `WeldData` creation to get the URL to the form that should be filled first. """ - status: String + continueURL: String + createdAt: Date + currentStepInfo: JSON + + """When a the data in any child `Submission` was last updated.""" + dataUpdatedAt: Date + + """The title displayed in the UI.""" + displayTitle: String """ - `true` when it is a test workflow submission. You will not be charged for signatures on test submissions, and they will not count toward your monthly completed workflows. + A `DocumentGroup` contains the documents after they have been filled and sent out for signature. The `DocumentGroup` will only exist here when that documents have been sent out for signature, or in the case of a workflow with no signers, when the workflow is completely finished. See the `DocumentGroup`'s `status` for lifecycle information. """ - isTest: Boolean + documentGroup: DocumentGroup + + """The `eid` is the object identifier.""" + eid: String + embeddedAt: Date """ - This `WeldData` has expired, either by passing the `expiredAt` date, or by being locked. + An array of actions during the signing process when using Anvil's Etch as the signature provider. These objects detail actions when files are signed, downloaded, etc. """ - isExpired: Boolean + etchLogEvents: [EtchLog] + + """A file listing of all files excluded from the signing process.""" + excludedFiles: [JSON] + expiresAt: Date """ - `true` when all forms in the workflow have been filled. There may be signers who need to sign. + A file listing of all files available in the `WeldData`. If there is a `DocumentGroup`, this will be equal to `DocumentGroup.files`. """ - isComplete: Boolean + files: [JSON] + forgesRequiringCompletion: [Forge] + hasPin: Boolean + hasSigners: Boolean """ `true` when status is `completed`: All forms have been filled, all signers have signed. """ - isCompleteAndSigned: Boolean + isAllComplete: Boolean """ - URL to the next form to be filled by the user. Use this after `WeldData` creation to get the URL to the form that should be filled first. + `true` when all forms in the workflow have been filled. There may be signers who need to sign. """ - continueURL: String + isComplete: Boolean """ - The specific webhook notification URL for this `WeldData`. We will POST to this URL when users take various actions. + This `WeldData` has expired, either by passing the `expiredAt` date, or by being locked. """ - webhookURL: String + isExpired: Boolean """ - How complete is this workflow submission. Will be a value between 0 and 1. + `true` when it is a test workflow submission. You will not be charged for signatures on test submissions, and they will not count toward your monthly completed workflows. """ - completionPercentage: Float - numberRemainingSigners: Int - payloadCanBeUpdated: Boolean - hasSigners: Boolean + isTest: Boolean nextSigner: Signer - hasPin: Boolean + nextSigners: [Signer] + numberRemainingSigners: Int + payloadCanBeUpdated: Boolean pin: String - agents: JSON """ - A file listing of all files available in the `WeldData`. If there is a `DocumentGroup`, this will be equal to `DocumentGroup.files`. + Possible values: `created`, `in-progress`, `ready-to-sign`, `awaiting-signatures`, `completed`, `declined` """ - files: [JSON] - - """A file listing of all files excluded from the signing process.""" - excludedFiles: [JSON] - - """The workflow this `WeldData` is related to.""" - weld: Weld - weldVersionId: Int + status: String + stylesheetURL: String """ An array of all the underlying `Submission` objects holding data from each webform. """ submissions: [Submission] + """When a column on the WeldData itself was updated.""" + updatedAt: Date + """ - A `DocumentGroup` contains the documents after they have been filled and sent out for signature. The `DocumentGroup` will only exist here when that documents have been sent out for signature, or in the case of a workflow with no signers, when the workflow is completely finished. See the `DocumentGroup`'s `status` for lifecycle information. + An array of files uploaded by users during the webform filling process. """ - documentGroup: DocumentGroup + userUploads: [UserUpload] """ An array of webhook notification calls to your server related to this `WeldData`. Get the status codes, response times, and retry information from each `WebhookLog`. @@ -1796,75 +2621,56 @@ type WeldData { webhookLogs: [WebhookLog] """ - An array of actions during the signing process when using Anvil's Etch as the signature provider. These objects detail actions when files are signed, downloaded, etc. + The specific webhook notification URL for this `WeldData`. We will POST to this URL when users take various actions. """ - etchLogEvents: [EtchLog] + webhookURL: String + + """The workflow this `WeldData` is related to.""" + weld: Weld + weldDataGroup: WeldDataGroup """An array of actions taken during the form filling process.""" weldDataLogEvents: [WeldDataLog] + weldVersionId: Int +} - """ - An array of files uploaded by users during the webform filling process. - """ - userUploads: [UserUpload] - forgesRequiringCompletion: [Forge] - weldDataGroup: WeldDataGroup +type WeldDataGroup { + archivedAt: Date createdAt: Date - - """When a column on the WeldData itself was updated.""" - updatedAt: Date - - """When a the data in any child `Submission` was last updated.""" dataUpdatedAt: Date - expiresAt: Date - archivedAt: Date -} - -type WeldDataPage { - """Total number of rows found for the query before pagination""" - rowCount: Int - - """Total number of pages of results""" - pageCount: Int - - """The requested page number""" - page: Int - - """The requested number of rows per page""" - pageSize: Int - - """The actual records for the requested page""" - items: [WeldData] -} -type WeldDataGroup { - id: Int! + """The `eid` is the object identifier.""" eid: String - title: String + id: Int! isTest: Boolean - dataUpdatedAt: Date - weldGroup: WeldGroup - weldDatas(isArchived: Boolean = false): [WeldData] - createdAt: Date + title: String updatedAt: Date - archivedAt: Date + weldDatas(isArchived: Boolean = false): [WeldData] + weldGroup: WeldGroup } type WeldDataGroupPage { - """Total number of rows found for the query before pagination""" - rowCount: Int - - """Total number of pages of results""" - pageCount: Int + """The actual records for the requested page""" + items: [WeldDataGroup] """The requested page number""" page: Int + """Total number of pages of results""" + pageCount: Int + """The requested number of rows per page""" pageSize: Int - """The actual records for the requested page""" - items: [WeldDataGroup] + """ + Total number of rows found for the query before pagination. Capped when the query was given a `countLimit`. + """ + rowCount: Int + + """ + False when `rowCount` stopped at the requested `countLimit`, meaning there are at least that many rows. True whenever the count ran to completion. + """ + rowCountIsExact: Boolean } """ @@ -1901,130 +2707,158 @@ voided: Voided ``` """ type WeldDataLog { - id: Int! - eid: String - """The action taken.""" action: String + createdAt: Date + + """The `eid` is the object identifier.""" + eid: String """Location of the user taking the action.""" formattedLocation: String + id: Int! """Any action-specific information.""" metadata: JSON + submission: Submission """The Anvil `User` who took this action, if applicable.""" user: User weldData: WeldData - submission: Submission - createdAt: Date +} + +type WeldDataPage { + """The actual records for the requested page""" + items: [WeldData] + + """The requested page number""" + page: Int + + """Total number of pages of results""" + pageCount: Int + + """The requested number of rows per page""" + pageSize: Int + + """ + Total number of rows found for the query before pagination. Capped when the query was given a `countLimit`. + """ + rowCount: Int + + """ + False when `rowCount` stopped at the requested `countLimit`, meaning there are at least that many rows. True whenever the count ran to completion. + """ + rowCountIsExact: Boolean +} + +""" +An object used by `createWeld` to specify a file. See the +[GraphQL reference](/docs/api/graphql/reference/#operation-createweld-Mutations) for more info. +""" +input WeldFile { + castEid: String + file: Upload + + """An id string set when creating the `Weld`.""" + id: String! + mimetype: String + name: String + src: String + stamp: Float + title: String } type WeldGroup { - id: Int! + createdAt: Date + + """The `eid` is the object identifier.""" eid: String + hasWeldDataGroups: Boolean + id: Int! name: String - slug: String organization: Organization - welds(isArchived: Boolean = false): [Weld] + slug: String + updatedAt: Date weldDataGroups( ignoreTest: Boolean = true - isTest: Boolean = false isArchived: Boolean = false - weldSlugFilter: [String] + isTest: Boolean = false - """The requested number of rows per page""" + """The requested number of rows per page (max: 500)""" limit: Int = 10 """The requested page number""" offset: Int = 1 + weldSlugFilter: [String] ): WeldDataGroupPage - createdAt: Date - updatedAt: Date + welds(isArchived: Boolean = false): [Weld] } -type WeldVersion { - eid: String! - publishedNumber: Int - publishedTitle: String - publishedDescription: String - publishedAt: Date - publishUpdatedAt: Date - publishingUser: User -} +type WeldPage { + """The actual records for the requested page""" + items: [Weld] -type WeldVersionPage { - """Total number of rows found for the query before pagination""" - rowCount: Int + """The requested page number""" + page: Int """Total number of pages of results""" pageCount: Int - """The requested page number""" - page: Int - """The requested number of rows per page""" pageSize: Int - """The actual records for the requested page""" - items: [WeldVersion] + """ + Total number of rows found for the query before pagination. Capped when the query was given a `countLimit`. + """ + rowCount: Int + + """ + False when `rowCount` stopped at the requested `countLimit`, meaning there are at least that many rows. True whenever the count ran to completion. + """ + rowCountIsExact: Boolean } -type UserPreferences { - acceptedSignature: Boolean +type WeldVersion { + config: JSON + createdAt: Date + + """The `eid` is the object identifier.""" + eid: String! + id: Int! name: String - initials: String - require2FA: Boolean + number: Int + publishUpdatedAt: Date + publishedAt: Date + publishedDescription: String + publishedNumber: Int + publishedTitle: String + publishingUser: User + slug: String + updatedAt: Date + updatedColumns: [String] + user: User } -""" -An object used by `createEtchPacket` to specify a file. See the -[Etch e-sign guide](/docs/api/e-signatures) for more info. -""" -input EtchFile { - """An id string set when creating the `EtchPacket`.""" - id: String! - title: String - castEid: String - file: Upload - base64File: Upload - fields: [JSON!] - pageSizes: [JSON!] - fontSize: Int - textColor: String +type WeldVersionPage { + """The actual records for the requested page""" + items: [WeldVersion] - """Filename set when creating the `EtchPacket`.""" - filename: String + """The requested page number""" + page: Int + + """Total number of pages of results""" + pageCount: Int + + """The requested number of rows per page""" + pageSize: Int """ - For generated PDFs, set to false to disable the "Generated at XXX" timestamp at the bottom of the document. + Total number of rows found for the query before pagination. Capped when the query was given a `countLimit`. """ - includeTimestamp: Boolean + rowCount: Int """ - For generated PDFs, you can specify a logo to be rendered at the top of the document. + False when `rowCount` stopped at the requested `countLimit`, meaning there are at least that many rows. True whenever the count ran to completion. """ - logo: JSON - - """Holds HTML/CSS to generate PDF""" - markup: JSON - - """Holds page customizations to generate PDF""" - page: JSON -} - -""" -An object used by `createWeld` to specify a file. See the -[GraphQL reference](/docs/api/graphql/reference/#operation-createweld-Mutations) for more info. -""" -input WeldFile { - """An id string set when creating the `Weld`.""" - id: String! - title: String - castEid: String - file: Upload - mimetype: String - name: String - src: String - stamp: Float + rowCountIsExact: Boolean } diff --git a/test-client.py b/test-client.py index 2efb7e29..5e431f77 100644 --- a/test-client.py +++ b/test-client.py @@ -27,7 +27,7 @@ from collections.abc import Callable from pathlib import Path -from python_anvil.api import Anvil +from python_anvil import Anvil, AnvilRequestError from python_anvil.api_resources.mutations import CreateEtchPacket from python_anvil.api_resources.payload import ( DocumentUpload, @@ -35,8 +35,6 @@ SignatureField, SignerField, ) -from python_anvil.api_resources.requests import PlainRequest, RestRequest -from python_anvil.http import GQLClient API_KEY = os.environ.get("ANVIL_API_KEY") @@ -61,6 +59,9 @@ def check(method: str, fn: Callable[[], str | None]) -> None: except Skip as e: results.append((method, "SKIP")) print(f" SKIP {method}: {e}") + except AnvilRequestError as e: + results.append((method, "FAIL")) + print(f" FAIL {method}: {e}") except Exception as e: results.append((method, "FAIL")) print(f" FAIL {method}: {type(e).__name__}: {e}") @@ -77,25 +78,6 @@ def assert_pdf(data: object) -> str: return f"{len(data)} bytes" -def make_client(api_key: str) -> Anvil: - """Make a 5.x client pointed at BASE_URL. - - 5.x has no base URL option: GraphQL goes to `endpoint_url`, and the REST - hosts are class attributes. The GraphQL client is also recreated to fetch - the schema from the API, instead of validating against the schema file - checked in with the client, which has drifted from the API. - """ - anvil = Anvil(api_key, endpoint_url=f"{BASE_URL}/graphql") - RestRequest.API_HOST = BASE_URL - PlainRequest.API_HOST = BASE_URL - anvil.gql_client = GQLClient.get_client( - api_key=api_key, - endpoint_url=f"{BASE_URL}/graphql", - fetch_schema_from_transport=True, - ) - return anvil - - def main() -> None: if not API_KEY: print( @@ -106,7 +88,7 @@ def main() -> None: print(f"Testing the Anvil Python client against {BASE_URL}\n") - client = make_client(API_KEY) + client = Anvil(API_KEY, base_url=BASE_URL) # What the later steps need, found as we go state: dict[str, object] = { @@ -119,8 +101,8 @@ def main() -> None: "weld_data_eid": None, } - def query() -> str: - data = client.query( + def request_graphql() -> str: + data = client.request_graphql( """ query TestClient { currentUser { eid } @@ -143,7 +125,7 @@ def query() -> str: if state["cast_eid"]: # The API won't fill a PDF with empty data, so use the template's example data - cast_data = client.query( + cast_data = client.request_graphql( "query TestClientCast ($eid: String!) { cast(eid: $eid) { exampleData } }", {"eid": state["cast_eid"]}, ) @@ -157,12 +139,17 @@ def query() -> str: return f"organization {organization['eid']}" - check("query", query) + check("request_graphql", request_graphql) def request_rest() -> str: - data = client.request_rest().post( - "generate-pdf", - {"title": "request_rest", "data": [{"content": "Sent with request_rest"}]}, + data = client.request_rest( + "POST", + "/api/v1/generate-pdf", + json={ + "title": "request_rest", + "data": [{"content": "Sent with request_rest"}], + }, + data_type="bytes", ) return assert_pdf(data) @@ -228,21 +215,20 @@ def fill_pdf_latest() -> str: check("fill_pdf (VERSION_LATEST)", fill_pdf_latest) - def fill_pdf_with_headers() -> str: + def fill_pdf_stream() -> str: skip_unless( state["cast_eid"], "No PDF template with example data found. Set ANVIL_PDF_TEMPLATE_ID.", ) - res = client.fill_pdf( + with client.fill_pdf( str(state["cast_eid"]), {"data": state["cast_data"] or {}}, - include_headers=True, - ) - if "x-ratelimit-limit" not in {k.lower() for k in res["headers"]}: - raise AssertionError("Expected the rate limit headers") - return f"{assert_pdf(res['response'])}, with headers" + data_type="stream", + ) as response: + data = b"".join(response.iter_content(chunk_size=64 * 1024)) + return assert_pdf(data) - check("fill_pdf (include_headers)", fill_pdf_with_headers) + check("fill_pdf (stream)", fill_pdf_stream) def create_etch_packet() -> str: packet = CreateEtchPacket( @@ -251,7 +237,49 @@ def create_etch_packet() -> str: # Send it right away, so it has signers that can sign is_draft=False, ) - # An open file is base64 encoded into the mutation's variables + packet.add_file( + DocumentUpload( + id="testFile", + title="Test File", + file=Anvil.prepare_graphql_file(PDF_TO_SIGN), + fields=[ + SignatureField( + id="signature", + type="signature", + page_num=0, + rect={"x": 100, "y": 100, "width": 150, "height": 30}, + ) + ], + ) + ) + packet.add_signer( + EtchSigner( + id="signer", + name="Test Signer", + email="test-signer@example.com", + # Signs through a URL from generate_etch_signing_url rather than by email + signer_type="embedded", + fields=[SignerField(file_id="testFile", field_id="signature")], + ) + ) + data = client.create_etch_packet(packet) + + created = data["createEtchPacket"] + state["etch_packet_eid"] = created["eid"] + document_group = created.get("documentGroup") or {} + state["document_group_eid"] = document_group.get("eid") + signers = document_group.get("signers") or [] + state["signer_eid"] = signers[0]["eid"] if signers else None + return str(created["eid"]) + + check("create_etch_packet", create_etch_packet) + + def create_etch_packet_base64() -> str: + packet = CreateEtchPacket( + name="Anvil Python client test (base64 upload)", is_test=True, is_draft=True + ) + # An open file that wasn't prepared with `prepare_graphql_file` is base64 + # encoded into the mutation's variables, as it always was with PDF_TO_SIGN.open("rb") as pdf: packet.add_file( DocumentUpload( @@ -273,43 +301,30 @@ def create_etch_packet() -> str: id="signer", name="Test Signer", email="test-signer@example.com", - # Signs through a URL from generate_etch_signing_url rather than by email signer_type="embedded", fields=[SignerField(file_id="testFile", field_id="signature")], ) ) data = client.create_etch_packet(packet) + return f"draft {data['createEtchPacket']['eid']}" - created = data["createEtchPacket"] - state["etch_packet_eid"] = created["eid"] - document_group = created.get("documentGroup") or {} - state["document_group_eid"] = document_group.get("eid") - signers = document_group.get("signers") or [] - state["signer_eid"] = signers[0]["eid"] if signers else None - return str(created["eid"]) - - check("create_etch_packet", create_etch_packet) + check("create_etch_packet (base64 upload)", create_etch_packet_base64) def get_etch_packet() -> str: skip_unless(state["etch_packet_eid"], "create_etch_packet failed") - data = client.query( - "query EtchPacket ($eid: String!) { etchPacket (eid: $eid) { eid status } }", - {"eid": state["etch_packet_eid"]}, - ) - packet = data["etchPacket"] + packet = client.get_etch_packet(str(state["etch_packet_eid"])) if packet["eid"] != state["etch_packet_eid"]: raise AssertionError("Got a different packet back") return str(packet["status"]) - check("etchPacket query", get_etch_packet) + check("get_etch_packet", get_etch_packet) def generate_etch_signing_url() -> str: skip_unless(state["signer_eid"], "create_etch_packet failed") - data = client.generate_etch_signing_url( + url = client.generate_etch_signing_url( signer_eid=str(state["signer_eid"]), client_user_id="anvil-python-test-client", ) - url = data["generateEtchSignURL"] if not isinstance(url, str) or not url.startswith(("http://", "https://")): raise AssertionError(f"Expected a URL, got {url!r}") return "got a URL" @@ -341,15 +356,12 @@ def forge_submit() -> str: def remove_weld_data() -> str: skip_unless(state["weld_data_eid"], "forge_submit did not create a submission") - data = client.mutate( - "mutation RemoveWeldData ($eid: String!) { removeWeldData (eid: $eid) }", - {"eid": state["weld_data_eid"]}, - ) - if data["removeWeldData"] is not True: + removed = client.remove_weld_data(str(state["weld_data_eid"])) + if removed is not True: raise AssertionError("Expected True") return f"removed {state['weld_data_eid']}" - check("removeWeldData mutation", remove_weld_data) + check("remove_weld_data", remove_weld_data) # The convenience queries check("get_current_user", lambda: client.get_current_user()["eid"]) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..a18c709a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import Any + +import pytest + +from python_anvil import Anvil +from tests.server import RecordedRequest, Server, start_server + + +Handler = Callable[[RecordedRequest, int], Mapping[str, Any] | None] + + +class TestServer(Server): + """The local server, with a `respond` to set what it answers with.""" + + __test__ = False + + def __init__(self, base_url: str = ""): + super().__init__(base_url=base_url) + self.handler: Handler = lambda request, index: {"json": {"data": {}}} + + def respond(self, handler: Handler | Mapping[str, Any]) -> None: + """Answer every request with `handler`'s response, or with one response.""" + if callable(handler): + self.handler = handler + else: + self.handler = lambda request, index: handler + + def respond_in_turn(self, *responses: Mapping[str, Any]) -> None: + """Answer the first request with the first response, and so on. The last repeats.""" + self.handler = lambda request, index: responses[min(index, len(responses) - 1)] + + +@pytest.fixture +def server(): + test_server = TestServer() + with start_server( + lambda request, index: test_server.handler(request, index) + ) as running: + test_server.base_url = running.base_url + test_server.requests = running.requests + yield test_server + + +@pytest.fixture +def make_client(server): + """A client that talks to the local server. Starts allowing 10 requests a second so tests aren't slow.""" + + def factory(**options: Any) -> Anvil: + options.setdefault("api_key", "my-api-key") + options.setdefault("base_url", server.base_url) + options.setdefault("request_limit", 10) + options.setdefault("request_limit_ms", 1000) + options.setdefault("timeout", 5) + return Anvil(**options) + + return factory + + +@pytest.fixture +def client(make_client) -> Anvil: + return make_client() diff --git a/tests/server.py b/tests/server.py new file mode 100644 index 00000000..8dd3533b --- /dev/null +++ b/tests/server.py @@ -0,0 +1,154 @@ +"""A local HTTP server for testing the client over a real connection. + +These tests run the real client (nothing stubbed) against this server, so we +see exactly what goes over the wire. + +`handler` receives the recorded request and its index, and returns a dict +describing the response: + {"status": 200, "headers": {}, "json": ..., "body": b"...", "delay": 0.0} +""" + +from __future__ import annotations + +import json as json_lib +import threading +import time +from collections.abc import Callable, Mapping +from contextlib import contextmanager +from dataclasses import dataclass, field +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + + +RATE_LIMIT_HEADERS = { + "x-ratelimit-limit": "10", + "x-ratelimit-interval-ms": "1000", +} + + +@dataclass +class RecordedRequest: + method: str + path: str + headers: Mapping[str, str] + body: bytes + received_at: float + responded_at: float | None = None + + @property + def json(self) -> Any: + return json_lib.loads(self.body) + + def multipart_parts(self) -> dict[str, MultipartPart]: + """The parts of a multipart body, by field name.""" + content_type = self.headers["content-type"] + assert content_type.startswith("multipart/form-data; boundary="), content_type + boundary = content_type.split("boundary=", 1)[1].encode() + parts: dict[str, MultipartPart] = {} + for raw in self.body.split(b"--" + boundary): + # Each part is "\r\n\r\n\r\n\r\n"; the last is "--\r\n" + if raw in (b"", b"--\r\n", b"--"): + continue + raw = raw.removeprefix(b"\r\n").removesuffix(b"\r\n") + head, _, body = raw.partition(b"\r\n\r\n") + headers: dict[str, str] = {} + for line in head.decode("latin1").split("\r\n"): + name, _, value = line.partition(":") + headers[name.strip().lower()] = value.strip() + disposition = headers.get("content-disposition", "") + name = _disposition_param(disposition, "name") + parts[name] = MultipartPart( + name=name, + filename=_disposition_param(disposition, "filename"), + content_type=headers.get("content-type"), + body=body, + ) + return parts + + +@dataclass +class MultipartPart: + name: str + filename: str | None + content_type: str | None + body: bytes + + @property + def json(self) -> Any: + return json_lib.loads(self.body) + + +@dataclass +class Server: + base_url: str + requests: list[RecordedRequest] = field(default_factory=list) + + +Handler = Callable[[RecordedRequest, int], Mapping[str, Any] | None] + + +@contextmanager +def start_server(handler: Handler | None = None): + """Start a server on a free port, yielding a `Server` with its base URL and the requests it got.""" + server = Server(base_url="") + respond = handler or (lambda request, index: {"json": {"data": {}}}) + + class RequestHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args: Any) -> None: + pass + + def handle_one(self) -> None: + length = int(self.headers.get("content-length") or 0) + body = self.rfile.read(length) if length else b"" + recorded = RecordedRequest( + method=self.command, + path=self.path, + headers={k.lower(): v for k, v in self.headers.items()}, + body=body, + received_at=time.monotonic(), + ) + server.requests.append(recorded) + + response = respond(recorded, len(server.requests) - 1) or {} + status = response.get("status", 200) + headers = {**RATE_LIMIT_HEADERS, **response.get("headers", {})} + if response.get("delay"): + time.sleep(response["delay"]) + + if "json" in response: + payload = json_lib.dumps(response["json"]).encode() + headers.setdefault("content-type", "application/json") + else: + payload = response.get("body", b"") + if isinstance(payload, str): + payload = payload.encode() + + self.send_response(status) + for name, value in headers.items(): + self.send_header(name, value) + self.send_header("content-length", str(len(payload))) + self.end_headers() + recorded.responded_at = time.monotonic() + self.wfile.write(payload) + + do_GET = do_POST = do_PUT = do_DELETE = handle_one + + httpd = ThreadingHTTPServer(("127.0.0.1", 0), RequestHandler) + httpd.daemon_threads = True + server.base_url = f"http://127.0.0.1:{httpd.server_port}" + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + yield server + finally: + httpd.shutdown() + httpd.server_close() + + +def _disposition_param(disposition: str, param: str) -> str: + marker = f'{param}="' + if marker not in disposition: + return "" + return disposition.split(marker, 1)[1].split('"', 1)[0] diff --git a/tests/test_api.py b/tests/test_api.py index 1c7d5afc..f4ff4f93 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,457 +1,569 @@ +"""The client's methods: what each sends, with the transport mocked. See test_http.py for the wire.""" + +from __future__ import annotations + import json -from collections.abc import MutableMapping -from typing import Any from unittest import mock import pytest -from python_anvil.api import Anvil, CreateEtchPacket +from python_anvil import Anvil, AnvilRequestError +from python_anvil.api_resources.mutations import CreateEtchPacket from python_anvil.api_resources.payload import ( CreateEtchPacketPayload, FillPDFPayload, ForgeSubmitPayload, + GeneratePDFPayload, ) -from python_anvil.constants import VALID_HOSTS +from python_anvil.graphql import mutations, queries from tests import payloads DEV_KEY = "MY-SECRET-KEY" -TEST_SCHEMA = """ -type GenerateEmbedURLResponse { - eid: String! - url: String! -} -""" - - -def describe_api(): - @pytest.fixture - @mock.patch("builtins.open", new_callable=mock.mock_open, read_data=TEST_SCHEMA) - def anvil(m_open): - return Anvil(api_key=DEV_KEY) - - def describe_init(): - @mock.patch("python_anvil.api.GQLClient") - @mock.patch("python_anvil.api.HTTPClient") - def test_init_key_default(mock_client, mock_gql): - Anvil(api_key="what") - mock_client.assert_called_once_with(api_key="what", environment="dev") - mock_gql.get_client.assert_called_once_with( - api_key="what", environment="dev", endpoint_url=None - ) - @mock.patch("python_anvil.api.GQLClient") - @mock.patch("python_anvil.api.HTTPClient") - def test_init_with_endpoint(mock_client, mock_gql): - Anvil(api_key="what", endpoint_url="http://somewhere.example") - mock_client.assert_called_once_with(api_key="what", environment="dev") - mock_gql.get_client.assert_called_once_with( - api_key="what", - environment="dev", - endpoint_url="http://somewhere.example", - ) +@pytest.fixture +def anvil(): + return Anvil(api_key=DEV_KEY) - @mock.patch("python_anvil.api.GQLClient") - @mock.patch("python_anvil.api.HTTPClient") - def test_init_key_prod(mock_client, mock_gql): - Anvil(api_key="what", environment="prod") - mock_client.assert_called_once_with(api_key="what", environment="prod") - mock_gql.get_client.assert_called_once_with( - api_key="what", environment="prod", endpoint_url=None - ) - @mock.patch("python_anvil.api.GQLClient") - @mock.patch("python_anvil.api.HTTPClient") - def test_init_no_key(mock_client, mock_gql): - with pytest.raises(ValueError): - Anvil(environment="prod") - - assert mock_client.call_count == 0 - assert mock_gql.get_client.call_count == 0 - - def describe_query(): - def test_query(): - # TODO: ... - pass - - def describe_fill_pdf(): - @mock.patch("python_anvil.api.RestRequest.post") - def test_dict_payload(m_request_post, anvil): - payload = {"data": {"this_data": "yes"}} - anvil.fill_pdf("some_template", payload=payload) - m_request_post.assert_called_once_with("fill/some_template.pdf", payload) - - @mock.patch("python_anvil.api.RestRequest.post") - def test_json_payload(m_request_post, anvil): - payload = """{ "data": {"jsonData": "is here"} }""" - anvil.fill_pdf("some_template", payload=payload) - m_request_post.assert_called_once_with( - "fill/some_template.pdf", - {"data": {"jsonData": "is here"}}, - ) +@pytest.fixture +def rest(anvil): + with mock.patch.object(anvil, "request_rest", return_value=b"%PDF-") as m: + yield m - @mock.patch("python_anvil.api.RestRequest.post") - def test_payload_obj(m_request_post, anvil): - payload = FillPDFPayload(data={"jsonData": "this was a payload instance"}) - anvil.fill_pdf("some_template", payload=payload) - m_request_post.assert_called_once_with( - "fill/some_template.pdf", - {"data": {"jsonData": "this was a payload instance"}}, - ) - @mock.patch("python_anvil.api.RestRequest.post") - def test_empty_payload(m_request_post, anvil): - with pytest.raises(ValueError): - anvil.fill_pdf("some_template", payload={}) - assert m_request_post.call_count == 0 - - @mock.patch("python_anvil.api.RestRequest.post") - def test_with_kwargs(m_request_post, anvil): - payload = """{ "data": {"jsonData": "is here"} }""" - anvil.fill_pdf("some_template", payload=payload, include_headers=True) - m_request_post.assert_called_once_with( - "fill/some_template.pdf", - {"data": {"jsonData": "is here"}}, - include_headers=True, - ) +@pytest.fixture +def graphql(anvil): + with mock.patch.object(anvil, "request_graphql", return_value={}) as m: + yield m - @mock.patch("python_anvil.api.RestRequest.post") - def test_with_version(m_request_post, anvil): - payload = {"data": {"one": "One string"}} - anvil.fill_pdf( - "some_template", - payload=payload, - include_headers=True, - version_number=Anvil.VERSION_LATEST, - ) - m_request_post.assert_called_once_with( - "fill/some_template.pdf", - {"data": {"one": "One string"}}, - include_headers=True, - params={"versionNumber": Anvil.VERSION_LATEST}, - ) - @mock.patch("python_anvil.api.RestRequest.post") - def test_with_params(m_request_post, anvil): - payload = {"data": {"one": "One string"}} - params = {"arbitrary": "Param"} - anvil.fill_pdf( - "some_template", payload=payload, include_headers=True, params=params - ) - m_request_post.assert_called_once_with( - "fill/some_template.pdf", - {"data": {"one": "One string"}}, - include_headers=True, - params=params, - ) +def describe_init(): + def it_requires_an_api_key_or_access_token(): + with pytest.raises( + ValueError, match=r"`api_key` or `access_token` is required" + ): + Anvil() + with pytest.raises(ValueError): + Anvil(api_key="") - def describe_generate_pdf(): - @mock.patch("python_anvil.api.RestRequest.post") - def test_dict_payload(m_request_post, anvil): - anvil.generate_pdf({"data": [{"d1": "data"}]}) - m_request_post.assert_called_once_with( - # Defaults to 'markdown' - "generate-pdf", - data={"data": [{"d1": "data"}], "type": "markdown"}, - ) + def it_accepts_an_access_token(): + client = Anvil(access_token="token") + assert client.client.auth_header.startswith("Bearer ") - @mock.patch("python_anvil.api.RestRequest.post") - def test_json_payload(m_request_post, anvil): - payload = """{ "data": [{ "d1": "data" }] }""" - anvil.generate_pdf(payload) - m_request_post.assert_called_once_with( - "generate-pdf", data={"data": [{"d1": "data"}], "type": "markdown"} - ) + def it_defaults_the_base_url_to_production(anvil): + assert anvil.base_url == "https://app.useanvil.com" - @mock.patch("python_anvil.api.RestRequest.post") - def test_payload_html_type(m_request_post, anvil): - anvil.generate_pdf({"data": {"html": "

Hello

"}, "type": "html"}) - m_request_post.assert_called_once_with( - "generate-pdf", - data={"data": {"html": "

Hello

"}, "type": "html"}, - ) + def it_accepts_a_custom_base_url(): + assert ( + Anvil(DEV_KEY, base_url="https://staging.useanvil.com/").base_url + == "https://staging.useanvil.com" + ) - @mock.patch("python_anvil.api.RestRequest.post") - def test_invalid_payload_html_payload(m_request_post, anvil): - with pytest.raises(ValueError): - anvil.generate_pdf({"data": {"no_html_here": "Nope"}, "type": "html"}) - assert m_request_post.call_count == 0 - - @mock.patch("python_anvil.api.RestRequest.post") - def test_payload_invalid_type(m_request_post, anvil): - with pytest.raises(ValueError): - anvil.generate_pdf( - {"data": [{"d1": "data"}], "type": "something_invalid"} - ) - assert m_request_post.call_count == 0 - - @mock.patch("python_anvil.api.RestRequest.post") - def test_invalid_data_for_markdown(m_request_post, anvil): - with pytest.raises(ValueError): - anvil.generate_pdf( - { - # This should be a plain dict, not a list - "data": {"d1": "data"}, - "type": "markdown", - } - ) - assert m_request_post.call_count == 0 - - def describe_current_user_query(): - @mock.patch("gql.Client.execute") - def test_get_current_user(m_request_post, anvil): - anvil.get_current_user() - assert m_request_post.call_count == 1 - - def describe_get_welds(): - @mock.patch("gql.Client.execute") - def test_get_weld(m_request_post, anvil): - anvil.get_welds() - assert m_request_post.call_count == 1 - - def describe_generate_etch_signing_url(): - @mock.patch("gql.Client.execute") - def test_get_url(m_request_post, anvil): - anvil.generate_etch_signing_url( - signer_eid="someId", client_user_id="anotherId" - ) - assert m_request_post.call_count == 1 - - def describe_download_documents(): - @mock.patch("python_anvil.api.PlainRequest.get") - def test_get_docs(m_request_post, anvil): - anvil.download_documents("someEid") - assert m_request_post.call_count == 1 - - def describe_get_cast(): - @mock.patch("gql.Client.execute") - def test_get_cast(m_request_post, anvil): - anvil.get_cast("castEid") - assert m_request_post.call_count == 1 - - def describe_get_casts(): - @mock.patch("gql.Client.execute") - def test_get_casts(m_request_post, anvil): - anvil.get_casts() - assert m_request_post.call_count == 1 - - def describe_create_etch_packet(): - expected_data = { - "name": "Packet name", - "signatureEmailSubject": "The subject", - "signers": [], - "files": [], - "isDraft": False, - "isTest": True, - "data": {"payloads": {}}, - "signaturePageOptions": {}, + def it_starts_with_a_limit_of_one_request_per_second(anvil): + limiter = anvil.client.rate_limiter + assert (limiter.limit, limiter.interval_ms) == (1, 1000) + + def it_accepts_custom_starting_rate_limits(): + limiter = Anvil( + DEV_KEY, request_limit=5, request_limit_ms=2000 + ).client.rate_limiter + assert (limiter.limit, limiter.interval_ms) == (5, 2000) + + def it_rejects_the_removed_endpoint_url(): + with pytest.raises( + TypeError, match=r"`endpoint_url` was removed in 6\.0\.0\. Pass `base_url`" + ): + Anvil(DEV_KEY, endpoint_url="https://graphql.staging.useanvil.com") + + def it_warns_that_environment_does_nothing(): + with pytest.warns(DeprecationWarning, match="`environment` does nothing"): + Anvil(DEV_KEY, environment="prod") + + def it_only_takes_the_api_key_positionally(): + with pytest.raises(TypeError): + Anvil(DEV_KEY, "https://staging.useanvil.com") # type: ignore[misc] + + +def describe_version_constants(): + def it_exposes_version_latest_as_minus_one(): + assert Anvil.VERSION_LATEST == -1 + + def it_exposes_version_latest_published_as_minus_two(): + assert Anvil.VERSION_LATEST_PUBLISHED == -2 + + +def describe_fill_pdf(): + def it_posts_a_dict_payload(anvil, rest): + assert ( + anvil.fill_pdf("some_template", {"data": {"jsonData": "yes"}}) == b"%PDF-" + ) + rest.assert_called_once_with( + "POST", + "/api/v1/fill/some_template.pdf", + json={"data": {"jsonData": "yes"}}, + params=None, + data_type="bytes", + ) + + def it_posts_a_json_payload(anvil, rest): + anvil.fill_pdf("some_template", '{ "data": {"jsonData": "is here"} }') + assert rest.call_args.kwargs["json"] == {"data": {"jsonData": "is here"}} + + def it_posts_a_payload_object(anvil, rest): + anvil.fill_pdf("some_template", FillPDFPayload(data={"a": 1}, font_size=10)) + assert rest.call_args.kwargs["json"] == {"data": {"a": 1}, "fontSize": 10} + + def it_accepts_camel_case_keys(anvil, rest): + anvil.fill_pdf( + "some_template", {"data": {"a": 1}, "fontSize": 10, "textColor": "#333"} + ) + assert rest.call_args.kwargs["json"] == { + "data": {"a": 1}, + "fontSize": 10, + "textColor": "#333", } - @mock.patch("gql.Client.execute") - def test_create_etch_packet_empty_payload(m_request_post, anvil): - with pytest.raises(TypeError): - anvil.create_etch_packet(payload={}) - assert m_request_post.call_count == 0 - - @mock.patch("gql.Client.execute") - def test_create_etch_packet_invalid_payload(m_request_post, anvil): - with pytest.raises(TypeError): - anvil.create_etch_packet({}) - assert m_request_post.call_count == 0 - - @mock.patch("gql.Client.execute") - def test_create_etch_packet_valid_payload_type(m_request_post, anvil): - payload = CreateEtchPacket( - name="Packet name", - signature_email_subject="The subject", - ) - anvil.create_etch_packet(payload=payload) - assert m_request_post.call_count == 1 - - variables = m_request_post.call_args[1]["variable_values"] - assert expected_data == variables - - @mock.patch("gql.Client.execute") - def test_create_etch_packet_passes_options(m_request_post, anvil): - payload = CreateEtchPacket( - is_test=False, - is_draft=True, - name="Packet name", - signature_email_subject="The subject", - ) - new_expected = dict(**expected_data) - new_expected["isTest"] = False - new_expected["isDraft"] = True - anvil.create_etch_packet(payload) - assert m_request_post.call_count == 1 - - variables = m_request_post.call_args[1]["variable_values"] - assert new_expected == variables - - @mock.patch("gql.Client.execute") - def test_create_etch_packet_valid_dict_type(m_request_post, anvil): - anvil.create_etch_packet( - {"name": "Packet name", "signature_email_subject": "The subject"} - ) - assert m_request_post.call_count == 1 + def it_rejects_an_empty_payload(anvil, rest): + with pytest.raises(ValueError): + anvil.fill_pdf("some_template", {"data": {}}) + with pytest.raises(ValueError): + anvil.fill_pdf("some_template", 123) # type: ignore[arg-type] + assert rest.call_count == 0 + + def it_sends_the_version_number(anvil, rest): + anvil.fill_pdf( + "some_template", {"data": {"one": "1"}}, version_number=Anvil.VERSION_LATEST + ) + assert rest.call_args.kwargs["params"] == {"versionNumber": -1} + + def it_sends_extra_params(anvil, rest): + anvil.fill_pdf( + "some_template", + {"data": {"one": "1"}}, + params={"arbitrary": "Param"}, + version_number=3, + ) + assert rest.call_args.kwargs["params"] == { + "arbitrary": "Param", + "versionNumber": 3, + } + + def it_streams_when_asked(anvil, rest): + anvil.fill_pdf("some_template", {"data": {"one": "1"}}, data_type="stream") + assert rest.call_args.kwargs["data_type"] == "stream" + + def it_rejects_json_as_a_data_type(anvil, rest): + with pytest.raises( + ValueError, match=r"data_type must be one of: bytes\|stream" + ): + anvil.fill_pdf("some_template", {"data": {"one": "1"}}, data_type="json") + + +def describe_generate_pdf(): + def it_posts_a_dict_payload_as_markdown_by_default(anvil, rest): + anvil.generate_pdf({"data": [{"d1": "data"}]}) + rest.assert_called_once_with( + "POST", + "/api/v1/generate-pdf", + json={"data": [{"d1": "data"}], "type": "markdown"}, + params=None, + data_type="bytes", + ) + + def it_posts_a_json_payload(anvil, rest): + anvil.generate_pdf(json.dumps({"data": [{"d1": "data"}], "title": "T"})) + assert rest.call_args.kwargs["json"] == { + "data": [{"d1": "data"}], + "title": "T", + "type": "markdown", + } + + def it_posts_an_html_payload(anvil, rest): + anvil.generate_pdf( + {"type": "html", "data": {"html": "

Hi

", "css": "h1 {}"}} + ) + assert rest.call_args.kwargs["json"] == { + "type": "html", + "data": {"html": "

Hi

", "css": "h1 {}"}, + } - variables = m_request_post.call_args[1]["variable_values"] - assert expected_data == variables + def it_posts_a_payload_object(anvil, rest): + anvil.generate_pdf(GeneratePDFPayload(data=[{"content": "x"}], font_size=12)) + assert rest.call_args.kwargs["json"] == { + "data": [{"content": "x"}], + "type": "markdown", + "fontSize": 12, + } + + def it_rejects_an_empty_or_invalid_payload(anvil, rest): + with pytest.raises(ValueError): + anvil.generate_pdf({}) + with pytest.raises(ValueError): + anvil.generate_pdf({"type": "html", "data": {"not_html": "x"}}) + with pytest.raises(ValueError): + anvil.generate_pdf({"data": [{"d1": "data"}], "type": "unknown"}) + assert rest.call_count == 0 + + +def describe_download_documents(): + def it_gets_the_zip(anvil, rest): + anvil.download_documents("dg123") + rest.assert_called_once_with( + "GET", "/api/document-group/dg123.zip", data_type="bytes" + ) + + +def describe_request_graphql(): + def it_returns_the_data(anvil): + response = mock.Mock( + status_code=200, headers={"content-type": "application/json"} + ) + response.json.return_value = {"data": {"currentUser": {"eid": "u1"}}} + with mock.patch.object( + anvil.client, "request", return_value=response + ) as request: + data = anvil.request_graphql("{ currentUser { eid } }", {"a": 1}) + + assert data == {"currentUser": {"eid": "u1"}} + request.assert_called_once_with( + "POST", + "/graphql", + json={"query": "{ currentUser { eid } }", "variables": {"a": 1}}, + timeout=None, + ) - @mock.patch( - "python_anvil.api_resources.mutations.create_etch_packet.create_unique_id" + def it_raises_for_an_unexpected_body(anvil): + response = mock.Mock( + status_code=200, headers={"content-type": "application/json"} ) - @mock.patch("gql.Client.execute") - def test_create_etch_packet_dict_with_signer( - m_request_post, m_create_unique, anvil + response.json.return_value = {"unexpected": True} + with ( + mock.patch.object(anvil.client, "request", return_value=response), + pytest.raises(ValueError, match="Unexpected GraphQL response"), ): - m_create_unique.return_value = "signer-mock-generated" - anvil.create_etch_packet(payload=payloads.ETCH_TEST_PAYLOAD) - assert m_request_post.call_count == 1 - - variables = m_request_post.call_args[1]["variable_values"] - assert variables == payloads.EXPECTED_ETCH_TEST_PAYLOAD - - @mock.patch( - "python_anvil.api_resources.mutations.create_etch_packet.create_unique_id" - ) - @mock.patch("gql.Client.execute") - def test_create_etch_packet_json(m_request_post, m_create_unique, anvil): - m_create_unique.return_value = "signer-mock-generated" - # We are currently removing `None`s from the payload, so do that here too. - payload = { - k: v - for k, v in payloads.EXPECTED_ETCH_TEST_PAYLOAD_JSON.items() - if v is not None - } - anvil.create_etch_packet(json=json.dumps(payload)) - assert m_request_post.call_count == 1 - - variables = m_request_post.call_args[1]["variable_values"] - assert payload == variables - - @mock.patch( - "python_anvil.api_resources.mutations.create_etch_packet.create_unique_id" - ) - @mock.patch("gql.Client.execute") - def test_adding_unsupported_fields(m_request_post, m_create_unique, anvil): - m_create_unique.return_value = "signer-mock-generated" - # We are currently removing `None`s from the payload, so do that here too. - payload = { - k: v - for k, v in payloads.EXPECTED_ETCH_TEST_PAYLOAD_JSON.items() - if v is not None + anvil.request_graphql("{ x }") + + def it_is_also_called_query(anvil, graphql): + anvil.query("{ x }", {"a": 1}) + graphql.assert_called_once_with("{ x }", {"a": 1}) + + def it_is_also_called_mutate(anvil, graphql): + anvil.mutate("mutation { x }", {"a": 1}) + graphql.assert_called_once_with("mutation { x }", {"a": 1}) + + def it_mutates_with_a_mutation_class(anvil, graphql): + builder = CreateEtchPacket(name="Packet") + anvil.mutate(builder, {"name": "Packet"}) + graphql.assert_called_once_with( + mutations.create_etch_packet.generate_mutation(), {"name": "Packet"} + ) + + +def describe_create_etch_packet(): + expected_data = { + "name": "Packet name", + "signatureEmailSubject": "The subject", + "signers": [], + "files": [], + "isDraft": False, + "isTest": True, + "data": {"payloads": {}}, + "signaturePageOptions": {}, + } + + def it_requires_a_payload(anvil, graphql): + with pytest.raises(TypeError): + anvil.create_etch_packet() + with pytest.raises(ValueError): + anvil.create_etch_packet("not a payload") # type: ignore[arg-type] + assert graphql.call_count == 0 + + def it_sends_a_mutation_builder(anvil, graphql): + graphql.return_value = {"createEtchPacket": {"eid": "p1"}} + builder = CreateEtchPacket( + name="Packet name", signature_email_subject="The subject" + ) + + data = anvil.create_etch_packet(payload=builder) + + assert data == {"createEtchPacket": {"eid": "p1"}} + mutation, variables = graphql.call_args.args + assert mutation == mutations.create_etch_packet.generate_mutation() + assert variables == expected_data + + def it_sends_the_builder_options(anvil, graphql): + builder = CreateEtchPacket( + is_test=False, + is_draft=True, + name="Packet name", + signature_email_subject="The subject", + organization_eid="org1", + allow_updates=True, + ) + anvil.create_etch_packet(builder) + variables = graphql.call_args.args[1] + assert variables == { + **expected_data, + "isTest": False, + "isDraft": True, + "organizationEid": "org1", + "allowUpdates": True, + } + + def it_sends_a_dict(anvil, graphql): + anvil.create_etch_packet( + {"name": "Packet name", "signature_email_subject": "The subject"} + ) + assert graphql.call_args.args[1] == expected_data + + @mock.patch( + "python_anvil.api_resources.mutations.create_etch_packet.create_unique_id" + ) + def it_sends_a_dict_with_signers_and_files(create_unique_id, anvil, graphql): + create_unique_id.return_value = "signer-mock-generated" + anvil.create_etch_packet(payload=payloads.ETCH_TEST_PAYLOAD) + assert graphql.call_args.args[1] == payloads.EXPECTED_ETCH_TEST_PAYLOAD + + @mock.patch( + "python_anvil.api_resources.mutations.create_etch_packet.create_unique_id" + ) + def it_sends_json(create_unique_id, anvil, graphql): + create_unique_id.return_value = "signer-mock-generated" + # We are currently removing `None`s from the payload, so do that here too. + payload = { + k: v + for k, v in payloads.EXPECTED_ETCH_TEST_PAYLOAD_JSON.items() + if v is not None + } + + anvil.create_etch_packet(json=json.dumps(payload)) + + assert graphql.call_args.args[1] == payload + + def it_sends_a_payload_model_with_extra_fields(anvil, graphql): + payload = { + k: v + for k, v in payloads.EXPECTED_ETCH_TEST_PAYLOAD_JSON.items() + if v is not None + } + cep = CreateEtchPacketPayload.model_validate(payload) + cep.newFeature = True # type: ignore[attr-defined] + + anvil.create_etch_packet(payload=cep) + + variables = graphql.call_args.args[1] + assert variables == cep.model_dump(by_alias=True, exclude_none=True) + assert variables["newFeature"] is True + + def it_sends_a_custom_response_query(anvil, graphql): + anvil.create_etch_packet( + {"name": "Packet name"}, response_query="{ eid status }" + ) + assert graphql.call_args.args[ + 0 + ] == mutations.create_etch_packet.generate_mutation("{ eid status }") + + def it_sends_a_custom_mutation(anvil, graphql): + anvil.create_etch_packet( + {"name": "Packet name"}, + mutation="mutation Mine { createEtchPacket { eid } }", + ) + assert graphql.call_args.args[0] == "mutation Mine { createEtchPacket { eid } }" + + def it_leaves_prepared_files_for_the_multipart_upload(anvil, graphql): + file = Anvil.prepare_graphql_file(b"%PDF", filename="x.pdf") + anvil.create_etch_packet( + { + "name": "Packet", + "files": [{"id": "f", "title": "F", "file": file, "fields": []}], } - cep = CreateEtchPacketPayload.model_validate(payload) - cep.newFeature = True # type: ignore[attr-defined] - anvil.create_etch_packet(payload=cep) - assert m_request_post.call_count == 1 - - variables = m_request_post.call_args[1]["variable_values"] - assert cep.model_dump(by_alias=True, exclude_none=True) == variables - assert "newFeature" in variables - - def describe_forge_submit(): - expected_data = { - "forgeEid": "forge1234", - "payload": {"field1": "Some data"}, - "isTest": True, + ) + assert graphql.call_args.args[1]["files"][0]["file"] is file + + +def describe_get_etch_packet(): + def it_queries_the_packet(anvil, graphql): + graphql.return_value = {"etchPacket": {"eid": "p1", "status": "sent"}} + + packet = anvil.get_etch_packet("p1") + + assert packet == {"eid": "p1", "status": "sent"} + graphql.assert_called_once_with( + queries.etch_packet.generate_query(), {"eid": "p1"} + ) + + def it_sends_a_custom_response_query(anvil, graphql): + graphql.return_value = {"etchPacket": {}} + anvil.get_etch_packet("p1", response_query="{ eid }") + assert graphql.call_args.args[0] == queries.etch_packet.generate_query( + "{ eid }" + ) + + +def describe_generate_etch_signing_url(): + def it_returns_the_url(anvil, graphql): + graphql.return_value = { + "generateEtchSignURL": "https://app.useanvil.com/sign/abc" } - @mock.patch("gql.Client.execute") - def test_invalid_payload(m_request_post, anvil): - with pytest.raises(TypeError): - anvil.forge_submit({}) - assert m_request_post.call_count == 0 + url = anvil.generate_etch_signing_url( + signer_eid="someId", client_user_id="anotherId" + ) + + assert url == "https://app.useanvil.com/sign/abc" + graphql.assert_called_once_with( + mutations.generate_etch_sign_url.generate_mutation(), + {"signerEid": "someId", "clientUserId": "anotherId"}, + ) - @mock.patch("gql.Client.execute") - def test_minimum_valid_data_forge_eid(m_request_post, anvil): - payload = ForgeSubmitPayload( + def it_is_also_called_generate_etch_sign_url(anvil, graphql): + graphql.return_value = {"generateEtchSignURL": "https://x"} + assert anvil.generate_etch_sign_url("s", "c") == "https://x" + + +def describe_forge_submit(): + expected_data = { + "forgeEid": "forge1234", + "payload": {"field1": "Some data"}, + "isTest": True, + } + + def it_requires_a_payload(anvil, graphql): + with pytest.raises(TypeError): + anvil.forge_submit() + with pytest.raises((TypeError, ValueError)): + anvil.forge_submit({}) + with pytest.raises(ValueError): + anvil.forge_submit("not a payload") # type: ignore[arg-type] + assert graphql.call_count == 0 + + def it_sends_the_minimum_payload_model(anvil, graphql): + anvil.forge_submit( + payload=ForgeSubmitPayload( forge_eid="forge1234", payload={"field1": "Some data"} ) - anvil.forge_submit(payload=payload) - assert m_request_post.call_count == 1 - assert {"variable_values": expected_data} in m_request_post.call_args - - @mock.patch("gql.Client.execute") - def test_minimum_valid_data_submission(m_request_post, anvil): - payload = ForgeSubmitPayload( - forge_eid="forge1234", - submission_eid="sub1234", - weld_data_eid="wd1234", - payload={"field1": "Updated data"}, + ) + mutation, variables = graphql.call_args.args + assert mutation == mutations.forge_submit.generate_mutation() + assert variables == expected_data + + def it_sends_a_dict(anvil, graphql): + anvil.forge_submit( + {"forge_eid": "forge1234", "payload": {"field1": "Some data"}} + ) + assert graphql.call_args.args[1] == expected_data + + def it_sends_json(anvil, graphql): + anvil.forge_submit( + json=json.dumps( + {"forgeEid": "forge1234", "payload": {"field1": "Some data"}} ) + ) + assert graphql.call_args.args[1] == expected_data + + def it_sends_an_update_to_a_submission(anvil, graphql): + payload = ForgeSubmitPayload( + forge_eid="forge1234", + submission_eid="sub1234", + weld_data_eid="wd1234", + payload={"field1": "Updated data"}, + enforce_payload_valid_on_create=True, + ) + anvil.forge_submit(payload=payload) + assert graphql.call_args.args[1] == { + **expected_data, + "submissionEid": "sub1234", + "weldDataEid": "wd1234", + "payload": {"field1": "Updated data"}, + "enforcePayloadValidOnCreate": True, + } - # We copy `expected_data` here as it can cause a race condition - # in other tests that use it. - _expected_data: MutableMapping[str, Any] = { - "variable_values": { - **expected_data, - "submissionEid": "sub1234", - "weldDataEid": "wd1234", - "payload": {"field1": "Updated data"}, - } + def it_sends_a_custom_response_query_or_mutation(anvil, graphql): + anvil.forge_submit({"forge_eid": "f", "payload": {}}, response_query="{ eid }") + assert graphql.call_args.args[0] == mutations.forge_submit.generate_mutation( + "{ eid }" + ) + anvil.forge_submit( + {"forge_eid": "f", "payload": {}}, + mutation="mutation Mine { forgeSubmit { eid } }", + ) + assert graphql.call_args.args[0] == "mutation Mine { forgeSubmit { eid } }" + + +def describe_remove_weld_data(): + def it_sends_the_mutation(anvil, graphql): + graphql.return_value = {"removeWeldData": True} + + assert anvil.remove_weld_data("wd1") is True + graphql.assert_called_once_with( + mutations.remove_weld_data.generate_mutation(), {"eid": "wd1"} + ) + + def it_sends_a_custom_mutation(anvil, graphql): + graphql.return_value = {"removeWeldData": False} + assert ( + anvil.remove_weld_data("wd1", mutation="mutation Mine { removeWeldData }") + is False + ) + assert graphql.call_args.args[0] == "mutation Mine { removeWeldData }" + + +def describe_convenience_queries(): + def it_gets_the_current_user(anvil, graphql): + graphql.return_value = {"currentUser": {"name": "Cameron"}} + assert anvil.get_current_user() == {"name": "Cameron"} + + def it_gets_a_cast(anvil, graphql): + graphql.return_value = {"cast": {"eid": "c1"}} + assert anvil.get_cast("c1") == {"eid": "c1"} + assert 'cast(eid: "c1")' in graphql.call_args.args[0] + assert "eid title fieldInfo" in graphql.call_args.args[0] + + def it_gets_a_cast_version_with_fields(anvil, graphql): + graphql.return_value = {"cast": {}} + anvil.get_cast("c1", fields=["eid", "name"], version_number=-1) + query = graphql.call_args.args[0] + assert 'cast(eid: "c1", versionNumber: -1)' in query + assert "eid name" in query + + def it_gets_the_casts_of_every_organization(anvil, graphql): + graphql.return_value = { + "currentUser": { + "organizations": [ + {"casts": {"items": [{"eid": "a"}]}}, + {"casts": {"items": [{"eid": "b"}]}}, + ] + } + } + assert anvil.get_casts() == [{"eid": "a"}, {"eid": "b"}] + assert "casts(isTemplate: true, limit: 500)" in graphql.call_args.args[0] + anvil.get_casts(show_all=True) + assert "casts(isTemplate: null, limit: 500)" in graphql.call_args.args[0] + + def it_gets_the_welds_of_every_organization(anvil, graphql): + graphql.return_value = { + "currentUser": { + "organizations": [ + {"welds": {"items": [{"eid": "a"}]}}, + {"welds": {"items": []}}, + ] } + } + assert anvil.get_welds() == [{"eid": "a"}] + assert "welds(limit: 500)" in graphql.call_args.args[0] - anvil.forge_submit(payload=payload) - assert m_request_post.call_count == 1 - assert _expected_data in m_request_post.call_args - - def describe_rest_request_absolute_url_behavior(): - @pytest.mark.parametrize( - "url, should_raise", - [ - ("some/relative/path", True), - ("https://external.example.com/full/path/file.pdf", True), - *[(host + "/some-endpoint", False) for host in VALID_HOSTS], - ], - ) - @mock.patch("python_anvil.api_resources.requests.AnvilRequest._request") - def test_get_behavior(mock_request, anvil, url, should_raise): - mock_request.return_value = (b"fake_content", 200, {}) - rest_client = anvil.request_fully_qualified() - - if should_raise: - with pytest.raises( - ValueError, - match=r"URL must start with one of: https://app\.useanvil\.com", - ): - rest_client.get(url) - else: - rest_client.get(url) - mock_request.assert_called_once_with( - "GET", - url, - params=None, - retry=True, - ) - - @pytest.mark.parametrize( - "url, should_raise", - [ - ("some/relative/path", True), - ("https://external.example.com/full/path/file.pdf", True), - *[(host + "/some-endpoint", False) for host in VALID_HOSTS], - ], - ) - @mock.patch("python_anvil.api_resources.requests.AnvilRequest._request") - def test_post_behavior(mock_request, anvil, url, should_raise): - mock_request.return_value = (b"fake_content", 200, {}) - rest_client = anvil.request_fully_qualified() - - if should_raise: - with pytest.raises( - ValueError, - match=r"URL must start with one of: https://app\.useanvil\.com", - ): - rest_client.post(url, data={}) - else: - rest_client.post(url, data={}) - mock_request.assert_called_once_with( - "POST", - url, - json={}, - retry=True, - params=None, - ) + def it_gets_a_weld(anvil, graphql): + graphql.return_value = {"weld": {"eid": "w1"}} + assert anvil.get_weld("w1") == {"eid": "w1"} + assert graphql.call_args.args[1] == {"eid": "w1"} + + +def describe_errors(): + def it_propagates_request_errors(anvil, graphql): + graphql.side_effect = AnvilRequestError( + [{"message": "Not Found"}], status_code=200 + ) + with pytest.raises(AnvilRequestError, match=r"200: Not Found"): + anvil.get_etch_packet("nope") diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 00000000..e5d3e849 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,72 @@ +import base64 + +import pytest + +from python_anvil.auth import build_auth_header, is_jwt + + +def b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode().rstrip("=") + + +JWT_HEADER = b64url(b'{"alg":"HS256","typ":"JWT"}') +JWT_PAYLOAD = b64url(b'{"sub":"123"}') +JWT = f"{JWT_HEADER}.{JWT_PAYLOAD}.c2ln" + + +def describe_build_auth_header(): + def it_builds_basic_auth_with_a_trailing_colon_after_the_api_key(): + header = build_auth_header(api_key="my-api-key") + assert header == "Basic " + base64.b64encode(b"my-api-key:").decode() + + def it_builds_bearer_auth_with_a_base64_encoded_access_token(): + header = build_auth_header(access_token="my-access-token") + assert header == "Bearer " + base64.b64encode(b"my-access-token").decode() + + def it_does_not_put_a_trailing_colon_after_the_access_token(): + header = build_auth_header(access_token="tok") + assert base64.b64decode(header.removeprefix("Bearer ")) == b"tok" + + def it_sends_a_jwt_access_token_as_is(): + assert build_auth_header(access_token=JWT) == f"Bearer {JWT}" + + def it_sends_an_unsigned_jwt_as_is(): + unsigned = JWT.rsplit(".", 1)[0] + "." + assert build_auth_header(access_token=unsigned) == f"Bearer {unsigned}" + + def it_base64_encodes_a_token_that_only_looks_like_a_jwt(): + token = "aaa.bbb.ccc" + assert ( + build_auth_header(access_token=token) + == "Bearer " + base64.b64encode(token.encode()).decode() + ) + + def it_prefers_the_access_token_when_both_are_given(): + assert build_auth_header(api_key="key", access_token="tok").startswith( + "Bearer " + ) + + def it_requires_one_of_them(): + with pytest.raises(ValueError, match="`api_key` or `access_token` is required"): + build_auth_header() + with pytest.raises(ValueError): + build_auth_header(api_key="", access_token="") + + +def describe_is_jwt(): + def it_is_true_for_three_base64url_segments_with_a_json_header_and_payload(): + assert is_jwt(JWT) + + def it_is_false_without_an_alg_in_the_header(): + header = b64url(b'{"typ":"JWT"}') + assert not is_jwt(f"{header}.{b64url(b'{}')}.sig") + + def it_is_false_when_a_segment_is_not_json(): + assert not is_jwt("aaa.bbb.ccc") + assert not is_jwt(f"{b64url(b'[1]')}.{b64url(b'{}')}.sig") + + def it_is_false_for_the_wrong_shape(): + assert not is_jwt("one.two") + assert not is_jwt("") + assert not is_jwt(None) + assert not is_jwt("has spaces.in it.x") diff --git a/tests/test_cli.py b/tests/test_cli.py index 3a727322..33356027 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,6 +5,7 @@ from click.testing import CliRunner from python_anvil.cli import cli +from python_anvil.errors import AnvilRequestError @pytest.fixture @@ -12,98 +13,124 @@ def runner(): return CliRunner() -def set_key(monkeypatch): +@pytest.fixture +def api_key(monkeypatch): monkeypatch.setenv("ANVIL_API_KEY", "MY_KEY") + monkeypatch.delenv("ANVIL_BASE_URL", raising=False) def describe_cli(): @mock.patch("python_anvil.api.Anvil.get_current_user") - def it_handles_no_key(anvil, runner): + def it_requires_an_api_key(get_current_user, runner, monkeypatch): + monkeypatch.delenv("ANVIL_API_KEY", raising=False) res = runner.invoke(cli, ["current-user"]) - assert anvil.call_count == 0 + assert get_current_user.call_count == 0 assert isinstance(res.exception, ValueError) @mock.patch("python_anvil.api.Anvil.get_current_user") - def it_handles_key(anvil, runner, monkeypatch): - set_key(monkeypatch) + def it_sends_requests_to_production_by_default(get_current_user, runner, api_key): + with mock.patch( + "python_anvil.cli.Anvil", wraps=lambda *a, **kw: mock.Mock() + ) as anvil_class: + runner.invoke(cli, ["current-user"]) + anvil_class.assert_called_once_with( + "MY_KEY", base_url="https://app.useanvil.com" + ) + + def it_sends_requests_to_anvil_base_url(runner, api_key, monkeypatch): + monkeypatch.setenv("ANVIL_BASE_URL", "https://staging.useanvil.com") + with mock.patch( + "python_anvil.cli.Anvil", wraps=lambda *a, **kw: mock.Mock() + ) as anvil_class: + runner.invoke(cli, ["current-user"]) + anvil_class.assert_called_once_with( + "MY_KEY", base_url="https://staging.useanvil.com" + ) + + @mock.patch("python_anvil.api.Anvil.get_current_user") + def it_reports_api_errors_in_one_line(get_current_user, runner, api_key): + get_current_user.side_effect = AnvilRequestError( + [{"message": "Forbidden"}], status_code=403 + ) + res = runner.invoke(cli, ["current-user"]) - assert anvil.call_count == 1 - assert not isinstance(res.exception, ValueError) + + assert res.exit_code == 1 + assert "Error: 403: Forbidden" in res.output def describe_current_user(): - @mock.patch("python_anvil.api.Anvil.query") - def it_queries(query, runner, monkeypatch): - set_key(monkeypatch) - query.return_value = {"currentUser": {"name": "Cameron"}} + @mock.patch("python_anvil.api.Anvil.request_graphql") + def it_prints_the_user(request_graphql, runner, api_key): + request_graphql.return_value = {"currentUser": {"name": "Cameron"}} res = runner.invoke(cli, ["current-user"]) - assert "{'name': 'Cameron'}" in res.output - assert "User data:" in res.output - assert query.call_count == 1 - @mock.patch("python_anvil.api.Anvil.query") - def it_handles_headers(query, runner, monkeypatch): - set_key(monkeypatch) - query.return_value = { - "response": {"currentUser": {"name": "Cameron"}}, - "headers": {"Header-1": "val1", "Header-2": "val2"}, - } - - res = runner.invoke(cli, ["--debug", "current-user"]) assert "{'name': 'Cameron'}" in res.output assert "User data:" in res.output - assert "{'Header-1': 'val1'," in res.output - assert query.call_count == 1 + assert request_graphql.call_count == 1 def describe_generate_pdf(): @mock.patch("python_anvil.api.Anvil.generate_pdf") - def it_handles_files(generate_pdf, runner, monkeypatch): - set_key(monkeypatch) - - in_data = json.dumps({"data": "", "title": "My Title"}) - generate_pdf.return_value = "Some bytes" + def it_writes_the_pdf(generate_pdf, runner, api_key): + in_data = json.dumps({"data": [{"content": "x"}], "title": "My Title"}) + generate_pdf.return_value = b"Some bytes" mock_open = mock.mock_open(read_data=in_data) with mock.patch("click.open_file", mock_open) as m: - runner.invoke(cli, ["generate-pdf", "-i", "infile", "-o", "outfile"]) - generate_pdf.assert_called_once_with(in_data, debug=False) - m().write.assert_called_once_with("Some bytes") + res = runner.invoke( + cli, ["generate-pdf", "-i", "infile", "-o", "outfile"] + ) - def describe_gql_query(): - @mock.patch("python_anvil.api.Anvil.query") - def it_works_query_only(query, runner, monkeypatch): - set_key(monkeypatch) + assert res.exit_code == 0, res.output + generate_pdf.assert_called_once_with(in_data) + m().write.assert_called_once_with(b"Some bytes") + + def describe_generate_etch_url(): + @mock.patch("python_anvil.api.Anvil.generate_etch_signing_url") + def it_prints_the_url(generate, runner, api_key): + generate.return_value = "https://app.useanvil.com/sign/abc" + + res = runner.invoke( + cli, ["generate-etch-url", "-c", "user1", "-s", "signer1"] + ) - query.return_value = { - "eid": "abc123", - "name": "Some User", - } + assert "Signing URL is: https://app.useanvil.com/sign/abc" in res.output + generate.assert_called_once_with( + signer_eid="signer1", client_user_id="user1" + ) - query_str = """ - query SomeQuery { - someQuery { eid name } - } - """ + def describe_create_etch(): + @mock.patch("python_anvil.api.Anvil.create_etch_packet") + def it_prints_the_packet_eid(create, runner, api_key, tmp_path): + create.return_value = {"createEtchPacket": {"eid": "packet123"}} + payload = tmp_path / "payload.json" + payload.write_text(json.dumps({"name": "x", "signers": [], "files": []})) - runner.invoke(cli, ["gql-query", "-q", query_str]) - query.assert_called_once_with(query_str, variables=None, debug=False) + res = runner.invoke(cli, ["create-etch", "-p", str(payload)]) + assert "Etch packet created with id: packet123" in res.output + create.assert_called_once_with(json=payload.read_bytes()) + + def describe_gql_query(): @mock.patch("python_anvil.api.Anvil.query") - def it_works_query_and_variables(query, runner, monkeypatch): - set_key(monkeypatch) + def it_works_with_a_query_only(query, runner, api_key): + query.return_value = {"eid": "abc123", "name": "Some User"} + query_str = "query SomeQuery { someQuery { eid name } }" - query.return_value = { - "eid": "abc123", - "name": "Some User", - } + res = runner.invoke(cli, ["gql-query", "-q", query_str]) - query_str = """ - query SomeQuery ($eid: String) { - someQuery(eid: $eid) { eid name } - } - """ + query.assert_called_once_with(query_str, variables=None) + assert json.loads(res.output) == {"eid": "abc123", "name": "Some User"} + + @mock.patch("python_anvil.api.Anvil.query") + def it_parses_the_variables_as_json(query, runner, api_key): + query.return_value = {} + query_str = ( + "query SomeQuery ($eid: String) { someQuery(eid: $eid) { eid name } }" + ) - variables = json.dumps({"eid": "abc123"}) + runner.invoke( + cli, ["gql-query", "-q", query_str, "-v", json.dumps({"eid": "abc123"})] + ) - runner.invoke(cli, ["gql-query", "-q", query_str, "-v", variables]) - query.assert_called_once_with(query_str, variables=variables, debug=False) + query.assert_called_once_with(query_str, variables={"eid": "abc123"}) diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 00000000..da802f59 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,137 @@ +import json + +import pytest + +from python_anvil.errors import ( + AnvilError, + AnvilRequestError, + format_error, + looks_like_json_error, + normalize_exception, + normalize_json_errors, +) +from python_anvil.exceptions import AnvilException, AnvilRequestException + + +def describe_looks_like_json_error(): + def it_is_true_for_a_graphql_errors_list(): + assert looks_like_json_error({"errors": [{"message": "Nope"}]}) + + def it_is_true_for_a_rest_error_with_a_message(): + assert looks_like_json_error({"message": "PDF did not generate"}) + + def it_is_true_for_a_rest_error_with_only_a_name(): + assert looks_like_json_error({"name": "ValidationError", "fields": []}) + + def it_is_false_for_successful_data(): + assert not looks_like_json_error({"data": {"currentUser": {"eid": "abc"}}}) + + def it_is_false_for_an_empty_object_or_no_json(): + assert not looks_like_json_error({}) + assert not looks_like_json_error(None) + assert not looks_like_json_error([1, 2]) + + +def describe_normalize_json_errors(): + def it_returns_the_graphql_errors_as_they_are(): + errors = [ + { + "message": "Not Found", + "path": ["etchPacket"], + "locations": [{"line": 2, "column": 3}], + } + ] + assert normalize_json_errors({"errors": errors}) == errors + + def it_wraps_a_single_rest_error_in_a_list(): + error = { + "name": "AssertionError", + "message": "PDF did not generate properly from given HTML!", + } + assert normalize_json_errors(error) == [error] + + def it_keeps_field_level_details_on_validation_errors(): + error = { + "name": "ValidationError", + "fields": [{"message": "Required", "property": "data"}], + } + assert normalize_json_errors(error) == [error] + + def it_falls_back_to_the_status_text_when_there_is_no_json(): + assert normalize_json_errors(None, "Bad Gateway") == [ + {"name": "Bad Gateway", "message": "Bad Gateway"} + ] + + def it_falls_back_to_the_status_text_when_the_json_has_no_error_fields(): + assert normalize_json_errors({"foo": "bar"}, "Not Found") == [ + {"name": "Not Found", "message": "Not Found"} + ] + + def it_defaults_to_unknown_error_without_a_status_text(): + assert normalize_json_errors(None) == [ + {"name": "Unknown Error", "message": "Unknown Error"} + ] + assert normalize_json_errors(None, "") == [ + {"name": "Unknown Error", "message": "Unknown Error"} + ] + + +def describe_normalize_exception(): + def it_picks_the_name_and_message_off_an_exception(): + try: + json.loads("") + except ValueError as e: + assert normalize_exception(e) == [ + {"name": "JSONDecodeError", "message": str(e)} + ] + + def it_falls_back_to_the_status_text_when_there_is_no_exception(): + assert normalize_exception(None, "Bad Gateway") == [ + {"name": "Bad Gateway", "message": "Bad Gateway"} + ] + + +def describe_anvil_request_error(): + def it_describes_every_error_in_its_message(): + error = AnvilRequestError( + [{"message": "First"}, {"name": "Second", "message": "thing"}], + status_code=400, + ) + assert str(error) == "400: First; Second: thing" + assert error.message == "First" + assert error.status_code == 400 + assert error.headers == {} + assert error.data is None + + def it_includes_validation_fields(): + error = AnvilRequestError( + [ + { + "name": "ValidationError", + "fields": [ + {"message": "Required", "property": "data"}, + {"message": "Too long"}, + ], + } + ], + status_code=400, + ) + assert str(error) == "400: ValidationError (data: Required, Too long)" + + def it_is_an_anvil_error_and_an_exception(): + error = AnvilRequestError([], status_code=500) + assert isinstance(error, AnvilError) + assert isinstance(error, Exception) + assert error.message == "Unknown Error" + + def it_keeps_the_old_exception_names(): + assert AnvilException is AnvilError + assert AnvilRequestException is AnvilRequestError + with pytest.raises(AnvilRequestException): + raise AnvilRequestError([{"message": "x"}], status_code=400) + + +def describe_describe_error(): + def it_handles_an_error_with_nothing_useful(): + assert format_error({}) == "Unknown Error" + assert format_error({"fields": "not a list"}) == "Unknown Error" diff --git a/tests/test_graphql_operations.py b/tests/test_graphql_operations.py new file mode 100644 index 00000000..48f0a619 --- /dev/null +++ b/tests/test_graphql_operations.py @@ -0,0 +1,114 @@ +"""The client's queries and mutations must match the API schema. + +The schema is the one @anvilco/types ships, refreshed with `make update-schema`. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest import mock + +import pytest +from graphql import GraphQLSchema, build_schema, parse, validate + +from python_anvil import Anvil +from python_anvil.api_resources.mutations import ( + CreateEtchPacket, + ForgeSubmit, + GenerateEtchSigningURL, +) +from python_anvil.graphql import mutations, queries + + +SCHEMA_PATH = Path(__file__).parent.parent / "schema" / "anvil_schema.graphql" + + +@pytest.fixture(scope="module") +def schema() -> GraphQLSchema: + return build_schema(SCHEMA_PATH.read_text()) + + +def assert_valid(schema: GraphQLSchema, document: str) -> None: + errors = validate(schema, parse(document)) + assert not errors, "\n".join(error.message for error in errors) + + +def describe_the_operations_module(): + @pytest.mark.parametrize( + "operation", + [ + queries.etch_packet.generate_query(), + mutations.create_etch_packet.generate_mutation(), + mutations.forge_submit.generate_mutation(), + mutations.generate_etch_sign_url.generate_mutation(), + mutations.remove_weld_data.generate_mutation(), + ], + ids=[ + "etchPacket", + "createEtchPacket", + "forgeSubmit", + "generateEtchSignURL", + "removeWeldData", + ], + ) + def it_matches_the_schema(schema, operation): + assert_valid(schema, operation) + + def it_accepts_a_response_query(schema): + assert_valid(schema, queries.etch_packet.generate_query("{ eid name }")) + assert_valid(schema, mutations.create_etch_packet.generate_mutation("{ eid }")) + assert_valid(schema, mutations.forge_submit.generate_mutation("{ eid }")) + + +def describe_the_mutation_builders(): + def it_uses_the_shared_mutations(schema): + assert ( + CreateEtchPacket(name="x").get_mutation() + == mutations.create_etch_packet.generate_mutation() + ) + assert ( + ForgeSubmit({}, forge_eid="x").get_mutation() + == mutations.forge_submit.generate_mutation() + ) + assert ( + GenerateEtchSigningURL("s", "c").get_mutation() + == mutations.generate_etch_sign_url.generate_mutation() + ) + assert_valid(schema, CreateEtchPacket(name="x").get_mutation("{ eid status }")) + + +def describe_the_convenience_queries(): + @pytest.fixture + def sent(): + """Every query the client's convenience methods send, by calling them with the transport mocked.""" + client = Anvil("key") + documents: list[str] = [] + + def capture(query, variables=None, **kwargs): + documents.append(query) + return { + "currentUser": { + "organizations": [{"casts": {"items": []}, "welds": {"items": []}}] + }, + "cast": {}, + "weld": {}, + } + + with mock.patch.object(client, "request_graphql", side_effect=capture): + client.get_current_user() + client.get_cast("cast123") + client.get_cast( + "cast123", + fields=["eid", "name"], + cast_args=[("versionNumber", "-1")], + ) + client.get_casts() + client.get_casts(show_all=True) + client.get_welds() + client.get_weld("weld123") + return documents + + def it_matches_the_schema(schema, sent): + assert len(sent) == 7 + for document in sent: + assert_valid(schema, document) diff --git a/tests/test_http.py b/tests/test_http.py index 80d8c4b3..bfe1a08a 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -1,144 +1,710 @@ -from unittest import mock +"""The client over a real HTTP connection, to a local server. See tests/server.py.""" + +from __future__ import annotations + +import base64 +import contextlib +import io +import threading +import time +from pathlib import Path import pytest +import requests + +from python_anvil import Anvil, AnvilRequestError, __version__ + + +ASSETS = Path(__file__).parent / "assets" +DUMMY_PDF = ASSETS / "dummy.pdf" + + +def decode_auth(header: str, scheme: str) -> str: + return base64.b64decode(header.removeprefix(f"{scheme} ")).decode() + + +def describe_base_url(): + def it_sends_fill_pdf_to_the_rest_api(server, client): + server.respond( + {"body": b"%PDF-1.7", "headers": {"content-type": "application/pdf"}} + ) + + data = client.fill_pdf("tpl123", {"data": {"a": 1}}) + + assert data == b"%PDF-1.7" + [request] = server.requests + assert request.method == "POST" + assert request.path == "/api/v1/fill/tpl123.pdf" + assert request.headers["content-type"] == "application/json" + assert request.json == {"data": {"a": 1}} + + def it_passes_version_latest_through_as_a_query_parameter(server, client): + server.respond( + {"body": b"%PDF-1.7", "headers": {"content-type": "application/pdf"}} + ) + + client.fill_pdf( + "tpl123", {"data": {"a": 1}}, version_number=Anvil.VERSION_LATEST + ) + + assert server.requests[0].path == "/api/v1/fill/tpl123.pdf?versionNumber=-1" + + def it_sends_generate_pdf_to_the_rest_api(server, client): + server.respond( + {"body": b"%PDF-1.7", "headers": {"content-type": "application/pdf"}} + ) + + data = client.generate_pdf({"data": [{"content": "hi"}]}) + + assert data == b"%PDF-1.7" + [request] = server.requests + assert request.path == "/api/v1/generate-pdf" + assert request.json == {"data": [{"content": "hi"}], "type": "markdown"} + + def it_sends_download_documents_to_the_document_group_zip(server, client): + server.respond( + {"body": b"PK\x03\x04", "headers": {"content-type": "application/zip"}} + ) + + data = client.download_documents("dg123") + + assert data == b"PK\x03\x04" + [request] = server.requests + assert request.method == "GET" + assert request.path == "/api/document-group/dg123.zip" + + def it_sends_graphql_requests_to_graphql(server, client): + server.respond({"json": {"data": {"forgeSubmit": {"eid": "sub1"}}}}) + + data = client.forge_submit({"forge_eid": "forge123", "payload": {}}) + + assert data == {"forgeSubmit": {"eid": "sub1"}} + [request] = server.requests + assert request.path == "/graphql" + assert request.headers["content-type"] == "application/json" + body = request.json + assert "mutation ForgeSubmit" in body["query"] + assert body["variables"] == { + "forgeEid": "forge123", + "payload": {}, + "isTest": True, + } + + def it_uses_a_full_url_as_is_when_it_is_on_the_base_url(server, client): + server.respond({"json": {"ok": True}}) + + client.request_rest("GET", f"{server.base_url}/api/v1/something") + + assert server.requests[0].path == "/api/v1/something" + + def it_accepts_a_base_url_with_a_trailing_slash(server, make_client): + client = make_client(base_url=server.base_url + "/") + server.respond({"json": {"ok": True}}) + + client.request_rest("GET", "/api/v1/something") + + assert client.base_url == server.base_url + assert server.requests[0].path == "/api/v1/something" + + def it_rejects_a_base_url_that_is_not_a_url(): + with pytest.raises(ValueError, match="base_url"): + Anvil("key", base_url="app.useanvil.com") + + +def describe_headers(): + def it_sends_basic_auth_with_the_api_key_and_a_trailing_colon(server, make_client): + client = make_client(api_key="my-api-key") + + client.request_rest("GET", "/api/v1/x") + + auth = server.requests[0].headers["authorization"] + assert auth.startswith("Basic ") + assert decode_auth(auth, "Basic") == "my-api-key:" + + def it_sends_bearer_auth_with_the_access_token(server, make_client): + client = make_client(api_key=None, access_token="my-access-token") + + client.request_rest("GET", "/api/v1/x") + + auth = server.requests[0].headers["authorization"] + assert auth.startswith("Bearer ") + assert decode_auth(auth, "Bearer") == "my-access-token" + + def it_sends_a_jwt_access_token_as_is(server, make_client): + header = ( + base64.urlsafe_b64encode(b'{"alg":"HS256","typ":"JWT"}') + .decode() + .rstrip("=") + ) + payload = base64.urlsafe_b64encode(b'{"sub":"user1"}').decode().rstrip("=") + jwt = f"{header}.{payload}.signature" + client = make_client(api_key=None, access_token=jwt) + + client.request_rest("GET", "/api/v1/x") + + assert server.requests[0].headers["authorization"] == f"Bearer {jwt}" + + def it_sends_the_default_user_agent(server, client): + client.request_rest("GET", "/api/v1/x") + + assert server.requests[0].headers["user-agent"] == f"python-anvil/{__version__}" + + def it_sends_a_custom_user_agent(server, make_client): + client = make_client(user_agent="my-app/1.0") + + client.request_rest("GET", "/api/v1/x") + + assert server.requests[0].headers["user-agent"] == "my-app/1.0" + + def it_sends_extra_headers(server, client): + client.request_rest("GET", "/api/v1/x", headers={"X-Extra": "yes"}) + + assert server.requests[0].headers["x-extra"] == "yes" + + +def describe_rate_limiting(): + def it_updates_the_limiter_from_the_first_response_headers(server, make_client): + client = make_client(request_limit=1, request_limit_ms=1000) + server.respond( + { + "json": {}, + "headers": { + "x-ratelimit-limit": "7", + "x-ratelimit-interval-ms": "3000", + }, + } + ) + + client.request_rest("GET", "/api/v1/x") + + limiter = client.client.rate_limiter + assert limiter.has_set_from_response + assert (limiter.limit, limiter.interval_ms) == (7, 3000) + + def it_only_reads_the_limits_from_the_first_response(server, client): + server.respond_in_turn( + { + "json": {}, + "headers": { + "x-ratelimit-limit": "7", + "x-ratelimit-interval-ms": "3000", + }, + }, + { + "json": {}, + "headers": {"x-ratelimit-limit": "2", "x-ratelimit-interval-ms": "100"}, + }, + ) + + client.request_rest("GET", "/api/v1/x") + client.request_rest("GET", "/api/v1/x") + + limiter = client.client.rate_limiter + assert (limiter.limit, limiter.interval_ms) == (7, 3000) + + def it_keeps_the_starting_limits_when_the_response_has_no_rate_limit_headers( + server, make_client + ): + client = make_client(request_limit=4, request_limit_ms=500) + server.respond( + { + "json": {}, + "headers": {"x-ratelimit-limit": "", "x-ratelimit-interval-ms": ""}, + } + ) + + client.request_rest("GET", "/api/v1/x") + + limiter = client.client.rate_limiter + assert limiter.has_set_from_response + assert (limiter.limit, limiter.interval_ms) == (4, 500) + + def it_holds_concurrent_requests_until_the_first_response_sets_the_limits( + server, client + ): + server.respond_in_turn( + {"json": {}, "delay": 0.2, "headers": {"x-ratelimit-limit": "10"}}, + {"json": {}}, + ) + + threads = [ + threading.Thread(target=client.request_rest, args=("GET", "/api/v1/x")) + for _ in range(3) + ] + for thread in threads: + thread.start() + time.sleep(0.01) + for thread in threads: + thread.join() + + first, *rest = server.requests + assert len(rest) == 2 + for request in rest: + assert request.received_at >= first.responded_at + + def it_never_sends_more_requests_in_any_interval_than_the_server_allows( + server, make_client + ): + client = make_client(request_limit=2, request_limit_ms=200) + server.respond( + { + "json": {}, + "headers": {"x-ratelimit-limit": "2", "x-ratelimit-interval-ms": "200"}, + } + ) + + threads = [ + threading.Thread(target=client.request_rest, args=("GET", "/api/v1/x")) + for _ in range(6) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + times = sorted(request.received_at for request in server.requests) + assert len(times) == 6 + for i in range(len(times) - 2): + # The third request after any one must be at least an interval later + assert times[i + 2] - times[i] >= 0.2 + + def it_sends_the_first_request_right_away(server, make_client): + client = make_client(request_limit=1, request_limit_ms=1000) + + started = time.monotonic() + client.request_rest("GET", "/api/v1/x") + + assert time.monotonic() - started < 0.5 + + def it_does_not_hold_back_requests_that_are_within_the_limit(server, make_client): + client = make_client(request_limit=5, request_limit_ms=1000) + server.respond( + { + "json": {}, + "headers": { + "x-ratelimit-limit": "5", + "x-ratelimit-interval-ms": "1000", + }, + } + ) + + started = time.monotonic() + for _ in range(5): + client.request_rest("GET", "/api/v1/x") + + assert time.monotonic() - started < 0.5 + + def it_lets_waiting_requests_go_ahead_when_the_first_request_fails( + server, make_client + ): + # A port nothing listens on, then the real server + client = make_client(request_limit=10) + client.client.base_url = "http://127.0.0.1:1" + + def first_fails(): + with contextlib.suppress(requests.ConnectionError): + client.request_rest("GET", "/api/v1/x") + + failing = threading.Thread(target=first_fails) + failing.start() + failing.join() + + client.client.base_url = server.base_url + client.request_rest("GET", "/api/v1/x") + + assert len(server.requests) == 1 + assert client.client.rate_limiter.has_set_from_response + + def it_falls_back_to_one_request_per_second_when_the_starting_limits_are_invalid(): + client = Anvil("key", request_limit=0, request_limit_ms=-5) + limiter = client.client.rate_limiter + assert (limiter.limit, limiter.interval_ms) == (1, 1000) + + def it_retries_a_429_after_retry_after_seconds_plus_a_buffer(server, client): + server.respond_in_turn( + { + "status": 429, + "json": {"message": "Too many"}, + "headers": {"retry-after": "0.2"}, + }, + {"json": {"ok": True}}, + ) + + data = client.request_rest("GET", "/api/v1/x") -from python_anvil.exceptions import AnvilRequestException -from python_anvil.http import HTTPClient - - -class HTTPResponse: - status_code = 200 - content = "" - headers: dict = {} - - -def describe_http_client(): - @pytest.fixture - def mock_response(): - class MockResponse: - status_code = 429 - headers = {"Retry-After": 1} - - return MockResponse - - def test_client(): - client = HTTPClient() - assert isinstance(client, HTTPClient) - - def describe_get_auth(): - def test_no_key(): - """Test that no key will raise an exception.""" - client = HTTPClient() - with pytest.raises(AttributeError): - client.get_auth() - - def test_key(): - key = "my_secret_ket!!!11!!" - client = HTTPClient(api_key=key) - assert client.get_auth() == key - - @mock.patch("python_anvil.http.b64encode") - def test_encoded_key(mock_b64): - key = "my_secret_ket!!!11!!" - client = HTTPClient(api_key=key) - client.get_auth(encode=True) - mock_b64.assert_called_once() - - def describe_request(): - @mock.patch("python_anvil.http.HTTPBasicAuth") - @mock.patch("python_anvil.http.HTTPClient.do_request") - def test_default_args(do_request, basic_auth): - basic_auth.return_value = "my_auth" - response = HTTPResponse() - do_request.return_value = response - - client = HTTPClient(api_key="my_key") - res = client.request("GET", "http://localhost") - - assert res == (response.content, response.status_code, response.headers) - do_request.assert_called_once_with( - "GET", - "http://localhost", - headers=None, - data=None, - auth="my_auth", - params=None, - retry=True, - files=None, + assert data == {"ok": True} + first, second = server.requests + assert second.received_at - first.responded_at >= 0.25 + + def it_retries_a_429_after_only_the_buffer_when_there_is_no_retry_after( + server, client + ): + server.respond_in_turn({"status": 429, "body": b""}, {"json": {"ok": True}}) + + data = client.request_rest("GET", "/api/v1/x") + + assert data == {"ok": True} + first, second = server.requests + assert 0.05 <= second.received_at - first.responded_at < 0.5 + + def it_keeps_retrying_until_the_request_is_not_rate_limited(server, client): + server.respond_in_turn( + {"status": 429, "body": b"", "headers": {"retry-after": "0"}}, + {"status": 429, "body": b"", "headers": {"retry-after": "0"}}, + {"json": {"ok": True}}, + ) + + assert client.request_rest("GET", "/api/v1/x") == {"ok": True} + assert len(server.requests) == 3 + + +def describe_errors(): + def it_raises_graphql_errors_from_a_200_response(server, client): + server.respond( + { + "json": { + "data": {"etchPacket": None}, + "errors": [{"message": "Not Found", "path": ["etchPacket"]}], + } + } + ) + + with pytest.raises(AnvilRequestError) as excinfo: + client.get_etch_packet("nope") + + error = excinfo.value + assert error.status_code == 200 + assert error.errors == [{"message": "Not Found", "path": ["etchPacket"]}] + assert error.data == {"etchPacket": None} + assert error.headers["x-ratelimit-limit"] == "10" + assert str(error) == "200: Not Found" + + def it_raises_a_rest_validation_error_with_its_fields(server, client): + server.respond( + { + "status": 400, + "json": { + "name": "ValidationError", + "status": 400, + "fields": [{"message": "Required", "property": "data"}], + }, + } + ) + + with pytest.raises(AnvilRequestError) as excinfo: + client.generate_pdf({"data": [{"content": "x"}]}) + + error = excinfo.value + assert error.status_code == 400 + assert error.errors[0]["name"] == "ValidationError" + assert error.errors[0]["fields"] == [ + {"message": "Required", "property": "data"} + ] + assert str(error) == "400: ValidationError (data: Required)" + + def it_raises_a_rest_error_with_a_message(server, client): + server.respond( + { + "status": 500, + "json": { + "name": "AssertionError", + "message": "PDF did not generate properly from given HTML!", + }, + } + ) + + with pytest.raises( + AnvilRequestError, match=r"500: AssertionError: PDF did not generate" + ): + client.generate_pdf({"type": "html", "data": {"html": "

"}}) + + def it_uses_the_status_text_for_a_non_json_error_response(server, client): + server.respond( + { + "status": 502, + "body": b"Bad Gateway", + "headers": {"content-type": "text/html"}, + } + ) + + with pytest.raises(AnvilRequestError) as excinfo: + client.request_rest("GET", "/api/v1/x") + + assert excinfo.value.status_code == 502 + assert excinfo.value.errors == [ + {"name": "Bad Gateway", "message": "Bad Gateway"} + ] + + def it_uses_the_status_text_for_an_empty_error_response(server, client): + server.respond({"status": 500, "body": b""}) + + with pytest.raises(AnvilRequestError) as excinfo: + client.request_rest("GET", "/api/v1/x") + + assert excinfo.value.errors == [ + {"name": "Internal Server Error", "message": "Internal Server Error"} + ] + + def it_raises_a_parsing_error_for_an_empty_json_response(server, client): + server.respond( + { + "status": 200, + "body": b"", + "headers": {"content-type": "application/json"}, + } + ) + + with pytest.raises(AnvilRequestError) as excinfo: + client.request_rest("GET", "/api/v1/x") + + [error] = excinfo.value.errors + assert error["name"] == "JSONDecodeError" + assert excinfo.value.status_code == 200 + + def it_raises_when_the_server_cannot_be_reached(): + client = Anvil("key", base_url="http://127.0.0.1:1", timeout=1) + + with pytest.raises(requests.ConnectionError): + client.request_rest("GET", "/api/v1/x") + + def it_treats_a_json_error_as_an_error_even_when_bytes_were_asked_for( + server, client + ): + server.respond( + {"status": 403, "json": {"message": "Forbidden", "name": "Forbidden"}} + ) + + with pytest.raises(AnvilRequestError, match="403: Forbidden"): + client.download_documents("dg123") + + +def describe_file_uploads(): + def it_sends_prepared_bytes_as_a_multipart_graphql_upload(server, client): + file = Anvil.prepare_graphql_file( + b"%PDF-1.7 bytes", filename="from-bytes.pdf", mimetype="application/pdf" + ) + + client.create_etch_packet( + { + "name": "Packet", + "files": [{"id": "file1", "title": "File", "file": file, "fields": []}], + } + ) + + [request] = server.requests + assert request.path == "/graphql" + parts = request.multipart_parts() + + operations = parts["operations"].json + assert operations["variables"]["files"][0]["file"] is None + assert operations["variables"]["files"][0]["id"] == "file1" + assert "createEtchPacket" in operations["query"] + + assert parts["map"].json == {"1": ["variables.files.0.file"]} + + file_part = parts["1"] + assert file_part.filename == "from-bytes.pdf" + assert file_part.content_type == "application/pdf" + assert file_part.body == b"%PDF-1.7 bytes" + + def it_sends_a_file_object_using_its_own_filename(server, client): + with DUMMY_PDF.open("rb") as file: + client.request_graphql( + "mutation Upload($file: Upload!) { upload(file: $file) }", + {"input": {"file": file}}, ) - def describe_do_request(): - @mock.patch("python_anvil.http.requests.Session") - def test_default_args(session): - mock_session = mock.MagicMock() - session.return_value = mock_session - client = HTTPClient(api_key="my_key") - client.do_request("GET", "http://localhost") - - # Should only be called once, never retried. - mock_session.request.assert_called_once_with( - "GET", - "http://localhost", - headers=None, - data=None, - auth=None, - params=None, - files=None, + parts = server.requests[0].multipart_parts() + assert parts["map"].json == {"1": ["variables.input.file"]} + assert parts["1"].filename == "dummy.pdf" + assert parts["1"].content_type == "application/pdf" + assert parts["1"].body == DUMMY_PDF.read_bytes() + + def it_sends_several_files_with_one_map_entry_each(server, client): + client.request_graphql( + "mutation Upload($files: [EtchFile!]) { createEtchPacket(files: $files) { eid } }", + { + "files": [ + { + "id": "a", + "file": Anvil.prepare_graphql_file(b"a", filename="a.pdf"), + }, + { + "id": "b", + "file": Anvil.prepare_graphql_file(b"b", filename="b.pdf"), + }, + ] + }, + ) + + parts = server.requests[0].multipart_parts() + assert parts["map"].json == { + "1": ["variables.files.0.file"], + "2": ["variables.files.1.file"], + } + assert parts["1"].filename == "a.pdf" + assert parts["2"].filename == "b.pdf" + + def it_sends_a_file_from_a_path_using_its_own_filename(server, client): + client.request_graphql( + "mutation { x }", + {"input": {"file": Anvil.prepare_graphql_file(str(DUMMY_PDF))}}, + ) + + parts = server.requests[0].multipart_parts() + assert parts["1"].filename == "dummy.pdf" + assert parts["1"].body == DUMMY_PDF.read_bytes() + + def it_sends_a_path_object_directly(server, client): + client.request_graphql("mutation { x }", {"input": {"file": DUMMY_PDF}}) + + parts = server.requests[0].multipart_parts() + assert parts["1"].filename == "dummy.pdf" + + def it_sends_a_file_object_with_no_name_using_the_given_filename(server, client): + file = Anvil.prepare_graphql_file(io.BytesIO(b"hello"), filename="given.pdf") + + client.request_graphql("mutation { x }", {"input": {"file": file}}) + + parts = server.requests[0].multipart_parts() + assert parts["1"].filename == "given.pdf" + assert parts["1"].body == b"hello" + + def it_names_a_file_with_no_filename_blob(server, client): + file = Anvil.prepare_graphql_file(b"hello", ignore_filename_validation=True) + + client.request_graphql("mutation { x }", {"input": {"file": file}}) + + assert server.requests[0].multipart_parts()["1"].filename == "blob" + + def it_sends_a_base64_upload_as_plain_json(server, client): + client.request_graphql( + "mutation { x }", + { + "input": { + "file": { + "data": "aGVsbG8=", + "filename": "hello.pdf", + "mimetype": "application/pdf", + } + } + }, + ) + + request = server.requests[0] + assert request.headers["content-type"] == "application/json" + assert request.json["variables"]["input"]["file"]["data"] == "aGVsbG8=" + + def it_rejects_a_file_that_is_not_under_a_file_key(client): + with pytest.raises(ValueError, match="Invalid file schema"): + client.request_graphql( + "mutation { x }", + {"document": Anvil.prepare_graphql_file(b"x", filename="x.pdf")}, ) - @mock.patch("python_anvil.http.RateLimitException") - @mock.patch("python_anvil.http.requests.Session") - def test_default_args_with_retry(session, ratelimit_exc, mock_response): - class MockException(Exception): - pass - - mock_session = mock.MagicMock() - mock_session.request.return_value = mock_response() - session.return_value = mock_session - ratelimit_exc.return_value = MockException() - - client = HTTPClient(api_key="my_key") - with pytest.raises(MockException): - client.do_request("GET", "http://localhost", retry=True) - - assert ratelimit_exc.call_count == 1 - - # Should only be called once, would retry but RateLimitException - # is mocked here. - mock_session.request.assert_called_once_with( - "GET", - "http://localhost", - headers=None, - data=None, - auth=None, - params=None, - files=None, + def describe_when_the_upload_is_retried_after_a_429(): + def it_sends_a_file_from_a_path_in_full_again(server, client): + server.respond_in_turn({"status": 429, "body": b""}, {"json": {"data": {}}}) + + client.request_graphql( + "mutation { x }", + {"input": {"file": Anvil.prepare_graphql_file(str(DUMMY_PDF))}}, ) - @mock.patch("python_anvil.http.RateLimitException") - @mock.patch("python_anvil.http.requests.Session") - def test_default_args_without_retry(session, ratelimit_exc, mock_response): - class MockException(Exception): - pass - - mock_session = mock.MagicMock() - mock_session.request.return_value = mock_response() - session.return_value = mock_session - ratelimit_exc.return_value = MockException() - - client = HTTPClient(api_key="my_key") - with pytest.raises(AnvilRequestException): - client.do_request("GET", "http://localhost", retry=False) - - assert ratelimit_exc.call_count == 0 - - # Should only be called once, never retried. - mock_session.request.assert_called_once_with( - "GET", - "http://localhost", - headers=None, - data=None, - auth=None, - params=None, - files=None, + first, second = server.requests + assert first.multipart_parts()["1"].body == DUMMY_PDF.read_bytes() + assert second.multipart_parts()["1"].body == DUMMY_PDF.read_bytes() + + def it_sends_a_seekable_file_object_in_full_again(server, client): + server.respond_in_turn({"status": 429, "body": b""}, {"json": {"data": {}}}) + file = Anvil.prepare_graphql_file( + io.BytesIO(b"hello again"), filename="again.pdf" ) + + client.request_graphql("mutation { x }", {"input": {"file": file}}) + + first, second = server.requests + assert first.multipart_parts()["1"].body == b"hello again" + assert second.multipart_parts()["1"].body == b"hello again" + + def it_fails_rather_than_sending_an_empty_file_for_a_file_object_that_cannot_be_read_again( + server, client + ): + server.respond_in_turn({"status": 429, "body": b""}, {"json": {"data": {}}}) + + class OnceOnly(io.BytesIO): + def seekable(self) -> bool: + return False + + file = Anvil.prepare_graphql_file(OnceOnly(b"once"), filename="once.pdf") + + with pytest.raises(ValueError, match=r"Can't send once\.pdf again"): + client.request_graphql("mutation { x }", {"input": {"file": file}}) + + assert len(server.requests) == 1 + + +def describe_responses(): + def it_returns_parsed_json_by_default_from_request_rest(server, client): + server.respond({"json": {"hello": "world"}}) + + assert client.request_rest("GET", "/api/v1/x") == {"hello": "world"} + + def it_returns_bytes_when_asked(server, client): + server.respond( + {"body": b"raw", "headers": {"content-type": "application/octet-stream"}} + ) + + assert client.request_rest("GET", "/api/v1/x", data_type="bytes") == b"raw" + + def it_returns_the_response_to_stream(server, client): + server.respond( + { + "body": b"%PDF-1.7 " + b"x" * 1000, + "headers": {"content-type": "application/pdf"}, + } + ) + + with client.fill_pdf("tpl", {"data": {"a": 1}}, data_type="stream") as response: + assert isinstance(response, requests.Response) + data = b"".join(response.iter_content(chunk_size=100)) + + assert data.startswith(b"%PDF-1.7") + assert len(data) == 1009 + + def it_rejects_an_unknown_data_type(client): + with pytest.raises(ValueError, match="data_type must be one of"): + client.request_rest("GET", "/api/v1/x", data_type="buffer") + + def it_rejects_json_for_the_binary_methods(client): + with pytest.raises( + ValueError, match=r"data_type must be one of: bytes\|stream" + ): + client.download_documents("dg", data_type="json") + + def it_sends_a_raw_body_passed_to_request_rest(server, client): + server.respond({"json": {}}) + + client.request_rest( + "POST", + "/api/v1/x", + data=b"raw body", + headers={"Content-Type": "text/plain"}, + ) + + assert server.requests[0].body == b"raw body" + + def it_sends_requests_with_the_given_session(server, make_client): + session = requests.Session() + session.headers["X-From-Session"] = "yes" + client = make_client(session=session) + server.respond({"json": {}}) + + client.request_rest("GET", "/api/v1/x") + + assert server.requests[0].headers["x-from-session"] == "yes" + + def it_closes_the_session_as_a_context_manager(make_client): + with make_client() as client: + session = client.client.session + assert isinstance(session, requests.Session) diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py new file mode 100644 index 00000000..793c86db --- /dev/null +++ b/tests/test_rate_limit.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import contextlib +import threading +import time +from unittest import mock + +from python_anvil.rate_limit import RateLimiter, retry_delay_seconds + + +def response(status: int = 200, headers: dict | None = None) -> mock.Mock: + resp = mock.Mock() + resp.status_code = status + resp.headers = headers or {} + return resp + + +def describe_rate_limiter(): + def it_starts_with_the_given_limits(): + limiter = RateLimiter(5, 2000) + assert (limiter.limit, limiter.interval_ms) == (5, 2000) + + def it_falls_back_to_one_per_second_when_the_starting_limits_are_invalid(): + for limit, interval in [(0, 1000), (1, 0), ("x", 1000), (None, None), (-1, -1)]: + limiter = RateLimiter(limit, interval) + assert (limiter.limit, limiter.interval_ms) == (1, 1000) + + def it_sets_the_limits_from_the_first_response_only(): + limiter = RateLimiter() + + limiter.run( + lambda: response( + headers={"X-RateLimit-Limit": "40", "X-RateLimit-Interval-Ms": "1000"} + ) + ) + limiter.run( + lambda: response( + headers={"x-ratelimit-limit": "2", "x-ratelimit-interval-ms": "100"} + ) + ) + + assert limiter.has_set_from_response + assert (limiter.limit, limiter.interval_ms) == (40, 1000) + + def it_keeps_the_starting_limits_when_the_headers_are_missing(): + limiter = RateLimiter(3, 500) + + limiter.run(lambda: response()) + + assert limiter.has_set_from_response + assert (limiter.limit, limiter.interval_ms) == (3, 500) + + def it_holds_other_requests_until_the_first_response(): + limiter = RateLimiter(10, 1000) + order: list[str] = [] + first_sent = threading.Event() + + def first(): + def send(): + order.append("first sent") + first_sent.set() + time.sleep(0.2) + order.append("first responded") + return response( + headers={ + "x-ratelimit-limit": "10", + "x-ratelimit-interval-ms": "1000", + } + ) + + limiter.run(send) + + def second(): + first_sent.wait() + + def send(): + order.append("second sent") + return response() + + limiter.run(send) + + threads = [threading.Thread(target=first), threading.Thread(target=second)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert order == ["first sent", "first responded", "second sent"] + + def it_lets_waiting_requests_go_when_the_first_request_fails(): + limiter = RateLimiter(10, 1000) + + def fail(): + raise ConnectionError("nope") + + with contextlib.suppress(ConnectionError): + limiter.run(fail) + + # Would hang if the setup gate were still closed + limiter.run(lambda: response()) + assert limiter.has_set_from_response + + def it_waits_when_the_limit_is_reached(): + limiter = RateLimiter(2, 200) + limiter.update_from_headers( + {"x-ratelimit-limit": "2", "x-ratelimit-interval-ms": "200"} + ) + sent: list[float] = [] + + def send(): + sent.append(time.monotonic()) + return response() + + for _ in range(4): + limiter.run(send) + + assert sent[2] - sent[0] >= 0.2 + assert sent[3] - sent[1] >= 0.2 + # But the first two went straight away + assert sent[1] - sent[0] < 0.1 + + def it_retries_a_429_after_retry_after_plus_the_buffer(): + limiter = RateLimiter(10, 1000) + responses = iter([response(429, {"retry-after": "0.1"}), response(200)]) + times: list[float] = [] + + def send(): + times.append(time.monotonic()) + return next(responses) + + result = limiter.run(send) + + assert result.status_code == 200 + assert times[1] - times[0] >= 0.15 + + def it_closes_the_429_response(): + limiter = RateLimiter(10, 1000) + rate_limited = response(429) + responses = iter([rate_limited, response(200)]) + + limiter.run(lambda: next(responses)) + + rate_limited.close.assert_called_once() + + +def describe_retry_delay_seconds(): + def it_adds_a_50ms_buffer_to_retry_after(): + assert retry_delay_seconds("2") == 2.05 + + def it_accepts_fractions_and_negatives(): + assert retry_delay_seconds("0.5") == 0.55 + assert retry_delay_seconds("-1") == 1.05 + + def it_is_only_the_buffer_without_retry_after(): + assert retry_delay_seconds(None) == 0.05 + assert retry_delay_seconds("soon") == 0.05 diff --git a/tests/test_upload.py b/tests/test_upload.py new file mode 100644 index 00000000..b556b15f --- /dev/null +++ b/tests/test_upload.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import io +import json +from pathlib import Path +from typing import Any + +import pytest + +from python_anvil import Anvil, UploadWithOptions, prepare_graphql_file +from python_anvil.upload import ( + MultipartRequest, + extract_files, + is_file, + validate_upload_schema, +) + + +DUMMY_PDF = Path(__file__).parent / "assets" / "dummy.pdf" + + +def describe_prepare_graphql_file(): + def it_requires_a_filename_for_bytes(): + with pytest.raises( + ValueError, + match=r"When passing bytes to prepare_graphql_file, `filename` must be provided", + ): + prepare_graphql_file(b"test") + + def it_accepts_bytes_with_a_filename(): + upload = prepare_graphql_file(b"test", filename="test.pdf") + assert isinstance(upload, UploadWithOptions) + assert upload.file == b"test" + assert upload.options == {"filename": "test.pdf"} + + def it_accepts_bytes_without_a_filename_when_told_to_ignore_validation(): + upload = prepare_graphql_file(b"test", ignore_filename_validation=True) + assert upload.options == {} + + def it_accepts_a_path_without_a_filename(): + assert prepare_graphql_file(str(DUMMY_PDF)).file == str(DUMMY_PDF) + assert prepare_graphql_file(DUMMY_PDF).file == DUMMY_PDF + + def it_accepts_an_open_file_without_a_filename(): + with DUMMY_PDF.open("rb") as file: + assert prepare_graphql_file(file).file is file + + def it_requires_a_filename_for_file_objects_with_no_name(): + with pytest.raises( + ValueError, match="When passing a BytesIO to prepare_graphql_file" + ): + prepare_graphql_file(io.BytesIO(b"test")) + + def it_passes_the_filename_and_mimetype_through(): + upload = prepare_graphql_file( + b"test", + filename="test.pdf", + mimetype="application/pdf", + ignore_filename_validation=True, + ) + assert upload.options == {"filename": "test.pdf", "mimetype": "application/pdf"} + assert "test.pdf" in repr(upload) + + def it_is_also_a_static_method_on_the_client(): + assert Anvil.prepare_graphql_file(b"x", filename="x.pdf").filename == "x.pdf" + + +def describe_is_file(): + def it_is_true_for_the_things_that_can_be_uploaded(): + assert is_file(prepare_graphql_file(b"x", filename="x")) + assert is_file(b"bytes") + assert is_file(bytearray(b"bytes")) + assert is_file(io.BytesIO()) + assert is_file(DUMMY_PDF) + + def it_is_false_for_strings_and_plain_data(): + assert not is_file("a/path.pdf") + assert not is_file({"data": "base64"}) + assert not is_file(None) + assert not is_file(1) + + +def describe_extract_files(): + def it_replaces_files_with_none_and_records_their_paths(): + file_a = prepare_graphql_file(b"a", filename="a.pdf") + file_b = io.BytesIO(b"b") + variables = { + "files": [{"id": "a", "file": file_a}, {"id": "b", "file": file_b}], + "name": "x", + } + + clone, files, paths = extract_files({"variables": variables}) + + assert clone == { + "variables": { + "files": [{"id": "a", "file": None}, {"id": "b", "file": None}], + "name": "x", + } + } + assert files == [file_a, file_b] + assert paths == {0: ["variables.files.0.file"], 1: ["variables.files.1.file"]} + + def it_sends_the_same_file_in_two_places_once(): + file = prepare_graphql_file(b"a", filename="a.pdf") + + _, files, paths = extract_files( + {"variables": {"one": {"file": file}, "two": {"file": file}}} + ) + + assert files == [file] + assert paths == {0: ["variables.one.file", "variables.two.file"]} + + def it_leaves_variables_without_files_alone(): + variables = { + "name": "x", + "signers": [{"email": "a@example.com"}], + "nested": {"deep": [1, 2]}, + } + clone, files, paths = extract_files({"variables": variables}) + assert clone == {"variables": variables} + assert files == [] + assert paths == {} + + +def describe_validate_upload_schema(): + def it_accepts_files_under_a_file_key(): + assert validate_upload_schema({"files": [{"file": b"x"}]}) + assert validate_upload_schema({"input": {"file": io.BytesIO()}}) + assert validate_upload_schema( + {"file": prepare_graphql_file(b"x", filename="x")} + ) + + def it_accepts_base64_uploads_with_a_filename_and_mimetype(): + assert validate_upload_schema( + { + "files": [ + { + "file": { + "data": "aGk=", + "filename": "hi.pdf", + "mimetype": "application/pdf", + } + } + ] + } + ) + + def it_rejects_base64_uploads_missing_a_filename_or_mimetype(): + assert not validate_upload_schema( + {"files": [{"file": {"data": "aGk=", "filename": "hi.pdf"}}]} + ) + assert not validate_upload_schema( + { + "files": [ + { + "file": { + "data": 1, + "filename": "hi.pdf", + "mimetype": "application/pdf", + } + } + ] + } + ) + + def it_accepts_other_upload_forms_like_a_url(): + assert validate_upload_schema( + {"files": [{"file": {"url": "https://example.com/x.pdf"}}]} + ) + + def it_rejects_files_anywhere_else(): + assert not validate_upload_schema({"document": b"x"}) + assert not validate_upload_schema({"files": [b"x"]}) + + def it_accepts_no_variables(): + assert validate_upload_schema(None) + assert validate_upload_schema({}) + + +def describe_multipart_request(): + def it_is_falsy_without_files(): + assert not MultipartRequest({"query": "{ x }", "variables": {"a": 1}}) + + def it_builds_the_operations_map_and_file_parts(): + file = prepare_graphql_file( + b"%PDF", filename="x.pdf", mimetype="application/pdf" + ) + request = MultipartRequest( + {"query": "mutation { x }", "variables": {"input": {"file": file}}} + ) + + assert request + parts = dict(request.build()) + assert json.loads(parts["operations"][1]) == { + "query": "mutation { x }", + "variables": {"input": {"file": None}}, + } + assert parts["operations"][2] == "application/json" + assert json.loads(parts["map"][1]) == {"1": ["variables.input.file"]} + assert parts["1"] == ("x.pdf", b"%PDF", "application/pdf") + + def it_guesses_the_mimetype_from_the_filename(): + request = MultipartRequest( + { + "query": "", + "variables": {"file": prepare_graphql_file(b"x", filename="x.png")}, + } + ) + assert dict(request.build())["1"][2] == "image/png" + + def it_opens_a_path_for_each_attempt_and_closes_it_after(): + request = MultipartRequest( + {"query": "", "variables": {"file": prepare_graphql_file(str(DUMMY_PDF))}} + ) + + first = dict(request.build())["1"] + assert first[0] == "dummy.pdf" + assert first[1].read() == DUMMY_PDF.read_bytes() + + second = dict(request.build())["1"] + assert first[1].closed + assert second[1].read() == DUMMY_PDF.read_bytes() + + request.close() + assert second[1].closed + + def it_rewinds_a_seekable_file_object_for_a_retry(): + file = io.BytesIO(b"hello") + request = MultipartRequest( + { + "query": "", + "variables": {"file": prepare_graphql_file(file, filename="h.txt")}, + } + ) + + assert dict(request.build())["1"][1].read() == b"hello" + assert dict(request.build())["1"][1].read() == b"hello" + + def it_refuses_to_resend_a_file_object_that_cannot_be_read_again(): + class OnceOnly(io.BytesIO): + def seekable(self) -> bool: + return False + + request = MultipartRequest( + { + "query": "", + "variables": { + "file": prepare_graphql_file(OnceOnly(b"x"), filename="o.txt") + }, + } + ) + request.build() + with pytest.raises(ValueError, match=r"Can't send o\.txt again"): + request.build() + + def it_rejects_things_that_are_not_files(): + not_a_file: Any = 123 + request = MultipartRequest( + { + "query": "", + "variables": {"file": UploadWithOptions(not_a_file, filename="x")}, + } + ) + with pytest.raises(TypeError, match="Can't upload a int"): + request.build() From d21a515308975556810e947babe138cb8e4e240a Mon Sep 17 00:00:00 2001 From: Derek Foster Date: Tue, 27 Oct 2026 22:34:50 +0000 Subject: [PATCH 2/6] Fix what an audit of the new transport against 5.1.2 found - Limits are taken from the first response that has the rate limit headers, not the first response: a proxy error or a 401 served before the API's middleware no longer fixes the client at 1 request/s for good. - A 429 with no usable Retry-After waits a second, not 50ms, so a 429 from a CDN with no header can't become a tight loop. - A 2xx JSON body with a `name` or `message` key is data, not an error; only `errors` or a non-2xx status is. A non-JSON error keeps the start of its body, including on the stream path. - A full URL on another host is refused rather than prefixed with the base URL, as FullyQualifiedRequest refused it. - `environment` is still accepted positionally, with the warning. - A builder's `mutation_res_query` still selects its response fields. - AnvilRequestError accepts a plain message, as the old exception did. - A plain variable that happens to be called `file` is sent as JSON. - `close()` leaves a caller-supplied session open. - `request_rest` explains itself when JSON is asked of a binary endpoint. - `ForgeSubmitPayload.webhook_url` is a `str`: as an `HttpUrl` the JSON encoder rejected it, before 6.0.0 too. - The changelog names every removed public name and the behavior changes the audit found undocumented: per-instance limits, no 5xx retries for GraphQL, and forgeSubmit's changed default fields. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 14 ++- python_anvil/api.py | 2 +- python_anvil/api_resources/mutations/base.py | 8 ++ .../mutations/create_etch_packet.py | 2 +- .../api_resources/mutations/forge_submit.py | 2 +- python_anvil/api_resources/payload.py | 3 +- python_anvil/constants.py | 2 + python_anvil/errors.py | 12 +- python_anvil/http.py | 61 ++++++++-- python_anvil/rate_limit.py | 61 ++++++---- python_anvil/upload.py | 6 +- tests/test_api.py | 32 +++++- tests/test_errors.py | 6 + tests/test_http.py | 104 ++++++++++++++++-- tests/test_rate_limit.py | 29 ++++- 15 files changed, 282 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 104328f7..f3431df9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,19 +13,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - BREAKING: One `requests`-based transport replaces `gql` for GraphQL and the `ratelimit` decorators for REST. Both dependencies are dropped. - `Anvil(api_key, base_url=...)` sends every request to one host: REST to `{base_url}/api/...`, GraphQL to `{base_url}/graphql`. `base_url` defaults to production. The `endpoint_url` argument is removed, and `environment` does nothing: the rate limits now come from the API. - OAuth access tokens are supported: `Anvil(access_token=...)`. JWTs are sent as-is, other tokens base64 encoded, like node-anvil. - - Rate limits are learned from the first response's `X-RateLimit-Limit` and `X-RateLimit-Interval-Ms` headers. Requests made before that response arrives wait for it. The client counts requests over a sliding window, like the API, and only waits when the limit has been reached. A `429` is retried after its `Retry-After` plus 50ms, for as long as it takes. The `retry` option is removed. - - API errors raise `AnvilRequestError`, with the normalized `errors` (GraphQL errors, REST errors and validation errors with their `fields`), the `status_code`, the `headers` and any partial `data`. The REST methods used to raise a bare `Exception`; GraphQL methods raised `gql`'s errors. `AnvilRequestException` and `AnvilException` remain as aliases. - - Methods return the data: the PDF or zip `bytes` for `fill_pdf`, `generate_pdf` and `download_documents`, and the response's `data` dict for the GraphQL methods. The `include_headers` and `debug` options are removed; the headers are on `AnvilRequestError`. + - Rate limits are learned from the first response that carries the `X-RateLimit-Limit` and `X-RateLimit-Interval-Ms` headers. Requests made before the first response arrives wait for it. The client counts requests over a sliding window, like the API, and only waits when the limit has been reached. The limit is per client instance; `ratelimit`'s was one bucket for the whole process, so an app that makes a client per request now relies on each one's first response. A `429` is retried after its `Retry-After` plus 50ms, or after a second when there is no usable `Retry-After`, for as long as it takes. The `retry` option is removed. + - GraphQL requests are no longer retried on `500`, `502`, `503` and `504`. `gql`'s transport retried them up to 5 times, mutations included, which could send a mutation twice. To retry at the HTTP level, pass a `requests.Session` with your own `HTTPAdapter(max_retries=Retry(...))` as `session`. + - API errors raise `AnvilRequestError`, with the normalized `errors` (GraphQL errors, REST errors and validation errors with their `fields`), the `status_code`, the `headers` and any partial `data`. An error is any status outside 2xx, or a 2xx GraphQL response with `errors`; a non-JSON error, like a `502` from a proxy, keeps the start of its body. The REST methods used to raise a bare `Exception`; GraphQL methods raised `gql`'s errors. `AnvilRequestException` and `AnvilException` remain as aliases, and still accept a plain message, but their base class is now `Exception` rather than `BaseException`, so `except Exception` catches them. + - Methods return the data: the PDF or zip `bytes` for `fill_pdf`, `generate_pdf` and `download_documents`, and the response's `data` dict for the GraphQL methods. A JSON response to a binary method, like `fill_pdf` with `outputFormat: "url"`, is returned parsed. The `include_headers`, `debug` and `retry` keyword arguments are removed and now raise `TypeError`, as do the `gql` options `query` and `mutate` used to pass through; the headers are on `AnvilRequestError`. - `data_type="stream"` on the binary methods returns the `requests.Response`, to stream a large PDF or zip. - Requests time out after 5 minutes by default (`timeout`). They never did before. - A `requests.Session` can be passed in (`session`), e.g. to use a proxy, and `user_agent`, `request_limit` and `request_limit_ms` match node-anvil's options. -- BREAKING: `request_rest` now sends a request, `request_rest("POST", "/api/v1/...", json=...)`, like node-anvil's `requestREST`. The `RestRequest`, `PlainRequest`, `GraphqlRequest` and `FullyQualifiedRequest` classes and `request_fully_qualified` are removed. +- BREAKING: `request_rest` now sends a request, `request_rest("POST", "/api/v1/...", json=...)`, like node-anvil's `requestREST`. A full URL is accepted if it is on the client's `base_url`, and refused otherwise. The `python_anvil.api_resources.requests` module with `RestRequest`, `PlainRequest`, `GraphqlRequest` and `FullyQualifiedRequest`, and `request_fully_qualified`, are removed. +- BREAKING: Removed with `gql`: `python_anvil.http.GQLClient`, `get_local_schema` and `get_gql_ds`, `Anvil.gql_client`, and `HTTPClient`'s old signature and `(content, status_code, headers)` return. `python_anvil.constants` loses `GRAPHQL_ENDPOINT`, `REST_ENDPOINT`, `ANVIL_HOST`, `VALID_HOSTS`, `RETRIES_LIMIT`, `REQUESTS_LIMIT` and `RATELIMIT_ENV`; `DEFAULT_BASE_URL` is the one host now. The mutation builders' `mutation` template attribute is gone (pass `mutation=` to the method instead); `mutation_res_query` still selects a builder's default response fields. - BREAKING: `generate_etch_signing_url` returns the URL. `get_etch_packet` returns the packet, and `remove_weld_data` whether the data was removed. - BREAKING: `query` and `mutate` take the operation as a string; `gql` documents and its DSL are gone. `request_graphql` is the same thing with node-anvil's name. - BREAKING: `get_casts` and `get_welds` query the API's paginated `casts` and `welds` fields, which the old queries no longer matched, and return up to 500 per organization. `get_current_user` no longer selects each organization's casts. - Add `get_etch_packet` and `remove_weld_data`, and `response_query` and `mutation` options on `create_etch_packet`, `forge_submit` and `get_etch_packet`, matching node-anvil. - Add `Anvil.prepare_graphql_file` (also `python_anvil.prepare_graphql_file`) for multipart uploads of a path, bytes or a file object, anywhere in a mutation's variables. Files given as open file objects used to be base64 encoded into the JSON; they still are unless prepared this way. -- The `createEtchPacket` mutation gains `organizationEid` and `allowUpdates`, and the mutations and default response fields now match node-anvil's exactly. +- The `createEtchPacket` mutation gains `organizationEid` and `allowUpdates`, and the mutations and default response fields now match node-anvil's exactly. `forgeSubmit`'s default fields drop `resolvedPayload` and `weldData.status`, and gain `continueURL` and `payloadValue`; pass `response_query` to select others. +- `forge_submit` with a `webhook_url` works. `ForgeSubmitPayload.webhook_url` was a pydantic `HttpUrl`, which the JSON encoder rejected, so it failed before sending; it is a `str` now. +- `fill_pdf` sends `version_number=0` (it used to drop it), and `generate_etch_signing_url` is also available as `generate_etch_sign_url`. - The client's queries and mutations are checked against the API schema in CI, and `make update-schema` refreshes the schema from the published `@anvilco/types` package. - Add `test-client.py`, which runs every method against a real environment: `ANVIL_API_KEY=... make test-client`. diff --git a/python_anvil/api.py b/python_anvil/api.py index 5e3cfb37..8f079bbb 100644 --- a/python_anvil/api.py +++ b/python_anvil/api.py @@ -93,6 +93,7 @@ class Anvil: def __init__( self, api_key: str | None = None, + environment: str | None = None, *, access_token: str | None = None, base_url: str = DEFAULT_BASE_URL, @@ -101,7 +102,6 @@ def __init__( request_limit_ms: int = DEFAULT_REQUEST_LIMIT_MS, timeout: float | tuple[float, float] | None = DEFAULT_TIMEOUT, session: requests.Session | None = None, - environment: str | None = None, endpoint_url: str | None = None, ): if endpoint_url is not None: diff --git a/python_anvil/api_resources/mutations/base.py b/python_anvil/api_resources/mutations/base.py index 8c9f6de9..0816358f 100644 --- a/python_anvil/api_resources/mutations/base.py +++ b/python_anvil/api_resources/mutations/base.py @@ -6,10 +6,18 @@ class BaseQuery: """Base class for the classes that build a GraphQL mutation and its variables.""" + # The fields the mutation selects unless `response_query` is given + default_response_query: str | None = None + # The same, set on a subclass or instance the way it was before 6.0.0 + mutation_res_query: str | None = None + def get_mutation(self, response_query: str | None = None) -> str: """The mutation to send, selecting `response_query` or the default fields.""" raise NotImplementedError + def response_fields(self, response_query: str | None = None) -> str | None: + return response_query or self.mutation_res_query or self.default_response_query + def create_payload(self) -> Any: """The model whose dump is the mutation's variables.""" raise NotImplementedError diff --git a/python_anvil/api_resources/mutations/create_etch_packet.py b/python_anvil/api_resources/mutations/create_etch_packet.py index 2fcf0ca5..63e048a2 100644 --- a/python_anvil/api_resources/mutations/create_etch_packet.py +++ b/python_anvil/api_resources/mutations/create_etch_packet.py @@ -80,7 +80,7 @@ def __init__( self.allow_updates = allow_updates def get_mutation(self, response_query: str | None = None) -> str: - return generate_mutation(response_query or self.default_response_query) + return generate_mutation(self.response_fields(response_query)) @classmethod def create_from_dict(cls, payload: dict) -> "CreateEtchPacket": diff --git a/python_anvil/api_resources/mutations/forge_submit.py b/python_anvil/api_resources/mutations/forge_submit.py index 58d3e7c6..dedf9e10 100644 --- a/python_anvil/api_resources/mutations/forge_submit.py +++ b/python_anvil/api_resources/mutations/forge_submit.py @@ -15,7 +15,7 @@ class ForgeSubmit(BaseQuery): default_response_query = DEFAULT_RESPONSE_QUERY def get_mutation(self, response_query: str | None = None) -> str: - return generate_mutation(response_query or self.default_response_query) + return generate_mutation(self.response_fields(response_query)) def __init__( self, diff --git a/python_anvil/api_resources/payload.py b/python_anvil/api_resources/payload.py index 7df7cd5e..01b35121 100644 --- a/python_anvil/api_resources/payload.py +++ b/python_anvil/api_resources/payload.py @@ -4,7 +4,6 @@ from pydantic import ( ConfigDict, Field, - HttpUrl, field_validator, ) @@ -241,7 +240,7 @@ class ForgeSubmitPayload(BaseModel): # even when `False` is used in the payload. is_test: bool | None = True timezone: str | None = None - webhook_url: HttpUrl | None = Field(None, alias="webhookURL") + webhook_url: str | None = Field(None, alias="webhookURL") group_array_id: str | None = None group_array_index: int | None = None diff --git a/python_anvil/constants.py b/python_anvil/constants.py index b8f5a5d6..608c3da4 100644 --- a/python_anvil/constants.py +++ b/python_anvil/constants.py @@ -24,6 +24,8 @@ # Added to every wait for the rate limit, and to a 429's Retry-After, to allow # for requests that were sent further apart than they arrive RETRY_BUFFER_MS = 50 +# How long to wait before retrying a 429 that has no usable Retry-After header +DEFAULT_RETRY_AFTER_MS = 1000 # Seconds to wait for a connection, and then for each read, before giving up DEFAULT_TIMEOUT = 300 diff --git a/python_anvil/errors.py b/python_anvil/errors.py index 96a1f3fc..27ef6b5a 100644 --- a/python_anvil/errors.py +++ b/python_anvil/errors.py @@ -35,19 +35,25 @@ class AnvilRequestError(AnvilError): def __init__( self, - errors: list[dict[str, Any]], + errors: list[dict[str, Any]] | str, *, - status_code: int, + status_code: int = 0, headers: Mapping[str, str] | None = None, data: Any = None, response: requests.Response | None = None, ): + # A plain message, the way AnvilRequestException was raised before 6.0.0 + if isinstance(errors, str): + errors = [{"message": errors}] self.errors = errors self.status_code = status_code self.headers: Mapping[str, str] = headers or {} self.data = data self.response = response - super().__init__(f"{status_code}: {'; '.join(format_error(e) for e in errors)}") + description = "; ".join(format_error(e) for e in errors) or "Unknown Error" + super().__init__( + f"{status_code}: {description}" if status_code else description + ) @property def message(self) -> str: diff --git a/python_anvil/http.py b/python_anvil/http.py index 5b0031f0..21938ecb 100644 --- a/python_anvil/http.py +++ b/python_anvil/http.py @@ -22,7 +22,6 @@ ) from python_anvil.errors import ( AnvilRequestError, - looks_like_json_error, normalize_exception, normalize_json_errors, ) @@ -61,12 +60,25 @@ def __init__( self.auth_header = build_auth_header(api_key, access_token) self.user_agent = user_agent or default_user_agent() self.timeout = timeout + self._owns_session = session is None self.session = session or requests.Session() self.rate_limiter = RateLimiter(request_limit, request_limit_ms) def url(self, path_or_url: str) -> str: - """The full URL for a path like `/api/v1/fill/abc.pdf`. A full URL on the base URL is used as-is.""" - if path_or_url.startswith(self.base_url): + """The full URL for a path like `/api/v1/fill/abc.pdf`. + + A full URL is used as-is if it is on the base URL. One on another host + is refused: it would get this client's credentials. + """ + parts = urlsplit(path_or_url) + if parts.scheme or parts.netloc: + base = urlsplit(self.base_url) + if (parts.scheme, parts.netloc) != (base.scheme, base.netloc): + raise ValueError( + f"{path_or_url!r} is not on this client's base_url, " + f"{self.base_url}. Pass a path like /api/v1/..., or make a " + "client with that base_url." + ) return path_or_url return f"{self.base_url}/{path_or_url.lstrip('/')}" @@ -115,7 +127,9 @@ def send() -> requests.Response: return self.rate_limiter.run(send) def close(self) -> None: - self.session.close() + """Close the HTTP session, if it is the client's own.""" + if self._owns_session: + self.session.close() def process_response( @@ -123,8 +137,9 @@ def process_response( ) -> Any: """The data in a response, or an `AnvilRequestError` if it is an error. - Whatever was asked for, a JSON response is parsed and checked for errors: - the API answers every request with JSON when something is wrong. + An error is any status outside 2xx, or a 2xx GraphQL response with + `errors`. Whatever was asked for, a JSON response is parsed: the API + answers every request with JSON when something is wrong. """ if data_type not in DATA_TYPES: raise ValueError(f"data_type must be one of: {'|'.join(DATA_TYPES)}") @@ -144,13 +159,20 @@ def process_response( "Problem parsing the JSON response for status %s: %s", status_code, e ) - if parse_error or looks_like_json_error(json_body) or status_code >= 300: + graphql_errors = isinstance(json_body, Mapping) and bool(json_body.get("errors")) + if parse_error or graphql_errors or status_code >= 300: + status_text = response.reason or "Unknown Error" if parse_error: errors = normalize_exception(parse_error) + elif is_json: + errors = normalize_json_errors(json_body, status_text) else: - errors = normalize_json_errors( - json_body, response.reason or "Unknown Error" - ) + # Not JSON, so not from the API itself: a proxy or a load balancer. + # Keep some of the body, which is all there is to go on. + errors = normalize_json_errors(None, status_text) + body = _body_snippet(response) + if body: + errors[0]["body"] = body # A GraphQL response can have data alongside its errors data = json_body.get("data") if isinstance(json_body, Mapping) else None response.close() @@ -162,8 +184,16 @@ def process_response( response=response, ) - if is_json or data_type == DATA_TYPE_JSON: - return json_body if is_json else response.json() + if is_json: + return json_body + if data_type == DATA_TYPE_JSON: + try: + return response.json() + except ValueError as e: + raise ValueError( + f"Expected a JSON response, but got {content_type or 'no content type'}. " + "Pass data_type='bytes' or 'stream' for a binary endpoint." + ) from e if data_type == DATA_TYPE_BYTES: return response.content if data_type == DATA_TYPE_STREAM: @@ -173,6 +203,13 @@ def process_response( ) # pragma: no cover +def _body_snippet(response: requests.Response, limit: int = 2000) -> str: + try: + return response.content[:limit].decode(response.encoding or "utf-8", "replace") + except Exception: + return "" + + def normalize_base_url(base_url: str) -> str: if not isinstance(base_url, str) or not base_url: raise ValueError("`base_url` must be a URL like https://app.useanvil.com") diff --git a/python_anvil/rate_limit.py b/python_anvil/rate_limit.py index 97652fb9..cdcc2c62 100644 --- a/python_anvil/rate_limit.py +++ b/python_anvil/rate_limit.py @@ -27,6 +27,7 @@ from python_anvil.constants import ( DEFAULT_REQUEST_LIMIT, DEFAULT_REQUEST_LIMIT_MS, + DEFAULT_RETRY_AFTER_MS, RETRY_BUFFER_MS, ) @@ -54,9 +55,12 @@ def __init__( # When the requests in the current window were sent, oldest first self._request_times: deque[float] = deque() - # Whether the limits have been set from an API response + # Whether the limits have been set from an API response. Until they + # have, every response is checked for the headers. self.has_set_from_response = False - # Whether a request is out whose response will set the limits + # Whether the first response has arrived, whatever it carried. Until + # it has, only the request whose response it will be may go. + self._gate_open = False self._setting_in_progress = False def set_limits(self, limit: Any, interval_ms: Any) -> bool: @@ -73,23 +77,31 @@ def set_limits(self, limit: Any, interval_ms: Any) -> bool: return True def update_from_headers(self, headers: Mapping[str, Any]) -> None: - """Set the limits from a response's rate limit headers, the first time only.""" + """Set the limits from a response's rate limit headers. + + The first response, whatever it carries, lets the requests waiting on + it go. The limits are taken from the first response that has them: a + response from a proxy, or an error served before the API's rate limit + middleware, doesn't fix the client at its starting limits for good. + """ with self._condition: - if self.has_set_from_response: - return - self.has_set_from_response = True + self._gate_open = True self._setting_in_progress = False self._condition.notify_all() - if not self.set_limits( - _header(headers, LIMIT_HEADER), _header(headers, INTERVAL_HEADER) - ): - logger.debug( - "The response had no valid rate limit headers; keeping %s requests " - "per %sms", - self.limit, - self.interval_ms, - ) + if self.has_set_from_response: + return + if self.set_limits( + _header(headers, LIMIT_HEADER), _header(headers, INTERVAL_HEADER) + ): + self.has_set_from_response = True + else: + logger.debug( + "The response had no valid rate limit headers; keeping %s " + "requests per %sms until one does", + self.limit, + self.interval_ms, + ) def run(self, send: Callable[[], requests.Response]) -> requests.Response: """Call `send` when the rate limit allows, retrying it after any 429. @@ -124,11 +136,11 @@ def run(self, send: Callable[[], requests.Response]) -> requests.Response: def _wait_for_turn(self) -> None: with self._condition: - # Until the first response has set the limits, only the request - # whose response will set them may go - while self._setting_in_progress and not self.has_set_from_response: + # Until the first response has arrived, only the request whose + # response it will be may go + while self._setting_in_progress and not self._gate_open: self._condition.wait() - if not self.has_set_from_response: + if not self._gate_open: self._setting_in_progress = True # Wait until another request would keep us within `limit` requests @@ -151,17 +163,22 @@ def _wait_for_turn(self) -> None: def _release_setup_gate(self) -> None: with self._condition: - if not self.has_set_from_response: + if not self._gate_open: self._setting_in_progress = False self._condition.notify_all() def retry_delay_seconds(retry_after: Any) -> float: - """How long to wait before retrying a 429: its Retry-After seconds plus the buffer.""" + """How long to wait before retrying a 429: its Retry-After seconds plus the buffer. + + Without a usable Retry-After (the header is optional, and may be an HTTP + date), a second: retrying every 50ms would hammer a server that is + already turning requests away. + """ try: seconds = abs(float(retry_after)) except (TypeError, ValueError): - seconds = 0.0 + seconds = DEFAULT_RETRY_AFTER_MS / 1000 return seconds + RETRY_BUFFER_MS / 1000 diff --git a/python_anvil/upload.py b/python_anvil/upload.py index ed5de1c6..5d9ba3c8 100644 --- a/python_anvil/upload.py +++ b/python_anvil/upload.py @@ -173,7 +173,11 @@ def validate_upload_schema( # Another form of the Upload scalar, like {"url": ...} return True - return is_file(value) + if isinstance(value, (list, tuple)): + return all(validate_upload_schema(child, value) for child in value) + + # A file, or a plain value that happens to be called `file` + return True class MultipartRequest: diff --git a/tests/test_api.py b/tests/test_api.py index f4ff4f93..d4fd0990 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -81,9 +81,37 @@ def it_warns_that_environment_does_nothing(): with pytest.warns(DeprecationWarning, match="`environment` does nothing"): Anvil(DEV_KEY, environment="prod") - def it_only_takes_the_api_key_positionally(): + def it_still_takes_environment_positionally_with_a_warning(): + with pytest.warns(DeprecationWarning, match="`environment` does nothing"): + client = Anvil(DEV_KEY, "prod") + assert client.base_url == "https://app.useanvil.com" + + def it_takes_the_other_options_by_keyword_only(): with pytest.raises(TypeError): - Anvil(DEV_KEY, "https://staging.useanvil.com") # type: ignore[misc] + Anvil(DEV_KEY, "prod", "https://staging.useanvil.com") # type: ignore[misc] + + def it_honours_mutation_res_query_from_before_6(anvil, graphql): + from python_anvil.api_resources.mutations import CreateEtchPacket + + builder = CreateEtchPacket(name="Packet") + builder.mutation_res_query = "{ eid }" + anvil.create_etch_packet(builder) + assert graphql.call_args.args[ + 0 + ] == mutations.create_etch_packet.generate_mutation("{ eid }") + # An explicit response_query still wins + anvil.create_etch_packet(builder, response_query="{ eid name }") + assert graphql.call_args.args[ + 0 + ] == mutations.create_etch_packet.generate_mutation("{ eid name }") + + def it_sends_a_forge_submit_webhook_url(anvil, graphql): + anvil.forge_submit( + {"forge_eid": "f", "payload": {}, "webhook_url": "https://example.com/hook"} + ) + variables = graphql.call_args.args[1] + assert variables["webhookURL"] == "https://example.com/hook" + json.dumps(variables) def describe_version_constants(): diff --git a/tests/test_errors.py b/tests/test_errors.py index da802f59..90521116 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -118,6 +118,12 @@ def it_includes_validation_fields(): ) assert str(error) == "400: ValidationError (data: Required, Too long)" + def it_can_be_raised_with_just_a_message_like_before_6(): + error = AnvilRequestError("Rate limit exceeded") + assert str(error) == "Rate limit exceeded" + assert error.errors == [{"message": "Rate limit exceeded"}] + assert error.status_code == 0 + def it_is_an_anvil_error_and_an_exception(): error = AnvilRequestError([], status_code=500) assert isinstance(error, AnvilError) diff --git a/tests/test_http.py b/tests/test_http.py index bfe1a08a..e3ad8708 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -8,6 +8,7 @@ import threading import time from pathlib import Path +from unittest import mock import pytest import requests @@ -216,9 +217,35 @@ def it_keeps_the_starting_limits_when_the_response_has_no_rate_limit_headers( client.request_rest("GET", "/api/v1/x") limiter = client.client.rate_limiter - assert limiter.has_set_from_response + assert not limiter.has_set_from_response assert (limiter.limit, limiter.interval_ms) == (4, 500) + def it_takes_the_limits_from_a_later_response_when_the_first_had_none( + server, client + ): + server.respond_in_turn( + { + "status": 502, + "body": b"bad gateway", + "headers": {"x-ratelimit-limit": "", "x-ratelimit-interval-ms": ""}, + }, + { + "json": {}, + "headers": { + "x-ratelimit-limit": "7", + "x-ratelimit-interval-ms": "3000", + }, + }, + ) + + with pytest.raises(AnvilRequestError): + client.request_rest("GET", "/api/v1/x") + client.request_rest("GET", "/api/v1/x") + + limiter = client.client.rate_limiter + assert limiter.has_set_from_response + assert (limiter.limit, limiter.interval_ms) == (7, 3000) + def it_holds_concurrent_requests_until_the_first_response_sets_the_limits( server, client ): @@ -336,16 +363,14 @@ def it_retries_a_429_after_retry_after_seconds_plus_a_buffer(server, client): first, second = server.requests assert second.received_at - first.responded_at >= 0.25 - def it_retries_a_429_after_only_the_buffer_when_there_is_no_retry_after( - server, client - ): + def it_retries_a_429_after_a_second_when_there_is_no_retry_after(server, client): server.respond_in_turn({"status": 429, "body": b""}, {"json": {"ok": True}}) data = client.request_rest("GET", "/api/v1/x") assert data == {"ok": True} first, second = server.requests - assert 0.05 <= second.received_at - first.responded_at < 0.5 + assert 1.0 <= second.received_at - first.responded_at < 2 def it_keeps_retrying_until_the_request_is_not_rate_limited(server, client): server.respond_in_turn( @@ -432,9 +457,57 @@ def it_uses_the_status_text_for_a_non_json_error_response(server, client): assert excinfo.value.status_code == 502 assert excinfo.value.errors == [ - {"name": "Bad Gateway", "message": "Bad Gateway"} + { + "name": "Bad Gateway", + "message": "Bad Gateway", + "body": "Bad Gateway", + } ] + def it_keeps_the_body_of_a_non_json_error_when_streaming(server, client): + server.respond( + { + "status": 503, + "body": b"upstream down", + "headers": {"content-type": "text/plain"}, + } + ) + + with pytest.raises(AnvilRequestError) as excinfo: + client.download_documents("dg", data_type="stream") + + assert excinfo.value.errors[0]["body"] == "upstream down" + + def it_does_not_treat_a_2xx_json_body_with_a_name_as_an_error(server, client): + server.respond({"json": {"name": "My Cast", "eid": "abc"}}) + + assert client.request_rest("GET", "/api/v1/x") == { + "name": "My Cast", + "eid": "abc", + } + + def it_treats_a_4xx_json_body_with_a_name_as_an_error(server, client): + server.respond({"status": 404, "json": {"name": "NotFound", "message": "Nope"}}) + + with pytest.raises(AnvilRequestError, match=r"404: NotFound: Nope"): + client.request_rest("GET", "/api/v1/x") + + def it_explains_json_asked_of_a_binary_endpoint(server, client): + server.respond( + {"body": b"%PDF-", "headers": {"content-type": "application/pdf"}} + ) + + with pytest.raises( + ValueError, match=r"Expected a JSON response, but got application/pdf" + ): + client.request_rest("GET", "/api/v1/x") + + def it_refuses_a_full_url_on_another_host(client): + with pytest.raises(ValueError, match=r"is not on this client's base_url"): + client.request_rest("GET", "https://app.useanvil.com/api/v1/x") + with pytest.raises(ValueError): + client.request_rest("GET", "https://app.useanvil.com.evil.example/x") + def it_uses_the_status_text_for_an_empty_error_response(server, client): server.respond({"status": 500, "body": b""}) @@ -704,7 +777,24 @@ def it_sends_requests_with_the_given_session(server, make_client): assert server.requests[0].headers["x-from-session"] == "yes" - def it_closes_the_session_as_a_context_manager(make_client): + def it_closes_its_own_session_as_a_context_manager(make_client): with make_client() as client: session = client.client.session assert isinstance(session, requests.Session) + + def it_does_not_close_a_session_it_was_given(server, make_client): + session = requests.Session() + with mock.patch.object(session, "close") as close, make_client(session=session): + pass + close.assert_not_called() + + def it_sends_a_plain_variable_called_file_as_json(server, client): + server.respond({"json": {"data": {"x": 1}}}) + + client.request_graphql( + "mutation ($file: String!) { x(file: $file) }", {"file": "abc"} + ) + + request = server.requests[0] + assert request.headers["content-type"] == "application/json" + assert request.json["variables"] == {"file": "abc"} diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py index 793c86db..bff61c7f 100644 --- a/tests/test_rate_limit.py +++ b/tests/test_rate_limit.py @@ -47,9 +47,27 @@ def it_keeps_the_starting_limits_when_the_headers_are_missing(): limiter.run(lambda: response()) - assert limiter.has_set_from_response + assert not limiter.has_set_from_response assert (limiter.limit, limiter.interval_ms) == (3, 500) + def it_takes_the_limits_from_the_first_response_that_has_them(): + limiter = RateLimiter(3, 500) + + limiter.run(lambda: response(status=502)) + limiter.run( + lambda: response( + headers={"x-ratelimit-limit": "40", "x-ratelimit-interval-ms": "1000"} + ) + ) + limiter.run( + lambda: response( + headers={"x-ratelimit-limit": "2", "x-ratelimit-interval-ms": "100"} + ) + ) + + assert limiter.has_set_from_response + assert (limiter.limit, limiter.interval_ms) == (40, 1000) + def it_holds_other_requests_until_the_first_response(): limiter = RateLimiter(10, 1000) order: list[str] = [] @@ -98,7 +116,7 @@ def fail(): # Would hang if the setup gate were still closed limiter.run(lambda: response()) - assert limiter.has_set_from_response + assert limiter._gate_open def it_waits_when_the_limit_is_reached(): limiter = RateLimiter(2, 200) @@ -151,6 +169,7 @@ def it_accepts_fractions_and_negatives(): assert retry_delay_seconds("0.5") == 0.55 assert retry_delay_seconds("-1") == 1.05 - def it_is_only_the_buffer_without_retry_after(): - assert retry_delay_seconds(None) == 0.05 - assert retry_delay_seconds("soon") == 0.05 + def it_waits_a_second_without_a_usable_retry_after(): + assert retry_delay_seconds(None) == 1.05 + assert retry_delay_seconds("soon") == 1.05 + assert retry_delay_seconds("Wed, 21 Oct 2026 07:28:00 GMT") == 1.05 From 2e8dde37e27e1ded8ded9ba50168da0ce884c2ea Mon Sep 17 00:00:00 2001 From: Derek Foster Date: Tue, 27 Oct 2026 23:22:28 +0000 Subject: [PATCH 3/6] Retry 5xx and connection errors, and share the rate limiter per credentials Both as the 5.x client did, rather than as node-anvil does: - Every request is retried on 500, 502, 503, 504 and connection errors, up to `max_retries` (5) times with exponential backoff from 100ms, the policy gql's transport applied to GraphQL requests. REST requests get it too now. Mutations are retried, as before; `max_retries=0` turns it off. Retries go through the rate limiter like any request, and a retried upload is rebuilt from its files. - The API's limit is per account, so every client in the process with the same base URL and credentials shares one limiter, and the limits the first response taught. A web app that makes a client per request is limited as a whole again, as it was with the `ratelimit` decorator. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 +- python_anvil/api.py | 11 ++ python_anvil/constants.py | 8 ++ python_anvil/http.py | 60 ++++++++++- python_anvil/rate_limit.py | 28 ++++++ tests/conftest.py | 8 ++ tests/test_http.py | 199 ++++++++++++++++++++++++++++++++++++- 7 files changed, 309 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3431df9..400ed8fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - BREAKING: One `requests`-based transport replaces `gql` for GraphQL and the `ratelimit` decorators for REST. Both dependencies are dropped. - `Anvil(api_key, base_url=...)` sends every request to one host: REST to `{base_url}/api/...`, GraphQL to `{base_url}/graphql`. `base_url` defaults to production. The `endpoint_url` argument is removed, and `environment` does nothing: the rate limits now come from the API. - OAuth access tokens are supported: `Anvil(access_token=...)`. JWTs are sent as-is, other tokens base64 encoded, like node-anvil. - - Rate limits are learned from the first response that carries the `X-RateLimit-Limit` and `X-RateLimit-Interval-Ms` headers. Requests made before the first response arrives wait for it. The client counts requests over a sliding window, like the API, and only waits when the limit has been reached. The limit is per client instance; `ratelimit`'s was one bucket for the whole process, so an app that makes a client per request now relies on each one's first response. A `429` is retried after its `Retry-After` plus 50ms, or after a second when there is no usable `Retry-After`, for as long as it takes. The `retry` option is removed. - - GraphQL requests are no longer retried on `500`, `502`, `503` and `504`. `gql`'s transport retried them up to 5 times, mutations included, which could send a mutation twice. To retry at the HTTP level, pass a `requests.Session` with your own `HTTPAdapter(max_retries=Retry(...))` as `session`. + - Rate limits are learned from the first response that carries the `X-RateLimit-Limit` and `X-RateLimit-Interval-Ms` headers. Requests made before the first response arrives wait for it. The client counts requests over a sliding window, like the API, and only waits when the limit has been reached. Every client in the process with the same `base_url` and credentials shares one limiter, and so the limits the API taught it; the starting `request_limit` only applies to the first of them. A `429` is retried after its `Retry-After` plus 50ms, or after a second when there is no usable `Retry-After`, for as long as it takes. The `retry` option is removed. + - Every request, REST included, is retried on `500`, `502`, `503`, `504` and connection errors, up to `max_retries` times (5) with exponential backoff from 100ms, the policy `gql`'s transport applied to GraphQL requests before. Mutations are retried too, as before. `max_retries=0` turns it off. - API errors raise `AnvilRequestError`, with the normalized `errors` (GraphQL errors, REST errors and validation errors with their `fields`), the `status_code`, the `headers` and any partial `data`. An error is any status outside 2xx, or a 2xx GraphQL response with `errors`; a non-JSON error, like a `502` from a proxy, keeps the start of its body. The REST methods used to raise a bare `Exception`; GraphQL methods raised `gql`'s errors. `AnvilRequestException` and `AnvilException` remain as aliases, and still accept a plain message, but their base class is now `Exception` rather than `BaseException`, so `except Exception` catches them. - Methods return the data: the PDF or zip `bytes` for `fill_pdf`, `generate_pdf` and `download_documents`, and the response's `data` dict for the GraphQL methods. A JSON response to a binary method, like `fill_pdf` with `outputFormat: "url"`, is returned parsed. The `include_headers`, `debug` and `retry` keyword arguments are removed and now raise `TypeError`, as do the `gql` options `query` and `mutate` used to pass through; the headers are on `AnvilRequestError`. - `data_type="stream"` on the binary methods returns the `requests.Response`, to stream a large PDF or zip. diff --git a/python_anvil/api.py b/python_anvil/api.py index 8f079bbb..fb487843 100644 --- a/python_anvil/api.py +++ b/python_anvil/api.py @@ -28,6 +28,7 @@ DATA_TYPE_BYTES, DATA_TYPE_JSON, DEFAULT_BASE_URL, + DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_LIMIT, DEFAULT_REQUEST_LIMIT_MS, DEFAULT_TIMEOUT, @@ -80,8 +81,16 @@ class Anvil: :param timeout: Seconds to wait for a connection, and then for each read, before a request fails. A `(connect, read)` tuple is also accepted. Defaults to 5 minutes; `None` waits forever. + :param max_retries: How many times to retry a request that got a 500, + 502, 503 or 504, or a connection error, with exponential backoff + from 100ms. Defaults to 5; `0` disables retries. Mutations are + retried too, as they were before 6.0.0. :param session: A `requests.Session` to send requests with, e.g. one configured with a proxy. Defaults to a session of the client's own. + + Every client in the process with the same `base_url` and credentials + shares one rate limiter, and so the limits the API taught it. The + starting `request_limit` only applies to the first of them. """ # Version number to use for latest versions (usually drafts) @@ -101,6 +110,7 @@ def __init__( request_limit: int = DEFAULT_REQUEST_LIMIT, request_limit_ms: int = DEFAULT_REQUEST_LIMIT_MS, timeout: float | tuple[float, float] | None = DEFAULT_TIMEOUT, + max_retries: int = DEFAULT_MAX_RETRIES, session: requests.Session | None = None, endpoint_url: str | None = None, ): @@ -128,6 +138,7 @@ def __init__( request_limit=request_limit, request_limit_ms=request_limit_ms, timeout=timeout, + max_retries=max_retries, session=session, ) diff --git a/python_anvil/constants.py b/python_anvil/constants.py index 608c3da4..2cd07d9d 100644 --- a/python_anvil/constants.py +++ b/python_anvil/constants.py @@ -29,3 +29,11 @@ # Seconds to wait for a connection, and then for each read, before giving up DEFAULT_TIMEOUT = 300 + +# How many times to retry a request that failed with one of these statuses or +# with a connection error, and the backoff before the first retry, doubled +# each time: 100ms, 200ms, 400ms, 800ms, 1.6s. The same policy gql's transport +# applied before 6.0.0. +DEFAULT_MAX_RETRIES = 5 +RETRY_STATUSES = (500, 502, 503, 504) +RETRY_BACKOFF_MS = 100 diff --git a/python_anvil/http.py b/python_anvil/http.py index 21938ecb..2ca50669 100644 --- a/python_anvil/http.py +++ b/python_anvil/http.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import time from collections.abc import Callable, Mapping from typing import Any from urllib.parse import urlsplit @@ -16,16 +17,19 @@ DATA_TYPE_STREAM, DATA_TYPES, DEFAULT_BASE_URL, + DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_LIMIT, DEFAULT_REQUEST_LIMIT_MS, DEFAULT_TIMEOUT, + RETRY_BACKOFF_MS, + RETRY_STATUSES, ) from python_anvil.errors import ( AnvilRequestError, normalize_exception, normalize_json_errors, ) -from python_anvil.rate_limit import RateLimiter +from python_anvil.rate_limit import RateLimiter, shared_limiter logger = logging.getLogger(__name__) @@ -41,7 +45,8 @@ class HTTPClient: """Sends requests to one Anvil environment with one set of credentials. Every request gets the Authorization and User-Agent headers, waits for the - rate limit, and is retried after a 429. See `RateLimiter`. + rate limit, and is retried after a 429 (see `RateLimiter`), a 5xx or a + connection error (see `max_retries`). """ def __init__( @@ -54,15 +59,20 @@ def __init__( request_limit: int = DEFAULT_REQUEST_LIMIT, request_limit_ms: int = DEFAULT_REQUEST_LIMIT_MS, timeout: float | tuple[float, float] | None = DEFAULT_TIMEOUT, + max_retries: int = DEFAULT_MAX_RETRIES, session: requests.Session | None = None, + rate_limiter: RateLimiter | None = None, ): self.base_url = normalize_base_url(base_url) self.auth_header = build_auth_header(api_key, access_token) self.user_agent = user_agent or default_user_agent() self.timeout = timeout + self.max_retries = max(int(max_retries), 0) self._owns_session = session is None self.session = session or requests.Session() - self.rate_limiter = RateLimiter(request_limit, request_limit_ms) + self.rate_limiter = rate_limiter or shared_limiter( + self.base_url, self.auth_header, request_limit, request_limit_ms + ) def url(self, path_or_url: str) -> str: """The full URL for a path like `/api/v1/fill/abc.pdf`. @@ -95,7 +105,7 @@ def request( stream: bool = False, timeout: float | tuple[float, float] | None = None, ) -> requests.Response: - """Send a request, within the rate limit and retried after any 429. + """Send a request, within the rate limit, retried after a 429, a 5xx or a connection error. The response is returned as the server sent it, errors and all. Use `process_response` to turn it into data or an `AnvilRequestError`. @@ -124,7 +134,39 @@ def send() -> requests.Response: timeout=timeout, ) - return self.rate_limiter.run(send) + attempt = 0 + while True: + try: + response = self.rate_limiter.run(send) + except requests.ConnectionError as e: + if attempt >= self.max_retries: + raise + delay = _backoff_seconds(attempt) + logger.warning( + "%s %s failed (%s). Retrying in %.1f seconds.", + method, + url, + e, + delay, + ) + else: + if ( + response.status_code not in RETRY_STATUSES + or attempt >= self.max_retries + ): + return response + delay = _backoff_seconds(attempt, response.headers.get("retry-after")) + # Nothing will read the body, so free the connection for the retry + response.close() + logger.warning( + "%s %s returned %s. Retrying in %.1f seconds.", + method, + url, + response.status_code, + delay, + ) + time.sleep(delay) + attempt += 1 def close(self) -> None: """Close the HTTP session, if it is the client's own.""" @@ -203,6 +245,14 @@ def process_response( ) # pragma: no cover +def _backoff_seconds(attempt: int, retry_after: Any = None) -> float: + """How long to wait before retry number `attempt + 1`: a 503's Retry-After, or exponential backoff.""" + try: + return abs(float(retry_after)) + except (TypeError, ValueError): + return RETRY_BACKOFF_MS / 1000 * 2**attempt + + def _body_snippet(response: requests.Response, limit: int = 2000) -> str: try: return response.content[:limit].decode(response.encoding or "utf-8", "replace") diff --git a/python_anvil/rate_limit.py b/python_anvil/rate_limit.py index cdcc2c62..2fa240ed 100644 --- a/python_anvil/rate_limit.py +++ b/python_anvil/rate_limit.py @@ -11,6 +11,11 @@ A request that still gets a `429 Too Many Requests` is retried after the `Retry-After` in the response, plus a small buffer. + +The API's limit is per account, so every client in the process with the +same base URL and credentials shares one limiter (`shared_limiter`), and +with it the limits the first response taught. Separate processes can't +share one; each learns the limits for itself. """ from __future__ import annotations @@ -168,6 +173,29 @@ def _release_setup_gate(self) -> None: self._condition.notify_all() +_shared: dict[tuple[str, str], RateLimiter] = {} +_shared_lock = threading.Lock() + + +def shared_limiter( + base_url: str, + auth_header: str, + limit: Any = DEFAULT_REQUEST_LIMIT, + interval_ms: Any = DEFAULT_REQUEST_LIMIT_MS, +) -> RateLimiter: + """The limiter every client with this base URL and credentials shares. + + The starting limits only apply when this makes the limiter, i.e. for the + first client; after that the limits come from the API anyway. + """ + key = (base_url, auth_header) + with _shared_lock: + limiter = _shared.get(key) + if limiter is None: + limiter = _shared[key] = RateLimiter(limit, interval_ms) + return limiter + + def retry_delay_seconds(retry_after: Any) -> float: """How long to wait before retrying a 429: its Retry-After seconds plus the buffer. diff --git a/tests/conftest.py b/tests/conftest.py index a18c709a..ddc92620 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,6 +33,14 @@ def respond_in_turn(self, *responses: Mapping[str, Any]) -> None: self.handler = lambda request, index: responses[min(index, len(responses) - 1)] +@pytest.fixture(autouse=True) +def fresh_shared_limiters(monkeypatch): + """Clients in different tests must not share rate limiters.""" + from python_anvil import rate_limit + + monkeypatch.setattr(rate_limit, "_shared", {}) + + @pytest.fixture def server(): test_server = TestServer() diff --git a/tests/test_http.py b/tests/test_http.py index e3ad8708..8bfa2fd9 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -225,8 +225,8 @@ def it_takes_the_limits_from_a_later_response_when_the_first_had_none( ): server.respond_in_turn( { - "status": 502, - "body": b"bad gateway", + "status": 401, + "body": b"unauthorized", "headers": {"x-ratelimit-limit": "", "x-ratelimit-interval-ms": ""}, }, { @@ -798,3 +798,198 @@ def it_sends_a_plain_variable_called_file_as_json(server, client): request = server.requests[0] assert request.headers["content-type"] == "application/json" assert request.json["variables"] == {"file": "abc"} + + +def describe_retries(): + def it_retries_a_503_and_returns_the_eventual_response(server, client): + server.respond_in_turn( + {"status": 503, "body": b"upstream down"}, {"json": {"ok": True}} + ) + + assert client.request_rest("GET", "/api/v1/x") == {"ok": True} + first, second = server.requests + assert second.received_at - first.responded_at >= 0.1 + + @pytest.mark.parametrize("status", [500, 502, 503, 504]) + def it_retries_each_5xx_that_gql_retried(server, client, status): + server.respond_in_turn({"status": status, "body": b""}, {"json": {"ok": True}}) + + assert client.request_rest("GET", "/api/v1/x") == {"ok": True} + assert len(server.requests) == 2 + + def it_does_not_retry_a_4xx(server, client): + server.respond({"status": 400, "json": {"message": "Bad"}}) + + with pytest.raises(AnvilRequestError): + client.request_rest("GET", "/api/v1/x") + assert len(server.requests) == 1 + + def it_gives_up_after_max_retries(server, make_client): + client = make_client(max_retries=2) + server.respond({"status": 502, "body": b"nope"}) + + with pytest.raises(AnvilRequestError) as excinfo: + client.request_rest("GET", "/api/v1/x") + + assert excinfo.value.status_code == 502 + assert len(server.requests) == 3 + + def it_can_be_turned_off(server, make_client): + client = make_client(max_retries=0) + server.respond({"status": 503, "body": b""}) + + with pytest.raises(AnvilRequestError): + client.request_rest("GET", "/api/v1/x") + assert len(server.requests) == 1 + + def it_backs_off_exponentially(server, make_client): + client = make_client(max_retries=3) + server.respond_in_turn( + {"status": 503, "body": b""}, + {"status": 503, "body": b""}, + {"status": 503, "body": b""}, + {"json": {"ok": True}}, + ) + + client.request_rest("GET", "/api/v1/x") + + gaps = [ + later.received_at - earlier.responded_at + for earlier, later in zip( + server.requests, server.requests[1:], strict=False + ) + ] + assert gaps[0] >= 0.1 and gaps[1] >= 0.2 and gaps[2] >= 0.4 + + def it_waits_for_retry_after_on_a_503(server, client): + server.respond_in_turn( + {"status": 503, "body": b"", "headers": {"retry-after": "0.5"}}, + {"json": {"ok": True}}, + ) + + client.request_rest("GET", "/api/v1/x") + + first, second = server.requests + assert second.received_at - first.responded_at >= 0.5 + + def it_retries_a_mutation_with_its_files(server, client): + server.respond_in_turn({"status": 502, "body": b""}, {"json": {"data": {}}}) + + client.request_graphql( + "mutation { x }", + {"input": {"file": Anvil.prepare_graphql_file(str(DUMMY_PDF))}}, + ) + + first, second = server.requests + assert first.multipart_parts()["1"].body == DUMMY_PDF.read_bytes() + assert second.multipart_parts()["1"].body == DUMMY_PDF.read_bytes() + + def it_retries_a_connection_error(make_client): + session = mock.Mock(spec=requests.Session) + good = mock.Mock(status_code=200, headers={"content-type": "application/json"}) + good.json.return_value = {"ok": True} + session.request.side_effect = [ + requests.ConnectionError("refused"), + requests.ConnectionError("refused"), + good, + ] + client = make_client(session=session, max_retries=2) + + assert client.request_rest("GET", "/api/v1/x") == {"ok": True} + assert session.request.call_count == 3 + + def it_raises_the_connection_error_after_max_retries(make_client): + session = mock.Mock(spec=requests.Session) + session.request.side_effect = requests.ConnectionError("refused") + client = make_client(session=session, max_retries=1) + + with pytest.raises(requests.ConnectionError): + client.request_rest("GET", "/api/v1/x") + assert session.request.call_count == 2 + + def it_counts_retries_against_the_rate_limit(server, make_client): + client = make_client(request_limit=1, request_limit_ms=300) + server.respond_in_turn( + { + "status": 503, + "body": b"", + "headers": {"x-ratelimit-limit": "1", "x-ratelimit-interval-ms": "300"}, + }, + {"json": {"ok": True}}, + ) + + client.request_rest("GET", "/api/v1/x") + + first, second = server.requests + assert second.received_at - first.received_at >= 0.3 + + +def describe_the_shared_rate_limiter(): + def it_is_shared_by_clients_with_the_same_base_url_and_credentials( + server, make_client + ): + first, second = make_client(api_key="k"), make_client(api_key="k") + server.respond( + { + "json": {}, + "headers": { + "x-ratelimit-limit": "7", + "x-ratelimit-interval-ms": "3000", + }, + } + ) + + first.request_rest("GET", "/api/v1/x") + + assert second.client.rate_limiter is first.client.rate_limiter + assert ( + second.client.rate_limiter.limit, + second.client.rate_limiter.interval_ms, + ) == (7, 3000) + + def it_is_separate_for_other_credentials_or_hosts(make_client): + base = make_client(api_key="k") + assert ( + make_client(api_key="other").client.rate_limiter + is not base.client.rate_limiter + ) + assert ( + Anvil("k", base_url="https://app.useanvil.com").client.rate_limiter + is not base.client.rate_limiter + ) + + def it_limits_clients_together(server, make_client): + server.respond( + { + "json": {}, + "headers": {"x-ratelimit-limit": "2", "x-ratelimit-interval-ms": "300"}, + } + ) + clients = [ + make_client(api_key="k", request_limit=2, request_limit_ms=300) + for _ in range(3) + ] + + threads = [ + threading.Thread(target=c.request_rest, args=("GET", "/api/v1/x")) + for c in clients + for _ in range(2) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + times = sorted(request.received_at for request in server.requests) + assert len(times) == 6 + for i in range(len(times) - 2): + assert times[i + 2] - times[i] >= 0.3 + + def it_takes_the_starting_limits_from_the_first_client(make_client): + first = make_client(api_key="k", request_limit=5, request_limit_ms=2000) + second = make_client(api_key="k", request_limit=1, request_limit_ms=1000) + assert ( + second.client.rate_limiter.limit, + second.client.rate_limiter.interval_ms, + ) == (5, 2000) + assert second.client.rate_limiter is first.client.rate_limiter From 3a58817781f540e29880ccf618339ca5eba6c22d Mon Sep 17 00:00:00 2001 From: Derek Foster Date: Wed, 28 Oct 2026 17:19:33 +0000 Subject: [PATCH 4/6] Check the rate limiting in test-client.py After the first request has taught the client the account's limit, send two more requests than it allows at once, from threads. The client must hold the extra ones back for a full interval, and none may get a 429. Co-Authored-By: Claude Fable 5.1 --- test-client.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/test-client.py b/test-client.py index 5e431f77..603af3b9 100644 --- a/test-client.py +++ b/test-client.py @@ -22,8 +22,11 @@ from __future__ import annotations +import logging import os import sys +import threading +import time from collections.abc import Callable from pathlib import Path @@ -51,6 +54,18 @@ class Skip(Exception): pass +class RateLimitLog(logging.Handler): + """Collects the client's warnings about requests the API rate limited.""" + + def __init__(self) -> None: + super().__init__() + self.rate_limited = 0 + + def emit(self, record: logging.LogRecord) -> None: + if "Rate limited" in record.getMessage(): + self.rate_limited += 1 + + def check(method: str, fn: Callable[[], str | None]) -> None: try: detail = fn() @@ -141,6 +156,57 @@ def request_graphql() -> str: check("request_graphql", request_graphql) + def rate_limiting() -> str: + # The first response taught the client the account's limit. Send more + # requests than it allows at once: the client must hold the extra ones + # back for a full interval, and the API should never have to answer 429. + limiter = client.client.rate_limiter + if not limiter.has_set_from_response: + raise AssertionError( + "The first response had no X-RateLimit-Limit and X-RateLimit-Interval-Ms headers" + ) + limit, interval_ms = limiter.limit, limiter.interval_ms + count = limit + 2 + if count > 50: + raise Skip( + f"The limit is {limit} per {interval_ms}ms: too many to test in one burst" + ) + + log = RateLimitLog() + logging.getLogger("python_anvil.rate_limit").addHandler(log) + failures: list[Exception] = [] + + def one() -> None: + try: + client.request_graphql("{ currentUser { eid } }") + except Exception as e: + failures.append(e) + + threads = [threading.Thread(target=one) for _ in range(count)] + started = time.monotonic() + for thread in threads: + thread.start() + for thread in threads: + thread.join() + elapsed = time.monotonic() - started + logging.getLogger("python_anvil.rate_limit").removeHandler(log) + + if failures: + raise failures[0] + if elapsed < interval_ms / 1000: + raise AssertionError( + f"{count} requests went out in {elapsed:.2f}s, faster than {limit} per {interval_ms}ms allows" + ) + if log.rate_limited: + raise AssertionError( + f"{log.rate_limited} of {count} requests got a 429 and were retried. The client " + "paces requests so the API never has to answer 429; another process using this " + "API key at the same time can cause it." + ) + return f"{limit} per {interval_ms}ms learned from the API; {count} requests took {elapsed:.2f}s, no 429s" + + check("rate limiting", rate_limiting) + def request_rest() -> str: data = client.request_rest( "POST", From ba2c75321bfb8658a79ec21823a199e23dda95a9 Mon Sep 17 00:00:00 2001 From: Derek Foster Date: Wed, 28 Oct 2026 17:25:36 +0000 Subject: [PATCH 5/6] Count a request against the rate limit until an interval after its response The API counts a request from when it arrives, which the client can't see: it is somewhere between when the request was sent and when the response came back. The limiter counted from when it was sent, with a 50ms buffer, which a new connection's TCP and TLS handshake alone can exceed. So a request the API still had in its window could already have left the client's, and test-client.py's burst got a 429. A request now counts from when it is sent until an interval after its response arrives (or after it fails, since it may have reached the API). The API's arrival time always lies in between, so it never counts more requests in its window than the client allowed, whatever the latency. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- python_anvil/rate_limit.py | 50 +++++++++++++++++++++++++++++--------- tests/test_rate_limit.py | 44 +++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 400ed8fd..747530da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - BREAKING: One `requests`-based transport replaces `gql` for GraphQL and the `ratelimit` decorators for REST. Both dependencies are dropped. - `Anvil(api_key, base_url=...)` sends every request to one host: REST to `{base_url}/api/...`, GraphQL to `{base_url}/graphql`. `base_url` defaults to production. The `endpoint_url` argument is removed, and `environment` does nothing: the rate limits now come from the API. - OAuth access tokens are supported: `Anvil(access_token=...)`. JWTs are sent as-is, other tokens base64 encoded, like node-anvil. - - Rate limits are learned from the first response that carries the `X-RateLimit-Limit` and `X-RateLimit-Interval-Ms` headers. Requests made before the first response arrives wait for it. The client counts requests over a sliding window, like the API, and only waits when the limit has been reached. Every client in the process with the same `base_url` and credentials shares one limiter, and so the limits the API taught it; the starting `request_limit` only applies to the first of them. A `429` is retried after its `Retry-After` plus 50ms, or after a second when there is no usable `Retry-After`, for as long as it takes. The `retry` option is removed. + - Rate limits are learned from the first response that carries the `X-RateLimit-Limit` and `X-RateLimit-Interval-Ms` headers. Requests made before the first response arrives wait for it. The client counts requests over a sliding window, like the API, and only waits when the limit has been reached. A request counts from when it is sent until an interval after its response arrives, so the API, which counts from when the request reached it, never sees more in its window than the client allowed. Every client in the process with the same `base_url` and credentials shares one limiter, and so the limits the API taught it; the starting `request_limit` only applies to the first of them. A `429` is retried after its `Retry-After` plus 50ms, or after a second when there is no usable `Retry-After`, for as long as it takes. The `retry` option is removed. - Every request, REST included, is retried on `500`, `502`, `503`, `504` and connection errors, up to `max_retries` times (5) with exponential backoff from 100ms, the policy `gql`'s transport applied to GraphQL requests before. Mutations are retried too, as before. `max_retries=0` turns it off. - API errors raise `AnvilRequestError`, with the normalized `errors` (GraphQL errors, REST errors and validation errors with their `fields`), the `status_code`, the `headers` and any partial `data`. An error is any status outside 2xx, or a 2xx GraphQL response with `errors`; a non-JSON error, like a `502` from a proxy, keeps the start of its body. The REST methods used to raise a bare `Exception`; GraphQL methods raised `gql`'s errors. `AnvilRequestException` and `AnvilException` remain as aliases, and still accept a plain message, but their base class is now `Exception` rather than `BaseException`, so `except Exception` catches them. - Methods return the data: the PDF or zip `bytes` for `fill_pdf`, `generate_pdf` and `download_documents`, and the response's `data` dict for the GraphQL methods. A JSON response to a binary method, like `fill_pdf` with `outputFormat: "url"`, is returned parsed. The `include_headers`, `debug` and `retry` keyword arguments are removed and now raise `TypeError`, as do the `gql` options `query` and `mutate` used to pass through; the headers are on `AnvilRequestError`. diff --git a/python_anvil/rate_limit.py b/python_anvil/rate_limit.py index 2fa240ed..2bed0078 100644 --- a/python_anvil/rate_limit.py +++ b/python_anvil/rate_limit.py @@ -5,6 +5,14 @@ so it only holds a request back when the limit has been reached, and never delays requests that are within it. +The API counts a request from the moment it arrives, which the client +can't see. It is somewhere between when the request was sent and when the +response came back, so a request counts here from when it is sent until an +interval after its response arrives. That way the API never counts more +requests in its window than the client counted in its own, whatever the +latency, and a new connection's handshake can't put a request on the wrong +side of the window. + Every response carries those headers, so the client's first request goes out alone, and the limits in its response become the limits for every request after it. Requests made in the meantime wait for that first response. @@ -58,8 +66,11 @@ def __init__( self.interval_ms: int = DEFAULT_REQUEST_LIMIT_MS self.set_limits(limit, interval_ms) - # When the requests in the current window were sent, oldest first - self._request_times: deque[float] = deque() + # How many requests are out now, and when the responses of recent + # ones arrived, oldest first. A request counts from when it is sent + # until an interval after its response. + self._in_flight = 0 + self._finished_at: deque[float] = deque() # Whether the limits have been set from an API response. Until they # have, every response is checked for the headers. self.has_set_from_response = False @@ -119,10 +130,13 @@ def run(self, send: Callable[[], requests.Response]) -> requests.Response: try: response = send() except BaseException: + # The request may still have reached the API, so it counts. # There won't be a response to set the limits from, so let - # the requests waiting for one go ahead + # the requests waiting for one go ahead. + self._finish() self._release_setup_gate() raise + self._finish() if not self.has_set_from_response: self.update_from_headers(response.headers) @@ -149,22 +163,34 @@ def _wait_for_turn(self) -> None: self._setting_in_progress = True # Wait until another request would keep us within `limit` requests - # in any `interval_ms` + # in any `interval_ms`: the ones out now, plus those whose response + # arrived within the last interval while True: # Not time.time(), so that changes to the system clock can't hold requests up now = time.monotonic() - # Requests sent before this no longer count. The buffer allows for - # requests that were sent further apart than they arrive. + # Responses that arrived before this no longer count. The buffer + # allows for the API's clock and the client's ticking apart. window_start = now - (self.interval_ms + RETRY_BUFFER_MS) / 1000 - while self._request_times and self._request_times[0] <= window_start: - self._request_times.popleft() + while self._finished_at and self._finished_at[0] <= window_start: + self._finished_at.popleft() - if len(self._request_times) < self.limit: - self._request_times.append(now) + if self._in_flight + len(self._finished_at) < self.limit: + self._in_flight += 1 return - # Wait for the oldest request to leave the window, then check again - self._condition.wait(timeout=self._request_times[0] - window_start) + if self._finished_at: + # Wait for the oldest response to leave the window, then check again + self._condition.wait(timeout=self._finished_at[0] - window_start) + else: + # Every slot is a request still out; a response will wake us + self._condition.wait() + + def _finish(self) -> None: + """A request's response arrived, or it failed: it counts for another interval.""" + with self._condition: + self._in_flight -= 1 + self._finished_at.append(time.monotonic()) + self._condition.notify_all() def _release_setup_gate(self) -> None: with self._condition: diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py index bff61c7f..0bf34803 100644 --- a/tests/test_rate_limit.py +++ b/tests/test_rate_limit.py @@ -137,6 +137,50 @@ def send(): # But the first two went straight away assert sent[1] - sent[0] < 0.1 + def it_counts_a_request_until_an_interval_after_its_response(): + # The API counts from when a request arrives, which is somewhere between + # when it was sent and when its response came back + limiter = RateLimiter(2, 200) + limiter.update_from_headers( + {"x-ratelimit-limit": "2", "x-ratelimit-interval-ms": "200"} + ) + sent: list[float] = [] + answered: list[float] = [] + + def slow_send(): + sent.append(time.monotonic()) + time.sleep(0.15) + answered.append(time.monotonic()) + return response() + + for _ in range(3): + limiter.run(slow_send) + + # The third waits for the first's response to leave the window, not its send + assert sent[2] - answered[0] >= 0.2 + assert sent[2] - sent[0] >= 0.35 + + def it_counts_a_request_that_failed(): + limiter = RateLimiter(1, 200) + limiter.update_from_headers( + {"x-ratelimit-limit": "1", "x-ratelimit-interval-ms": "200"} + ) + + def fail(): + raise ConnectionError("dropped") + + failed_at = time.monotonic() + with contextlib.suppress(ConnectionError): + limiter.run(fail) + sent: list[float] = [] + + def send(): + sent.append(time.monotonic()) + return response() + + limiter.run(send) + assert sent[0] - failed_at >= 0.2 + def it_retries_a_429_after_retry_after_plus_the_buffer(): limiter = RateLimiter(10, 1000) responses = iter([response(429, {"retry-after": "0.1"}), response(200)]) From 8d20ae874f26d9fb4972dd7cc54a1b8efe481234 Mon Sep 17 00:00:00 2001 From: Derek Foster Date: Wed, 28 Oct 2026 17:41:40 +0000 Subject: [PATCH 6/6] Harden the retries and the shared limiter after a second audit - A raw `data=` file object is rewound for a retry, and one that can't be is refused, rather than sent again empty. With 5xx retries on by default, a retried upload through request_rest would have succeeded with no body. - The slot a request takes in the rate limiter is given back even when an interrupt lands between taking it and sending; a leaked slot on a shared limiter with a limit of 1 would have blocked every client with those credentials for good. - The shared-limiter registry holds each limiter only as long as a client does, and is keyed on a hash of the credentials, so a service that makes a client per OAuth token neither grows without bound nor keeps every token it has seen. - A Retry-After header is capped at a minute, and one that isn't a finite number of seconds falls back to the default, instead of a proxy's mistake blocking a thread or crashing in time.sleep. - Read timeouts and connections dropped mid-response are deliberately not retried, since the API may have processed the request; the docs now say so instead of claiming gql's exact policy. - The backoff tests check upper bounds too, pytest-timeout turns a limiter hang into a failure, and the new parameters and paths have tests. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- poetry.lock | 17 +++++++- pyproject.toml | 3 ++ python_anvil/api.py | 6 ++- python_anvil/constants.py | 12 ++++-- python_anvil/http.py | 49 +++++++++++++++++++-- python_anvil/rate_limit.py | 30 ++++++++++--- tests/conftest.py | 4 +- tests/test_http.py | 88 ++++++++++++++++++++++++++++++++++++-- tests/test_rate_limit.py | 6 +++ 10 files changed, 196 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 747530da..d7295d32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Anvil(api_key, base_url=...)` sends every request to one host: REST to `{base_url}/api/...`, GraphQL to `{base_url}/graphql`. `base_url` defaults to production. The `endpoint_url` argument is removed, and `environment` does nothing: the rate limits now come from the API. - OAuth access tokens are supported: `Anvil(access_token=...)`. JWTs are sent as-is, other tokens base64 encoded, like node-anvil. - Rate limits are learned from the first response that carries the `X-RateLimit-Limit` and `X-RateLimit-Interval-Ms` headers. Requests made before the first response arrives wait for it. The client counts requests over a sliding window, like the API, and only waits when the limit has been reached. A request counts from when it is sent until an interval after its response arrives, so the API, which counts from when the request reached it, never sees more in its window than the client allowed. Every client in the process with the same `base_url` and credentials shares one limiter, and so the limits the API taught it; the starting `request_limit` only applies to the first of them. A `429` is retried after its `Retry-After` plus 50ms, or after a second when there is no usable `Retry-After`, for as long as it takes. The `retry` option is removed. - - Every request, REST included, is retried on `500`, `502`, `503`, `504` and connection errors, up to `max_retries` times (5) with exponential backoff from 100ms, the policy `gql`'s transport applied to GraphQL requests before. Mutations are retried too, as before. `max_retries=0` turns it off. + - Every request, REST included, is retried on `500`, `502`, `503` and `504`, and when it couldn't connect, up to `max_retries` times (5) with exponential backoff from 100ms, the statuses and count `gql`'s transport retried for GraphQL requests before. Mutations are retried too, as before. A request that was sent but got no complete response, like a read timeout, is not retried, since the API may have processed it; `gql` retried those too. `max_retries=0` turns it off. - API errors raise `AnvilRequestError`, with the normalized `errors` (GraphQL errors, REST errors and validation errors with their `fields`), the `status_code`, the `headers` and any partial `data`. An error is any status outside 2xx, or a 2xx GraphQL response with `errors`; a non-JSON error, like a `502` from a proxy, keeps the start of its body. The REST methods used to raise a bare `Exception`; GraphQL methods raised `gql`'s errors. `AnvilRequestException` and `AnvilException` remain as aliases, and still accept a plain message, but their base class is now `Exception` rather than `BaseException`, so `except Exception` catches them. - Methods return the data: the PDF or zip `bytes` for `fill_pdf`, `generate_pdf` and `download_documents`, and the response's `data` dict for the GraphQL methods. A JSON response to a binary method, like `fill_pdf` with `outputFormat: "url"`, is returned parsed. The `include_headers`, `debug` and `retry` keyword arguments are removed and now raise `TypeError`, as do the `gql` options `query` and `mutate` used to pass through; the headers are on `AnvilRequestError`. - `data_type="stream"` on the binary methods returns the `requests.Response`, to stream a large PDF or zip. diff --git a/poetry.lock b/poetry.lock index 3862185f..5a47c0e1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1020,6 +1020,21 @@ files = [ [package.dependencies] pytest = ">=7,<10" +[[package]] +name = "pytest-timeout" +version = "2.4.0" +description = "pytest plugin to abort hanging tests" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"}, + {file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + [[package]] name = "requests" version = "2.34.2" @@ -1239,4 +1254,4 @@ cli = ["click", "tabulate"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.16" -content-hash = "94229947f8ace0de8b1b0d99dbfe4e638820930c65bba905a1f54bf7bcef4094" +content-hash = "fe738c8cceafaf4bfa8df11ca65a70b619ed65ec1dfd01bed7e7a65bc36d87d0" diff --git a/pyproject.toml b/pyproject.toml index b8820739..9d4a10f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ mypy = ">=1.18" pytest = ">=8" pytest-cov = ">=6" pytest-describe = ">=2.2" +pytest-timeout = ">=2.3" types-requests = ">=2.28" types-tabulate = ">=0.9" # To check the client's queries and mutations against the API schema @@ -117,6 +118,8 @@ cache_dir = ".cache/mypy" [tool.pytest.ini_options] addopts = "--strict-markers -r sxX" +# A rate limiter regression would hang a test rather than fail it +timeout = 60 testpaths = ["tests"] cache_dir = ".cache/pytest" diff --git a/python_anvil/api.py b/python_anvil/api.py index fb487843..cc99a5f8 100644 --- a/python_anvil/api.py +++ b/python_anvil/api.py @@ -82,9 +82,11 @@ class Anvil: before a request fails. A `(connect, read)` tuple is also accepted. Defaults to 5 minutes; `None` waits forever. :param max_retries: How many times to retry a request that got a 500, - 502, 503 or 504, or a connection error, with exponential backoff + 502, 503 or 504, or that couldn't connect, with exponential backoff from 100ms. Defaults to 5; `0` disables retries. Mutations are - retried too, as they were before 6.0.0. + retried too, as they were before 6.0.0. A request that was sent but + got no complete response, like a read timeout, is not retried, since + the API may have processed it. :param session: A `requests.Session` to send requests with, e.g. one configured with a proxy. Defaults to a session of the client's own. diff --git a/python_anvil/constants.py b/python_anvil/constants.py index 2cd07d9d..67f3c598 100644 --- a/python_anvil/constants.py +++ b/python_anvil/constants.py @@ -24,16 +24,20 @@ # Added to every wait for the rate limit, and to a 429's Retry-After, to allow # for requests that were sent further apart than they arrive RETRY_BUFFER_MS = 50 -# How long to wait before retrying a 429 that has no usable Retry-After header +# How long to wait before retrying a 429 that has no usable Retry-After header, +# and the most a Retry-After header can make the client wait DEFAULT_RETRY_AFTER_MS = 1000 +MAX_RETRY_AFTER_MS = 60_000 # Seconds to wait for a connection, and then for each read, before giving up DEFAULT_TIMEOUT = 300 # How many times to retry a request that failed with one of these statuses or -# with a connection error, and the backoff before the first retry, doubled -# each time: 100ms, 200ms, 400ms, 800ms, 1.6s. The same policy gql's transport -# applied before 6.0.0. +# couldn't connect, and the backoff before the first retry, doubled each time: +# 100ms, 200ms, 400ms, 800ms, 1.6s. The statuses and count are what gql's +# transport retried before 6.0.0. A request that was sent but got no complete +# response (a read timeout, a connection dropped mid-response) is not retried: +# the API may have processed it. DEFAULT_MAX_RETRIES = 5 RETRY_STATUSES = (500, 502, 503, 504) RETRY_BACKOFF_MS = 100 diff --git a/python_anvil/http.py b/python_anvil/http.py index 2ca50669..9ec1b2ef 100644 --- a/python_anvil/http.py +++ b/python_anvil/http.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import math import time from collections.abc import Callable, Mapping from typing import Any @@ -21,6 +22,7 @@ DEFAULT_REQUEST_LIMIT, DEFAULT_REQUEST_LIMIT_MS, DEFAULT_TIMEOUT, + MAX_RETRY_AFTER_MS, RETRY_BACKOFF_MS, RETRY_STATUSES, ) @@ -112,6 +114,8 @@ def request( :param files: A function that returns the multipart parts to send, so they can be built again for a retry. + :param data: A raw body. A file object is rewound for a retry; one + that can't be is refused rather than sent empty. """ url = self.url(path_or_url) all_headers = { @@ -120,8 +124,13 @@ def request( **(headers or {}), } timeout = self.timeout if timeout is None else timeout + sends = 0 def send() -> requests.Response: + nonlocal sends + if sends: + _rewind(data) + sends += 1 return self.session.request( method.upper(), url, @@ -246,11 +255,45 @@ def process_response( def _backoff_seconds(attempt: int, retry_after: Any = None) -> float: - """How long to wait before retry number `attempt + 1`: a 503's Retry-After, or exponential backoff.""" + """How long to wait before retry number `attempt + 1`: the response's Retry-After, or exponential backoff.""" + seconds = parse_retry_after(retry_after) + if seconds is None: + return RETRY_BACKOFF_MS / 1000 * 2**attempt + return seconds + + +def parse_retry_after(retry_after: Any) -> float | None: + """A Retry-After header as seconds, capped, or None if it isn't a number of seconds. + + The header may also be an HTTP date, which isn't honored. A cap, because + the header is the server's to set, and a proxy's mistake shouldn't block + a thread for an hour. + """ try: - return abs(float(retry_after)) + seconds = abs(float(retry_after)) except (TypeError, ValueError): - return RETRY_BACKOFF_MS / 1000 * 2**attempt + return None + if not math.isfinite(seconds): + return None + return min(seconds, MAX_RETRY_AFTER_MS / 1000) + + +def _rewind(data: Any) -> None: + """Put a file-like body back at its start for a retry, or refuse to send it again.""" + if ( + data is None + or isinstance(data, (bytes, bytearray, str)) + or not hasattr(data, "read") + ): + return + if getattr(data, "seekable", lambda: False)(): + data.seek(0) + return + raise ValueError( + "Can't send the request's body again because it has already been read. " + "To send a body in a request that may be retried, pass bytes or a " + "seekable file object." + ) def _body_snippet(response: requests.Response, limit: int = 2000) -> str: diff --git a/python_anvil/rate_limit.py b/python_anvil/rate_limit.py index 2bed0078..6ec46d79 100644 --- a/python_anvil/rate_limit.py +++ b/python_anvil/rate_limit.py @@ -28,9 +28,12 @@ from __future__ import annotations +import hashlib import logging +import math import threading import time +import weakref from collections import deque from collections.abc import Callable, Mapping from typing import Any @@ -41,6 +44,7 @@ DEFAULT_REQUEST_LIMIT, DEFAULT_REQUEST_LIMIT_MS, DEFAULT_RETRY_AFTER_MS, + MAX_RETRY_AFTER_MS, RETRY_BUFFER_MS, ) @@ -126,14 +130,19 @@ def run(self, send: Callable[[], requests.Response]) -> requests.Response: request again. """ while True: - self._wait_for_turn() + # `_wait_for_turn` takes a slot, which `_finish` must give back + # whatever happens, even an interrupt that lands between the two + acquired = False try: + self._wait_for_turn() + acquired = True response = send() except BaseException: # The request may still have reached the API, so it counts. # There won't be a response to set the limits from, so let # the requests waiting for one go ahead. - self._finish() + if acquired: + self._finish() self._release_setup_gate() raise self._finish() @@ -199,7 +208,12 @@ def _release_setup_gate(self) -> None: self._condition.notify_all() -_shared: dict[tuple[str, str], RateLimiter] = {} +# Keyed on a hash of the credentials, not the credentials, and holding each +# limiter only as long as a client does: a service that makes a client per +# OAuth token must not keep every token it has ever seen. +_shared: weakref.WeakValueDictionary[tuple[str, str], RateLimiter] = ( + weakref.WeakValueDictionary() +) _shared_lock = threading.Lock() @@ -212,9 +226,11 @@ def shared_limiter( """The limiter every client with this base URL and credentials shares. The starting limits only apply when this makes the limiter, i.e. for the - first client; after that the limits come from the API anyway. + first client; after that the limits come from the API anyway. A limiter + lives as long as a client holds it; the next client made after the last + one is gone learns the limits again from its first response. """ - key = (base_url, auth_header) + key = (base_url, hashlib.sha256(auth_header.encode()).hexdigest()) with _shared_lock: limiter = _shared.get(key) if limiter is None: @@ -233,7 +249,9 @@ def retry_delay_seconds(retry_after: Any) -> float: seconds = abs(float(retry_after)) except (TypeError, ValueError): seconds = DEFAULT_RETRY_AFTER_MS / 1000 - return seconds + RETRY_BUFFER_MS / 1000 + if not math.isfinite(seconds): + seconds = DEFAULT_RETRY_AFTER_MS / 1000 + return min(seconds, MAX_RETRY_AFTER_MS / 1000) + RETRY_BUFFER_MS / 1000 def _header(headers: Mapping[str, Any], name: str) -> Any: diff --git a/tests/conftest.py b/tests/conftest.py index ddc92620..deb7be0d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,9 +36,11 @@ def respond_in_turn(self, *responses: Mapping[str, Any]) -> None: @pytest.fixture(autouse=True) def fresh_shared_limiters(monkeypatch): """Clients in different tests must not share rate limiters.""" + import weakref + from python_anvil import rate_limit - monkeypatch.setattr(rate_limit, "_shared", {}) + monkeypatch.setattr(rate_limit, "_shared", weakref.WeakValueDictionary()) @pytest.fixture diff --git a/tests/test_http.py b/tests/test_http.py index 8bfa2fd9..5d850014 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -325,7 +325,7 @@ def it_lets_waiting_requests_go_ahead_when_the_first_request_fails( server, make_client ): # A port nothing listens on, then the real server - client = make_client(request_limit=10) + client = make_client(request_limit=10, max_retries=0) client.client.base_url = "http://127.0.0.1:1" def first_fails(): @@ -859,7 +859,9 @@ def it_backs_off_exponentially(server, make_client): server.requests, server.requests[1:], strict=False ) ] - assert gaps[0] >= 0.1 and gaps[1] >= 0.2 and gaps[2] >= 0.4 + assert 0.1 <= gaps[0] < 0.2 + assert 0.2 <= gaps[1] < 0.4 + assert 0.4 <= gaps[2] < 0.8 def it_waits_for_retry_after_on_a_503(server, client): server.respond_in_turn( @@ -870,7 +872,59 @@ def it_waits_for_retry_after_on_a_503(server, client): client.request_rest("GET", "/api/v1/x") first, second = server.requests - assert second.received_at - first.responded_at >= 0.5 + assert 0.5 <= second.received_at - first.responded_at < 0.9 + + def it_caps_a_huge_retry_after(server, client): + server.respond_in_turn( + {"status": 503, "body": b"", "headers": {"retry-after": "1e9"}}, + {"json": {"ok": True}}, + ) + with mock.patch("python_anvil.http.time.sleep") as sleep: + client.request_rest("GET", "/api/v1/x") + assert sleep.call_args.args[0] == 60 + + def it_backs_off_for_a_retry_after_that_is_not_a_number(server, client): + server.respond_in_turn( + {"status": 503, "body": b"", "headers": {"retry-after": "nan"}}, + { + "status": 503, + "body": b"", + "headers": {"retry-after": "Wed, 21 Oct 2026 07:28:00 GMT"}, + }, + {"json": {"ok": True}}, + ) + with mock.patch("python_anvil.http.time.sleep") as sleep: + client.request_rest("GET", "/api/v1/x") + assert [call.args[0] for call in sleep.call_args_list] == [0.1, 0.2] + + def it_rewinds_a_file_body_for_a_retry(server, client): + server.respond_in_turn({"status": 503, "body": b""}, {"json": {"ok": True}}) + + client.request_rest("POST", "/api/v1/x", data=io.BytesIO(b"hello world")) + + first, second = server.requests + assert first.body == b"hello world" + assert second.body == b"hello world" + + def it_refuses_to_resend_a_body_that_cannot_be_rewound(server, client): + server.respond_in_turn({"status": 503, "body": b""}, {"json": {"ok": True}}) + + class OnceOnly(io.BytesIO): + def seekable(self) -> bool: + return False + + with pytest.raises(ValueError, match=r"Can't send the request's body again"): + client.request_rest("POST", "/api/v1/x", data=OnceOnly(b"hello")) + assert len(server.requests) == 1 + + def it_does_not_retry_a_read_timeout(make_client): + session = mock.Mock(spec=requests.Session) + session.request.side_effect = requests.ReadTimeout("slow") + client = make_client(session=session) + + with pytest.raises(requests.ReadTimeout): + client.request_rest("GET", "/api/v1/x") + assert session.request.call_count == 1 def it_retries_a_mutation_with_its_files(server, client): server.respond_in_turn({"status": 502, "body": b""}, {"json": {"data": {}}}) @@ -985,6 +1039,34 @@ def it_limits_clients_together(server, make_client): for i in range(len(times) - 2): assert times[i + 2] - times[i] >= 0.3 + def it_accepts_a_limiter_of_its_own(server, make_client): + from python_anvil.http import HTTPClient + from python_anvil.rate_limit import RateLimiter + + own = RateLimiter(3, 300) + client = HTTPClient("k", base_url=server.base_url, rate_limiter=own) + assert client.rate_limiter is own + assert make_client(api_key="k").client.rate_limiter is not own + + def it_forgets_a_limiter_once_no_client_holds_it(server, make_client): + import gc + import weakref + + first = make_client(api_key="k") + limiter = weakref.ref(first.client.rate_limiter) + del first + gc.collect() + assert limiter() is None + assert make_client(api_key="k").client.rate_limiter is not None + + def it_does_not_keep_the_credentials_in_the_registry(server, make_client): + from python_anvil import rate_limit + + make_client(api_key="super-secret-key") + for key in list(rate_limit._shared): + assert "super-secret-key" not in repr(key) + assert "Basic" not in repr(key) + def it_takes_the_starting_limits_from_the_first_client(make_client): first = make_client(api_key="k", request_limit=5, request_limit_ms=2000) second = make_client(api_key="k", request_limit=1, request_limit_ms=1000) diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py index 0bf34803..bd4943bc 100644 --- a/tests/test_rate_limit.py +++ b/tests/test_rate_limit.py @@ -217,3 +217,9 @@ def it_waits_a_second_without_a_usable_retry_after(): assert retry_delay_seconds(None) == 1.05 assert retry_delay_seconds("soon") == 1.05 assert retry_delay_seconds("Wed, 21 Oct 2026 07:28:00 GMT") == 1.05 + assert retry_delay_seconds("nan") == 1.05 + assert retry_delay_seconds("inf") == 1.05 + + def it_caps_retry_after_at_a_minute(): + assert retry_delay_seconds("1e9") == 60.05 + assert retry_delay_seconds("3600") == 60.05