Skip to content

Commit 7edecab

Browse files
feat: spec-level Postel's-Law tolerant readers + codegen-stable enum aliases
Pipeline rewrite. `scripts/typegen.sh` now runs through the shared `@devhelm/openapi-tools` preprocessor (which calls `relaxResponseEnumsInSpec`) before `datamodel-codegen` sees the spec. Response-DTO multi-value enum fields decode as plain `str` — so `MonitorDto.type`, `IncidentDto.status`, etc. accept future API values added after this SDK version was built. Request DTOs keep their StrEnum classes and `Field(discriminator=…)` wiring for strict authoring-time validation. `scripts/emit_response_enums.py` (new) emits `_enums.py` from the *un-relaxed* spec, with one `Literal[...]` alias per `(SchemaName, propertyName)` pair (164 aliases). Names are stable across spec evolution because they don't depend on `datamodel-codegen`'s suffixed names (`Status1`…`Status15`, `Type1`…`Type6`) which shifted on every spec change. `types.py` now imports every public enum alias (`IncidentStatus`, `MonitorType`, `CustomDomainStatus`, `ConfirmationPolicyType`, …) from `_enums.py` so the public API stays stable while the runtime stays Postel-tolerant. Hand-coded `ConfirmationPolicyType` literal removed in favour of the auto-generated alias (caught a wrong value in the process — the literal is `"multi_region"`, not `"confirmation"`). Negative tests around response-DTO enum rejection are flipped to assert acceptance (tolerance). Request-DTO negative tests stay strict. See `mini/runbooks/api-contract.md` § 3.2 for the cross-surface design and `_enums.py` rationale. Coverage: 742 / 742 tests pass; ruff + mypy clean (26 source files). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6a6409f commit 7edecab

10 files changed

Lines changed: 1020 additions & 573 deletions

File tree

docs/openapi/monitoring-api.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5187,7 +5187,7 @@
51875187
"Invites"
51885188
],
51895189
"summary": "Resend invite",
5190-
"operationId": "resend",
5190+
"operationId": "resend_1",
51915191
"parameters": [
51925192
{
51935193
"name": "inviteId",

scripts/emit_response_enums.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#!/usr/bin/env python3
2+
"""Generate ``src/devhelm/_enums.py`` from the *un-relaxed* OpenAPI spec.
3+
4+
Why this exists
5+
===============
6+
7+
Under the spec-level Postel's-Law relaxation
8+
(see ``mini/runbooks/api-contract.md`` § 3 and the design notes at
9+
the top of ``../mini/packages/openapi-tools/src/preprocess.ts``)
10+
multi-value enums on response-shape DTOs are dropped from the
11+
preprocessed spec before code generation. That makes the runtime
12+
behaviour tolerant — ``MonitorDto.type`` decodes any string, including
13+
new wire values added by the API after the SDK was built — but it also
14+
removes the StrEnum classes that ``types.py`` historically re-exported
15+
under public-facing names like ``IncidentStatus``,
16+
``CustomDomainStatus``, and ``MonitorDtoType``.
17+
18+
We *also* don't want to depend on ``datamodel-codegen``'s numbered
19+
suffixes (``Status1``…``Status15``, ``Type1``…``Type6``) for the
20+
remaining request-side enums — those numbers shift whenever the spec
21+
gains or loses an enum, which would force a churn of hand-written
22+
imports in ``types.py`` on every schema evolution.
23+
24+
This script eliminates both problems by emitting one ``Literal[...]``
25+
alias per ``(schemaName, propertyName)`` pair for every named
26+
multi-value enum in the **un-relaxed** spec. The alias name is
27+
``<SchemaName><PascalProperty>`` so it is stable across spec evolution
28+
and independent of codegen numbering. ``types.py`` imports everything
29+
from the resulting ``_enums.py`` and re-exports under the SDK's
30+
public-facing aliases (``IncidentStatus``, ``MonitorType``, …).
31+
32+
Sequencing matters: the script is invoked from ``scripts/typegen.sh``
33+
*after* datamodel-codegen has consumed the preprocessed spec, but it
34+
reads the original (un-relaxed) spec directly so multi-value enums
35+
survive even on response DTOs.
36+
"""
37+
38+
from __future__ import annotations
39+
40+
import json
41+
import re
42+
import sys
43+
from pathlib import Path
44+
from typing import Any
45+
46+
ROOT = Path(__file__).resolve().parents[1]
47+
SPEC_PATH = ROOT / "docs" / "openapi" / "monitoring-api.json"
48+
OUTPUT = ROOT / "src" / "devhelm" / "_enums.py"
49+
50+
51+
def pascal_property(name: str) -> str:
52+
parts = re.split(r"[_\-]", name)
53+
return "".join(p[:1].upper() + p[1:] for p in parts if p)
54+
55+
56+
def collect_named_enums(
57+
spec: dict[str, Any],
58+
) -> dict[str, list[str]]:
59+
"""Return ``{<SchemaName><PascalProperty>: [values]}`` for every
60+
multi-value enum on any named schema's property — request, response,
61+
or anywhere in between. Naming is uniform across request and
62+
response shapes so consumers can reference a stable alias regardless
63+
of which side a value travels on.
64+
"""
65+
schemas = (spec.get("components") or {}).get("schemas") or {}
66+
out: dict[str, list[str]] = {}
67+
68+
def visit(schema_name: str, properties: dict[str, Any] | None) -> None:
69+
if not properties:
70+
return
71+
for prop_name, prop in properties.items():
72+
if not isinstance(prop, dict):
73+
continue
74+
enum = prop.get("enum")
75+
# Emit length-1 enums too — those are discriminator tags
76+
# installed by `inlineDiscriminatorSubtypesWithInfo` and the
77+
# only way to source the canonical value from the spec
78+
# rather than hand-coding it. Keeps types.py free of magic
79+
# strings (e.g. ConfirmationPolicy.type = "multi_region").
80+
if (
81+
isinstance(enum, list)
82+
and len(enum) >= 1
83+
and all(isinstance(v, str) for v in enum)
84+
):
85+
alias = schema_name + pascal_property(prop_name)
86+
out[alias] = list(enum)
87+
items = prop.get("items")
88+
if isinstance(items, dict):
89+
items_enum = items.get("enum")
90+
if (
91+
isinstance(items_enum, list)
92+
and len(items_enum) >= 1
93+
and all(isinstance(v, str) for v in items_enum)
94+
):
95+
alias = schema_name + pascal_property(prop_name) + "Item"
96+
out[alias] = list(items_enum)
97+
98+
for name, schema in schemas.items():
99+
# Skip anonymous / lowercase schemas — those are inline types
100+
# that don't have a stable name and we don't surface them.
101+
if not name or not name[0].isupper():
102+
continue
103+
if not isinstance(schema, dict):
104+
continue
105+
visit(name, schema.get("properties"))
106+
for member in schema.get("allOf") or []:
107+
if isinstance(member, dict):
108+
visit(name, member.get("properties"))
109+
110+
return out
111+
112+
113+
def render(aliases: dict[str, list[str]]) -> str:
114+
lines = [
115+
'"""Auto-generated enum literal aliases (uniform request + response).',
116+
"",
117+
"DO NOT EDIT — regenerated on every ``typegen.sh`` run from the",
118+
"*un-relaxed* OpenAPI spec. See ``scripts/emit_response_enums.py``",
119+
"and ``mini/runbooks/api-contract.md`` § 3 for the design.",
120+
"",
121+
"Each alias is a ``typing.Literal[...]`` of the wire-format values",
122+
"the API currently accepts (request-side) or emits (response-side)",
123+
"for the named ``<SchemaName><Property>`` field. Naming is",
124+
"stable: it does not depend on ``datamodel-codegen``'s suffixed",
125+
"names (``Status1``, ``Type5``…) which shift on every spec change.",
126+
"",
127+
"Response-DTO fields decode to plain ``str`` in ``_generated.py``",
128+
"(Postel-tolerant on receive). Request-DTO fields keep strict",
129+
"validation through the corresponding ``StrEnum`` in",
130+
"``_generated.py`` (strict on send). These aliases give SDK",
131+
"callers a single canonical name they can annotate against in",
132+
"either direction.",
133+
'"""',
134+
"",
135+
"from __future__ import annotations",
136+
"",
137+
"from typing import Literal",
138+
"",
139+
]
140+
for alias in sorted(aliases):
141+
values = aliases[alias]
142+
rendered = ", ".join(f'"{v}"' for v in values)
143+
lines.append(f"{alias} = Literal[{rendered}]")
144+
lines.append("")
145+
lines.append("__all__ = [")
146+
for alias in sorted(aliases):
147+
lines.append(f' "{alias}",')
148+
lines.append("]")
149+
lines.append("")
150+
return "\n".join(lines)
151+
152+
153+
def main() -> int:
154+
if not SPEC_PATH.exists():
155+
print(f"error: spec not found at {SPEC_PATH}", file=sys.stderr)
156+
return 1
157+
spec = json.loads(SPEC_PATH.read_text())
158+
aliases = collect_named_enums(spec)
159+
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
160+
OUTPUT.write_text(render(aliases))
161+
print(f"emit_enums: wrote {len(aliases)} aliases → {OUTPUT}")
162+
return 0
163+
164+
165+
if __name__ == "__main__":
166+
raise SystemExit(main())

scripts/typegen.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,15 @@ uv run python "$SCRIPT_DIR/inject_strict_config.py" "$OUTPUT"
8181
# child env (e.g. inherited VIRTUAL_ENV from a pytest parent).
8282
uv run ruff format --quiet "$OUTPUT" || echo "warning: ruff format skipped" >&2
8383

84+
# Emit Literal aliases for every named multi-value enum from the
85+
# *un-relaxed* spec. Provides ``types.py`` with codegen-stable public
86+
# enum names (``IncidentStatus``, ``MonitorType`` …) that don't depend
87+
# on ``datamodel-codegen``'s numbered suffixes. See
88+
# ``mini/runbooks/api-contract.md`` § 3 and the script docstring.
89+
echo "=> Emitting enum literal aliases..."
90+
uv run python "$SCRIPT_DIR/emit_response_enums.py"
91+
uv run ruff format --quiet "$ROOT_DIR/src/devhelm/_enums.py" \
92+
|| echo "warning: ruff format on _enums.py skipped" >&2
93+
8494
rm -f "$PREPROCESSED"
8595
echo "=> Generated: $OUTPUT"

src/devhelm/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@
120120
StatusPageUpdateStatus,
121121
TagDto,
122122
TestChannelResult,
123+
Tier,
123124
TriggerRuleSeverity,
124125
TriggerRuleType,
125126
UpdateAlertChannelRequest,
@@ -289,6 +290,7 @@
289290
"StatusPageOverallStatus",
290291
"StatusPageUpdateCreatedBy",
291292
"StatusPageUpdateStatus",
293+
"Tier",
292294
"TriggerRuleSeverity",
293295
"TriggerRuleType",
294296
"UpdateAssertionSeverity",

0 commit comments

Comments
 (0)