Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,28 @@ strict = true
show_error_codes = true
pretty = true
warn_unreachable = true
disallow_any_explicit = true
plugins = ["pydantic.mypy"]
mypy_path = "src"
exclude = ["src/devhelm/_generated\\.py$"]
# P5 (cast budget): _generated.py is checked in strict mode along with
# everything else. The single justified suppression in there is the
# `[assignment]` collision documented in scripts/typegen.sh.
#
# `disallow_any_explicit` is enforced everywhere except the JSON-boundary
# modules below. The boundary intentionally uses `Any` to model unparsed
# JSON values; the resource layer (and everything customers import) is
# `Any`-free, which is what the docstring on `tests/test_typing.py`
# actually promises.

[[tool.mypy.overrides]]
module = [
"devhelm._errors",
"devhelm._http",
"devhelm._pagination",
"devhelm._validation",
"devhelm._generated",
]
disallow_any_explicit = false

[tool.pytest.ini_options]
testpaths = ["tests"]
134 changes: 134 additions & 0 deletions scripts/inject_strict_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Inject `model_config = ConfigDict(extra='forbid')` into every generated
Pydantic BaseModel and RootModel class.

datamodel-code-generator does not emit a config block when the source
OpenAPI spec lacks `additionalProperties: false`. Springdoc never emits
that key, so we patch every generated class here.

This implements policies P1 (response extras forbidden) and P2 (request
extras forbidden) from `mini/cowork/design/040-codegen-policies.md`.

The transform is purely syntactic: scan each line, find `class Foo(BaseModel):`
or `class Foo(RootModel[...]):` and inject `model_config = ConfigDict(...)`
on the next non-empty indented line.

Idempotent: skips classes that already declare `model_config`.
"""

from __future__ import annotations

import re
import sys
from pathlib import Path

# RootModel subclasses cannot set `extra='forbid'` (Pydantic raises
# `root-model-extra`), so skip them. Their behavior is governed by the
# inner type, which on its own enforces strict validation.
CLASS_RE = re.compile(r"^class\s+([A-Za-z_][\w]*)\s*\(\s*(BaseModel)\s*\)\s*:\s*$")
CONFIG_LINE = " model_config = ConfigDict(extra='forbid')"


# StrEnum members that shadow inherited str methods need a `# type: ignore`
# because mypy thinks they're overriding the base method with an incompatible
# type. Listed explicitly so we get failures (instead of silent no-ops) when
# datamodel-codegen renames things.
STR_ENUM_COLLISIONS = {
# member name -> mypy ignore code
"count": "assignment",
"index": "assignment",
"title": "assignment",
"lower": "assignment",
"upper": "assignment",
"format": "assignment",
}

STR_ENUM_RE = re.compile(r"^class\s+([A-Za-z_][\w]*)\s*\(\s*StrEnum\s*\)\s*:\s*$")
STR_ENUM_MEMBER_RE = re.compile(r"^(\s+)([a-z_][\w]*)\s*=\s*(.+?)\s*$")


def inject(source: str) -> tuple[str, int]:
"""Return (new_source, count_of_classes_modified)."""
if "from pydantic import" in source and "ConfigDict" not in source:
source = source.replace(
"from pydantic import",
"from pydantic import ConfigDict, ",
1,
)
source = source.replace("ConfigDict, ConfigDict, ", "ConfigDict, ", 1)

lines = source.splitlines(keepends=True)
out: list[str] = []
i = 0
modified = 0
in_str_enum = False
while i < len(lines):
line = lines[i]
# Handle StrEnum-member collisions before the BaseModel pass below.
# We track whether we're inside a StrEnum body and patch any member
# whose name shadows an inherited str method.
if STR_ENUM_RE.match(line.rstrip("\n")):
in_str_enum = True
out.append(line)
i += 1
continue
if in_str_enum:
stripped = line.lstrip()
# End of class body: dedented non-blank line.
if stripped and not line.startswith((" ", "\t")):
in_str_enum = False
else:
m_member = STR_ENUM_MEMBER_RE.match(line.rstrip("\n"))
if m_member and m_member.group(2) in STR_ENUM_COLLISIONS:
code = STR_ENUM_COLLISIONS[m_member.group(2)]
if "type: ignore" not in line:
line = line.rstrip("\n") + f" # type: ignore[{code}]\n"
modified += 1
out.append(line)
i += 1
continue

out.append(line)
m = CLASS_RE.match(line.rstrip("\n"))
if not m:
i += 1
continue
# Look at the very next line. If it's already model_config or pass,
# leave the class alone (idempotency / empty class).
next_idx = i + 1
next_line = lines[next_idx] if next_idx < len(lines) else ""
if "model_config" in next_line:
i += 1
continue
# Replace bare `pass` (empty class body) with model_config. Use
# exact match (NOT startswith) — fields like `passed: Annotated[...]`
# also start with "pass" but are not empty class markers.
if next_line.strip() in ("pass", "pass\n"):
out.append(CONFIG_LINE + "\n")
i += 2 # skip the pass
modified += 1
continue
out.append(CONFIG_LINE + "\n")
modified += 1
i += 1
return "".join(out), modified


def main() -> int:
if len(sys.argv) != 2:
print("usage: inject_strict_config.py <path-to-_generated.py>", file=sys.stderr)
return 1
path = Path(sys.argv[1])
if not path.exists():
print(f"error: file not found: {path}", file=sys.stderr)
return 1
src = path.read_text()
new_src, modified = inject(src)
if new_src != src:
path.write_text(new_src)
print(f"inject_strict_config: patched {modified} class(es) in {path}")
return 0


if __name__ == "__main__":
sys.exit(main())
51 changes: 51 additions & 0 deletions scripts/regen-from.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
#
# Regenerate _generated.py from an arbitrary OpenAPI spec file.
#
# Usage: scripts/regen-from.sh <path-to-spec.json>
#
# This script is the per-artifact entry point used by the spec-evolution
# harness (`mono/tests/surfaces/evolution/`). It MUST be idempotent and MUST
# leave the working tree clean enough that subsequent runs see the new spec.
#
# Behavior:
# - copies <path-to-spec.json> over docs/openapi/monitoring-api.json
# - invokes the existing typegen.sh pipeline
# - prints absolute path to the regenerated _generated.py on stdout
#
# The caller (harness fixture) is responsible for:
# - backing up the original spec before the first call
# - restoring it at session teardown
# - invalidating Python's module cache between runs (via subprocess isolation)
#
set -euo pipefail

if [[ $# -ne 1 ]]; then
echo "usage: $0 <path-to-spec.json>" >&2
exit 1
fi

INPUT_SPEC="$1"
if [[ ! -f "$INPUT_SPEC" ]]; then
echo "error: spec not found at $INPUT_SPEC" >&2
exit 1
fi

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
TARGET_SPEC="$ROOT_DIR/docs/openapi/monitoring-api.json"
OUTPUT="$ROOT_DIR/src/devhelm/_generated.py"

# Resolve to absolute paths so we can detect the (legitimate) case where the
# caller passes the vendored spec back in directly (e.g. post-session teardown
# in the harness re-regens from the restored baseline). Skipping the copy in
# that case avoids `cp: 'X' and 'X' are identical` failing under set -e.
INPUT_ABS="$(cd "$(dirname "$INPUT_SPEC")" && pwd)/$(basename "$INPUT_SPEC")"
TARGET_ABS="$(cd "$(dirname "$TARGET_SPEC")" && pwd)/$(basename "$TARGET_SPEC")"
if [[ "$INPUT_ABS" != "$TARGET_ABS" ]]; then
cp "$INPUT_SPEC" "$TARGET_SPEC"
fi

"$SCRIPT_DIR/typegen.sh" >&2

echo "$OUTPUT"
12 changes: 12 additions & 0 deletions scripts/typegen.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,17 @@ uv run datamodel-codegen \
--input-file-type openapi \
--formatters ruff-format

# Post-process: inject `model_config = ConfigDict(extra='forbid')` into every
# generated class so that requests with unknown fields and responses with
# unknown fields BOTH fail loudly. Implements P1 + P2 from
# `mini/cowork/design/040-codegen-policies.md`.
echo "=> Injecting strict-fail config (extra='forbid') into generated models..."
uv run python "$SCRIPT_DIR/inject_strict_config.py" "$OUTPUT"

# Re-format after injection so the file stays ruff-clean. Non-fatal so the
# spec-evolution harness keeps moving even if ruff is misconfigured in the
# child env (e.g. inherited VIRTUAL_ENV from a pytest parent).
uv run ruff format --quiet "$OUTPUT" || echo "warning: ruff format skipped" >&2

rm -f "$PREPROCESSED"
echo "=> Generated: $OUTPUT"
21 changes: 20 additions & 1 deletion src/devhelm/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
"""DevHelm SDK for Python — typed client for monitors, incidents, alerting, and more."""

from devhelm._errors import AuthError, DevhelmError
from devhelm._errors import (
AuthError,
DevhelmApiError,
DevhelmAuthError,
DevhelmConflictError,
DevhelmError,
DevhelmNotFoundError,
DevhelmRateLimitError,
DevhelmServerError,
DevhelmTransportError,
DevhelmValidationError,
)
from devhelm._pagination import CursorPage, Page
from devhelm._validation import RequestBody
from devhelm.client import Devhelm
Expand Down Expand Up @@ -121,6 +132,14 @@
"Devhelm",
# Errors
"DevhelmError",
"DevhelmValidationError",
"DevhelmApiError",
"DevhelmAuthError",
"DevhelmNotFoundError",
"DevhelmConflictError",
"DevhelmRateLimitError",
"DevhelmServerError",
"DevhelmTransportError",
"AuthError",
# Pagination
"Page",
Expand Down
Loading
Loading