From 9a2d4e70e83d41dd817bd76a4bf4282a93d32d40 Mon Sep 17 00:00:00 2001 From: Ander Date: Wed, 30 Sep 2026 13:15:15 +0200 Subject: [PATCH] fix(batch): a response enum value the SDK did not know yet failed the whole response, so response enums now accept unknown values Response enums marked `x-extensible-enum: true` in docs/openapi.yaml get a `_missing_` hook (src/zenrows/batch/_open_enum.py) added after codegen by scripts/open_extensible_enums.py: an unknown value parses as an UNKNOWN member that keeps the raw value. Request-side enums (JobType, ScheduleState, ...) stay strict so a typo still fails locally. PyYAML becomes an explicit dev dependency for the post-step, and CI now fails if the generated models drift from the spec. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 ++ DEVELOPMENT.md | 21 +++++ Makefile | 6 ++ docs/openapi.yaml | 10 +++ pyproject.toml | 2 + scripts/open_extensible_enums.py | 88 ++++++++++++++++++++ src/zenrows/batch/_open_enum.py | 50 +++++++++++ src/zenrows/batch/models.py | 33 +++++++- tests/test_open_enums.py | 138 +++++++++++++++++++++++++++++++ uv.lock | 2 + 10 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 scripts/open_extensible_enums.py create mode 100644 src/zenrows/batch/_open_enum.py create mode 100644 tests/test_open_enums.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06e9d94..76f0302 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,5 +30,11 @@ jobs: - name: "Lint, format check, and typecheck" run: make check + - name: "Generated models match the spec" + # Offline: codegen reads docs/openapi.yaml. Ignores the timestamp header. + run: | + make generate + git diff --exit-code -I '^# timestamp:' -- src/zenrows/batch/models.py + - name: "Unit tests" run: make test diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 89c9b72..7c4b30b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -67,6 +67,27 @@ reads it to emit the models. To refresh after a backend spec change: 4. If the wire shape changed, update `src/zenrows/batch/client.py` so the facade method signatures still typecheck. +## Open enums (generated) + +docs/openapi.yaml marks the response enums `x-extensible-enum: true`: the +server may add values at any time. After datamodel-codegen runs, +`make generate` calls `scripts/open_extensible_enums.py`, which matches each +extensible schema in the spec to its generated Enum (by value set) and adds + +```python +_missing_ = classmethod(open_enum_missing) +``` + +from `src/zenrows/batch/_open_enum.py`. An unknown value becomes a cached +`UNKNOWN` pseudo-member keeping the raw value (serializes back verbatim, +hashable, picklable, absent from iteration). Enums without the flag +(request side, e.g. `JobType`) stay strict, so a typo still fails locally. +The step is a script because datamodel-codegen does not expose schema +extensions to enum templates. + +`tests/test_open_enums.py` derives both sets from the spec and fails if a +regeneration drops the hook or applies it to a strict enum. + ## Publishing ```bash diff --git a/Makefile b/Makefile index ba8e32e..56a08eb 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,11 @@ format: # Regenerate the pydantic v2 models from the backend's canonical spec. # docs/openapi.yaml is the SDK-local copy of the spec; refresh it from the # backend when the API changes. +# Open enums: scripts/open_extensible_enums.py then adds a `_missing_` hook +# (src/zenrows/batch/_open_enum.py) to every enum the spec marks +# `x-extensible-enum: true`, so a value the server adds later parses as an +# UNKNOWN member keeping the raw value. Other (request-side) enums stay strict. +# tests/test_open_enums.py fails if a regeneration drops the hook. # The HTTP client + facade are HAND-WRITTEN in src/zenrows/batch/client.py; # only the type definitions come from this command. generate: @@ -48,6 +53,7 @@ generate: --capitalise-enum-members \ --reuse-model \ --use-default + uv run python scripts/open_extensible_enums.py docs/openapi.yaml src/zenrows/batch/models.py # Regenerate the markdown API reference (docs/batch-client-reference.md) from # the SDK's docstrings via pydoc-markdown (ephemeral — no permanent dep). The diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e8f4c7d..07f401e 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1154,6 +1154,7 @@ components: JobStatus: type: string + x-extensible-enum: true enum: [open, closed, deleted] description: | - `open` — initial run still accepting `addTasks`. Only @@ -1179,6 +1180,7 @@ components: RunTrigger: type: string + x-extensible-enum: true enum: [manual, scheduled] description: | What set this run in motion. Always set. @@ -1190,6 +1192,7 @@ components: RunStatus: type: string + x-extensible-enum: true enum: [running, pending, completed, stopped, failed, deleted] description: | In-flight: @@ -1214,15 +1217,18 @@ components: TaskStatus: type: string + x-extensible-enum: true enum: [pending, processing, successful, failed] ResultType: type: string + x-extensible-enum: true enum: [html, json, markdown, plaintext, pdf] description: Body format of a successful task result; matches the job's `format` 1:1. Format: type: string + x-extensible-enum: true enum: [html, json, markdown, plaintext, pdf] description: | Derived server-side from `zenrows_params` at submit time. @@ -1567,6 +1573,7 @@ components: `/resume`. ingest_status: type: string + x-extensible-enum: true enum: [pending, done] description: | Present only on runs created by a large (202) submission @@ -1580,6 +1587,7 @@ components: updated_at: { type: string, format: date-time } failure_reason: type: string + x-extensible-enum: true enum: [insufficient_credits, subscription_inactive] description: | Present only when `status == failed`: the account-level @@ -2112,6 +2120,7 @@ components: index: { type: integer } reason: type: string + x-extensible-enum: true enum: - malformed_url - unsupported_scheme @@ -2133,6 +2142,7 @@ components: ExportStatus: type: string + x-extensible-enum: true enum: [pending, running, completed, failed] description: | Lifecycle state of a results export. diff --git a/pyproject.toml b/pyproject.toml index 19da5c5..34d6de9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,8 @@ dev-dependencies = [ # OpenAPI → pydantic-v2 models. Run via `uv run make-models` # (Makefile recipe). Output lands at src/zenrows/batch/models.py. "datamodel-code-generator>=0.26", + # scripts/open_extensible_enums.py reads the spec (make generate). + "pyyaml>=6", ] # --- ruff --- diff --git a/scripts/open_extensible_enums.py b/scripts/open_extensible_enums.py new file mode 100644 index 0000000..8fdf309 --- /dev/null +++ b/scripts/open_extensible_enums.py @@ -0,0 +1,88 @@ +"""Post-generation step for `make generate`: open the extensible enums. + +datamodel-codegen does not pass schema extensions to enum templates, so +this step reads the spec itself. Every schema with an `enum` list and +`x-extensible-enum: true` (the response side) is matched to its generated +Enum class by its set of values, and that class gets + + _missing_ = classmethod(open_enum_missing) + +(see src/zenrows/batch/_open_enum.py). Enums without the flag (request +side) stay strict, so a typo still fails locally. Deterministic; exits +non-zero if a value set is both extensible and strict, or an extensible +schema has no generated class. + +Usage: python scripts/open_extensible_enums.py SPEC MODELS_PY +""" + +import ast +import sys +from pathlib import Path + +import yaml + +HOOK = ( + " # Open enum (x-extensible-enum): unknown values parse as UNKNOWN.\n" + " _missing_ = classmethod(open_enum_missing)\n" +) +IMPORT = "from zenrows.batch._open_enum import open_enum_missing\n" + + +def collect(node, extensible: set, strict: set) -> None: + if isinstance(node, dict): + if isinstance(node.get("enum"), list): + values = frozenset(node["enum"]) + (extensible if node.get("x-extensible-enum") is True else strict).add(values) + for v in node.values(): + collect(v, extensible, strict) + elif isinstance(node, list): + for v in node: + collect(v, extensible, strict) + + +def main(spec_path: str, models_path: str) -> int: + extensible: set = set() + strict: set = set() + collect(yaml.safe_load(Path(spec_path).read_text()), extensible, strict) + if clash := extensible & strict: + print(f"ambiguous enum value sets (extensible and strict): {clash}", file=sys.stderr) + return 1 + + src = Path(models_path).read_text() + if "open_enum_missing" in src: + print("models already processed; regenerate first", file=sys.stderr) + return 1 + lines = src.splitlines(keepends=True) + inserts: list[int] = [] + matched: set = set() + for node in ast.parse(src).body: + if not isinstance(node, ast.ClassDef): + continue + if not any(isinstance(b, ast.Name) and b.id == "Enum" for b in node.bases): + continue + values = frozenset( + stmt.value.value + for stmt in node.body + if isinstance(stmt, ast.Assign) and isinstance(stmt.value, ast.Constant) + ) + if values in extensible: + matched.add(values) + inserts.append(node.end_lineno or 0) + if missing := extensible - matched: + print(f"extensible enums with no generated class: {missing}", file=sys.stderr) + return 1 + + for idx in sorted(inserts, reverse=True): + lines.insert(idx, "\n" + HOOK) + future = next((i for i, ln in enumerate(lines) if ln.startswith("from __future__")), None) + if future is None: + print(f"{models_path}: no `from __future__` import to anchor on", file=sys.stderr) + return 1 + lines.insert(future + 1, IMPORT) + Path(models_path).write_text("".join(lines)) + print(f"opened {len(inserts)} extensible enums") + return 0 + + +if __name__ == "__main__": + sys.exit(main(*sys.argv[1:3])) diff --git a/src/zenrows/batch/_open_enum.py b/src/zenrows/batch/_open_enum.py new file mode 100644 index 0000000..62e091c --- /dev/null +++ b/src/zenrows/batch/_open_enum.py @@ -0,0 +1,50 @@ +"""Open (extensible) enums for the generated Batch models. + +The Batch API marks its response enums `x-extensible-enum: true`: +clients must accept values the spec does not list yet. A plain `Enum` +raises on an unknown value, so pydantic would reject the whole +response the day the server adds one. + +`make generate` renders every enum in `models.py` through +`codegen/templates/Enum.jinja2`, which wires this module's +`open_enum_missing` in as the enum's `_missing_` hook. An unknown value +then resolves to a pseudo-member that: + +- keeps the raw wire value in `.value` (so JSON serialization emits it); +- is named `UNKNOWN` and is not part of iteration / `len()`; +- is cached per (enum, value), so equal raw values give the same object, + and it compares equal only to itself; +- is hashable and pickles back through the same lookup. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Any + +UNKNOWN_NAME = "UNKNOWN" + + +def open_enum_missing(cls: type[Enum], value: Any) -> Enum | None: + """`Enum._missing_` hook: return an `UNKNOWN` pseudo-member for `value`.""" + if value is None: + # pydantic-core's JSON path probes `_missing_(None)` before retrying + # with the real input; answering it would swallow the raw value. + # None is never a wire enum value (nullable fields are `X | None`). + return None + try: + hash(value) + except TypeError: + return None # unhashable — let Enum raise its normal ValueError + member = object.__new__(cls) + member._name_ = UNKNOWN_NAME + member._value_ = value + # Cache so `cls(value) is cls(value)`. Not added to `_member_map_`, + # so the pseudo-member never shows up when iterating the enum. + cls._value2member_map_.setdefault(value, member) + return cls._value2member_map_[value] + + +def is_unknown(member: Enum) -> bool: + """True when `member` is a value the SDK's spec did not list.""" + return member._name_ == UNKNOWN_NAME and member not in type(member).__members__.values() diff --git a/src/zenrows/batch/models.py b/src/zenrows/batch/models.py index dac59fe..6c78b03 100644 --- a/src/zenrows/batch/models.py +++ b/src/zenrows/batch/models.py @@ -1,8 +1,9 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-08-25T14:35:19+00:00 +# timestamp: 2026-09-30T11:15:03+00:00 from __future__ import annotations +from zenrows.batch._open_enum import open_enum_missing from enum import Enum from typing import Annotated, Any, Literal @@ -41,6 +42,9 @@ class JobStatus(Enum): CLOSED = "closed" DELETED = "deleted" + # Open enum (x-extensible-enum): unknown values parse as UNKNOWN. + _missing_ = classmethod(open_enum_missing) + class ScheduleState(Enum): """ @@ -72,6 +76,9 @@ class RunTrigger(Enum): MANUAL = "manual" SCHEDULED = "scheduled" + # Open enum (x-extensible-enum): unknown values parse as UNKNOWN. + _missing_ = classmethod(open_enum_missing) + class RunStatus(Enum): """ @@ -104,6 +111,9 @@ class RunStatus(Enum): FAILED = "failed" DELETED = "deleted" + # Open enum (x-extensible-enum): unknown values parse as UNKNOWN. + _missing_ = classmethod(open_enum_missing) + class TaskStatus(Enum): PENDING = "pending" @@ -111,6 +121,9 @@ class TaskStatus(Enum): SUCCESSFUL = "successful" FAILED = "failed" + # Open enum (x-extensible-enum): unknown values parse as UNKNOWN. + _missing_ = classmethod(open_enum_missing) + class ResultType(Enum): """ @@ -123,6 +136,9 @@ class ResultType(Enum): PLAINTEXT = "plaintext" PDF = "pdf" + # Open enum (x-extensible-enum): unknown values parse as UNKNOWN. + _missing_ = classmethod(open_enum_missing) + class Format(Enum): """ @@ -143,6 +159,9 @@ class Format(Enum): PLAINTEXT = "plaintext" PDF = "pdf" + # Open enum (x-extensible-enum): unknown values parse as UNKNOWN. + _missing_ = classmethod(open_enum_missing) + class Method(Enum): """ @@ -331,6 +350,9 @@ class IngestStatus(Enum): PENDING = "pending" DONE = "done" + # Open enum (x-extensible-enum): unknown values parse as UNKNOWN. + _missing_ = classmethod(open_enum_missing) + class FailureReason(Enum): """ @@ -345,6 +367,9 @@ class FailureReason(Enum): INSUFFICIENT_CREDITS = "insufficient_credits" SUBSCRIPTION_INACTIVE = "subscription_inactive" + # Open enum (x-extensible-enum): unknown values parse as UNKNOWN. + _missing_ = classmethod(open_enum_missing) + class Run(BaseModel): run_id: str @@ -730,6 +755,9 @@ class Reason(Enum): UNKNOWN_PARAM = "unknown_param" INVALID_PARAM_VALUE = "invalid_param_value" + # Open enum (x-extensible-enum): unknown values parse as UNKNOWN. + _missing_ = classmethod(open_enum_missing) + class InvalidTask(BaseModel): index: int @@ -780,6 +808,9 @@ class ExportStatus(Enum): COMPLETED = "completed" FAILED = "failed" + # Open enum (x-extensible-enum): unknown values parse as UNKNOWN. + _missing_ = classmethod(open_enum_missing) + class StartExportResponse(BaseModel): """ diff --git a/tests/test_open_enums.py b/tests/test_open_enums.py new file mode 100644 index 0000000..e876bf7 --- /dev/null +++ b/tests/test_open_enums.py @@ -0,0 +1,138 @@ +"""Contract: a Batch response never fails to parse because the server +added an enum value (docs/openapi.yaml marks response enums +`x-extensible-enum`). + +Enums the spec marks extensible must carry the open-enum `_missing_` hook +(added by scripts/open_extensible_enums.py); every other enum must stay +strict. Both sets are derived from docs/openapi.yaml and the generated +module, so a regeneration that drops or over-applies the hook fails here. +""" + +import enum +import inspect +import json +import pickle +from pathlib import Path + +import pytest +import yaml +from pydantic import TypeAdapter + +from zenrows.batch import models +from zenrows.batch._open_enum import is_unknown +from zenrows.batch.models import Job, Run + +FUTURE = "some_future_value" + +GENERATED_ENUMS = [ + obj + for _, obj in inspect.getmembers(models, inspect.isclass) + if issubclass(obj, enum.Enum) and obj.__module__ == models.__name__ +] + + +SPEC = yaml.safe_load((Path(__file__).parent.parent / "docs" / "openapi.yaml").read_text()) + + +def _extensible_value_sets(node, out: set) -> set: + if isinstance(node, dict): + if isinstance(node.get("enum"), list) and node.get("x-extensible-enum") is True: + out.add(frozenset(node["enum"])) + for v in node.values(): + _extensible_value_sets(v, out) + elif isinstance(node, list): + for v in node: + _extensible_value_sets(v, out) + return out + + +EXTENSIBLE_SETS = _extensible_value_sets(SPEC, set()) +EXTENSIBLE = [c for c in GENERATED_ENUMS if frozenset(m.value for m in c) in EXTENSIBLE_SETS] +STRICT = [c for c in GENERATED_ENUMS if c not in EXTENSIBLE] + + +def test_module_has_enums(): + # Guard against the iteration silently finding nothing. + assert len(GENERATED_ENUMS) >= 17 + assert len(EXTENSIBLE) >= len(EXTENSIBLE_SETS) >= 9 + assert STRICT, "request-side enums must stay strict" + assert models.FailureReason in EXTENSIBLE + assert models.JobType in STRICT + + +@pytest.mark.parametrize("enum_cls", STRICT, ids=lambda c: c.__name__) +def test_non_extensible_enum_rejects_unknown_value(enum_cls): + with pytest.raises(ValueError): + enum_cls(FUTURE) + with pytest.raises(ValueError): # pydantic ValidationError is a ValueError + TypeAdapter(enum_cls).validate_python(FUTURE) + + +def test_request_typo_still_fails_locally(): + with pytest.raises(ValueError): + models.SubmitJobRequest.model_validate( + {"type": "regulr", "tasks": [{"url": "https://example.com"}]} + ) + + +@pytest.mark.parametrize("enum_cls", EXTENSIBLE, ids=lambda c: c.__name__) +def test_unknown_value_parses_keeps_raw_value_and_round_trips(enum_cls): + adapter = TypeAdapter(enum_cls) + member = adapter.validate_python(FUTURE) + assert isinstance(member, enum_cls) + assert member.value == FUTURE + assert member.name == "UNKNOWN" + assert is_unknown(member) + assert adapter.validate_json(json.dumps(FUTURE)) is member + assert adapter.dump_json(member) == json.dumps(FUTURE).encode() + assert adapter.dump_python(member, mode="json") == FUTURE + # Equal only to itself; hashable; picklable; not listed as a member. + assert all(member != known for known in enum_cls) + assert member not in list(enum_cls) + assert {member: 1}[enum_cls(FUTURE)] == 1 + assert pickle.loads(pickle.dumps(member)) is member + + +@pytest.mark.parametrize("enum_cls", GENERATED_ENUMS, ids=lambda c: c.__name__) +def test_known_values_still_map_to_members(enum_cls): + adapter = TypeAdapter(enum_cls) + for known in enum_cls: + parsed = adapter.validate_python(known.value) + assert parsed is known + assert not is_unknown(parsed) + + +RUN = { + "run_id": "01R000000000000000000A", + "job_id": "01J000000000000000000A", + "run_sequence": 1, + "status": "failed", + "stats": {"total": 10, "completed": 3, "successful": 3, "failed": 0}, + "created_at": "2026-09-30T10:00:00Z", + "updated_at": "2026-09-30T10:05:00Z", + "failure_reason": "some_future_reason", +} + + +def test_run_with_future_failure_reason_parses(): + run = Run.model_validate(RUN) + assert run.failure_reason is not None + assert run.failure_reason.value == "some_future_reason" + assert json.loads(run.model_dump_json())["failure_reason"] == "some_future_reason" + + +def test_job_with_future_values_parses(): + job = Job.model_validate( + { + "job_id": "01J000000000000000000A", + "type": "regular", + "status": "some_future_status", + "created_at": "2026-09-30T10:00:00Z", + "updated_at": "2026-09-30T10:05:00Z", + "latest_run": {**RUN, "status": "some_future_run_status"}, + } + ) + assert job.status.value == "some_future_status" + assert job.latest_run is not None + assert job.latest_run.failure_reason is not None + assert job.latest_run.failure_reason.value == "some_future_reason" diff --git a/uv.lock b/uv.lock index 3c540a1..bcb3080 100644 --- a/uv.lock +++ b/uv.lock @@ -986,6 +986,7 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-httpx" }, + { name = "pyyaml" }, { name = "respx" }, { name = "ruff" }, { name = "ty" }, @@ -1006,6 +1007,7 @@ dev = [ { name = "pytest", specifier = ">=8" }, { name = "pytest-asyncio", specifier = ">=0.24" }, { name = "pytest-httpx", specifier = ">=0.30" }, + { name = "pyyaml", specifier = ">=6" }, { name = "respx", specifier = ">=0.21" }, { name = "ruff", specifier = ">=0.7" }, { name = "ty" },