diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcb65aad..e4984143 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,8 @@ jobs: - run: make install - run: make lint + # python_anvil/types must be regenerated when schema/ changes + - run: make check-generated test: name: Test diff --git a/.github/workflows/sync-api-types.yml b/.github/workflows/sync-api-types.yml new file mode 100644 index 00000000..6d9c0158 --- /dev/null +++ b/.github/workflows/sync-api-types.yml @@ -0,0 +1,158 @@ +name: Sync API Types + +# Refreshes schema/ from the live Anvil API and the published @anvilco/types, +# regenerates python_anvil/types, and opens one PR when any of it changed. +# Runs after a production rollout completes: Foundry's postsync step sends +# `anvil-production-deploy` to every repo in its --webhook-repos list +# (anvil-infra: k8s/anvil/base/foundry/postsync.yaml). That dispatch is +# best-effort with no retry, so the weekly cron is a backstop for a dropped one. +# anvil-js's sync-api-types.yml does the same for @anvilco/types. + +on: + repository_dispatch: + types: [anvil-production-deploy] + schedule: + - cron: '0 10 * * 1' # Every Monday at 10am UTC, after anvil-js's 9am sync + workflow_dispatch: + +# BRANCH is force-pushed, so never let two runs race on it. +concurrency: sync-api-types + +env: + BRANCH: chore/sync-api-types + SCHEMA_DIR: schema + TYPES_DIR: python_anvil/types + +jobs: + sync-api-types: + name: Sync API Types + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + # Always master, so a manual run from another branch can't put that + # branch's commits in the PR. No persisted credentials: CI_GITHUB_TOKEN + # is only handed to git and gh in the steps that push, not to the + # install and the generators. + - uses: actions/checkout@v7 + with: + ref: master + persist-credentials: false + + - run: pipx install poetry + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version-file: '.python-version' + cache: 'poetry' + + - run: make install + + # One step, so the types are never regenerated from a spec that failed + # to fetch, and a failed generation is discarded along with the specs. + - name: Fetch the specs and generate the types + id: generate + continue-on-error: true + run: | + make update-schema + make generate + + - name: Discard the output if that failed + if: steps.generate.outcome != 'success' + run: git checkout -- "$SCHEMA_DIR" "$TYPES_DIR" + + - name: Check for changes + id: changes + run: | + echo "changed=$(git diff --quiet -- "$SCHEMA_DIR" "$TYPES_DIR" && echo false || echo true)" >> "$GITHUB_OUTPUT" + git diff --stat + + - name: Open or update PR + if: steps.changes.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.CI_GITHUB_TOKEN }} + EVENT_NAME: ${{ github.event_name }} + DEPLOY_VERSION: ${{ github.event.client_payload.version }} + DEPLOY_PR_URL: ${{ github.event.client_payload.pullRequest.url }} + DEPLOYED_AT: ${{ github.event.client_payload.deployedAt }} + run: | + # Not GITHUB_TOKEN: pushes and PRs made with it don't trigger + # workflows, so the PR would never get its CI checks. + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + + # A deploy that didn't move the API again would otherwise force-push + # an identical commit, re-running CI and discarding any edits made + # on the branch. + PR=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') + if [ -n "$PR" ] && git fetch -q --depth=1 origin "$BRANCH" && git diff --quiet FETCH_HEAD -- "$SCHEMA_DIR" "$TYPES_DIR"; then + echo "PR #$PR already has this output" + exit 0 + fi + + # Note the sync under Unreleased in the changelog, once per release + python3 - <<'PY' + import re + from pathlib import Path + + path = Path("CHANGELOG.md") + text = path.read_text(encoding="utf-8") + note = "- Sync the generated API types in `python_anvil.types` with the production API." + match = re.search(r"^## \[Unreleased\]\n(?P
.*?)(?=^## \[|\Z)", text, re.M | re.S) + if match and note not in match["body"]: + body = match["body"].rstrip("\n") + section = f"{body}\n{note}\n\n" if body else f"\n{note}\n\n" + text = text[: match.start("body")] + section + text[match.end("body") :] + path.write_text(text, encoding="utf-8") + PY + + if [ "$EVENT_NAME" = repository_dispatch ]; then + TRIGGER="Anvil production deploy \`$DEPLOY_VERSION\` ($DEPLOY_PR_URL), deployed $DEPLOYED_AT." + else + TRIGGER="\`$EVENT_NAME\` run." + fi + { + echo "Automated sync of the generated types from the production Anvil API. Triggered by $TRIGGER" + echo + echo "- \`$SCHEMA_DIR/\`: the GraphQL schema (from the published \`@anvilco/types\`) and the OpenAPI spec" + echo "- \`$TYPES_DIR/\`: regenerated from them with \`make generate\`" + echo + echo "If a type or field was removed or renamed, note it under Unreleased in CHANGELOG.md as a breaking change before merging." + echo + echo "A later deploy that changes these files again rebuilds this branch from \`master\`, replacing any edits made here." + } > "$RUNNER_TEMP/pr-body.md" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -B "$BRANCH" + git add -A -- "$SCHEMA_DIR" "$TYPES_DIR" CHANGELOG.md + git commit -m "Sync the generated API types from production" + git push --force origin "$BRANCH" + + if [ -n "$PR" ]; then + gh pr edit "$PR" --body-file "$RUNNER_TEMP/pr-body.md" + else + gh pr create \ + --title "Sync the generated API types from production" \ + --body-file "$RUNNER_TEMP/pr-body.md" \ + --base master \ + --head "$BRANCH" + fi + + # Production moved back to match master (e.g. a rollback before the PR + # merged), so an open sync PR is now wrong. + - name: Close stale PR + if: steps.changes.outputs.changed == 'false' && steps.generate.outcome == 'success' + env: + GH_TOKEN: ${{ secrets.CI_GITHUB_TOKEN }} + run: | + PR=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') + if [ -n "$PR" ]; then + gh pr close "$PR" --delete-branch --comment "Production now matches master, so this sync is no longer needed." + fi + + - name: Fail if the generator failed + if: steps.generate.outcome != 'success' + run: | + echo "::error::Fetching the specs or generating the types failed. See the step above." + exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index d7295d32..8bfd5d6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,8 +30,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`. +- `Anvil()` with no key reads `ANVIL_API_KEY` from the environment, the way the examples and the CLI do. The client keeps `api_key` and `access_token` as attributes. +- Every method takes a `timeout` keyword argument, overriding the client's timeout for that request. +- `create_etch_packet` and `forge_submit` take the payload as a JSON string too, like `fill_pdf` and `generate_pdf`, and their dicts may hold any kind of file (a template reference, Markdown, HTML or an upload) or model instances, with snake_case or camelCase keys. `query` and `mutate` take `variables` as a JSON string. A `response_query` may be given with or without braces. An `in-person` signer is accepted. An uploaded open file is sent under its own filename rather than `document.pdf`. +- `GeneratePDFPayload` extends the generated request type like the other payload models, so `page`, `logo`, `include_timestamp` and `output_format` are typed. `utils.camel_to_snake` keeps acronyms whole now (`webhookURL` -> `webhook_url`; it gave `webhook_u_r_l`). The CLI's `cast` and `weld` without an eid exit with a usage error (code 2) instead of a traceback. `CREATE_ETCH_PACKET` and `FORGE_SUBMIT` are still exported from the builder modules; a `BaseQuery` subclass with its own `mutation` template still works. - 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`. +- Add `python_anvil.types`: pydantic models generated from the API's OpenAPI spec and GraphQL schema, the Python counterpart of `@anvilco/types`. `types.rest` has the REST request bodies, `types.graphql` every type in the schema, and `types.operations` the arguments of every query and mutation, like `CreateEtchPacketArgs`. The client's payload models extend them, so `FillPDFPayload`, `EtchSigner`, `CreateEtchPacketPayload` and `ForgeSubmitPayload` now accept every option the API has. BREAKING: they also validate those options the way the API documents them: an enum value the API doesn't accept (like `signature_mode="wave"`), or a number out of its documented range (like `token_valid_for_minutes` above a day), is a `ValidationError` before anything is sent, where 5.x passed it through for the API to reject. `font_size` and `routing_order` keep accepting any value, as they did. `ForgeSubmitPayload.webhook_url` is a plain `str` now, not an `HttpUrl`. +- A Sync API Types workflow refreshes `schema/` and regenerates `python_anvil/types` after each production deploy, opening a PR when anything changed. `make update-schema generate` does the same by hand. ## [v5.0.3] - 2025-02-24 diff --git a/Makefile b/Makefile index 624c9d34..cf0a26dd 100644 --- a/Makefile +++ b/Makefile @@ -30,9 +30,17 @@ test-client: install ## Run every method against a real environment (staging by poetry run python test-client.py .PHONY: update-schema -update-schema: install ## Refresh schema/anvil_schema.graphql from the published @anvilco/types +update-schema: install ## Refresh schema/ from the published @anvilco/types and the API's OpenAPI spec poetry run python dev/fetch_schema.py +.PHONY: generate +generate: install ## Regenerate python_anvil/types from schema/ + poetry run python dev/generate_types.py + +.PHONY: check-generated +check-generated: generate ## Fail if python_anvil/types is out of date with schema/ + git diff --exit-code -- python_anvil/types + .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 index 06d13e6f..2e07ed0a 100644 --- a/dev/fetch_schema.py +++ b/dev/fetch_schema.py @@ -1,15 +1,20 @@ #!/usr/bin/env python3 -"""Refreshes schema/anvil_schema.graphql from the published @anvilco/types package. +"""Refreshes the API specs the generated types come from. -@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. +- schema/anvil_schema.graphql, from the published @anvilco/types npm package + (https://www.npmjs.com/package/@anvilco/types). It 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. +- schema/openapi.json, the API's own OpenAPI spec, from production. + +Neither needs an API key. Then run `dev/generate_types.py` (`make generate`) +to regenerate python_anvil/types from them. Usage: - poetry run python dev/fetch_schema.py [version] + poetry run python dev/fetch_schema.py [@anvilco/types version] -Defaults to the latest version. On any failure the existing file is untouched. +Defaults to the latest version. On any failure the existing files are untouched. """ from __future__ import annotations @@ -25,24 +30,31 @@ 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" +OPENAPI_URL = "https://app.useanvil.com/api/v1/openapi.json" + +SCHEMA_DIR = Path(__file__).resolve().parent.parent / "schema" +SCHEMA_PATH = SCHEMA_DIR / "anvil_schema.graphql" +OPENAPI_PATH = SCHEMA_DIR / "openapi.json" -def fetch_schema(version: str = "latest") -> tuple[str, str]: +def fetch_graphql_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: + with urllib.request.urlopen(metadata_url, timeout=60) 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: + with urllib.request.urlopen(tarball_url, timeout=60) as response: tarball = response.read() with tarfile.open(fileobj=io.BytesIO(tarball), mode="r:gz") as archive: - member = archive.extractfile(SCHEMA_IN_PACKAGE) + try: + member = archive.extractfile(SCHEMA_IN_PACKAGE) + except KeyError: + member = None if member is None: raise FileNotFoundError( f"{SCHEMA_IN_PACKAGE} is not in {PACKAGE}@{version}" @@ -50,13 +62,34 @@ def fetch_schema(version: str = "latest") -> tuple[str, str]: return member.read().decode("utf-8"), version +def fetch_openapi_spec() -> str: + """The OpenAPI spec, pretty-printed with sorted keys so the diff is only real changes.""" + with urllib.request.urlopen(OPENAPI_URL, timeout=60) as response: + spec = json.load(response) + if "openapi" not in spec or "paths" not in spec: + raise ValueError(f"{OPENAPI_URL} did not return an OpenAPI spec") + return json.dumps(spec, indent=2, sort_keys=True) + "\n" + + def main() -> None: version = sys.argv[1] if len(sys.argv) > 1 else "latest" - schema, version = fetch_schema(version) - OUTPUT_PATH.write_text(schema) + + schema, version = fetch_graphql_schema(version) + openapi = fetch_openapi_spec() + + SCHEMA_PATH.write_text(schema, encoding="utf-8") print( - f"Wrote the schema from {PACKAGE}@{version} to {OUTPUT_PATH.relative_to(Path.cwd())}" + f"Wrote the GraphQL schema from {PACKAGE}@{version} to {_relative(SCHEMA_PATH)}" ) + OPENAPI_PATH.write_text(openapi, encoding="utf-8") + print(f"Wrote the OpenAPI spec from {OPENAPI_URL} to {_relative(OPENAPI_PATH)}") + + +def _relative(path: Path) -> str: + try: + return str(path.relative_to(Path.cwd())) + except ValueError: + return str(path) if __name__ == "__main__": diff --git a/dev/generate_types.py b/dev/generate_types.py new file mode 100644 index 00000000..06334e7f --- /dev/null +++ b/dev/generate_types.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +"""Generates python_anvil/types from the API specs in schema/. + +- types/rest.py: pydantic models for the REST API's request bodies, from + schema/openapi.json, with datamodel-code-generator. +- types/graphql.py: pydantic models for every type in the GraphQL schema, + objects, inputs and enums, from schema/anvil_schema.graphql, with + datamodel-code-generator. +- types/operations.py: a pydantic model of the arguments of every query and + mutation, e.g. CreateEtchPacketArgs, from the same schema with graphql-core. + +Reads the checked-in specs only, so this runs offline. `dev/fetch_schema.py` +(`make update-schema`) is what refreshes them. + +Usage: poetry run python dev/generate_types.py +""" + +from __future__ import annotations + +import json +import keyword +import re +import subprocess +import sys +from pathlib import Path + +from graphql import ( + GraphQLArgument, + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLList, + GraphQLNamedType, + GraphQLNonNull, + GraphQLScalarType, + GraphQLSchema, + GraphQLType, + build_schema, +) + +# graphql-core marks an argument without a default with its own Undefined +from graphql.pyutils import Undefined +from pydantic import BaseModel + + +ROOT = Path(__file__).resolve().parent.parent +SCHEMA_PATH = ROOT / "schema" / "anvil_schema.graphql" +OPENAPI_PATH = ROOT / "schema" / "openapi.json" +TYPES_DIR = ROOT / "python_anvil" / "types" +REST_PATH = TYPES_DIR / "rest.py" +GRAPHQL_PATH = TYPES_DIR / "graphql.py" +OPERATIONS_PATH = TYPES_DIR / "operations.py" + +BASE_CLASS = "python_anvil.types._base.GeneratedModel" + +# The same for both datamodel-code-generator runs +CODEGEN_OPTIONS = [ + "--output-model-type", + "pydantic_v2.BaseModel", + "--base-class", + BASE_CLASS, + "--target-python-version", + "3.10", + "--snake-case-field", + "--allow-population-by-field-name", + "--extra-fields", + "allow", + "--use-standard-collections", + "--use-union-operator", + "--use-annotated", + "--field-constraints", + "--use-schema-description", + "--use-double-quotes", + "--disable-timestamp", + # Enums as Literal types, so the fields take and give plain strings + "--enum-field-as-literal", + "all", + # The outputs are formatted with ruff afterwards + "--formatters", + "builtin", +] + +# The schema's custom scalars, as Python types +GRAPHQL_SCALARS = { + "String": "str", + "Int": "int", + "Float": "float", + "Boolean": "bool", + "ID": "str", + "Date": "str", + "JSON": "Any", + "Upload": "Any", +} + + +def header(source: Path, extra: str = "") -> str: + return ( + f"# Generated by dev/generate_types.py from {source.relative_to(ROOT)}. Do not edit by hand.\n" + "# Run `make generate` to regenerate.\n" + extra + ) + + +def run_codegen( + input_path: Path, input_type: str, output_path: Path, *extra: str +) -> None: + subprocess.run( + [ + sys.executable, + "-m", + "datamodel_code_generator", + "--input", + str(input_path), + "--input-file-type", + input_type, + "--output", + str(output_path), + *CODEGEN_OPTIONS, + *extra, + ], + check=True, + cwd=ROOT, + ) + + +def generate_rest() -> None: + run_codegen(OPENAPI_PATH, "openapi", REST_PATH, "--collapse-root-models") + source = REST_PATH.read_text(encoding="utf-8") + # Plain strings for emails and dates. EmailStr would need the email-validator + # package, and AwareDatetime would turn a date string into a datetime that + # the JSON encoder can't send, and reject one with no timezone. + source = _use_plain_str(source, ["EmailStr", "AwareDatetime"]) + source = re.sub( + r"^# generated by datamodel-codegen:\n# filename: .*\n", + "", + source, + flags=re.M, + ) + REST_PATH.write_text(header(OPENAPI_PATH) + source, encoding="utf-8") + + +def _use_plain_str(source: str, type_names: list[str]) -> str: + """Replace pydantic types with `str`, and drop them from the pydantic import. + + The import may be on one line or wrapped over several, so the names are + removed from the import first, by whichever form they take, and then + replaced everywhere else. + """ + for name in type_names: + # `from pydantic import A, Name, B` or a wrapped ` Name,` line + source = re.sub( + rf"^(from pydantic import [^(\n]*?)\b{name}, ", r"\1", source, flags=re.M + ) + source = re.sub( + rf"^(from pydantic import [^(\n]*?), {name}\b", r"\1", source, flags=re.M + ) + source = re.sub(rf"^\s*{name},\n", "", source, flags=re.M) + source = re.sub(rf"\b{name}\b", "str", source) + return source + + +def generate_graphql() -> None: + overrides = json.dumps({"JSON": "typing.Any", "Upload": "typing.Any"}) + run_codegen( + SCHEMA_PATH, + "graphql", + GRAPHQL_PATH, + "--graphql-no-typename", + "--type-overrides", + overrides, + ) + source = GRAPHQL_PATH.read_text(encoding="utf-8") + source = re.sub( + r"^# generated by datamodel-codegen:\n# filename: .*\n", + "", + source, + flags=re.M, + ) + GRAPHQL_PATH.write_text(header(SCHEMA_PATH) + source, encoding="utf-8") + + +def generate_operations(schema: GraphQLSchema) -> None: + """A model of the arguments of every query and mutation.""" + lines = [ + header( + SCHEMA_PATH, + '"""The arguments of every query and mutation in the Anvil GraphQL schema, as pydantic models.\n\n' + "Field names are snake_case; `model_dump(by_alias=True, exclude_none=True)` gives the\n" + "camelCase variables to send. Input object and enum types come from `python_anvil.types.graphql`.\n" + '"""\n', + ), + "from __future__ import annotations", + "", + "from typing import Annotated, Any", + "", + "from pydantic import Field", + "", + "from python_anvil.types import graphql", + "from python_anvil.types._base import GeneratedModel", + "", + "", + ] + + names: dict[str, str] = {} + all_names: list[str] = [] + for operation, root in ( + ("query", schema.query_type), + ("mutation", schema.mutation_type), + ): + if root is None: + continue + for field_name, field in root.fields.items(): + class_name = f"{field_name[0].upper()}{field_name[1:]}Args" + if class_name in names: + raise ValueError(f"{class_name} would be both a query and a mutation") + names[class_name] = field_name + all_names.append(class_name) + + lines.append(f"class {class_name}(GeneratedModel):") + description = f"Arguments of the `{field_name}` {operation}." + if field.description: + description = f"{description}\n\n{_indent(field.description.strip())}" + lines.append(f' """{description}\n """') + lines.append("") + if not field.args: + lines.append(" pass") + for arg_name, arg in field.args.items(): + lines.extend(_argument_lines(arg_name, arg)) + lines.extend(["", ""]) + + lines.append("__all__ = [") + lines.extend(f' "{name}",' for name in sorted(all_names)) + lines.append("]") + lines.append("") + OPERATIONS_PATH.write_text("\n".join(lines), encoding="utf-8") + + +def _argument_lines(arg_name: str, arg: GraphQLArgument) -> list[str]: + required = isinstance(arg.type, GraphQLNonNull) and arg.default_value is Undefined + annotation = _python_type(arg.type) + if not required: + annotation = f"{annotation} | None" + + field_name = _snake_case(arg_name) + # A Python keyword, or a name pydantic's BaseModel already uses (like `schema`) + if keyword.iskeyword(field_name) or hasattr(BaseModel, field_name): + field_name = f"{field_name}_" + field_options = [f'alias="{arg_name}"'] if field_name != arg_name else [] + descriptions = [] + if arg.description: + descriptions.append(arg.description.strip()) + if arg.default_value is not Undefined: + descriptions.append( + f"Defaults to `{_graphql_literal(arg.default_value)}` when left out." + ) + if descriptions: + field_options.append(f"description={json.dumps(' '.join(descriptions))}") + + if field_options: + annotation = f"Annotated[{annotation}, Field({', '.join(field_options)})]" + default = "" if required else " = None" + return [f" {field_name}: {annotation}{default}"] + + +def _python_type(graphql_type: GraphQLType) -> str: + if isinstance(graphql_type, GraphQLNonNull): + return _python_type(graphql_type.of_type) + if isinstance(graphql_type, GraphQLList): + inner = _python_type(graphql_type.of_type) + if not isinstance(graphql_type.of_type, GraphQLNonNull): + inner = f"{inner} | None" + return f"list[{inner}]" + if isinstance(graphql_type, GraphQLScalarType): + return GRAPHQL_SCALARS.get(graphql_type.name, "Any") + if isinstance(graphql_type, (GraphQLEnumType, GraphQLInputObjectType)): + return f"graphql.{graphql_type.name}" + if isinstance(graphql_type, GraphQLNamedType): # pragma: no cover + return "Any" + raise TypeError(f"Unexpected GraphQL type {graphql_type!r}") # pragma: no cover + + +def _graphql_literal(value: object) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if value is None: + return "null" + return json.dumps(value) + + +def _snake_case(name: str) -> str: + """Convert camelCase to snake_case, keeping acronyms and their plurals whole: mergePDFs -> merge_pdfs.""" + # An acronym followed by a word: "URLFor" -> "URL_For". Two lowercase letters + # or more make a word, so a plural "s" stays with its acronym. + name = re.sub(r"([A-Z]+)([A-Z][a-z]{2,})", r"\1_\2", name) + name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name) + return name.lower() + + +def _indent(text: str, spaces: int = 4) -> str: + return "\n".join( + f"{' ' * spaces}{line}" if line else "" for line in text.splitlines() + ) + + +def main() -> None: + TYPES_DIR.mkdir(exist_ok=True) + generate_rest() + generate_graphql() + generate_operations(build_schema(SCHEMA_PATH.read_text(encoding="utf-8"))) + + outputs = [str(REST_PATH), str(GRAPHQL_PATH), str(OPERATIONS_PATH)] + subprocess.run( + [sys.executable, "-m", "ruff", "check", "--fix", "--quiet", *outputs], + check=True, + cwd=ROOT, + ) + subprocess.run( + [sys.executable, "-m", "ruff", "format", "--quiet", *outputs], + check=True, + cwd=ROOT, + ) + for output in outputs: + print(f"Wrote {Path(output).relative_to(ROOT)}") + + +if __name__ == "__main__": + main() diff --git a/poetry.lock b/poetry.lock index 5a47c0e1..9a77b34d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -6,12 +6,27 @@ version = "0.8.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"}, {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, ] +[[package]] +name = "argcomplete" +version = "3.7.2" +description = "Bash tab completion for argparse" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "argcomplete-3.7.2-py3-none-any.whl", hash = "sha256:6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19"}, + {file = "argcomplete-3.7.2.tar.gz", hash = "sha256:aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9"}, +] + +[package.extras] +test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] + [[package]] name = "ast-serialize" version = "0.13.1" @@ -79,6 +94,60 @@ files = [ {file = "ast_serialize-0.13.1.tar.gz", hash = "sha256:401cc84e8888d4d76f2c8d296fa006ed1a5ff993b6aff90dd777c03973efd025"}, ] +[[package]] +name = "black" +version = "26.10.1" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "sys_platform != \"emscripten\"" +files = [ + {file = "black-26.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51d5e417e700fe6ec0b0ecdc408c6f6cb5def80328f31f724993d82c6486b746"}, + {file = "black-26.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:70ccbd175b7f6be29d2b727ee7ca6b4c54053df59da653a6df80b175d20a94fa"}, + {file = "black-26.10.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5bd3518d8e97138fef295230b1e9804076d69fa4e3594071494a8c68abe6266"}, + {file = "black-26.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:0ce08b367307b0fd91c9dd1d4084e62b05b3055475f951f0f34a46b6e2393b64"}, + {file = "black-26.10.1-cp310-cp310-win_arm64.whl", hash = "sha256:19fa8f5beb5e77c54c9c7e21d00cc93ed6c8b6228ee385616906d6befe081143"}, + {file = "black-26.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9a0219b29cd70e49f920acb7081e6ce5025c719008447c521d0200dcad93206a"}, + {file = "black-26.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7bdade400bfe24d78a7762896acc2f9a8e1a17fb0fd0536bf6b7c7097cf3eec7"}, + {file = "black-26.10.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff57f63029aa1353fa8b1b0c8971fd88a6c92dc766608d2eee33ad2deb23270e"}, + {file = "black-26.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:3414a0c52901964dceabd98c7c56beac0f964115a116ecedcce7247359b14017"}, + {file = "black-26.10.1-cp311-cp311-win_arm64.whl", hash = "sha256:1935b32f5326028019856e18cb42b4da63db23765dc84464cec723e0de478a9b"}, + {file = "black-26.10.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe85fc4019bee59bc495c0f2a8ee76c5cd02c7015508d94a967ba2376f39a52c"}, + {file = "black-26.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:182f6c32be38074b16d378498c498b32cb51928178ee611485344972c35ec9c6"}, + {file = "black-26.10.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b5347d760f0c02bb00dd249384cab71c3bf828b4f68d5b401eb116e0390f147d"}, + {file = "black-26.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:4d9a90516db1d99c25dbb20cc0998e0e01531dd903466c7744e56d66f864220a"}, + {file = "black-26.10.1-cp312-cp312-win_arm64.whl", hash = "sha256:2ffbc023a12d0c729408823b8f10514490bd0baa301d0d4e21a7240249f9507f"}, + {file = "black-26.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b6272cfd7e1e8e271f5b0e0207259fe2834687e5cb9b5f620b34a44db9754993"}, + {file = "black-26.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:978113a40223a6aaefc17364176a809a320e6b288683841427fff04c6d7b4130"}, + {file = "black-26.10.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03c0ddd93bb392e71209903a691767eb366fe1a76deb9509ccbaae9e1f14bb52"}, + {file = "black-26.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6dba8138cdc99061ef07b958ac082d2aa057b6961d1936f9717c350f02bab5f"}, + {file = "black-26.10.1-cp313-cp313-win_arm64.whl", hash = "sha256:d42dd2fac7c342ae67e64ee99c9532e20b2a84e92c79ed3317fa2ef54c801d93"}, + {file = "black-26.10.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8375962579d537364cc0efa19b1474481915d3a793f9fc0774901814c5e5b5f4"}, + {file = "black-26.10.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d8b3a9074a680b3c5749633714e9ae3992a1e5a23343a97ad61cd9b119b444d2"}, + {file = "black-26.10.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:289282aa2e09d3162312a3be1788ff21b08e9ea9cc4a81e656024728b32428fb"}, + {file = "black-26.10.1-cp314-cp314-win_amd64.whl", hash = "sha256:5cd88fd7b444ca51f3fc883b6f6657ea53a258b0b2eef6d9f2dfcfa17ce0e27b"}, + {file = "black-26.10.1-cp314-cp314-win_arm64.whl", hash = "sha256:2520037aa62f8a1454d0811b8f5c88b444445b03a4bfba480d8d220893b64c34"}, + {file = "black-26.10.1-py3-none-any.whl", hash = "sha256:28842f9a8207cc1df6eb983a35a14c5a0dfcd603d214fe82d84bef552afd2e3a"}, + {file = "black-26.10.1.tar.gz", hash = "sha256:5f9f83beae62437e060dafd53d7f1fc327e3d3494f74d72ee5c2b73eb90fc4e7"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=1.0.0" +platformdirs = ">=2" +pytokens = ">=0.4.0,<0.5.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ; sys_platform == \"win32\""] + [[package]] name = "certifi" version = "2026.10.19" @@ -435,6 +504,51 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +[[package]] +name = "datamodel-code-generator" +version = "0.86.0" +description = "Datamodel Code Generator" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "datamodel_code_generator-0.86.0-py3-none-any.whl", hash = "sha256:1a3d6f0a14a33e334b4251d04e67b0b6ef05c96d4625fb99bd09d716e30ea084"}, + {file = "datamodel_code_generator-0.86.0.tar.gz", hash = "sha256:1834ba0e84307dfed625eeacfbb07d790ede01a3adf5414a1c9947c5b9139078"}, +] + +[package.dependencies] +argcomplete = ">=2.10.1,<4" +black = [ + {version = ">=19.10b0", markers = "python_version < \"3.15\" and sys_platform != \"emscripten\""}, + {version = ">=26.5", markers = "python_version >= \"3.15\" and sys_platform != \"emscripten\""}, +] +genson = ">=1.2.1,<2" +inflect = ">=4.1,<8" +isort = {version = ">=4.3.21,<9", markers = "sys_platform != \"emscripten\""} +jinja2 = ">=2.10.1,<4" +pydantic = [ + {version = ">=2,<2.10 || >2.10,<2.10.1 || >2.10.1,<2.10.2 || >2.10.2,<3", markers = "python_version < \"3.14\""}, + {version = ">=2.12,<3", markers = "python_version == \"3.14\""}, + {version = ">=2.14,<3", markers = "python_version >= \"3.15\""}, +] +pyyaml = ">=6.0.1" +tomli = {version = ">=2.2.1,<3", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.16,<5" + +[package.extras] +all = ["black (>=19.10b0) ; python_version < \"3.15\" and sys_platform != \"emscripten\"", "black (>=26.5) ; python_version >= \"3.15\" and sys_platform != \"emscripten\"", "graphql-core (>=3.2.3)", "grpcio-tools (>=1.62,<2)", "httpx (>=0.24.1)", "isort (>=4.3.21,<9) ; sys_platform != \"emscripten\"", "openapi-spec-validator (>=0.2.8,<0.8)", "prance (>=0.18.2)", "pysnooper (>=0.4.1,<2)", "ruff (>=0.9.10)", "watchfiles (>=1.1)"] +black = ["black (>=19.10b0) ; python_version < \"3.15\" and sys_platform != \"emscripten\"", "black (>=26.5) ; python_version >= \"3.15\" and sys_platform != \"emscripten\""] +debug = ["pysnooper (>=0.4.1,<2)"] +graphql = ["graphql-core (>=3.2.3)"] +http = ["httpx (>=0.24.1)"] +httpx2 = ["httpx2 (>=2,<3)"] +isort = ["isort (>=4.3.21,<9) ; sys_platform != \"emscripten\""] +protobuf = ["grpcio-tools (>=1.62,<2)"] +ruff = ["ruff (>=0.9.10)"] +ryaml = ["ryaml (>=0.5.1)"] +validation = ["openapi-spec-validator (>=0.2.8,<0.8)", "prance (>=0.18.2)"] +watch = ["watchfiles (>=1.1)"] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -454,6 +568,18 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "genson" +version = "1.4.0" +description = "GenSON is a powerful, user-friendly JSON Schema generator." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "genson-1.4.0-py3-none-any.whl", hash = "sha256:03bc71bbe52defde70660cc4dcd1ea1097997da5a1cbb90a9dbd3acc7c9e1b65"}, + {file = "genson-1.4.0.tar.gz", hash = "sha256:bc7f1c1bae87a21ca44d81149aec95a3f4468d676de9b8b08caa064f3c50b3da"}, +] + [[package]] name = "graphql-core" version = "3.3.0" @@ -481,6 +607,31 @@ files = [ [package.extras] all = ["coverage (>=7.10.0)", "hypothesis (>=6.141.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.16.0)", "ty (>=0.0.37)"] +[[package]] +name = "inflect" +version = "7.6.0" +description = "Correctly generate plurals, singular nouns, ordinals, indefinite articles" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "inflect-7.6.0-py3-none-any.whl", hash = "sha256:9fefad5c0c174d8a38f8c33d3033817e38fca8475f7f226c62efc39d46c83e8a"}, + {file = "inflect-7.6.0.tar.gz", hash = "sha256:b46875b50534a7cd5fcfb5a7c971bb93fe68948c43ca53e9833bd1e1a69221bd"}, +] + +[package.dependencies] +"jaraco.functools" = "*" +more_itertools = ">=8.5.0" +typeguard = ">=4.0.1" + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["pygments", "pytest (>=6,!=8.1.*)"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + [[package]] name = "iniconfig" version = "2.3.1" @@ -493,6 +644,63 @@ files = [ {file = "iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960"}, ] +[[package]] +name = "isort" +version = "8.0.1" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.10.0" +groups = ["dev"] +markers = "sys_platform != \"emscripten\"" +files = [ + {file = "isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75"}, + {file = "isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d"}, +] + +[package.extras] +colors = ["colorama"] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +description = "Functools like those found in stdlib" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30"}, + {file = "jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280"}, +] + +[package.dependencies] +more_itertools = "*" + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["jaraco.classes", "pytest (>=6,!=8.1.*)"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + [[package]] name = "librt" version = "0.16.0" @@ -641,6 +849,193 @@ files = [ {file = "librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc"}, ] +[[package]] +name = "markupsafe" +version = "3.0.4" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "markupsafe-3.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd8ea6ebee7aedbf7c749fa80521d9ccf1ba473e0d1e14805caafbaad281c889"}, + {file = "markupsafe-3.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dff05cb7016dff1e9fd68f4122c127b65dfc59de5306cfb7ad92f956f230bee2"}, + {file = "markupsafe-3.0.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf63c214fe879a65e69a386f915e36104fc84254ab141240f8854602d8e0be2a"}, + {file = "markupsafe-3.0.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2a6ef68ae94aed8721934072b27a3b654ea2100b97e4ab864cf1489c90926fbc"}, + {file = "markupsafe-3.0.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd9f8797427910198f95bced71ddfed61130d7e349213bfb8466c9c99e2c46a8"}, + {file = "markupsafe-3.0.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1aca03ede943eb80ab3d63bb082c84b7aab85ea83bd0fd0c200260945fb49d9"}, + {file = "markupsafe-3.0.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0764a13d34cae40db7bbf3a09b7e9b491bf4603e20b263a7a9d6b8e324975d0a"}, + {file = "markupsafe-3.0.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9388003072b95f2f1e3fd908604194d653ba21330d811961a78b7da1a77e9e36"}, + {file = "markupsafe-3.0.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8698d70a8081ee8c090dbb394768b5789a1da8b131b5499f89d071dd3cfaf6be"}, + {file = "markupsafe-3.0.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf053da3c97a4bc5ecfbb218cdd2983febd91c617be8367d139882aa11e490aa"}, + {file = "markupsafe-3.0.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:9438a2648b2195980cb2dd8e53ed7b8df91319e2d0b70ae61a9e1d1bc8d3bec9"}, + {file = "markupsafe-3.0.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:88d59b473bfb03259722600839af9bbd7fa13a2eb514beefeedb95997882f69a"}, + {file = "markupsafe-3.0.4-cp310-cp310-win32.whl", hash = "sha256:4a540e2d3192792fc84eced57bef37851ccb2b41f73291bb17408eea77bcd278"}, + {file = "markupsafe-3.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:5c22873ad1f0532ba40fa1727f3c0fc1bbbaab6d373d4cbe3f0dc74b2e2521c7"}, + {file = "markupsafe-3.0.4-cp310-cp310-win_arm64.whl", hash = "sha256:3d23795802fc8bd72534836d64489bbf0f67c088959091bdb22e10735a5107bf"}, + {file = "markupsafe-3.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346"}, + {file = "markupsafe-3.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91"}, + {file = "markupsafe-3.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef"}, + {file = "markupsafe-3.0.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169"}, + {file = "markupsafe-3.0.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb"}, + {file = "markupsafe-3.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808"}, + {file = "markupsafe-3.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692"}, + {file = "markupsafe-3.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d"}, + {file = "markupsafe-3.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21"}, + {file = "markupsafe-3.0.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707"}, + {file = "markupsafe-3.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e"}, + {file = "markupsafe-3.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7"}, + {file = "markupsafe-3.0.4-cp311-cp311-win32.whl", hash = "sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5"}, + {file = "markupsafe-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3"}, + {file = "markupsafe-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e"}, + {file = "markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6"}, + {file = "markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f"}, + {file = "markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b"}, + {file = "markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df"}, + {file = "markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c"}, + {file = "markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581"}, + {file = "markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77"}, + {file = "markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c"}, + {file = "markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749"}, + {file = "markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed"}, + {file = "markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786"}, + {file = "markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e"}, + {file = "markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237"}, + {file = "markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7"}, + {file = "markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9"}, + {file = "markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1"}, + {file = "markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1"}, + {file = "markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96"}, + {file = "markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148"}, + {file = "markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e"}, + {file = "markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248"}, + {file = "markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72"}, + {file = "markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2"}, + {file = "markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85"}, + {file = "markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde"}, + {file = "markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6"}, + {file = "markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f"}, + {file = "markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39"}, + {file = "markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee"}, + {file = "markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2"}, + {file = "markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46"}, + {file = "markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17"}, + {file = "markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0"}, + {file = "markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5"}, + {file = "markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc"}, + {file = "markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed"}, + {file = "markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59"}, + {file = "markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453"}, + {file = "markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b"}, + {file = "markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6"}, + {file = "markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634"}, + {file = "markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f"}, + {file = "markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9"}, + {file = "markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f"}, + {file = "markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c"}, + {file = "markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300"}, + {file = "markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0"}, + {file = "markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977"}, + {file = "markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7"}, + {file = "markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17"}, + {file = "markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c"}, + {file = "markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4"}, + {file = "markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c"}, + {file = "markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe"}, + {file = "markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a"}, + {file = "markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2"}, + {file = "markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977"}, + {file = "markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289"}, + {file = "markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe"}, + {file = "markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a"}, + {file = "markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733"}, + {file = "markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34"}, + {file = "markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978"}, + {file = "markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc"}, + {file = "markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc"}, + {file = "markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932"}, + {file = "markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6"}, + {file = "markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691"}, + {file = "markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464"}, + {file = "markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c"}, + {file = "markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65"}, + {file = "markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163"}, + {file = "markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92"}, + {file = "markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a"}, + {file = "markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429"}, + {file = "markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8"}, + {file = "markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97"}, + {file = "markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b"}, + {file = "markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9"}, + {file = "markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653"}, + {file = "markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369"}, + {file = "markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19"}, + {file = "markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e"}, + {file = "markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811"}, + {file = "markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea"}, + {file = "markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916"}, + {file = "markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741"}, + {file = "markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b"}, + {file = "markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214"}, + {file = "markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67"}, + {file = "markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad"}, + {file = "markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99"}, + {file = "markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002"}, + {file = "markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e"}, + {file = "markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c"}, + {file = "markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8"}, + {file = "markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe"}, + {file = "markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2"}, + {file = "markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38"}, + {file = "markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494"}, + {file = "markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d"}, + {file = "markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894"}, + {file = "markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78"}, + {file = "markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c"}, + {file = "markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba"}, + {file = "markupsafe-3.0.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f291bcf42ae98eb5107edb162c3c998b4a89648fd8e99ed4cbd12705292788cd"}, + {file = "markupsafe-3.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ac0c7c9f1609b0c4c114feb1d7a3409564c7fb77e360bed9e97e5d25dfeaf868"}, + {file = "markupsafe-3.0.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6768d67d1bce64270e0fdc2e69309d68b9b18ae56ddf6c711d168e9d051c2cac"}, + {file = "markupsafe-3.0.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:14bd2d845d62ab678eaf81da89d7b621b51756c72346745c1a594c09d49207a2"}, + {file = "markupsafe-3.0.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:007e1ffd9bf65bb6ee96df7b258fc632a4868dd5566037986c64781f35a36e98"}, + {file = "markupsafe-3.0.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e8b3d0b18fd623afa12ecb2ce8d8becef69f9b5440c6330c7972200e0bb84b0"}, + {file = "markupsafe-3.0.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57f9947a7e57a081c1e3e0a2dd0d2dcf290a4531450e6f611e30084c222a7295"}, + {file = "markupsafe-3.0.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b61687d0828e72bf5cda24a2690188f37170bd31c9359ac97e4e66569f120a16"}, + {file = "markupsafe-3.0.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:0cee7cb0f9a1b6892ea482237d9403b3d1b4603aee057d0ff01f0fac2d019a97"}, + {file = "markupsafe-3.0.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:94e4c421742086aeee4c32a506eec8859d7634aad943f7e6aacf70f813478768"}, + {file = "markupsafe-3.0.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9240187afb63d2f9ddc3e032c670356fe941f6e20662ea168a5dc3f1f317e1b3"}, + {file = "markupsafe-3.0.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e841068dc0be4cb6dfb5c890eb88cbdcff2f4a332393c7ec94e8e618bd32c1a8"}, + {file = "markupsafe-3.0.4-cp39-cp39-win32.whl", hash = "sha256:f61efe1d2fe0de16158a5fe1d1cf3c14bdb6aecd54d8938fd26512c525c1f624"}, + {file = "markupsafe-3.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:2b2b1e18af909b448bb3cf9e3433366f7a8726271fc214e8b10e0f62a78c724b"}, + {file = "markupsafe-3.0.4-cp39-cp39-win_arm64.whl", hash = "sha256:6669c1bf34080161ce49c589cc512ef24d4c704ac9d2b2d3667f519c60418378"}, + {file = "markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6"}, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "python_version < \"3.14\"" +files = [ + {file = "more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192"}, + {file = "more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d"}, +] + +[[package]] +name = "more-itertools" +version = "11.2.1" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.11" +groups = ["dev"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "more_itertools-11.2.1-py3-none-any.whl", hash = "sha256:35a7377edd1dd6608dcb2cdf534ded55ea32d49448875ad042cd3f879fb1ded0"}, + {file = "more_itertools-11.2.1.tar.gz", hash = "sha256:cbf08fd0af284dc69718b9b76a8e6203df624d70209ea511e4219d350c856f63"}, +] + [[package]] name = "mypy" version = "2.4.0" @@ -762,6 +1157,32 @@ hyperscan = ["hyperscan (>=0.7)"] optional = ["typing-extensions (>=4)"] re2 = ["google-re2 (>=1.1)"] +[[package]] +name = "platformdirs" +version = "4.12.4" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +markers = "python_version < \"3.14\" and sys_platform != \"emscripten\"" +files = [ + {file = "platformdirs-4.12.4-py3-none-any.whl", hash = "sha256:78bfb9db2a8471ed7eebe3c3c932da413911042994e699b384fbb4493fa872d7"}, + {file = "platformdirs-4.12.4.tar.gz", hash = "sha256:63743c02414e755de4e31b8f68125c1407495b86c5a006e203c01ff8b9924250"}, +] + +[[package]] +name = "platformdirs" +version = "4.13.5" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.11" +groups = ["dev"] +markers = "python_version >= \"3.14\" and sys_platform != \"emscripten\"" +files = [ + {file = "platformdirs-4.13.5-py3-none-any.whl", hash = "sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e"}, + {file = "platformdirs-4.13.5.tar.gz", hash = "sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0"}, +] + [[package]] name = "pluggy" version = "1.7.0" @@ -780,7 +1201,7 @@ version = "2.14.1" description = "Data validation using Python type hints" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454"}, {file = "pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26"}, @@ -802,7 +1223,7 @@ version = "2.50.1" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "pydantic_core-2.50.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b281a3b0f0822618fe5e3e0d8a2048b6356b14388505dc9374ccffeb69989713"}, {file = "pydantic_core-2.50.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fa4c8bc12c1354c5550c0c35c1852c8c1901e89e06561724e03f8d0342e1f87"}, @@ -1035,6 +1456,145 @@ files = [ [package.dependencies] pytest = ">=7.0.0" +[[package]] +name = "pytokens" +version = "0.4.1" +description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "sys_platform != \"emscripten\"" +files = [ + {file = "pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c"}, + {file = "pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7"}, + {file = "pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2"}, + {file = "pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d"}, + {file = "pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16"}, + {file = "pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6"}, + {file = "pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1"}, + {file = "pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9"}, + {file = "pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68"}, + {file = "pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1"}, + {file = "pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4"}, + {file = "pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78"}, + {file = "pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d"}, + {file = "pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324"}, + {file = "pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9"}, + {file = "pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975"}, + {file = "pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a"}, + {file = "pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918"}, + {file = "pytokens-0.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1"}, + {file = "pytokens-0.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6"}, + {file = "pytokens-0.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037"}, + {file = "pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db"}, + {file = "pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1"}, + {file = "pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a"}, + {file = "pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de"}, + {file = "pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a"}, +] + +[package.extras] +dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] + +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + [[package]] name = "requests" version = "2.34.2" @@ -1176,6 +1736,21 @@ files = [ {file = "tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6"}, ] +[[package]] +name = "typeguard" +version = "4.6.0" +description = "Run-time type checker for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "typeguard-4.6.0-py3-none-any.whl", hash = "sha256:79878165bb86f2cf5d41d159a0ff1792a796cf496882d2fe1b1c6c7049b9cdd7"}, + {file = "typeguard-4.6.0.tar.gz", hash = "sha256:e7414f09111317de3e335de92cd397c5c0ca00b1cc1676de12e1d444a79b3f21"}, +] + +[package.dependencies] +typing_extensions = ">=4.14.0" + [[package]] name = "types-requests" version = "2.33.0.20261006" @@ -1221,7 +1796,7 @@ version = "0.4.4" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147"}, {file = "typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47"}, @@ -1254,4 +1829,4 @@ cli = ["click", "tabulate"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.16" -content-hash = "fe738c8cceafaf4bfa8df11ca65a70b619ed65ec1dfd01bed7e7a65bc36d87d0" +content-hash = "763a763217229c900896b5bc45df05a804d656d5400bd7ceb32bd4c576420b0b" diff --git a/pyproject.toml b/pyproject.toml index 9d4a10f1..fa9e899e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ requires-python = ">=3.10,<3.16" dependencies = [ "requests>=2.28.2,<3", "pydantic>=2.8,<3", + # The generated types use TypeAliasType on Python 3.10 and 3.11 + "typing-extensions>=4.12", ] [project.optional-dependencies] @@ -56,8 +58,11 @@ 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 +# To check the client's queries and mutations against the API schema, and to +# generate python_anvil/types from it and the OpenAPI spec (dev/generate_types.py) graphql-core = ">=3.2" +# Pinned to a minor: `make check-generated` byte-compares its output +datamodel-code-generator = "~0.86" # So the CLI and its tests run from a dev install without --all-extras click = ">=8.0,<9" tabulate = ">=0.9,<0.11" @@ -98,6 +103,10 @@ ignore = [ # pytest-describe tests nest functions and shadow fixtures by design "tests/**" = ["D", "B018", "RUF012"] "examples/**" = ["D"] +# Generated from the API specs; their docstrings are the API's descriptions +"python_anvil/types/rest.py" = ["D", "UP", "RUF"] +"python_anvil/types/graphql.py" = ["D", "UP", "RUF"] +"python_anvil/types/operations.py" = ["D", "UP", "RUF"] [tool.ruff.lint.pydocstyle] convention = "pep257" diff --git a/python_anvil/api.py b/python_anvil/api.py index cc99a5f8..0916299a 100644 --- a/python_anvil/api.py +++ b/python_anvil/api.py @@ -4,6 +4,7 @@ import json as json_lib import logging +import os import warnings from collections.abc import Mapping from typing import Any, TypeVar @@ -66,7 +67,8 @@ class Anvil: 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 api_key: Your API key, from your organization's settings. Read from + the `ANVIL_API_KEY` environment variable when not given. :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, @@ -130,7 +132,12 @@ def __init__( stacklevel=2, ) if not (api_key or access_token): - raise ValueError("`api_key` or `access_token` is required") + # The way every example and the CLI find the key + api_key = os.environ.get("ANVIL_API_KEY") or None + if not (api_key or access_token): + raise ValueError( + "`api_key` or `access_token` is required, or set ANVIL_API_KEY" + ) self.client = HTTPClient( api_key, @@ -215,6 +222,7 @@ def fill_pdf( version_number: int | None = None, data_type: str = DATA_TYPE_BYTES, params: Mapping[str, Any] | None = None, + timeout: float | tuple[float, float] | None = None, ) -> Any: """Fill a PDF template with your data. Returns the PDF. @@ -226,6 +234,7 @@ def fill_pdf( recent version, published or not. :param data_type: `"bytes"` (the default) or `"stream"`. See `request_rest`. :param params: Extra query string parameters. + :param timeout: Overrides the client's timeout for this request. """ data = _parse_payload(payload, FillPDFPayload) _check_binary_data_type(data_type) @@ -240,6 +249,7 @@ def fill_pdf( json=data.model_dump(by_alias=True, exclude_none=True), params=query or None, data_type=data_type, + timeout=timeout, ) def generate_pdf( @@ -248,6 +258,7 @@ def generate_pdf( *, data_type: str = DATA_TYPE_BYTES, params: Mapping[str, Any] | None = None, + timeout: float | tuple[float, float] | None = None, ) -> Any: """Generate a PDF from Markdown or HTML. Returns the PDF. @@ -268,10 +279,15 @@ def generate_pdf( json=data.model_dump(by_alias=True, exclude_none=True), params=params, data_type=data_type, + timeout=timeout, ) def download_documents( - self, document_group_eid: str, *, data_type: str = DATA_TYPE_BYTES + self, + document_group_eid: str, + *, + data_type: str = DATA_TYPE_BYTES, + timeout: float | tuple[float, float] | None = None, ) -> Any: """Download the documents of an Etch packet's document group as a zip file. @@ -284,6 +300,7 @@ def download_documents( "GET", f"/api/document-group/{document_group_eid}.zip", data_type=data_type, + timeout=timeout, ) # GraphQL @@ -291,19 +308,25 @@ def download_documents( def request_graphql( self, query: str, - variables: Mapping[str, Any] | None = None, + variables: Mapping[str, Any] | str | bytes | 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. Returns the response's `data`. Files anywhere in `variables` are sent - as a multipart upload: see `prepare_graphql_file`. + as a multipart upload: see `prepare_graphql_file`. `variables` may be + a JSON string. See the GraphQL reference for every query and mutation: https://www.useanvil.com/docs/api/graphql/reference/ """ - operations = {"query": query, "variables": dict(variables or {})} + given = ( + json_lib.loads(variables) + if isinstance(variables, (str, bytes)) + else variables + ) + operations = {"query": query, "variables": dict(given or {})} # Checks for both file uploads and base64 uploads if not validate_upload_schema(operations["variables"]): @@ -330,25 +353,39 @@ def request_graphql( return body["data"] or {} def query( - self, query: str, variables: Mapping[str, Any] | None = None + self, + query: str, + variables: Mapping[str, Any] | str | bytes | None = None, + *, + timeout: float | tuple[float, float] | None = None, ) -> dict[str, Any]: """Send a GraphQL query. The same as `request_graphql`.""" - return self.request_graphql(query, variables) + return self.request_graphql(query, variables, timeout=timeout) def mutate( - self, query: str | BaseQuery, variables: Mapping[str, Any] | None = None + self, + query: str | BaseQuery, + variables: Mapping[str, Any] | str | bytes | None = None, + *, + timeout: float | tuple[float, float] | 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) + return self.request_graphql(mutation, variables, timeout=timeout) def create_etch_packet( self, - payload: dict | CreateEtchPacketPayload | CreateEtchPacket | None = None, + payload: dict + | str + | bytes + | CreateEtchPacketPayload + | CreateEtchPacket + | None = None, json: str | bytes | None = None, *, response_query: str | None = None, mutation: str | None = None, + timeout: float | tuple[float, float] | None = None, ) -> dict[str, Any]: """Create an Etch e-sign packet, and send it to the first signer unless it's a draft. @@ -364,11 +401,13 @@ def create_etch_packet( :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: + if not payload and json is None: raise TypeError("One of the arguments `payload` or `json` must exist") if json is not None: payload = CreateEtchPacketPayload.model_validate_json(json) + elif isinstance(payload, (str, bytes)): + payload = CreateEtchPacketPayload.model_validate_json(payload) if isinstance(payload, dict): builder = CreateEtchPacket.create_from_dict(payload) @@ -385,11 +424,15 @@ def create_etch_packet( by_alias=True, exclude_none=True ) return self.request_graphql( - mutation or builder.get_mutation(response_query), variables + mutation or builder.get_mutation(response_query), variables, timeout=timeout ) def get_etch_packet( - self, eid: str, *, response_query: str | None = None + self, + eid: str, + *, + response_query: str | None = None, + timeout: float | tuple[float, float] | None = None, ) -> dict[str, Any]: """Get an Etch packet's details and status. Returns the packet. @@ -397,11 +440,19 @@ def get_etch_packet( :param response_query: The fields to select, instead of the defaults. """ data = self.request_graphql( - queries.etch_packet.generate_query(response_query), {"eid": eid} + queries.etch_packet.generate_query(response_query), + {"eid": eid}, + timeout=timeout, ) return data["etchPacket"] - def generate_etch_signing_url(self, signer_eid: str, client_user_id: str) -> str: + def generate_etch_signing_url( + self, + signer_eid: str, + client_user_id: str, + *, + timeout: float | tuple[float, float] | None = None, + ) -> str: """Generate the URL an embedded signer signs at. Returns the URL. The packet must have been sent, and the signer must be an `embedded` signer. @@ -413,7 +464,9 @@ def generate_etch_signing_url(self, signer_eid: str, client_user_id: str) -> str 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) + mutation.get_mutation(), + mutation.create_payload().model_dump(by_alias=True), + timeout=timeout, ) return data["generateEtchSignURL"] @@ -421,11 +474,12 @@ def generate_etch_signing_url(self, signer_eid: str, client_user_id: str) -> str def forge_submit( self, - payload: dict | ForgeSubmitPayload | None = None, + payload: dict | str | bytes | ForgeSubmitPayload | None = None, json: str | bytes | None = None, *, response_query: str | None = None, mutation: str | None = None, + timeout: float | tuple[float, float] | None = None, ) -> dict[str, Any]: """Submit data to a webform (forge) in a workflow (weld). Returns the response's `data`. @@ -437,11 +491,13 @@ def forge_submit( of the defaults. :param mutation: Your own `forgeSubmit` mutation, instead of the client's. """ - if payload is None and json is None: + if not payload 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) + elif isinstance(payload, (str, bytes)): + payload = ForgeSubmitPayload.model_validate_json(payload) if isinstance(payload, dict): builder = ForgeSubmit.create_from_dict(payload) @@ -456,17 +512,25 @@ def forge_submit( by_alias=True, exclude_none=True ) return self.request_graphql( - mutation or builder.get_mutation(response_query), variables + mutation or builder.get_mutation(response_query), variables, timeout=timeout ) - def remove_weld_data(self, eid: str, *, mutation: str | None = None) -> bool: + def remove_weld_data( + self, + eid: str, + *, + mutation: str | None = None, + timeout: float | tuple[float, float] | None = None, + ) -> bool: """Remove a workflow submission's data (a weld data). Returns whether it was removed. :param eid: The weld data's eid, from `forge_submit`'s `weldData`. :param mutation: Your own `removeWeldData` mutation, instead of the client's. """ data = self.request_graphql( - mutation or mutations.remove_weld_data.generate_mutation(), {"eid": eid} + mutation or mutations.remove_weld_data.generate_mutation(), + {"eid": eid}, + timeout=timeout, ) return bool(data["removeWeldData"]) diff --git a/python_anvil/api_resources/mutations/base.py b/python_anvil/api_resources/mutations/base.py index 0816358f..08881916 100644 --- a/python_anvil/api_resources/mutations/base.py +++ b/python_anvil/api_resources/mutations/base.py @@ -2,6 +2,8 @@ from typing import Any +from python_anvil.graphql.selection import selection + class BaseQuery: """Base class for the classes that build a GraphQL mutation and its variables.""" @@ -10,13 +12,21 @@ class BaseQuery: 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 + # A subclass's own mutation, as before 6.0.0: a template whose `{query}` + # is replaced with the response fields + mutation: 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 + if self.mutation is None: + raise NotImplementedError + return self.mutation.format(query=self.response_fields(response_query) or "") def response_fields(self, response_query: str | None = None) -> str | None: - return response_query or self.mutation_res_query or self.default_response_query + """The selection to put after the mutation, in braces whether given with them or not.""" + return selection( + 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.""" diff --git a/python_anvil/api_resources/mutations/create_etch_packet.py b/python_anvil/api_resources/mutations/create_etch_packet.py index 63e048a2..227b347d 100644 --- a/python_anvil/api_resources/mutations/create_etch_packet.py +++ b/python_anvil/api_resources/mutations/create_etch_packet.py @@ -4,6 +4,8 @@ from mimetypes import guess_type from typing import Any +from pydantic import TypeAdapter + from python_anvil.api_resources.mutations.base import BaseQuery from python_anvil.api_resources.payload import ( AttachableEtchFile, @@ -16,11 +18,16 @@ DEFAULT_RESPONSE_QUERY, generate_mutation, ) -from python_anvil.utils import create_unique_id +from python_anvil.utils import camel_to_snake, create_unique_id logger: Logger = logging.getLogger(__name__) +_ATTACHABLE_FILE: TypeAdapter[AttachableEtchFile] = TypeAdapter(AttachableEtchFile) + +# The mutation, under the name it had before 6.0.0 +CREATE_ETCH_PACKET = generate_mutation() + class CreateEtchPacket(BaseQuery): """Builds a `createEtchPacket` mutation, adding signers and files one at a time. @@ -54,10 +61,7 @@ def __init__( ): # `name` is required when `payload` is not present. if not payload and not name: - raise TypeError( - "Missing 2 required positional arguments: 'name' and " - "'signature_email_subject'" - ) + raise TypeError("`name` is required") self.name = name self.signature_email_subject = signature_email_subject @@ -84,7 +88,11 @@ def get_mutation(self, response_query: str | None = None) -> str: @classmethod def create_from_dict(cls, payload: dict) -> "CreateEtchPacket": - """Create a new instance of `CreateEtchPacket` from a dict payload.""" + """Create a new instance of `CreateEtchPacket` from a dict payload. + + Keys may be snake_case or camelCase, like the mutation's variables. + """ + payload = {camel_to_snake(k): v for k, v in payload.items()} try: mutation = cls( **{k: v for k, v in payload.items() if k not in ["signers", "files"]} @@ -93,13 +101,11 @@ def create_from_dict(cls, payload: dict) -> "CreateEtchPacket": raise ValueError( f"`payload` must be a valid CreateEtchPacket instance or dict. {e}" ) from e - if "signers" in payload: - for signer in payload["signers"]: - mutation.add_signer(EtchSigner(**signer)) + for signer in payload.get("signers") or []: + mutation.add_signer(signer) - if "files" in payload: - for file in payload["files"]: - mutation.add_file(DocumentUpload(**file)) + for file in payload.get("files") or []: + mutation.add_file(file) return mutation @@ -116,11 +122,6 @@ def add_signer(self, signer: dict | EtchSigner): else: raise ValueError("Signer must be either a dict or EtchSigner type") - if data.signer_type not in ["embedded", "email"]: - raise ValueError( - "Etch signer `signer_type` must be only 'embedded' or 'email" - ) - if not data.id: data.id = create_unique_id("signer") if not data.routing_order: @@ -136,12 +137,16 @@ def add_signer(self, signer: dict | EtchSigner): self.signers.append(data) - def add_file(self, file: AttachableEtchFile): - """Add file to a pending list of Upload objects. + def add_file(self, file: AttachableEtchFile | dict): + """Add a file to a pending list of files. - Files will not be uploaded when running this method. They will be - uploaded when the mutation actually runs. + A `DocumentUpload`, `EtchCastRef`, `DocumentMarkdown` or `DocumentMarkup`, + or a dict of one of those, with snake_case or camelCase keys. Files + will not be uploaded when running this method. They will be uploaded + when the mutation actually runs. """ + if isinstance(file, dict): + file = _ATTACHABLE_FILE.validate_python(file) if ( isinstance(file, DocumentUpload) and isinstance(file.file, BufferedIOBase) @@ -188,7 +193,7 @@ def create_payload(self) -> CreateEtchPacketPayload: return self.payload if not self.name: - raise TypeError("`name` and `signature_email_subject` cannot be None") + raise TypeError("`name` is required") payload = CreateEtchPacketPayload( is_test=self.is_test, diff --git a/python_anvil/api_resources/mutations/forge_submit.py b/python_anvil/api_resources/mutations/forge_submit.py index dedf9e10..f2af0b09 100644 --- a/python_anvil/api_resources/mutations/forge_submit.py +++ b/python_anvil/api_resources/mutations/forge_submit.py @@ -7,6 +7,11 @@ DEFAULT_RESPONSE_QUERY, generate_mutation, ) +from python_anvil.utils import camel_to_snake + + +# The mutation, under the name it had before 6.0.0 +FORGE_SUBMIT = generate_mutation() class ForgeSubmit(BaseQuery): @@ -57,9 +62,8 @@ def __init__( @classmethod def create_from_dict(cls, payload: dict[str, Any]): - # Parse the data through the model class to validate and pass it back - # as variables in this class. - return cls(**payload) + """Create a `ForgeSubmit` from a dict. Keys may be snake_case or camelCase.""" + return cls(**{camel_to_snake(k): v for k, v in payload.items()}) def create_payload(self): # If provided a payload and no forge_eid, we'll assume that it's the diff --git a/python_anvil/api_resources/mutations/helpers.py b/python_anvil/api_resources/mutations/helpers.py index c72cbc90..3f68cf3e 100644 --- a/python_anvil/api_resources/mutations/helpers.py +++ b/python_anvil/api_resources/mutations/helpers.py @@ -1,4 +1,4 @@ -from python_anvil.api_resources.base import BaseModel +from pydantic import BaseModel def get_payload_attrs(payload_model: type[BaseModel]) -> list[str]: diff --git a/python_anvil/api_resources/payload.py b/python_anvil/api_resources/payload.py index 01b35121..803a1073 100644 --- a/python_anvil/api_resources/payload.py +++ b/python_anvil/api_resources/payload.py @@ -8,6 +8,7 @@ ) from python_anvil.models import FileCompatibleBaseModel +from python_anvil.types import operations, rest from .base import BaseModel @@ -18,11 +19,17 @@ class EmbeddedLogo(BaseModel): max_height: int | None = None -class FillPDFPayload(BaseModel): +class FillPDFPayload(rest.FillPdfRequest): + """The body of a fill PDF request: `data` and the API's styling options. + + See `python_anvil.types.rest.FillPdfRequest` for every option. + """ + data: list[dict[str, Any]] | dict[str, Any] - title: str | None = None - font_size: int | None = None - text_color: str | None = None + # As before 6.0.0, any size; the API says what it accepts + font_size: int | None = Field(None, alias="fontSize") + # The API defaults this to "pdf"; leave it out rather than always sending it + output_format: Literal["pdf", "url"] | None = Field(None, alias="outputFormat") @field_validator("data") @classmethod @@ -32,15 +39,20 @@ def data_cannot_be_empty(cls, v): return v -class GeneratePDFPayload(BaseModel): - data: list[dict[str, Any]] | dict[Literal["html", "css"], str] - logo: EmbeddedLogo | None = None - title: str | None = None +class GeneratePDFPayload(rest.GenerateMarkdownPdfRequest): + """The body of a generate PDF request, from Markdown (the default) or HTML. + + See `python_anvil.types.rest.GenerateMarkdownPdfRequest` and + `GenerateHtmlPdfRequest` for every option. + """ + + data: list[dict[str, Any]] | dict[Literal["html", "css"], str] # type: ignore[assignment] + logo: EmbeddedLogo | Literal[False] | None = None # type: ignore[assignment] type: Literal["markdown", "html"] | None = "markdown" - page: dict[str, Any] | None = None - font_size: int | None = None - font_family: str | None = None - text_color: str | None = None + # As before 6.0.0, any size; the API says what it accepts + font_size: int | None = Field(None, alias="fontSize") + # The API defaults this to "pdf"; leave it out rather than always sending it + output_format: Literal["pdf", "url"] | None = Field(None, alias="outputFormat") class GenerateEtchSigningURLPayload(BaseModel): @@ -53,21 +65,23 @@ class SignerField(BaseModel): field_id: str -class EtchSigner(BaseModel): - """Dataclass representing etch signers.""" +class EtchSigner(rest.EtchSigner): + """A signer of an Etch packet: who signs, which fields, and how. + + See `python_anvil.types.rest.EtchSigner` for every option. + """ name: str email: str - fields: list[SignerField] - signer_type: str = "email" + fields: list[SignerField] # type: ignore[assignment] + signer_type: Literal["email", "embedded", "in-person"] = Field( + "email", alias="signerType" + ) # id will be generated if `None` - id: str | None = None - routing_order: int | None = None - redirect_url: str | None = Field(None, alias="redirectURL") - accept_each_field: bool | None = None - enable_emails: list[str] | None = None - # signature_mode can be "draw" or "text" (default: text) - signature_mode: str | None = None + id: str | None = None # type: ignore[assignment] + # As before 6.0.0, any order; the API says what it accepts + routing_order: int | None = Field(None, alias="routingOrder") + enable_emails: bool | list[str] | None = Field(None, alias="enableEmails") # type: ignore[assignment] class SignatureField(BaseModel): @@ -189,60 +203,41 @@ class CreateEtchFilePayload(BaseModel): payloads: str | dict[str, FillPDFPayload] -class CreateEtchPacketPayload(FileCompatibleBaseModel): - """ - Payload for createEtchPacket. +class CreateEtchPacketPayload(FileCompatibleBaseModel, operations.CreateEtchPacketArgs): + """The variables of the `createEtchPacket` mutation. - See the full packet payload defined here: + See `python_anvil.types.operations.CreateEtchPacketArgs` for every + variable, and the full packet payload defined here: https://www.useanvil.com/docs/api/e-signatures#tying-it-all-together """ name: str - signers: list[EtchSigner] + signers: list[EtchSigner] # type: ignore[assignment] # NOTE: This is a list of `AttachableEtchFile` objects, but we need to # override the default `FileCompatibleBaseModel` to handle multipart/form-data # uploads correctly. This field name is referenced in the models.py file. - files: list["AttachableEtchFile"] - signature_email_subject: str | None = None - signature_email_body: str | None = None - is_draft: bool | None = False - is_test: bool | None = True - merge_pdfs: bool | None = Field(None, alias="mergePDFs") + files: list["AttachableEtchFile"] # type: ignore[assignment] + is_draft: bool | None = Field(False, alias="isDraft") + is_test: bool | None = Field(True, alias="isTest") data: CreateEtchFilePayload | None = None - signature_page_options: dict[Any, Any] | None = None - webhook_url: str | None = Field(None, alias="webhookURL") - reply_to_name: Any | None = None - reply_to_email: Any | None = None - 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): - """ - Payload for forgeSubmit. + signature_page_options: dict[Any, Any] | None = Field( + None, alias="signaturePageOptions" + ) + enable_emails: bool | list[str] | None = Field(None, alias="enableEmails") + + +class ForgeSubmitPayload(operations.ForgeSubmitArgs): + """The variables of the `forgeSubmit` mutation. - See full payload defined here: + See `python_anvil.types.operations.ForgeSubmitArgs` for every variable, + and the full payload defined here: https://www.useanvil.com/docs/api/graphql/reference/#operation-forgesubmit-Mutations """ - forge_eid: str payload: dict[str, Any] - weld_data_eid: str | None = None - submission_eid: str | None = None - # Defaults to True when not provided/is None - enforce_payload_valid_on_create: bool | None = None - current_step: int | None = None - complete: bool | None = None # Note that if using a development API key, this will be forced to `True` # even when `False` is used in the payload. - is_test: bool | None = True - timezone: str | None = None - webhook_url: str | None = Field(None, alias="webhookURL") - group_array_id: str | None = None - group_array_index: int | None = None + is_test: bool | None = Field(True, alias="isTest") UploadableFile = Base64Upload | BufferedIOBase diff --git a/python_anvil/graphql/mutations/create_etch_packet.py b/python_anvil/graphql/mutations/create_etch_packet.py index 9aeb0a29..9dce7310 100644 --- a/python_anvil/graphql/mutations/create_etch_packet.py +++ b/python_anvil/graphql/mutations/create_etch_packet.py @@ -1,3 +1,6 @@ +from python_anvil.graphql.selection import selection + + DEFAULT_RESPONSE_QUERY = """{ eid name @@ -29,6 +32,7 @@ def generate_mutation(response_query: str | None = None) -> str: + response_query = selection(response_query) return f""" mutation CreateEtchPacket( $name: String, diff --git a/python_anvil/graphql/mutations/forge_submit.py b/python_anvil/graphql/mutations/forge_submit.py index 9ee8a60f..04d07243 100644 --- a/python_anvil/graphql/mutations/forge_submit.py +++ b/python_anvil/graphql/mutations/forge_submit.py @@ -1,3 +1,6 @@ +from python_anvil.graphql.selection import selection + + DEFAULT_RESPONSE_QUERY = """{ id eid @@ -24,6 +27,7 @@ def generate_mutation(response_query: str | None = None) -> str: + response_query = selection(response_query) return f""" mutation ForgeSubmit( $forgeEid: String!, diff --git a/python_anvil/graphql/queries/etch_packet.py b/python_anvil/graphql/queries/etch_packet.py index a8918f9b..e377e900 100644 --- a/python_anvil/graphql/queries/etch_packet.py +++ b/python_anvil/graphql/queries/etch_packet.py @@ -1,3 +1,6 @@ +from python_anvil.graphql.selection import selection + + DEFAULT_RESPONSE_QUERY = """{ id eid @@ -30,6 +33,7 @@ def generate_query(response_query: str | None = None) -> str: + response_query = selection(response_query) return f""" query EtchPacket ( $eid: String!, diff --git a/python_anvil/graphql/selection.py b/python_anvil/graphql/selection.py new file mode 100644 index 00000000..d29a085d --- /dev/null +++ b/python_anvil/graphql/selection.py @@ -0,0 +1,15 @@ +"""The response fields of an operation, with the braces a caller may leave out.""" + +from __future__ import annotations + + +def selection(response_query: str | None) -> str | None: + """`"{ eid name }"` for `"eid name"` or `"{ eid name }"`; `None` stays `None`.""" + if response_query is None: + return None + fields = response_query.strip() + if not fields: + return None + if fields.startswith("{"): + return fields + return f"{{ {fields} }}" diff --git a/python_anvil/http.py b/python_anvil/http.py index 9ec1b2ef..a6fc853f 100644 --- a/python_anvil/http.py +++ b/python_anvil/http.py @@ -66,6 +66,8 @@ def __init__( rate_limiter: RateLimiter | None = None, ): self.base_url = normalize_base_url(base_url) + self.api_key = api_key + self.access_token = access_token self.auth_header = build_auth_header(api_key, access_token) self.user_agent = user_agent or default_user_agent() self.timeout = timeout diff --git a/python_anvil/models.py b/python_anvil/models.py index 25f6f12f..e488753d 100644 --- a/python_anvil/models.py +++ b/python_anvil/models.py @@ -2,6 +2,7 @@ import os from io import BufferedReader, BytesIO from mimetypes import guess_type +from typing import Any from pydantic import BaseModel, ConfigDict @@ -39,16 +40,20 @@ def _check_if_serialization_iterator(self, value): value, "__next__" ) - def _process_file_data(self, file_obj): + def _process_file_data(self, file_obj, original=None): """Process file object into base64 encoded dict format.""" # Read the file data and encode it as base64 file_content = file_obj.read() - # Get filename - handle both regular files and BytesIO objects - filename = getattr(file_obj, "name", "document.pdf") - + # The name of the file as it was given; the copy read here has none + filename = ( + getattr(original, "name", None) + or getattr(file_obj, "name", None) + or "document.pdf" + ) if isinstance(filename, (bytes, bytearray)): filename = filename.decode("utf-8") + filename = str(filename) # manage mimetype based on file extension mimetype = guess_type(filename)[0] or "application/pdf" @@ -59,13 +64,21 @@ def _process_file_data(self, file_obj): "filename": os.path.basename(filename), } + def _original_file_item(self, index: int) -> Any: + files = getattr(self, "files", None) + if not isinstance(files, list) or index >= len(files): + return None + return files[index] + def model_dump(self, **kwargs): data = super().model_dump(**kwargs) for key, value in data.items(): if key == "file" and self._check_if_serialization_iterator(value): # Direct file case file_obj = self._iterator_to_buffered_reader(value) - data[key] = self._process_file_data(file_obj) + data[key] = self._process_file_data( + file_obj, getattr(self, "file", None) + ) elif key == "files" and isinstance(value, list): # List of objects case for index, item in enumerate(value): @@ -75,5 +88,10 @@ def model_dump(self, **kwargs): and self._check_if_serialization_iterator(item["file"]) ): file_obj = self._iterator_to_buffered_reader(item["file"]) - data[key][index]["file"] = self._process_file_data(file_obj) + original = getattr( + self._original_file_item(index), "file", None + ) + data[key][index]["file"] = self._process_file_data( + file_obj, original + ) return data diff --git a/python_anvil/types/__init__.py b/python_anvil/types/__init__.py new file mode 100644 index 00000000..50e81e6a --- /dev/null +++ b/python_anvil/types/__init__.py @@ -0,0 +1,58 @@ +"""Types generated from the Anvil API's OpenAPI spec and GraphQL schema. + +The Python counterpart of `@anvilco/types`: pydantic models, regenerated +from the API with `make update-schema generate`. + +- `python_anvil.types.rest`: the REST API's request bodies, like + `FillPdfRequest` and `GenerateMarkdownPdfRequest`. +- `python_anvil.types.graphql`: every type in the GraphQL schema, by name: + objects like `EtchPacket` and inputs like `EtchFile`. +- `python_anvil.types.operations`: the arguments of every query and + mutation, like `CreateEtchPacketArgs` and `ForgeSubmitArgs`. + +Field names are snake_case with camelCase aliases, and either works when +constructing a model. `model_dump(by_alias=True, exclude_none=True)` gives +the camelCase JSON the API wants. The client's own payload models in +`python_anvil.api_resources.payload` extend these. + +The named request models and the schema's types are stable names. The REST +module also has models for the spec's unnamed nested objects, like `Fields1` +and `Data2`, named by position; those can change when the spec does. +""" + +from python_anvil.types import graphql, operations, rest +from python_anvil.types._base import GeneratedModel +from python_anvil.types.operations import ( + CreateEtchPacketArgs, + EtchPacketArgs, + ForgeSubmitArgs, + GenerateEtchSignURLArgs, + RemoveWeldDataArgs, +) +from python_anvil.types.rest import ( + EtchFinishPageOptions, + EtchSignaturePageOptions, + FillPdfRequest, + GenerateHtmlPdfRequest, + GenerateMarkdownPdfRequest, + PageOptions, +) + + +__all__ = [ + "CreateEtchPacketArgs", + "EtchFinishPageOptions", + "EtchPacketArgs", + "EtchSignaturePageOptions", + "FillPdfRequest", + "ForgeSubmitArgs", + "GenerateEtchSignURLArgs", + "GenerateHtmlPdfRequest", + "GenerateMarkdownPdfRequest", + "GeneratedModel", + "PageOptions", + "RemoveWeldDataArgs", + "graphql", + "operations", + "rest", +] diff --git a/python_anvil/types/_base.py b/python_anvil/types/_base.py new file mode 100644 index 00000000..7892ec72 --- /dev/null +++ b/python_anvil/types/_base.py @@ -0,0 +1,21 @@ +"""The base class of the generated models.""" + +from pydantic import BaseModel, ConfigDict + + +class GeneratedModel(BaseModel): + """A generated model. + + Fields are snake_case with camelCase aliases, and can be set by either + name. Enum fields hold their plain values, so `model_dump` gives JSON. + Extra fields are kept, so a field the API added that these types don't + have yet still gets sent. + """ + + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + use_enum_values=True, + # The API may well have a field that starts with `model_` + protected_namespaces=(), + ) diff --git a/python_anvil/types/graphql.py b/python_anvil/types/graphql.py new file mode 100644 index 00000000..ec122b3d --- /dev/null +++ b/python_anvil/types/graphql.py @@ -0,0 +1,2626 @@ +# Generated by dev/generate_types.py from schema/anvil_schema.graphql. Do not edit by hand. +# Run `make generate` to regenerate. + +from __future__ import annotations + +from typing import Annotated, Any + +from pydantic import ConfigDict, Field +from typing_extensions import TypeAliasType + +from python_anvil.types._base import GeneratedModel + + +Boolean = TypeAliasType("Boolean", bool) +""" +The `Boolean` scalar type represents `true` or `false`. +""" + + +Date = TypeAliasType("Date", str) +""" +Datetime value represented in `ISO8601`. +""" + + +Float = TypeAliasType("Float", float) +""" +The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point). +""" + + +ID = TypeAliasType("ID", str) +""" +The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID. +""" + + +Int = TypeAliasType("Int", int) +""" +The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1. +""" + + +String = TypeAliasType("String", str) +""" +The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text. +""" + + +class Amount(GeneratedModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + amount: Annotated[Float, Field(description="Monetary amount value")] + currency: Annotated[String, Field(description="Currency code (e.g., USD)")] + + +class BillingPeriod(GeneratedModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + end: Annotated[ + String, + Field( + description="End date (exclusive) of the billing period in ISO 8601 format; from Metronome this is ending_before (the first instant outside the billing period)" + ), + ] + start: Annotated[ + String, + Field( + description="Start date (inclusive) of the billing period in ISO 8601 format" + ), + ] + + +class BillingSubscription(GeneratedModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + attributes: Annotated[ + Any, Field(description="Arbitrary subscription attributes") + ] = None + billing_interval: Annotated[ + String | None, + Field(alias="billingInterval", description="monthly | annual | null"), + ] = None + id: Annotated[ID, Field(description="Subscription identifier")] + name: Annotated[String, Field(description="Subscription name")] + price: Annotated[ + Amount | None, Field(description="Amount for the subscription") + ] = None + status: Annotated[String, Field(description="active | available | expired")] + + +class CastRedirectMapping(GeneratedModel): + """ + A mapping from an old redirect path to its canonical destination + """ + + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + cast_eid: Annotated[ + String, + Field(alias="castEid", description="The Cast EID this redirect belongs to"), + ] + from_: Annotated[ + String, + Field( + alias="from", + description="The old redirect path (e.g., /forms/old-tag/old-slug/)", + ), + ] + to: Annotated[ + String, + Field( + description="The canonical destination path (e.g., /forms/current-tag/current-slug/)" + ), + ] + + +class File(GeneratedModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + encoding: String + key: String + mimetype: String + name: String + src: String + + +class GenerateEmbedURLResponse(GeneratedModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + request_token_eid: Annotated[ + String, + Field( + alias="requestTokenEid", + description="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.", + ), + ] + url: Annotated[ + String, + Field( + description="The URL to send your embedded User to. They will be logged in and then redirected to the appropriate page for their embedded access.\nYou can direct them back to this URL if they need to access their embedded experience again as long as their session has not expired." + ), + ] + + +class Integration(GeneratedModel): + """ + A third-party integration connection (e.g. HubSpot) for an organization. + """ + + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + is_connected: Annotated[ + Boolean, + Field( + alias="isConnected", + description="Whether the organization has an active connection for this integration.", + ), + ] + object_types: Annotated[ + Any, + Field( + alias="objectTypes", + description="Available object types, normalized into the `{ k, v }` shape expected by Dropdown.", + ), + ] = None + + +class Invoice(GeneratedModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + end_date: Annotated[Date | None, Field(alias="endDate")] = None + id: String | None = None + invoice_url: Annotated[String | None, Field(alias="invoiceUrl")] = None + start_date: Annotated[Date | None, Field(alias="startDate")] = None + status: String | None = None + total_amount: Annotated[Float | None, Field(alias="totalAmount")] = None + + +class InvoiceBucketItem(GeneratedModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + included_units: Annotated[Int | None, Field(alias="includedUnits")] = None + unit_type: Annotated[String | None, Field(alias="unitType")] = None + used_units: Annotated[Int | None, Field(alias="usedUnits")] = None + + +class InvoiceUnitItem(GeneratedModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + base_units: Annotated[Int | None, Field(alias="baseUnits")] = None + total_amount: Annotated[Float | None, Field(alias="totalAmount")] = None + unit_amount: Annotated[Float | None, Field(alias="unitAmount")] = None + unit_type: Annotated[String | None, Field(alias="unitType")] = None + used_units: Annotated[Int | None, Field(alias="usedUnits")] = None + + +class MetricUsage(GeneratedModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + billable_usage: Annotated[ + Int, + Field( + alias="billableUsage", + description="Usage that will be billed (usage not covered by credits or prepaid)", + ), + ] + cost: Annotated[ + Amount, Field(description="Cost for billable usage (billableUsage * unitPrice)") + ] + credits_initial: Annotated[ + Int | None, + Field( + alias="creditsInitial", + description="Initial number of free credits granted for this metric", + ), + ] = None + credits_remaining: Annotated[ + Int | None, + Field( + alias="creditsRemaining", + description="Number of free credits remaining for this metric (null if no credits)", + ), + ] = None + key: Annotated[ + ID, Field(description="Metric key (e.g., pdfApiCalls, workflowSubmissions)") + ] + label: Annotated[String, Field(description="Display label for the metric")] + prepaid_initial: Annotated[ + Int | None, + Field( + alias="prepaidInitial", + description="Initial number of prepaid units from bulk purchases", + ), + ] = None + prepaid_remaining: Annotated[ + Int | None, + Field( + alias="prepaidRemaining", + description="Number of prepaid units remaining from bulk purchases (null if no prepaid)", + ), + ] = None + unit: Annotated[ + String, Field(description="Unit label (e.g., calls, submissions, packets)") + ] + unit_price: Annotated[ + Amount, Field(alias="unitPrice", description="Price per unit") + ] + + +class ObjectRelationship(GeneratedModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + id: Int + related_object_eid: Annotated[String, Field(alias="relatedObjectEid")] + related_object_type: Annotated[String | None, Field(alias="relatedObjectType")] = ( + None + ) + relation: String | None = None + source_object_eid: Annotated[String, Field(alias="sourceObjectEid")] + source_object_type: Annotated[String | None, Field(alias="sourceObjectType")] = None + + +class PaymentMethod(GeneratedModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + exp_month: Annotated[Int | None, Field(alias="expMonth")] = None + exp_year: Annotated[Int | None, Field(alias="expYear")] = None + id: String + is_default: Annotated[Boolean | None, Field(alias="isDefault")] = None + last4: String | None = None + provider_name: Annotated[String | None, Field(alias="providerName")] = None + status: String | None = None + type: String | None = None + + +class UserPreferences(GeneratedModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + accepted_signature: Annotated[Boolean | None, Field(alias="acceptedSignature")] = ( + None + ) + initials: String | None = None + mcp_organization_eid: Annotated[ + String | None, Field(alias="mcpOrganizationEid") + ] = None + name: String | None = None + require2_fa: Annotated[Boolean | None, Field(alias="require2FA")] = None + + +class EtchFile(GeneratedModel): + """ + An object used by `createEtchPacket` to specify a file. See the + [Etch e-sign guide](/docs/api/e-signatures) for more info. + """ + + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + base64_file: Annotated[Any, Field(alias="base64File")] = None + cast_eid: Annotated[String | None, Field(alias="castEid")] = None + fields: list[Any] | None = None + file: Any = None + filename: Annotated[ + String | None, Field(description="Filename set when creating the `EtchPacket`.") + ] = None + font_size: Annotated[Int | None, Field(alias="fontSize")] = None + id: Annotated[ + String, Field(description="An id string set when creating the `EtchPacket`.") + ] + include_timestamp: Annotated[ + Boolean | None, + Field( + alias="includeTimestamp", + description='For generated PDFs, set to false to disable the "Generated at XXX" timestamp at the bottom of the document.', + ), + ] = None + logo: Annotated[ + Any, + Field( + description="For generated PDFs, you can specify a logo to be rendered at the top of the document." + ), + ] = None + markup: Annotated[Any, Field(description="Holds HTML/CSS to generate PDF")] = None + page: Annotated[ + Any, Field(description="Holds page customizations to generate PDF") + ] = None + page_sizes: Annotated[list[Any] | None, Field(alias="pageSizes")] = None + text_color: Annotated[String | None, Field(alias="textColor")] = None + title: String | None = None + + +class GenerateExpressionDataInput(GeneratedModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + is_boolean: Annotated[ + Boolean | None, + Field( + alias="isBoolean", + description="A `Boolean` indicating whether the resulting expression shoudl resolve to a boolean value.", + ), + ] = False + user_statement: Annotated[ + String, + Field( + alias="userStatement", + description="A human-readable statement representing the expression you are trying to create. References to fields must be wrapped in double-brackets like so: `{{"}}, + { + "id": "up", + "title": "T", + "file": { + "data": "aGk=", + "filename": "x.pdf", + "mimetype": "application/pdf", + }, + "fields": [], + }, + EtchCastRef(id="tpl2", cast_eid="cast2"), + ], + "signers": [EtchSigner(name="S", email="s@example.com", fields=[])], + } + ) + files = graphql.call_args.args[1]["files"] + assert [f["id"] for f in files] == ["tpl", "md", "html", "up", "tpl2"] + assert files[0] == {"id": "tpl", "castEid": "cast1"} + assert files[1]["fields"][0]["content"] == "hi" + assert files[2]["markup"] == {"html": "
"} + assert files[3]["file"]["data"] == "aGk=" + assert graphql.call_args.args[1]["signers"][0]["name"] == "S" + + def an_in_person_signer_is_accepted(anvil, graphql): + packet = CreateEtchPacket(name="n") + packet.add_signer( + EtchSigner( + name="S", email="s@example.com", fields=[], signer_type="in-person" + ) + ) + anvil.create_etch_packet(packet) + assert graphql.call_args.args[1]["signers"][0]["signerType"] == "in-person" + + def response_fields_may_be_given_without_braces(anvil, graphql): + graphql.return_value = {"createEtchPacket": {}, "etchPacket": {}} + anvil.create_etch_packet({"name": "n"}, response_query="eid name") + assert graphql.call_args.args[ + 0 + ] == mutations.create_etch_packet.generate_mutation("{ eid name }") + anvil.get_etch_packet("e", response_query="eid") + assert graphql.call_args.args[0] == queries.etch_packet.generate_query( + "{ eid }" + ) + + def an_uploaded_file_keeps_its_own_name(): + with DUMMY_PDF.open("rb") as pdf: + upload = DocumentUpload(id="f", title="T", file=pdf, fields=[]) + dumped = upload.model_dump(by_alias=True) + assert dumped["file"]["filename"] == "dummy.pdf" + assert dumped["file"]["mimetype"] == "application/pdf" + + with DUMMY_PDF.open("rb") as pdf: + packet = CreateEtchPacket(name="n") + packet.add_file(DocumentUpload(id="f", title="T", file=pdf, fields=[])) + dumped = packet.create_payload().model_dump(by_alias=True) + assert dumped["files"][0]["file"]["filename"] == "dummy.pdf" + + def a_file_with_no_name_is_still_document_pdf(): + dumped = DocumentUpload( + id="f", title="T", file=io.BytesIO(b"x"), fields=[] + ).model_dump(by_alias=True) + assert dumped["file"]["filename"] == "document.pdf" + + +def describe_forge_submit_payloads(): + def an_empty_dict_is_a_type_error_as_before(anvil, graphql): + with pytest.raises(TypeError, match="`json` or `payload` are required"): + anvil.forge_submit({}) + + def a_json_string_works_as_the_payload(anvil, graphql): + anvil.forge_submit(json.dumps({"forgeEid": "f", "payload": {"a": 1}})) + assert graphql.call_args.args[1]["forgeEid"] == "f" + + def the_builder_still_takes_a_dict(anvil, graphql): + anvil.mutate( + ForgeSubmit({"a": 1}, forge_eid="f"), {"forgeEid": "f", "payload": {"a": 1}} + ) + assert "forgeSubmit" in graphql.call_args.args[0] + + +def describe_generate_pdf_payload(): + def it_dumps_the_documented_examples_as_before(): + markdown = GeneratePDFPayload( + title="Example", + data=[ + {"label": "Name", "content": "Sally"}, + {"table": {"firstRowHeaders": True, "rows": [["a"]]}}, + ], + ) + assert markdown.model_dump(by_alias=True, exclude_none=True) == { + "title": "Example", + "type": "markdown", + "data": [ + {"label": "Name", "content": "Sally"}, + {"table": {"firstRowHeaders": True, "rows": [["a"]]}}, + ], + } + html = GeneratePDFPayload( + type="html", title="T", data={"html": "