diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5cf1f6e..37862b1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,6 +4,33 @@ on: types: [opened, reopened, synchronize, ready_for_review] jobs: + # Typecheck and the offline tests. Needs no credentials, so this still runs on + # forks, where the integration job has no secrets and every test errors. + unit-tests: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + name: Checkout fragment-python + + - name: Use Python 3.10.14 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.10.14' + + - name: Install dependencies + run: | + pip install poetry + poetry install --with dev + + - name: Typecheck + run: make typecheck + + - name: Run offline tests + run: make unit + integration-tests: runs-on: ubuntu-latest permissions: @@ -30,9 +57,6 @@ jobs: pip install poetry poetry install --with dev - - name: Typecheck - run: poetry run mypy -p fragment - - name: Run tests run: poetry run pytest -v diff --git a/Makefile b/Makefile index 1f27728..a30bf20 100644 --- a/Makefile +++ b/Makefile @@ -21,8 +21,15 @@ sort_order: style: poetry run black fragment/ tests/ +# tests/ is checked too: tests/type_checks/ asserts what a caller sees when +# calling the generated client, which no runtime test can cover. typecheck: poetry run mypy -p fragment + poetry run mypy tests/ + +# Everything that needs no credentials and no network. +unit: + poetry run pytest -m "not integration" # Integration tests. Requires CLIENT_ID, CLIENT_SECRET, SCOPE, AUTH_URL and # API_URL in the environment; the tests fail if any are missing. diff --git a/pyproject.toml b/pyproject.toml index 08b54ad..27e6d14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,11 +43,16 @@ testpaths = ["tests"] # generated code rather than a hand-written approximation of it. Regenerate with # `make snapshots`. pythonpath = ["tests/snapshots/001-marketing-schema"] +# Everything unmarked runs offline. `make unit` deselects this marker. +markers = ["integration: needs live API credentials; see tests/conftest.py"] [tool.mypy] # Same path pytest uses, so `mypy tests/` can resolve the snapshotted `sdk` # package and actually typecheck calls against the generated typed payloads. mypy_path = "tests/snapshots/001-marketing-schema" +# tests/type_checks/ writes "this call is rejected" as `# type: ignore[...]`. +# This turns the day it stops being rejected into a failure, not a silent pass. +warn_unused_ignores = true [tool.pylint.messages_control] max-line-length = 88 diff --git a/tests/conftest.py b/tests/conftest.py index 7c6f93b..603c73c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,5 @@ import os -from typing import AsyncIterator, Dict +from typing import AsyncIterator, TypedDict import pytest import pytest_asyncio @@ -9,8 +9,23 @@ REQUIRED_ENV_VARS = ("CLIENT_ID", "CLIENT_SECRET", "SCOPE", "AUTH_URL", "API_URL") +class Credentials(TypedDict): + """The `Client` keyword arguments read from the environment. + + A TypedDict rather than `Dict[str, str]` so `Client(**credentials)` + typechecks: against a plain str mapping, a key could land on `http_client`, + which takes an `AsyncClient`. + """ + + client_id: str + client_secret: str + auth_scope: str + auth_url: str + api_url: str + + @pytest.fixture(scope="session") -def credentials() -> Dict[str, str]: +def credentials() -> Credentials: missing = [name for name in REQUIRED_ENV_VARS if not os.environ.get(name)] if missing: pytest.fail( @@ -29,6 +44,19 @@ def credentials() -> Dict[str, str]: @pytest_asyncio.fixture -async def client(credentials: Dict[str, str]) -> AsyncIterator[Client]: +async def client(credentials: Credentials) -> AsyncIterator[Client]: async with Client(**credentials) as graphql_client: yield graphql_client + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + """Mark anything needing live credentials as `integration`. + + Derived from fixture usage rather than written at the top of each module, so + a new integration test cannot forget it and a merge cannot drop it. Losing + one `pytestmark` line silently put two credential-bound tests into the + offline run, where they errored on missing environment variables. + """ + for item in items: + if "credentials" in getattr(item, "fixturenames", ()): + item.add_marker(pytest.mark.integration) diff --git a/tests/test_typed_entries_generated.py b/tests/test_typed_entries_generated.py new file mode 100644 index 0000000..7beb5d7 --- /dev/null +++ b/tests/test_typed_entries_generated.py @@ -0,0 +1,324 @@ +"""Tests that import and use generated code rather than reading it as text. + +String assertions on `render_module`'s output cannot distinguish a working +module from one that merely contains the right substrings: a duplicated field +declaration or an annotation the header does not import satisfies both. These +tests import what codegen emits -- header, base class and per-entry classes -- +and exercise the resulting classes. + +Offline; no credentials required. +""" + +import importlib +import itertools +import sys +from pathlib import Path +from types import ModuleType +from typing import Any, Dict, List, Optional, Type + +import pytest +from ariadne_codegen.utils import str_to_snake_case +from graphql import OperationDefinitionNode, parse + +# `sdk` is the snapshotted client, on sys.path via `pythonpath` in pyproject.toml. +from sdk import typed_entries as snapshot_typed_entries +from sdk.input_types import AddLedgerEntryInput + +from fragment.codegen.typed_entries import EntrySpec, extract_entry_spec, render_module + +SNAPSHOT_QUERIES = ( + Path(__file__).parent / "snapshots" / "001-marketing-schema" / "queries.graphql" +) + +_PACKAGE_COUNTER = itertools.count() + + +def build_module(tmp_path: Path, specs: List[EntrySpec]) -> ModuleType: + """Render `specs` and import the result as a module. + + The throwaway package re-exports the snapshot's `base_model` and + `input_types`, so the rendered module's own relative imports resolve as + written instead of being replaced by injected names. The package name is + unique per call so repeated renders are not served from `sys.modules`. + """ + name = f"generated_typed_entries_{next(_PACKAGE_COUNTER)}" + package = tmp_path / name + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "base_model.py").write_text( + "from sdk.base_model import BaseModel\n", encoding="utf-8" + ) + (package / "input_types.py").write_text( + "from sdk.input_types import * # noqa: F401,F403\n", encoding="utf-8" + ) + (package / "typed_entries.py").write_text(render_module(specs), encoding="utf-8") + + sys.path.insert(0, str(tmp_path)) + try: + return importlib.import_module(f"{name}.typed_entries") + finally: + sys.path.remove(str(tmp_path)) + + +def operation(parameters: str, variables: str) -> OperationDefinitionNode: + doc = parse( + f"""mutation PostThing($ik: SafeString!, $ledgerIk: SafeString!, {variables}) {{ + addLedgerEntry( + ik: $ik + entry: {{ledger: {{ik: $ledgerIk}}, type: "thing", typeVersion: 2, + parameters: {{{parameters}}}}} + ) {{ __typename }} + }}""" + ) + node = doc.definitions[0] + assert isinstance(node, OperationDefinitionNode) + return node + + +def spec_from( + parameters: str, variables: str, annotations: Dict[str, str] +) -> EntrySpec: + spec = extract_entry_spec(operation(parameters, variables), annotations) + assert spec is not None + return spec + + +def models_in(module: ModuleType) -> List[Type[Any]]: + base = module.TypedLedgerEntry + return [ + value + for value in vars(module).values() + if isinstance(value, type) and issubclass(value, base) and value is not base + ] + + +@pytest.fixture +def thing(tmp_path: Path) -> Type[Any]: + """A model covering four parameter shapes at once. + + A plain parameter, a camelCase one, one that escapes because it shadows a + builtin, and an optional one. The optional case is synthetic: every + CLI-generated parameter is non-null, so no snapshot query produces one. + """ + module = build_module( + tmp_path, + [ + spec_from( + parameters="amount: $amount, userId: $userId, type: $entryType, " + "memo: $memo", + variables="$amount: String!, $userId: String!, " + "$entryType: String!, $memo: String", + annotations={ + "amount": "str", + "user_id": "str", + "entry_type": "str", + "memo": "Optional[str]", + }, + ) + ], + ) + return module.ThingV2 + + +def test_rendered_module_imports_as_written(thing: Type[Any]) -> None: + """The emitted header covers everything the classes reference.""" + assert thing.ENTRY_TYPE == "thing" + assert thing.TYPE_VERSION == 2 + + +def test_rendered_class_declares_each_field_exactly_once(thing: Type[Any]) -> None: + """A duplicated declaration is invisible in the source but not on the class. + + Pydantic keeps the last of two identically named fields, so both Schema + names would read one value. + """ + field_names = list(thing.PARAMETER_FIELDS.values()) + assert len(field_names) == len(set(field_names)), field_names + assert set(field_names) <= set(thing.model_fields) + + +def test_colliding_parameters_carry_their_own_values(tmp_path: Path) -> None: + """`user_id` and `userId` reduce to one Python field but two wire keys.""" + module = build_module( + tmp_path, + [ + spec_from( + parameters="user_id: $snake, userId: $camel", + variables="$snake: String!, $camel: String!", + annotations={"snake": "str", "camel": "str"}, + ) + ], + ) + entry = module.ThingV2( + ik="ik-1", ledger_ik="prod", user_id="SNAKE", user_id_2="CAMEL" + ) + assert entry.to_input().entry.parameters == { + "user_id": "SNAKE", + "userId": "CAMEL", + } + + +def test_required_parameters_are_required_and_optional_ones_are_not( + thing: Type[Any], +) -> None: + entry = thing(ik="ik-1", ledger_ik="prod", amount="100", user_id="u", type_="t") + assert entry.memo is None + + with pytest.raises(ValueError): + thing(ik="ik-1", ledger_ik="prod", amount="100") + + +def test_optional_parameter_annotation_survives_into_the_model( + thing: Type[Any], +) -> None: + field = thing.model_fields["memo"] + assert field.annotation is Optional[str] + assert not field.is_required() + + +def test_unset_optional_parameter_is_left_out_of_the_payload( + thing: Type[Any], +) -> None: + entry = thing(ik="ik-1", ledger_ik="prod", amount="100", user_id="u", type_="t") + assert entry.to_input().entry.parameters == { + "amount": "100", + "userId": "u", + "type": "t", + } + + +def test_set_optional_parameter_reaches_the_payload(thing: Type[Any]) -> None: + entry = thing( + ik="ik-1", ledger_ik="prod", amount="100", user_id="u", type_="t", memo="note" + ) + assert entry.to_input().entry.parameters["memo"] == "note" + + +def test_escaped_field_keeps_its_schema_name_on_the_wire(thing: Type[Any]) -> None: + """`type` shadows a builtin, so the field is `type_`; the wire key is not.""" + dumped = thing( + ik="ik-1", ledger_ik="prod", amount="100", user_id="u", type_="t" + ).model_dump(by_alias=True) + assert dumped == { + "ik": "ik-1", + "entry": { + "ledger": {"ik": "prod"}, + "type": "thing", + "typeVersion": 2, + "parameters": {"amount": "100", "userId": "u", "type": "t"}, + }, + } + + +def test_to_entry_inputs_preserves_order(thing: Type[Any]) -> None: + """`addLedgerEntries` commits and reports in input order.""" + module = sys.modules[thing.__module__] + entries = [ + thing(ik=f"ik-{n}", ledger_ik="prod", amount=str(n), user_id="u", type_="t") + for n in range(3) + ] + inputs = module.to_entry_inputs(entries) + assert all(isinstance(entry, AddLedgerEntryInput) for entry in inputs) + assert [entry.ik for entry in inputs] == ["ik-0", "ik-1", "ik-2"] + + +def test_module_with_no_entry_types_is_still_importable(tmp_path: Path) -> None: + """The shape `fragment/sdk/typed_entries.py` has. + + The std queries type nothing, so the shipped SDK gets the base class, the + helper and the explanatory note -- and `fragment/sdk/__init__.py` imports + that module on every import of the package. + """ + module = build_module(tmp_path, []) + assert module.TypedLedgerEntry.ENTRY_TYPE == "" + assert module.to_entry_inputs([]) == [] + + +# --- The committed snapshot, the artifact a customer gets --------------------- + + +def snapshot_models() -> List[Type[Any]]: + return models_in(snapshot_typed_entries) + + +def test_the_snapshot_generated_some_models() -> None: + """Keeps the parametrized tests below from passing vacuously.""" + assert len(snapshot_models()) == 9 + + +@pytest.mark.parametrize("model", snapshot_models(), ids=lambda m: m.__name__) +def test_every_snapshot_model_serialises(model: Type[Any]) -> None: + """Every parameter in the marketing Schema is a required `String`.""" + entry = model( + ik="ik-1", + ledger_ik="prod", + **{field: field for field in model.PARAMETER_FIELDS.values()}, + ) + dumped: Dict[str, Any] = entry.model_dump(by_alias=True) + assert dumped["entry"]["type"] == model.ENTRY_TYPE + assert dumped["entry"]["typeVersion"] == model.TYPE_VERSION + assert set(dumped["entry"]["parameters"]) == set(model.PARAMETER_FIELDS) + + +@pytest.mark.parametrize("model", snapshot_models(), ids=lambda m: m.__name__) +def test_every_snapshot_model_is_exported(model: Type[Any]) -> None: + import sdk + + assert model.__name__ in sdk.__all__ + assert getattr(sdk, model.__name__) is model + + +def test_snapshot_models_do_not_shadow_a_base_class_field() -> None: + """A parameter named `ik` or `posted` escapes rather than replacing the base.""" + base_fields = set(snapshot_typed_entries.TypedLedgerEntry.model_fields) + for model in snapshot_models(): + overlap = set(model.PARAMETER_FIELDS.values()) & base_fields + assert not overlap, f"{model.__name__} shadows {overlap}" + + +def test_versioned_snapshot_models_are_distinct() -> None: + v1 = snapshot_typed_entries.OrderPlacedV1 + v2 = snapshot_typed_entries.OrderPlacedV2 + assert (v1.ENTRY_TYPE, v1.TYPE_VERSION) == ("order_placed", 1) + assert (v2.ENTRY_TYPE, v2.TYPE_VERSION) == ("order_placed", 2) + assert set(v2.PARAMETER_FIELDS) - set(v1.PARAMETER_FIELDS) == {"service_fee"} + + +def test_no_snapshot_parameter_is_optional() -> None: + """Why the optional case in `thing` is synthetic. + + A CLI-generated Schema that produces a nullable parameter fails this, and + the optional path gains real coverage. + """ + assert all( + model.model_fields[name].is_required() + for model in snapshot_models() + for name in model.PARAMETER_FIELDS.values() + ) + + +def test_snapshot_module_matches_a_fresh_render_of_its_own_queries( + tmp_path: Path, +) -> None: + """Narrows `make check-snapshots` to the renderer, without the network.""" + document = parse(SNAPSHOT_QUERIES.read_text(encoding="utf-8")) + specs = [] + for definition in document.definitions: + if not isinstance(definition, OperationDefinitionNode): + continue + spec = extract_entry_spec(definition, _annotations_for(definition)) + if spec is not None: + specs.append(spec) + + module = build_module(tmp_path, specs) + assert {model.__name__ for model in models_in(module)} == { + model.__name__ for model in snapshot_models() + } + + +def _annotations_for(definition: OperationDefinitionNode) -> Dict[str, str]: + """Every marketing-Schema variable is a non-null String.""" + return { + str_to_snake_case(vd.variable.name.value): "str" + for vd in definition.variable_definitions + } diff --git a/tests/test_typed_entries_warnings.py b/tests/test_typed_entries_warnings.py new file mode 100644 index 0000000..c50c6eb --- /dev/null +++ b/tests/test_typed_entries_warnings.py @@ -0,0 +1,255 @@ +"""Tests for the paths where codegen degrades instead of failing. + +Each produces a working SDK that is quietly worse than intended: a parameter +that lost its type, a batch method that does not typecheck, models nothing +accepts. The warning is the only signal, so it is what these assert on. + +`addLedgerEntries` and its `entries` argument are owned upstream in +fragment-dev/graphql-queries. Neither degraded path is reachable from the +queries as they stand; they cover a rename of either name. + +Offline; no credentials required. +""" + +import ast +import logging +from pathlib import Path +from typing import Any, Dict + +import pytest +from graphql import GraphQLSchema, OperationDefinitionNode, parse + +from fragment.codegen.plugins.generate_typed_entries import GenerateTypedLedgerEntries +from fragment.codegen.typed_entries import extract_entry_spec + +BATCH_METHOD = '''async def add_ledger_entries( + self, entries: list[AddLedgerEntryInput], **kwargs: Any +) -> AddLedgerEntries: + query = gql("""mutation addLedgerEntries { __typename }""") + variables: dict[str, object] = {"entries": entries} + response = await self.execute(query=query, variables=variables, **kwargs) + return AddLedgerEntries.model_validate(self.get_data(response))''' + +# Carries the `amount` annotation `typed_operation` needs, so extracting a spec +# from it does not trip the unresolvable-parameter warning on its own. +POST_METHOD = """async def post_thing( + self, ik: Any, ledger_ik: Any, amount: str, **kwargs: Any +) -> PostThing: + return PostThing.model_validate({})""" + + +@pytest.fixture(autouse=True) +def capture_console(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.WARNING, logger="console") + + +def plugin(tmp_path: Path) -> GenerateTypedLedgerEntries: + config: Dict[str, Any] = { + "tool": { + "ariadne-codegen": { + "target_package_path": str(tmp_path), + "target_package_name": "sdk", + "queries_path": "queries/", + } + } + } + return GenerateTypedLedgerEntries(GraphQLSchema(), config) + + +def method(source: str) -> ast.AsyncFunctionDef: + node = ast.parse(source).body[0] + assert isinstance(node, ast.AsyncFunctionDef) + return node + + +def operation(source: str) -> OperationDefinitionNode: + node = parse(source).definitions[0] + assert isinstance(node, OperationDefinitionNode) + return node + + +BATCH_OPERATION = operation( + "mutation addLedgerEntries($entries: [AddLedgerEntryInput!]!) " + "{ addLedgerEntries(entries: $entries) { __typename } }" +) + + +def typed_operation(name: str = "PostThing") -> OperationDefinitionNode: + return operation( + f"""mutation {name}($ik: SafeString!, $ledgerIk: SafeString!, $amount: String!) {{ + addLedgerEntry( + ik: $ik + entry: {{ledger: {{ik: $ledgerIk}}, type: "thing", + parameters: {{amount: $amount}}}} + ) {{ __typename }} + }}""" + ) + + +# --- Extraction --------------------------------------------------------------- + + +def test_unresolvable_parameter_type_warns_and_falls_back_to_any( + caplog: pytest.LogCaptureFixture, +) -> None: + """A parameter with no matching client argument keeps its wire key. + + Dropping it would change the payload, so `Any` is the fallback; the cost is + the caller's type checking on that one field. + """ + spec = extract_entry_spec(typed_operation(), annotations={}) + assert spec is not None + assert [(p.name, p.annotation) for p in spec.parameters] == [("amount", "Any")] + + assert "Could not resolve a type for parameter 'amount'" in caplog.text + assert "PostThing" in caplog.text + + +def test_resolvable_parameter_type_warns_about_nothing( + caplog: pytest.LogCaptureFixture, +) -> None: + spec = extract_entry_spec(typed_operation(), annotations={"amount": "str"}) + assert spec is not None + assert caplog.text == "" + + +def test_field_collision_names_the_parameter_that_moved( + caplog: pytest.LogCaptureFixture, +) -> None: + """The rename is invisible in the payload, so the log is the only signal.""" + spec = extract_entry_spec( + operation("""mutation PostThing($ik: SafeString!, $ledgerIk: SafeString!, + $a: String!, $b: String!) { + addLedgerEntry( + ik: $ik + entry: {ledger: {ik: $ledgerIk}, type: "thing", + parameters: {user_id: $a, userId: $b}} + ) { __typename } + }"""), + annotations={"a": "str", "b": "str"}, + ) + assert spec is not None + assert "'userId'" in caplog.text + assert "'user_id_2'" in caplog.text + assert "wire payload is unaffected" in caplog.text + + +# --- The batch method --------------------------------------------------------- + + +def test_missing_entries_argument_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A renamed argument costs callers their type checking.""" + renamed = method(BATCH_METHOD.replace("entries:", "items:", 1)) + instance = plugin(tmp_path) + + instance.generate_client_method(renamed, BATCH_OPERATION) + + assert not instance.widened_entries_argument + assert "Could not find an 'entries' argument" in caplog.text + assert "will not typecheck" in caplog.text + + +def test_missing_variables_assignment_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Widening without the coercion lets a non-list sequence reach json.dumps.""" + without_assignment = method( + BATCH_METHOD.replace( + 'variables: dict[str, object] = {"entries": entries}', "pass" + ) + ) + instance = plugin(tmp_path) + + instance.generate_client_method(without_assignment, BATCH_OPERATION) + + assert instance.widened_entries_argument + assert "Could not find the 'entries' variables assignment" in caplog.text + assert "fail to serialise" in caplog.text + + +def test_the_intact_batch_method_warns_about_nothing( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An intact method is silent, so the warnings above mean what they say.""" + instance = plugin(tmp_path) + + rewritten = instance.generate_client_method(method(BATCH_METHOD), BATCH_OPERATION) + + assert instance.widened_entries_argument + assert caplog.text == "" + source = ast.unparse(rewritten) + assert "Sequence[Union[AddLedgerEntryInput, TypedLedgerEntry]]" in source + assert "{'entries': list(entries)}" in source + + +def test_typed_models_with_no_batch_operation_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Typed models with no batch method to accept them.""" + instance = plugin(tmp_path) + instance.generate_client_method(method(POST_METHOD), typed_operation()) + + instance.generate_init_code("__all__ = []\n") + + assert not instance.saw_batch_operation + assert "found no 'addLedgerEntries' operation" in caplog.text + + +def test_seeing_the_batch_operation_warns_about_nothing( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + instance = plugin(tmp_path) + instance.generate_client_method(method(POST_METHOD), typed_operation()) + instance.generate_client_method(method(BATCH_METHOD), BATCH_OPERATION) + + instance.generate_init_code("__all__ = []\n") + + assert caplog.text == "" + + +# --- What the plugin writes --------------------------------------------------- + + +def test_generate_init_code_writes_a_module_that_parses(tmp_path: Path) -> None: + instance = plugin(tmp_path) + instance.generate_client_method(method(POST_METHOD), typed_operation()) + instance.generate_client_method(method(BATCH_METHOD), BATCH_OPERATION) + + init_code = instance.generate_init_code("__all__ = []\n") + + written = (tmp_path / "sdk" / "typed_entries.py").read_text(encoding="utf-8") + ast.parse(written) + assert written.startswith("# Generated by fragment") + assert "# Source: queries/" in written + assert "class ThingV1(TypedLedgerEntry):" in written + + ast.parse(init_code) + assert '"ThingV1",' in init_code + assert "from .typed_entries import" in init_code + + +def test_generate_init_code_creates_the_package_directory(tmp_path: Path) -> None: + """The hook does not depend on ariadne having made the directory first.""" + instance = plugin(tmp_path / "nested" / "deeper") + instance.generate_init_code("__all__ = []\n") + + assert (tmp_path / "nested" / "deeper" / "sdk" / "typed_entries.py").exists() + + +def test_client_imports_are_only_added_when_the_argument_was_widened( + tmp_path: Path, +) -> None: + """An unwidened client imports no name it never references.""" + instance = plugin(tmp_path) + code = "from typing import Any\n\nclass Client:\n pass\n" + + assert instance.generate_client_code(code) == code + + instance.generate_client_method(method(BATCH_METHOD), BATCH_OPERATION) + widened = instance.generate_client_code(code) + + assert "from .typed_entries import TypedLedgerEntry" in widened + assert "from typing import Sequence" in widened + ast.parse(widened) diff --git a/tests/type_checks/batch_entries.py b/tests/type_checks/batch_entries.py new file mode 100644 index 0000000..b9bc964 --- /dev/null +++ b/tests/type_checks/batch_entries.py @@ -0,0 +1,119 @@ +"""Type-level assertions for the typed batch API. Checked by mypy, never run. + +The point of the typed payloads is what a type checker says about them, and +nothing else in the suite can assert that -- a runtime test passes just as +happily against `entries: Any`. `make typecheck` covers this file. + +Each `# type: ignore[...]` is an assertion in both directions. It says the call +below is expected to be rejected, and because `warn_unused_ignores` is on, mypy +fails if the call ever starts passing. So loosening the signature breaks this +file just as surely as tightening it too far does. + +Imports resolve through `mypy_path` in pyproject.toml, which points at the +snapshotted `sdk` -- the same package the runtime tests import. +""" + +from typing import List, Sequence, Union + +from sdk.client import Client +from sdk.input_types import AddLedgerEntryInput +from sdk.typed_entries import CardSettleV1, OrderPlacedV1, TypedLedgerEntry + + +def order_placed() -> OrderPlacedV1: + return OrderPlacedV1( + ik="ik", + ledger_ik="prod", + user_id="u", + order_id="o", + order_cost="1000", + currency="USD", + platform_fee="100", + driver_fee="200", + restaurant_id="r", + driver_id="d", + ) + + +async def accepts_the_shapes_callers_actually_build( + client: Client, + typed: OrderPlacedV1, + other: CardSettleV1, + raw: AddLedgerEntryInput, +) -> None: + """None of these may error. A comprehension over orders is the common shape.""" + await client.add_ledger_entries(entries=[typed, typed]) + + # `Sequence`, not `list`, is what makes this one work: `list` is invariant, + # so a pre-built `list[OrderPlacedV1]` is not a `list[Union[...]]`. + prebuilt: List[OrderPlacedV1] = [order_placed() for _ in range(3)] + await client.add_ledger_entries(entries=prebuilt) + + await client.add_ledger_entries(entries=[raw]) + await client.add_ledger_entries(entries=[typed, raw]) + await client.add_ledger_entries(entries=(typed,)) + await client.add_ledger_entries(entries=[typed], headers={"X-Test": "1"}) + + mixed: Sequence[Union[AddLedgerEntryInput, TypedLedgerEntry]] = [typed, other, raw] + await client.add_ledger_entries(entries=mixed) + + +async def rejects_what_is_not_an_entry(client: Client, typed: OrderPlacedV1) -> None: + """The widening must not have degraded into `Any`.""" + await client.add_ledger_entries(entries=["not an entry"]) # type: ignore[list-item] + await client.add_ledger_entries(entries=[{"ik": "x"}]) # type: ignore[list-item] + await client.add_ledger_entries(entries=typed) # type: ignore[arg-type] + await client.add_ledger_entries(entries=None) # type: ignore[arg-type] + + +def rejects_a_missing_parameter() -> OrderPlacedV1: + """Every templated parameter of `order_placed` V1 is required.""" + return OrderPlacedV1( # type: ignore[call-arg] + ik="ik", + ledger_ik="prod", + user_id="u", + ) + + +def rejects_a_parameter_of_the_wrong_type() -> OrderPlacedV1: + return OrderPlacedV1( + ik="ik", + ledger_ik="prod", + user_id=1, # type: ignore[arg-type] + order_id="o", + order_cost="1000", + currency="USD", + platform_fee="100", + driver_fee="200", + restaurant_id="r", + driver_id="d", + ) + + +def rejects_a_parameter_this_version_does_not_have() -> OrderPlacedV1: + """`service_fee` arrived in V2. Asking V1 for it is the mistake the split prevents.""" + return OrderPlacedV1( # type: ignore[call-arg] + ik="ik", + ledger_ik="prod", + user_id="u", + order_id="o", + order_cost="1000", + currency="USD", + platform_fee="100", + service_fee="50", + driver_fee="200", + restaurant_id="r", + driver_id="d", + ) + + +def parameters_are_reachable_under_their_python_names(typed: OrderPlacedV1) -> str: + """Snake_cased fields, and `PARAMETER_FIELDS` holding the Schema names.""" + schema_name: str = typed.PARAMETER_FIELDS["user_id"] + typed.nonexistent_field # type: ignore[attr-defined] + return typed.order_cost + typed.driver_fee + schema_name + + +def to_input_is_the_raw_type(typed: OrderPlacedV1) -> AddLedgerEntryInput: + """So a caller can adjust the payload before sending without losing types.""" + return typed.to_input()