Skip to content

Commit da35cf3

Browse files
authored
Merge pull request #4 from devhelmhq/chore/codegen-audit-phase-0-and-1
Codegen audit: tighten P1/P3/P4/P5 across SDK boundaries
2 parents 4c83e7f + c725199 commit da35cf3

15 files changed

Lines changed: 1191 additions & 129 deletions

pyproject.toml

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,28 @@ strict = true
6767
show_error_codes = true
6868
pretty = true
6969
warn_unreachable = true
70+
disallow_any_explicit = true
7071
plugins = ["pydantic.mypy"]
7172
mypy_path = "src"
72-
exclude = ["src/devhelm/_generated\\.py$"]
73+
# P5 (cast budget): _generated.py is checked in strict mode along with
74+
# everything else. The single justified suppression in there is the
75+
# `[assignment]` collision documented in scripts/typegen.sh.
76+
#
77+
# `disallow_any_explicit` is enforced everywhere except the JSON-boundary
78+
# modules below. The boundary intentionally uses `Any` to model unparsed
79+
# JSON values; the resource layer (and everything customers import) is
80+
# `Any`-free, which is what the docstring on `tests/test_typing.py`
81+
# actually promises.
82+
83+
[[tool.mypy.overrides]]
84+
module = [
85+
"devhelm._errors",
86+
"devhelm._http",
87+
"devhelm._pagination",
88+
"devhelm._validation",
89+
"devhelm._generated",
90+
]
91+
disallow_any_explicit = false
7392

7493
[tool.pytest.ini_options]
7594
testpaths = ["tests"]

scripts/inject_strict_config.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
#!/usr/bin/env python3
2+
"""Inject `model_config = ConfigDict(extra='forbid')` into every generated
3+
Pydantic BaseModel and RootModel class.
4+
5+
datamodel-code-generator does not emit a config block when the source
6+
OpenAPI spec lacks `additionalProperties: false`. Springdoc never emits
7+
that key, so we patch every generated class here.
8+
9+
This implements policies P1 (response extras forbidden) and P2 (request
10+
extras forbidden) from `mini/cowork/design/040-codegen-policies.md`.
11+
12+
The transform is purely syntactic: scan each line, find `class Foo(BaseModel):`
13+
or `class Foo(RootModel[...]):` and inject `model_config = ConfigDict(...)`
14+
on the next non-empty indented line.
15+
16+
Idempotent: skips classes that already declare `model_config`.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import re
22+
import sys
23+
from pathlib import Path
24+
25+
# RootModel subclasses cannot set `extra='forbid'` (Pydantic raises
26+
# `root-model-extra`), so skip them. Their behavior is governed by the
27+
# inner type, which on its own enforces strict validation.
28+
CLASS_RE = re.compile(r"^class\s+([A-Za-z_][\w]*)\s*\(\s*(BaseModel)\s*\)\s*:\s*$")
29+
CONFIG_LINE = " model_config = ConfigDict(extra='forbid')"
30+
31+
32+
# StrEnum members that shadow inherited str methods need a `# type: ignore`
33+
# because mypy thinks they're overriding the base method with an incompatible
34+
# type. Listed explicitly so we get failures (instead of silent no-ops) when
35+
# datamodel-codegen renames things.
36+
STR_ENUM_COLLISIONS = {
37+
# member name -> mypy ignore code
38+
"count": "assignment",
39+
"index": "assignment",
40+
"title": "assignment",
41+
"lower": "assignment",
42+
"upper": "assignment",
43+
"format": "assignment",
44+
}
45+
46+
STR_ENUM_RE = re.compile(r"^class\s+([A-Za-z_][\w]*)\s*\(\s*StrEnum\s*\)\s*:\s*$")
47+
STR_ENUM_MEMBER_RE = re.compile(r"^(\s+)([a-z_][\w]*)\s*=\s*(.+?)\s*$")
48+
49+
50+
def inject(source: str) -> tuple[str, int]:
51+
"""Return (new_source, count_of_classes_modified)."""
52+
if "from pydantic import" in source and "ConfigDict" not in source:
53+
source = source.replace(
54+
"from pydantic import",
55+
"from pydantic import ConfigDict, ",
56+
1,
57+
)
58+
source = source.replace("ConfigDict, ConfigDict, ", "ConfigDict, ", 1)
59+
60+
lines = source.splitlines(keepends=True)
61+
out: list[str] = []
62+
i = 0
63+
modified = 0
64+
in_str_enum = False
65+
while i < len(lines):
66+
line = lines[i]
67+
# Handle StrEnum-member collisions before the BaseModel pass below.
68+
# We track whether we're inside a StrEnum body and patch any member
69+
# whose name shadows an inherited str method.
70+
if STR_ENUM_RE.match(line.rstrip("\n")):
71+
in_str_enum = True
72+
out.append(line)
73+
i += 1
74+
continue
75+
if in_str_enum:
76+
stripped = line.lstrip()
77+
# End of class body: dedented non-blank line.
78+
if stripped and not line.startswith((" ", "\t")):
79+
in_str_enum = False
80+
else:
81+
m_member = STR_ENUM_MEMBER_RE.match(line.rstrip("\n"))
82+
if m_member and m_member.group(2) in STR_ENUM_COLLISIONS:
83+
code = STR_ENUM_COLLISIONS[m_member.group(2)]
84+
if "type: ignore" not in line:
85+
line = line.rstrip("\n") + f" # type: ignore[{code}]\n"
86+
modified += 1
87+
out.append(line)
88+
i += 1
89+
continue
90+
91+
out.append(line)
92+
m = CLASS_RE.match(line.rstrip("\n"))
93+
if not m:
94+
i += 1
95+
continue
96+
# Look at the very next line. If it's already model_config or pass,
97+
# leave the class alone (idempotency / empty class).
98+
next_idx = i + 1
99+
next_line = lines[next_idx] if next_idx < len(lines) else ""
100+
if "model_config" in next_line:
101+
i += 1
102+
continue
103+
# Replace bare `pass` (empty class body) with model_config. Use
104+
# exact match (NOT startswith) — fields like `passed: Annotated[...]`
105+
# also start with "pass" but are not empty class markers.
106+
if next_line.strip() in ("pass", "pass\n"):
107+
out.append(CONFIG_LINE + "\n")
108+
i += 2 # skip the pass
109+
modified += 1
110+
continue
111+
out.append(CONFIG_LINE + "\n")
112+
modified += 1
113+
i += 1
114+
return "".join(out), modified
115+
116+
117+
def main() -> int:
118+
if len(sys.argv) != 2:
119+
print("usage: inject_strict_config.py <path-to-_generated.py>", file=sys.stderr)
120+
return 1
121+
path = Path(sys.argv[1])
122+
if not path.exists():
123+
print(f"error: file not found: {path}", file=sys.stderr)
124+
return 1
125+
src = path.read_text()
126+
new_src, modified = inject(src)
127+
if new_src != src:
128+
path.write_text(new_src)
129+
print(f"inject_strict_config: patched {modified} class(es) in {path}")
130+
return 0
131+
132+
133+
if __name__ == "__main__":
134+
sys.exit(main())

scripts/regen-from.sh

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Regenerate _generated.py from an arbitrary OpenAPI spec file.
4+
#
5+
# Usage: scripts/regen-from.sh <path-to-spec.json>
6+
#
7+
# This script is the per-artifact entry point used by the spec-evolution
8+
# harness (`mono/tests/surfaces/evolution/`). It MUST be idempotent and MUST
9+
# leave the working tree clean enough that subsequent runs see the new spec.
10+
#
11+
# Behavior:
12+
# - copies <path-to-spec.json> over docs/openapi/monitoring-api.json
13+
# - invokes the existing typegen.sh pipeline
14+
# - prints absolute path to the regenerated _generated.py on stdout
15+
#
16+
# The caller (harness fixture) is responsible for:
17+
# - backing up the original spec before the first call
18+
# - restoring it at session teardown
19+
# - invalidating Python's module cache between runs (via subprocess isolation)
20+
#
21+
set -euo pipefail
22+
23+
if [[ $# -ne 1 ]]; then
24+
echo "usage: $0 <path-to-spec.json>" >&2
25+
exit 1
26+
fi
27+
28+
INPUT_SPEC="$1"
29+
if [[ ! -f "$INPUT_SPEC" ]]; then
30+
echo "error: spec not found at $INPUT_SPEC" >&2
31+
exit 1
32+
fi
33+
34+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
35+
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
36+
TARGET_SPEC="$ROOT_DIR/docs/openapi/monitoring-api.json"
37+
OUTPUT="$ROOT_DIR/src/devhelm/_generated.py"
38+
39+
# Resolve to absolute paths so we can detect the (legitimate) case where the
40+
# caller passes the vendored spec back in directly (e.g. post-session teardown
41+
# in the harness re-regens from the restored baseline). Skipping the copy in
42+
# that case avoids `cp: 'X' and 'X' are identical` failing under set -e.
43+
INPUT_ABS="$(cd "$(dirname "$INPUT_SPEC")" && pwd)/$(basename "$INPUT_SPEC")"
44+
TARGET_ABS="$(cd "$(dirname "$TARGET_SPEC")" && pwd)/$(basename "$TARGET_SPEC")"
45+
if [[ "$INPUT_ABS" != "$TARGET_ABS" ]]; then
46+
cp "$INPUT_SPEC" "$TARGET_SPEC"
47+
fi
48+
49+
"$SCRIPT_DIR/typegen.sh" >&2
50+
51+
echo "$OUTPUT"

scripts/typegen.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,17 @@ uv run datamodel-codegen \
5555
--input-file-type openapi \
5656
--formatters ruff-format
5757

58+
# Post-process: inject `model_config = ConfigDict(extra='forbid')` into every
59+
# generated class so that requests with unknown fields and responses with
60+
# unknown fields BOTH fail loudly. Implements P1 + P2 from
61+
# `mini/cowork/design/040-codegen-policies.md`.
62+
echo "=> Injecting strict-fail config (extra='forbid') into generated models..."
63+
uv run python "$SCRIPT_DIR/inject_strict_config.py" "$OUTPUT"
64+
65+
# Re-format after injection so the file stays ruff-clean. Non-fatal so the
66+
# spec-evolution harness keeps moving even if ruff is misconfigured in the
67+
# child env (e.g. inherited VIRTUAL_ENV from a pytest parent).
68+
uv run ruff format --quiet "$OUTPUT" || echo "warning: ruff format skipped" >&2
69+
5870
rm -f "$PREPROCESSED"
5971
echo "=> Generated: $OUTPUT"

src/devhelm/__init__.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
"""DevHelm SDK for Python — typed client for monitors, incidents, alerting, and more."""
22

3-
from devhelm._errors import AuthError, DevhelmError
3+
from devhelm._errors import (
4+
AuthError,
5+
DevhelmApiError,
6+
DevhelmAuthError,
7+
DevhelmConflictError,
8+
DevhelmError,
9+
DevhelmNotFoundError,
10+
DevhelmRateLimitError,
11+
DevhelmServerError,
12+
DevhelmTransportError,
13+
DevhelmValidationError,
14+
)
415
from devhelm._pagination import CursorPage, Page
516
from devhelm._validation import RequestBody
617
from devhelm.client import Devhelm
@@ -121,6 +132,14 @@
121132
"Devhelm",
122133
# Errors
123134
"DevhelmError",
135+
"DevhelmValidationError",
136+
"DevhelmApiError",
137+
"DevhelmAuthError",
138+
"DevhelmNotFoundError",
139+
"DevhelmConflictError",
140+
"DevhelmRateLimitError",
141+
"DevhelmServerError",
142+
"DevhelmTransportError",
124143
"AuthError",
125144
# Pagination
126145
"Page",

0 commit comments

Comments
 (0)