diff --git a/pyproject.toml b/pyproject.toml index cb0b86e..876821d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/scripts/inject_strict_config.py b/scripts/inject_strict_config.py new file mode 100644 index 0000000..cc5914f --- /dev/null +++ b/scripts/inject_strict_config.py @@ -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 ", 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()) diff --git a/scripts/regen-from.sh b/scripts/regen-from.sh new file mode 100755 index 0000000..862119d --- /dev/null +++ b/scripts/regen-from.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# Regenerate _generated.py from an arbitrary OpenAPI spec file. +# +# Usage: scripts/regen-from.sh +# +# 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 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 " >&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" diff --git a/scripts/typegen.sh b/scripts/typegen.sh index dd398f4..a008d7c 100755 --- a/scripts/typegen.sh +++ b/scripts/typegen.sh @@ -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" diff --git a/src/devhelm/__init__.py b/src/devhelm/__init__.py index a41cec7..3848a5e 100644 --- a/src/devhelm/__init__.py +++ b/src/devhelm/__init__.py @@ -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 @@ -121,6 +132,14 @@ "Devhelm", # Errors "DevhelmError", + "DevhelmValidationError", + "DevhelmApiError", + "DevhelmAuthError", + "DevhelmNotFoundError", + "DevhelmConflictError", + "DevhelmRateLimitError", + "DevhelmServerError", + "DevhelmTransportError", "AuthError", # Pagination "Page", diff --git a/src/devhelm/_errors.py b/src/devhelm/_errors.py index 1c1324a..0d4d6b5 100644 --- a/src/devhelm/_errors.py +++ b/src/devhelm/_errors.py @@ -1,62 +1,166 @@ +"""Error taxonomy for the DevHelm SDK. + +Three top-level classes (P4 — see `mono/cowork/design/040-codegen-policies.md`): + + DevhelmValidationError + Local request/response shape validation failed. Raised before any HTTP + I/O for request validation, and after a response is received but before + it's returned to the caller for response validation. + + DevhelmApiError + The API returned a non-2xx status. Always carries an HTTP status code + and the parsed error body. Subclassed by HTTP class for ergonomics. + + DevhelmTransportError + The HTTP request never made it to a server response — connection + refused, DNS failure, timeout, TLS error, etc. Wraps the underlying + httpx exception. + +All three inherit from `DevhelmError`, the legacy umbrella class kept around +for `except DevhelmError:` catch-all sites. Callers should prefer catching +the specific subclass. +""" + from __future__ import annotations import json -from typing import Literal - -DevhelmErrorCode = Literal["AUTH", "NOT_FOUND", "CONFLICT", "VALIDATION", "API"] +from typing import Any, Sequence class DevhelmError(Exception): - """Base error for all DevHelm API errors.""" + """Umbrella class — every typed SDK error inherits from this. + + Use this in catch-all sites; otherwise prefer the specific subclass. + """ + + +class DevhelmValidationError(DevhelmError): + """Raised when local validation of a request or response fails. + + `errors` mirrors `pydantic.ValidationError.errors()` when the source is + a Pydantic failure; otherwise it's a single-element list with `loc`, + `msg`, and `type`. + """ + + def __init__( + self, + message: str, + *, + errors: Sequence[Any] | None = None, + cause: Exception | None = None, + ) -> None: + super().__init__(message) + self.message = message + # `Sequence[Any]` so we accept both the structured Pydantic + # `ErrorDetails` records and plain dicts callers may build by hand. + self.errors: list[Any] = list(errors) if errors else [] + self.__cause__ = cause + + +class DevhelmApiError(DevhelmError): + """Raised when the API returns a non-2xx response. + + Always carries the HTTP status code and the (best-effort parsed) error + body. Use the subclasses below for HTTP-class-specific handling. + """ - code: DevhelmErrorCode status: int message: str detail: str | None + body: dict[str, Any] | str | None def __init__( self, - code: DevhelmErrorCode, message: str, + *, status: int, detail: str | None = None, + body: dict[str, Any] | str | None = None, ) -> None: super().__init__(message) - self.code = code self.status = status self.message = message self.detail = detail + self.body = body + + +class DevhelmAuthError(DevhelmApiError): + """401 or 403 from the API.""" + + +class DevhelmNotFoundError(DevhelmApiError): + """404 from the API.""" + + +class DevhelmConflictError(DevhelmApiError): + """409 from the API — typically idempotency or unique-constraint conflicts.""" + +class DevhelmRateLimitError(DevhelmApiError): + """429 from the API. Caller should back off.""" -class AuthError(DevhelmError): - """Raised on 401/403 authentication or authorization failures.""" - def __init__(self, message: str, status: int) -> None: - super().__init__("AUTH", message, status) +class DevhelmServerError(DevhelmApiError): + """5xx from the API — transient or upstream failures.""" -def error_from_response(status: int, body: str) -> DevhelmError: - """Map an HTTP error response to a typed DevhelmError.""" +class DevhelmTransportError(DevhelmError): + """The HTTP request did not produce a server response. + + Connection refused, DNS resolution failure, TLS handshake failure, + request/read/write timeout, etc. Wraps the underlying httpx exception + on `__cause__` for full traceback. + """ + + def __init__(self, message: str, *, cause: Exception | None = None) -> None: + super().__init__(message) + self.message = message + if cause is not None: + self.__cause__ = cause + + +def error_from_response(status: int, body: str) -> DevhelmApiError: + """Map an HTTP error response to a typed DevhelmApiError subclass.""" message = f"HTTP {status}" detail: str | None = None + parsed_body: dict[str, Any] | str | None = body or None try: parsed = json.loads(body) if isinstance(parsed, dict): + parsed_body = parsed message = str(parsed.get("message") or parsed.get("error") or message) raw_detail = parsed.get("detail") if raw_detail is not None: detail = str(raw_detail) except (json.JSONDecodeError, ValueError): - if body: - message = body + pass if status in (401, 403): - return AuthError(message, status) + return DevhelmAuthError(message, status=status, detail=detail, body=parsed_body) if status == 404: - return DevhelmError("NOT_FOUND", message, status, detail) + return DevhelmNotFoundError( + message, status=status, detail=detail, body=parsed_body + ) if status == 409: - return DevhelmError("CONFLICT", message, status, detail) - if status in (400, 422): - return DevhelmError("VALIDATION", message, status, detail) - return DevhelmError("API", message, status, detail) + return DevhelmConflictError( + message, status=status, detail=detail, body=parsed_body + ) + if status == 429: + return DevhelmRateLimitError( + message, status=status, detail=detail, body=parsed_body + ) + if status >= 500: + return DevhelmServerError( + message, status=status, detail=detail, body=parsed_body + ) + return DevhelmApiError(message, status=status, detail=detail, body=parsed_body) + + +# --------------------------------------------------------------------------- +# Backwards-compatible aliases (no customers yet, but our own tests + scripts +# still import the legacy names; flip these to deprecation warnings once the +# rest of the codebase is migrated). +# --------------------------------------------------------------------------- + +AuthError = DevhelmAuthError diff --git a/src/devhelm/_generated.py b/src/devhelm/_generated.py index 6db3fcb..9c59626 100644 --- a/src/devhelm/_generated.py +++ b/src/devhelm/_generated.py @@ -1,16 +1,17 @@ # generated by datamodel-codegen: # filename: .openapi-preprocessed.json -# timestamp: 2026-04-20T17:00:30+00:00 +# timestamp: 2026-04-20T20:13:19+00:00 from __future__ import annotations from typing import Annotated, Any, Literal -from pydantic import AwareDatetime, BaseModel, EmailStr, Field, RootModel +from pydantic import ConfigDict, AwareDatetime, BaseModel, EmailStr, Field, RootModel from enum import StrEnum from uuid import UUID from datetime import date as date_aliased class AcquireDeployLockRequest(BaseModel): + model_config = ConfigDict(extra="forbid") locked_by: Annotated[ str, Field( @@ -30,6 +31,7 @@ class AcquireDeployLockRequest(BaseModel): class AddCustomDomainRequest(BaseModel): + model_config = ConfigDict(extra="forbid") hostname: Annotated[ str, Field( @@ -49,6 +51,7 @@ class NewStatus(StrEnum): class AddIncidentUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") body: Annotated[ str | None, Field(description="Update message or post-mortem notes") ] = None @@ -69,6 +72,7 @@ class AddIncidentUpdateRequest(BaseModel): class AddResourceGroupMemberRequest(BaseModel): + model_config = ConfigDict(extra="forbid") member_type: Annotated[ str, Field( @@ -84,6 +88,7 @@ class AddResourceGroupMemberRequest(BaseModel): class AdminAddSubscriberRequest(BaseModel): + model_config = ConfigDict(extra="forbid") email: Annotated[ EmailStr, Field( @@ -101,6 +106,7 @@ class Status(StrEnum): class AffectedComponent(BaseModel): + model_config = ConfigDict(extra="forbid") component_id: Annotated[ UUID, Field(alias="componentId", description="Status page component ID") ] @@ -110,6 +116,7 @@ class AffectedComponent(BaseModel): class AlertChannelDisplayConfig(BaseModel): + model_config = ConfigDict(extra="forbid") recipients: Annotated[ list[str] | None, Field(description="Email recipients list (email channels)") ] = None @@ -150,6 +157,7 @@ class ChannelType(StrEnum): class AlertChannelDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique alert channel identifier")] name: Annotated[str, Field(description="Human-readable channel name")] channel_type: Annotated[ @@ -210,6 +218,7 @@ class EventType(StrEnum): class AlertDeliveryDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID incident_id: Annotated[ UUID, @@ -291,6 +300,7 @@ class Type(StrEnum): class ApiKeyAuthConfig(BaseModel): + model_config = ConfigDict(extra="forbid") type: Literal["api_key"] header_name: Annotated[ str, @@ -310,6 +320,7 @@ class ApiKeyAuthConfig(BaseModel): class ApiKeyCreateResponse(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[int, Field(description="Unique API key identifier")] name: Annotated[ str, Field(description="Human-readable name for this API key", min_length=1) @@ -335,6 +346,7 @@ class ApiKeyCreateResponse(BaseModel): class ApiKeyDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[int, Field(description="Unique API key identifier")] name: Annotated[ str, Field(description="Human-readable name for this API key", min_length=1) @@ -379,6 +391,7 @@ class Severity(StrEnum): class AssertionResultDto(BaseModel): + model_config = ConfigDict(extra="forbid") type: Annotated[str, Field(description="Assertion type", examples=["status_code"])] passed: Annotated[bool, Field(description="Whether the assertion passed")] severity: Annotated[Severity, Field(description="Assertion severity")] @@ -439,6 +452,7 @@ class AssertionType(StrEnum): class AssertionTestResultDto(BaseModel): + model_config = ConfigDict(extra="forbid") assertion_type: Annotated[ AssertionType, Field(alias="assertionType", description="Assertion type evaluated"), @@ -453,6 +467,7 @@ class AssertionTestResultDto(BaseModel): class AuditEventDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[int, Field(description="Unique audit event identifier")] actor_id: Annotated[ int | None, @@ -504,6 +519,7 @@ class Type1(StrEnum): class BasicAuthConfig(BaseModel): + model_config = ConfigDict(extra="forbid") type: Literal["basic"] vault_secret_id: Annotated[ UUID | None, @@ -519,6 +535,7 @@ class Type2(StrEnum): class BearerAuthConfig(BaseModel): + model_config = ConfigDict(extra="forbid") type: Literal["bearer"] vault_secret_id: Annotated[ UUID | None, @@ -534,6 +551,7 @@ class Type3(StrEnum): class BodyContainsAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type3 substring: Annotated[ str, @@ -552,6 +570,7 @@ class Action(StrEnum): class CategoryDto(BaseModel): + model_config = ConfigDict(extra="forbid") category: Annotated[ str, Field(description="Category name (e.g. CI/CD, Cloud, Payments)") ] @@ -568,6 +587,7 @@ class OrgRole(StrEnum): class ChangeRoleRequest(BaseModel): + model_config = ConfigDict(extra="forbid") org_role: Annotated[ OrgRole, Field(alias="orgRole", description="New role to assign") ] @@ -583,12 +603,14 @@ class Status2(StrEnum): class ChangeStatusRequest(BaseModel): + model_config = ConfigDict(extra="forbid") status: Annotated[ Status2, Field(description="New membership status (ACTIVE or SUSPENDED)") ] class ChartBucketDto(BaseModel): + model_config = ConfigDict(extra="forbid") bucket: Annotated[ AwareDatetime, Field( @@ -631,6 +653,7 @@ class ChartBucketDto(BaseModel): class ComponentImpact(BaseModel): + model_config = ConfigDict(extra="forbid") component_id: Annotated[ UUID, Field(alias="componentId", description="Status page component UUID") ] @@ -668,6 +691,7 @@ class ComponentImpact(BaseModel): class ComponentPosition(BaseModel): + model_config = ConfigDict(extra="forbid") component_id: Annotated[ UUID, Field(alias="componentId", description="Component ID") ] @@ -681,6 +705,7 @@ class ComponentPosition(BaseModel): class ComponentStatusDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[str, Field(description="Component UUID")] name: Annotated[str, Field(description="Human-readable component name")] status: Annotated[ @@ -692,6 +717,7 @@ class ComponentStatusDto(BaseModel): class ComponentUptimeDayDto(BaseModel): + model_config = ConfigDict(extra="forbid") date: Annotated[ AwareDatetime, Field(description="Date of the daily bucket (ISO 8601)") ] @@ -736,6 +762,7 @@ class ComponentUptimeDayDto(BaseModel): class ComponentUptimeSummaryDto(BaseModel): + model_config = ConfigDict(extra="forbid") day: Annotated[ float | None, Field(description="Uptime percentage over the last 24 hours", examples=[99.95]), @@ -762,6 +789,7 @@ class Type4(StrEnum): class ConfirmationPolicy(BaseModel): + model_config = ConfigDict(extra="forbid") type: Annotated[ Type4, Field(description="How incident confirmation is coordinated across regions"), @@ -783,6 +811,7 @@ class ConfirmationPolicy(BaseModel): class CreateApiKeyRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str, Field( @@ -801,6 +830,7 @@ class CreateApiKeyRequest(BaseModel): class CreateEnvironmentRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str, Field( @@ -836,6 +866,7 @@ class RoleOffered(StrEnum): class CreateInviteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") email: Annotated[ EmailStr, Field(description="Email address to invite", min_length=1) ] @@ -846,6 +877,7 @@ class CreateInviteRequest(BaseModel): class CreateMaintenanceWindowRequest(BaseModel): + model_config = ConfigDict(extra="forbid") monitor_id: Annotated[ UUID | None, Field( @@ -895,6 +927,7 @@ class Severity3(StrEnum): class CreateManualIncidentRequest(BaseModel): + model_config = ConfigDict(extra="forbid") title: Annotated[ str, Field(description="Short summary of the incident", min_length=1) ] @@ -928,11 +961,12 @@ class ManagedBy(StrEnum): class HealthThresholdType(StrEnum): - count = "COUNT" + count = "COUNT" # type: ignore[assignment] percentage = "PERCENTAGE" class CreateSecretRequest(BaseModel): + model_config = ConfigDict(extra="forbid") key: Annotated[ str, Field( @@ -952,6 +986,7 @@ class CreateSecretRequest(BaseModel): class CreateStatusPageComponentGroupRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str, Field(description="Group display name", max_length=255, min_length=0) ] @@ -976,6 +1011,7 @@ class Type6(StrEnum): class CreateStatusPageComponentRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str, Field(description="Component display name", max_length=255, min_length=0) ] @@ -1047,6 +1083,7 @@ class Impact(StrEnum): class CreateStatusPageIncidentRequest(BaseModel): + model_config = ConfigDict(extra="forbid") title: Annotated[ str, Field( @@ -1101,6 +1138,7 @@ class CreateStatusPageIncidentRequest(BaseModel): class CreateStatusPageIncidentUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") status: Annotated[ Status3, Field(description="Incident status at this point in the timeline") ] @@ -1134,6 +1172,7 @@ class IncidentMode(StrEnum): class CreateTagRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str, Field( @@ -1160,6 +1199,7 @@ class SubscribedEvent(RootModel[str]): class CreateWebhookEndpointRequest(BaseModel): + model_config = ConfigDict(extra="forbid") url: Annotated[ str, Field( @@ -1187,10 +1227,12 @@ class CreateWebhookEndpointRequest(BaseModel): class CreateWorkspaceRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[str, Field(description="Workspace name", min_length=1)] class DayIncident(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Status page incident UUID")] title: Annotated[str, Field(description="Incident title")] status: Annotated[ @@ -1229,6 +1271,7 @@ class DayIncident(BaseModel): class DekRotationResultDto(BaseModel): + model_config = ConfigDict(extra="forbid") previous_dek_version: Annotated[ int, Field(alias="previousDekVersion", description="DEK version before rotation"), @@ -1259,6 +1302,7 @@ class DekRotationResultDto(BaseModel): class DeleteChannelResult(BaseModel): + model_config = ConfigDict(extra="forbid") affected_policies: Annotated[ int, Field( @@ -1276,6 +1320,7 @@ class DeleteChannelResult(BaseModel): class DeliveryAttemptDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID delivery_id: Annotated[UUID, Field(alias="deliveryId")] attempt_number: Annotated[ @@ -1331,6 +1376,7 @@ class DeliveryAttemptDto(BaseModel): class DeployLockDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique lock identifier")] locked_by: Annotated[ str, @@ -1358,6 +1404,7 @@ class ChannelType1(StrEnum): class DiscordChannelConfig(BaseModel): + model_config = ConfigDict(extra="forbid") channel_type: Annotated[ChannelType1, Field(alias="channelType")] webhook_url: Annotated[ str, Field(alias="webhookUrl", description="Discord webhook URL", min_length=1) @@ -1376,6 +1423,7 @@ class CheckType(StrEnum): class Dns(BaseModel): + model_config = ConfigDict(extra="forbid") check_type: Literal["dns"] hostname: Annotated[str | None, Field(description="Target hostname")] = None requested_types: Annotated[ @@ -1404,6 +1452,7 @@ class Type7(StrEnum): class DnsExpectedCnameAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type7 value: Annotated[ str, @@ -1419,6 +1468,7 @@ class Type8(StrEnum): class DnsExpectedIpsAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type8 ips: Annotated[ list[str], @@ -1434,6 +1484,7 @@ class Type9(StrEnum): class DnsMaxAnswersAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type9 record_type: Annotated[ str, @@ -1453,6 +1504,7 @@ class Type10(StrEnum): class DnsMinAnswersAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type10 record_type: Annotated[ str, @@ -1482,6 +1534,7 @@ class RecordType(StrEnum): class DnsMonitorConfig(BaseModel): + model_config = ConfigDict(extra="forbid") hostname: Annotated[str, Field(description="Domain name to resolve", min_length=1)] record_types: Annotated[ list[RecordType] | None, @@ -1514,6 +1567,7 @@ class Type11(StrEnum): class DnsRecordContainsAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type11 record_type: Annotated[ str, @@ -1537,6 +1591,7 @@ class Type12(StrEnum): class DnsRecordEqualsAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type12 record_type: Annotated[ str, @@ -1557,6 +1612,7 @@ class Type13(StrEnum): class DnsResolvesAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type13 @@ -1565,6 +1621,7 @@ class Type14(StrEnum): class DnsResponseTimeAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type14 max_ms: Annotated[ int, @@ -1580,6 +1637,7 @@ class Type15(StrEnum): class DnsResponseTimeWarnAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type15 warn_ms: Annotated[ int, @@ -1595,6 +1653,7 @@ class Type16(StrEnum): class DnsTtlHighAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type16 max_ttl: Annotated[ int, @@ -1610,6 +1669,7 @@ class Type17(StrEnum): class DnsTtlLowAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type17 min_ttl: Annotated[ int, @@ -1625,6 +1685,7 @@ class Type18(StrEnum): class DnsTxtContainsAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type18 substring: Annotated[ str, @@ -1640,6 +1701,7 @@ class ChannelType2(StrEnum): class EmailChannelConfig(BaseModel): + model_config = ConfigDict(extra="forbid") channel_type: Annotated[ChannelType2, Field(alias="channelType")] recipients: Annotated[ list[EmailStr], @@ -1648,6 +1710,7 @@ class EmailChannelConfig(BaseModel): class EntitlementDto(BaseModel): + model_config = ConfigDict(extra="forbid") key: Annotated[str, Field(description="Entitlement key")] value: Annotated[ int, Field(description="Effective limit value (overrides applied)") @@ -1664,6 +1727,7 @@ class EntitlementDto(BaseModel): class EnvironmentDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique environment identifier")] org_id: Annotated[ int, @@ -1707,6 +1771,7 @@ class EnvironmentDto(BaseModel): class ErrorResponse(BaseModel): + model_config = ConfigDict(extra="forbid") status: Annotated[ int, Field( @@ -1731,6 +1796,7 @@ class ErrorResponse(BaseModel): class EscalationStep(BaseModel): + model_config = ConfigDict(extra="forbid") delay_minutes: Annotated[ int, Field( @@ -1765,6 +1831,7 @@ class EscalationStep(BaseModel): class FailureDetail(BaseModel): + model_config = ConfigDict(extra="forbid") monitor_id: Annotated[ UUID, Field(alias="monitorId", description="Monitor ID that failed") ] @@ -1772,6 +1839,7 @@ class FailureDetail(BaseModel): class GroupComponentOrder(BaseModel): + model_config = ConfigDict(extra="forbid") group_id: Annotated[ UUID, Field(alias="groupId", description="Group these components belong to") ] @@ -1789,6 +1857,7 @@ class Type19(StrEnum): class HeaderAuthConfig(BaseModel): + model_config = ConfigDict(extra="forbid") type: Literal["header"] header_name: Annotated[ str, @@ -1821,6 +1890,7 @@ class Operator(StrEnum): class HeaderValueAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type20 header_name: Annotated[ str, @@ -1846,6 +1916,7 @@ class Type21(StrEnum): class HeartbeatIntervalDriftAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type21 max_deviation_percent: Annotated[ int, @@ -1863,6 +1934,7 @@ class Type22(StrEnum): class HeartbeatMaxIntervalAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type22 max_seconds: Annotated[ int, @@ -1875,6 +1947,7 @@ class HeartbeatMaxIntervalAssertion(BaseModel): class HeartbeatMonitorConfig(BaseModel): + model_config = ConfigDict(extra="forbid") expected_interval: Annotated[ int, Field( @@ -1899,6 +1972,7 @@ class Type23(StrEnum): class HeartbeatPayloadContainsAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type23 path: Annotated[ str, @@ -1913,6 +1987,7 @@ class HeartbeatPayloadContainsAssertion(BaseModel): class HeartbeatPingResponse(BaseModel): + model_config = ConfigDict(extra="forbid") ok: Annotated[ bool, Field(description="Always true on a 2xx response", examples=[True]) ] @@ -1923,6 +1998,7 @@ class Type24(StrEnum): class HeartbeatReceivedAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type24 @@ -1931,6 +2007,7 @@ class CheckType1(StrEnum): class Http(BaseModel): + model_config = ConfigDict(extra="forbid") check_type: Literal["http"] timing: Annotated[ dict[str, dict[str, Any]] | None, @@ -1955,6 +2032,7 @@ class Method(StrEnum): class HttpMonitorConfig(BaseModel): + model_config = ConfigDict(extra="forbid") url: Annotated[ str, Field(description="Target URL to send requests to", min_length=1) ] @@ -1996,6 +2074,7 @@ class CheckType2(StrEnum): class Icmp(BaseModel): + model_config = ConfigDict(extra="forbid") check_type: Literal["icmp"] host: Annotated[str, Field(description="Target host", examples=["1.1.1.1"])] packets_sent: Annotated[ @@ -2028,6 +2107,7 @@ class Icmp(BaseModel): class IcmpMonitorConfig(BaseModel): + model_config = ConfigDict(extra="forbid") host: Annotated[ str, Field(description="Target hostname or IP address to ping", min_length=1) ] @@ -2050,6 +2130,7 @@ class Type25(StrEnum): class IcmpPacketLossAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type25 max_percent: Annotated[ float, @@ -2067,6 +2148,7 @@ class Type26(StrEnum): class IcmpReachableAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type26 @@ -2075,6 +2157,7 @@ class Type27(StrEnum): class IcmpResponseTimeAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type27 max_ms: Annotated[ int, @@ -2090,6 +2173,7 @@ class Type28(StrEnum): class IcmpResponseTimeWarnAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type28 warn_ms: Annotated[ int, @@ -2122,6 +2206,7 @@ class ResolutionReason(StrEnum): class IncidentDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique incident identifier")] monitor_id: Annotated[ UUID | None, @@ -2310,6 +2395,7 @@ class IncidentDto(BaseModel): class IncidentFilterParams(BaseModel): + model_config = ConfigDict(extra="forbid") status: Annotated[ Status6 | None, Field( @@ -2390,12 +2476,14 @@ class IncidentFilterParams(BaseModel): class IncidentRef(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Status page incident ID")] title: Annotated[str, Field(description="Incident title")] impact: Annotated[str, Field(description="Incident impact level")] class IncidentsSummaryDto(BaseModel): + model_config = ConfigDict(extra="forbid") active: int resolved_today: Annotated[int, Field(alias="resolvedToday")] mttr30d: float | None = None @@ -2414,6 +2502,7 @@ class CreatedBy(StrEnum): class IncidentUpdateDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID incident_id: Annotated[UUID, Field(alias="incidentId")] old_status: Annotated[OldStatus | None, Field(alias="oldStatus")] = None @@ -2434,6 +2523,7 @@ class TierAvailability(StrEnum): class IntegrationFieldDto(BaseModel): + model_config = ConfigDict(extra="forbid") key: str label: str type: str @@ -2446,6 +2536,7 @@ class IntegrationFieldDto(BaseModel): class InviteDto(BaseModel): + model_config = ConfigDict(extra="forbid") invite_id: Annotated[ int, Field(alias="inviteId", description="Unique invite identifier") ] @@ -2482,6 +2573,7 @@ class Type29(StrEnum): class JsonPathAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type29 path: Annotated[ str, @@ -2502,6 +2594,7 @@ class JsonPathAssertion(BaseModel): class KeyInfo(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[int, Field(description="Key ID")] name: Annotated[str, Field(description="Human-readable key name")] created_at: Annotated[ @@ -2525,6 +2618,7 @@ class Status8(StrEnum): class LinkedStatusPageIncidentDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID status_page_id: Annotated[UUID, Field(alias="statusPageId")] status_page_name: Annotated[str, Field(alias="statusPageName")] @@ -2537,6 +2631,7 @@ class LinkedStatusPageIncidentDto(BaseModel): class MaintenanceComponentRef(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Component identifier")] name: Annotated[str, Field(description="Component name")] status: Annotated[ @@ -2545,6 +2640,7 @@ class MaintenanceComponentRef(BaseModel): class MaintenanceUpdateDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique update identifier")] status: Annotated[str, Field(description="Status at the time of this update")] body: Annotated[str | None, Field(description="Update message from the vendor")] = ( @@ -2557,6 +2653,7 @@ class MaintenanceUpdateDto(BaseModel): class MaintenanceWindowDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique maintenance window identifier")] monitor_id: Annotated[ UUID | None, @@ -2606,6 +2703,7 @@ class MaintenanceWindowDto(BaseModel): class MatchRule(BaseModel): + model_config = ConfigDict(extra="forbid") type: Annotated[ str, Field(description="Rule type, e.g. severity_gte, monitor_id_in, region_in") ] @@ -2634,6 +2732,7 @@ class Type30(StrEnum): class McpConnectsAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type30 @@ -2642,6 +2741,7 @@ class Type31(StrEnum): class McpHasCapabilityAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type31 capability: Annotated[ str, @@ -2657,6 +2757,7 @@ class Type32(StrEnum): class McpMinToolsAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type32 min: Annotated[ int, Field(description="Minimum number of tools the server must expose") @@ -2668,6 +2769,7 @@ class Type33(StrEnum): class McpProtocolVersionAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type33 version: Annotated[ str, @@ -2683,6 +2785,7 @@ class Type34(StrEnum): class McpResponseTimeAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type34 max_ms: Annotated[ int, @@ -2698,6 +2801,7 @@ class Type35(StrEnum): class McpResponseTimeWarnAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type35 warn_ms: Annotated[ int, @@ -2713,6 +2817,7 @@ class CheckType3(StrEnum): class McpServer(BaseModel): + model_config = ConfigDict(extra="forbid") check_type: Literal["mcp_server"] url: Annotated[str | None, Field(description="MCP server URL")] = None protocol_version: Annotated[ @@ -2735,6 +2840,7 @@ class McpServer(BaseModel): class McpServerMonitorConfig(BaseModel): + model_config = ConfigDict(extra="forbid") command: Annotated[ str, Field(description="Command to execute to start the MCP server", min_length=1), @@ -2754,6 +2860,7 @@ class Type36(StrEnum): class McpToolAvailableAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type36 tool_name: Annotated[ str, @@ -2770,6 +2877,7 @@ class Type37(StrEnum): class McpToolCountChangedAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type37 expected_count: Annotated[ int, @@ -2790,6 +2898,7 @@ class Status9(StrEnum): class MemberDto(BaseModel): + model_config = ConfigDict(extra="forbid") user_id: Annotated[ int, Field(alias="userId", description="User identifier of the member") ] @@ -2841,6 +2950,7 @@ class AuthType(StrEnum): class MonitorAuthDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID monitor_id: Annotated[UUID, Field(alias="monitorId")] auth_type: Annotated[AuthType, Field(alias="authType")] @@ -2857,11 +2967,13 @@ class Type38(StrEnum): class MonitorReference(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Monitor identifier")] name: Annotated[str, Field(description="Monitor name")] class MonitorsSummaryDto(BaseModel): + model_config = ConfigDict(extra="forbid") total: Annotated[ int, Field(description="Total number of monitors in the organization") ] @@ -2890,6 +3002,7 @@ class MonitorsSummaryDto(BaseModel): class MonitorTestResultDto(BaseModel): + model_config = ConfigDict(extra="forbid") passed: bool error: str | None = None status_code: Annotated[int | None, Field(alias="statusCode")] = None @@ -2915,6 +3028,7 @@ class ChangedVia(StrEnum): class NewTagRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[str, Field(description="Tag name", max_length=100, min_length=0)] color: Annotated[ str | None, @@ -2941,6 +3055,7 @@ class CompletionReason(StrEnum): class NotificationDispatchDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique dispatch record identifier")] incident_id: Annotated[ UUID, Field(alias="incidentId", description="Incident this dispatch is for") @@ -3022,6 +3137,7 @@ class NotificationDispatchDto(BaseModel): class NotificationDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[int, Field(description="Unique notification identifier")] type: Annotated[ str, Field(description="Notification category (e.g. incident, monitor, team)") @@ -3059,6 +3175,7 @@ class ChannelType3(StrEnum): class OpsGenieChannelConfig(BaseModel): + model_config = ConfigDict(extra="forbid") channel_type: Annotated[ChannelType3, Field(alias="channelType")] api_key: Annotated[ str, @@ -3074,6 +3191,7 @@ class OpsGenieChannelConfig(BaseModel): class OrganizationDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[int, Field(description="Unique organization identifier")] name: Annotated[str, Field(description="Organization name")] email: Annotated[str, Field(description="Billing and contact email")] @@ -3089,11 +3207,13 @@ class OrganizationDto(BaseModel): class OrgInfo(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[int, Field(description="Organization ID")] name: Annotated[str, Field(description="Organization name")] class Pageable(BaseModel): + model_config = ConfigDict(extra="forbid") page: Annotated[int, Field(ge=0)] size: Annotated[int, Field(ge=1)] sort: list[str] @@ -3104,6 +3224,7 @@ class ChannelType4(StrEnum): class PagerDutyChannelConfig(BaseModel): + model_config = ConfigDict(extra="forbid") channel_type: Annotated[ChannelType4, Field(alias="channelType")] routing_key: Annotated[ str, @@ -3122,6 +3243,7 @@ class PagerDutyChannelConfig(BaseModel): class PageSection(BaseModel): + model_config = ConfigDict(extra="forbid") group_id: Annotated[ UUID | None, Field(alias="groupId", description="Group ID when this section is a group"), @@ -3148,6 +3270,7 @@ class Tier(StrEnum): class PlanInfo(BaseModel): + model_config = ConfigDict(extra="forbid") tier: Annotated[Tier, Field(description="Resolved plan tier")] subscription_status: Annotated[ str | None, @@ -3176,6 +3299,7 @@ class PlanInfo(BaseModel): class PollChartBucketDto(BaseModel): + model_config = ConfigDict(extra="forbid") bucket: Annotated[ AwareDatetime, Field(description="Start of the time bucket (ISO 8601)") ] @@ -3211,6 +3335,7 @@ class Status11(StrEnum): class PublishStatusPageIncidentRequest(BaseModel): + model_config = ConfigDict(extra="forbid") title: Annotated[ str | None, Field( @@ -3248,6 +3373,7 @@ class PublishStatusPageIncidentRequest(BaseModel): class RateLimitInfo(BaseModel): + model_config = ConfigDict(extra="forbid") requests_per_minute: Annotated[ int, Field( @@ -3263,6 +3389,7 @@ class RateLimitInfo(BaseModel): class RecoveryPolicy(BaseModel): + model_config = ConfigDict(extra="forbid") consecutive_successes: Annotated[ int, Field( @@ -3291,6 +3418,7 @@ class Type40(StrEnum): class RedirectCountAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type40 max_count: Annotated[ int, @@ -3306,6 +3434,7 @@ class Type41(StrEnum): class RedirectTargetAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type41 expected: Annotated[ str, @@ -3324,6 +3453,7 @@ class Type42(StrEnum): class RegexBodyAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type42 pattern: Annotated[ str, @@ -3334,6 +3464,7 @@ class RegexBodyAssertion(BaseModel): class RegionStatusDto(BaseModel): + model_config = ConfigDict(extra="forbid") region: Annotated[str, Field(description="Region identifier", examples=["us-east"])] passed: Annotated[ bool, @@ -3363,6 +3494,7 @@ class RegionStatusDto(BaseModel): class RemoveMonitorTagsRequest(BaseModel): + model_config = ConfigDict(extra="forbid") tag_ids: Annotated[ list[UUID], Field( @@ -3374,6 +3506,7 @@ class RemoveMonitorTagsRequest(BaseModel): class ReorderComponentsRequest(BaseModel): + model_config = ConfigDict(extra="forbid") positions: Annotated[ list[ComponentPosition], Field( @@ -3384,6 +3517,7 @@ class ReorderComponentsRequest(BaseModel): class ReorderPageLayoutRequest(BaseModel): + model_config = ConfigDict(extra="forbid") sections: Annotated[ list[PageSection], Field(description="Top-level sections in their new order", min_length=1), @@ -3398,6 +3532,7 @@ class ReorderPageLayoutRequest(BaseModel): class ResolveIncidentRequest(BaseModel): + model_config = ConfigDict(extra="forbid") body: Annotated[ str | None, Field(description="Optional resolution message or post-mortem notes"), @@ -3418,6 +3553,7 @@ class ThresholdStatus(StrEnum): class ResourceGroupHealthDto(BaseModel): + model_config = ConfigDict(extra="forbid") status: Annotated[ Status12, Field(description="Worst-of health status across all members") ] @@ -3456,6 +3592,7 @@ class ResourceGroupHealthDto(BaseModel): class ResourceGroupMemberDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique group member record identifier")] group_id: Annotated[ UUID, @@ -3563,6 +3700,7 @@ class Type43(StrEnum): class ResponseSizeAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type43 max_bytes: Annotated[ int, @@ -3578,6 +3716,7 @@ class Type44(StrEnum): class ResponseTimeAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type44 threshold_ms: Annotated[ int, @@ -3593,6 +3732,7 @@ class Type45(StrEnum): class ResponseTimeWarnAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type45 warn_ms: Annotated[ int, @@ -3611,6 +3751,7 @@ class CurrentStatus(StrEnum): class ResultSummaryDto(BaseModel): + model_config = ConfigDict(extra="forbid") current_status: Annotated[ CurrentStatus, Field( @@ -3647,6 +3788,7 @@ class ResultSummaryDto(BaseModel): class RetryStrategy(BaseModel): + model_config = ConfigDict(extra="forbid") type: Annotated[ str, Field(description="Retry strategy kind, e.g. fixed interval between attempts"), @@ -3664,6 +3806,7 @@ class RetryStrategy(BaseModel): class ScheduledMaintenanceDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique maintenance record identifier")] external_id: Annotated[ str, @@ -3725,6 +3868,7 @@ class ScheduledMaintenanceDto(BaseModel): class SecretDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique secret identifier")] key: Annotated[ str, Field(description="Secret key name, unique within the workspace") @@ -3762,6 +3906,7 @@ class SecretDto(BaseModel): class SeoMetadataDto(BaseModel): + model_config = ConfigDict(extra="forbid") short_description: Annotated[ str | None, Field( @@ -3779,6 +3924,7 @@ class SeoMetadataDto(BaseModel): class ServiceCatalogDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID slug: str name: str @@ -3803,6 +3949,7 @@ class ServiceCatalogDto(BaseModel): class ServiceComponentDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID external_id: Annotated[str, Field(alias="externalId")] name: str @@ -3866,6 +4013,7 @@ class ServiceComponentDto(BaseModel): class ServiceDayDetailDto(BaseModel): + model_config = ConfigDict(extra="forbid") date: Annotated[ date_aliased, Field(description="UTC calendar day this rollup covers") ] @@ -3912,6 +4060,7 @@ class ServiceDayDetailDto(BaseModel): class ServiceIncidentDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID service_id: Annotated[UUID, Field(alias="serviceId")] service_slug: Annotated[str | None, Field(alias="serviceSlug")] = None @@ -3931,12 +4080,14 @@ class ServiceIncidentDto(BaseModel): class ServiceIncidentUpdateDto(BaseModel): + model_config = ConfigDict(extra="forbid") status: str body: str | None = None display_at: Annotated[AwareDatetime | None, Field(alias="displayAt")] = None class ServiceLiveStatusDto(BaseModel): + model_config = ConfigDict(extra="forbid") overall_status: Annotated[ str | None, Field( @@ -3968,6 +4119,7 @@ class ServiceLiveStatusDto(BaseModel): class ServicePollResultDto(BaseModel): + model_config = ConfigDict(extra="forbid") service_id: Annotated[UUID, Field(alias="serviceId", description="Service ID")] timestamp: Annotated[ AwareDatetime, @@ -4025,6 +4177,7 @@ class ServicePollResultDto(BaseModel): class ServicePollSummaryDto(BaseModel): + model_config = ConfigDict(extra="forbid") uptime_percentage: Annotated[ float | None, Field( @@ -4078,11 +4231,13 @@ class ServicePollSummaryDto(BaseModel): class ServiceStatusDto(BaseModel): + model_config = ConfigDict(extra="forbid") overall_status: Annotated[str, Field(alias="overallStatus")] last_polled_at: Annotated[AwareDatetime | None, Field(alias="lastPolledAt")] = None class ServiceSubscribeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") component_id: Annotated[ UUID | None, Field( @@ -4106,6 +4261,7 @@ class AlertSensitivity(StrEnum): class ServiceSubscriptionDto(BaseModel): + model_config = ConfigDict(extra="forbid") subscription_id: Annotated[ UUID, Field(alias="subscriptionId", description="Unique subscription identifier"), @@ -4156,6 +4312,7 @@ class ServiceSubscriptionDto(BaseModel): class SetAlertChannelsRequest(BaseModel): + model_config = ConfigDict(extra="forbid") channel_ids: Annotated[ list[UUID], Field( @@ -4166,102 +4323,127 @@ class SetAlertChannelsRequest(BaseModel): class SetMonitorAuthRequest(BaseModel): + model_config = ConfigDict(extra="forbid") config: ApiKeyAuthConfig | BasicAuthConfig | BearerAuthConfig | HeaderAuthConfig class SingleValueResponseAlertChannelDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: AlertChannelDto class SingleValueResponseAlertDeliveryDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: AlertDeliveryDto class SingleValueResponseApiKeyCreateResponse(BaseModel): + model_config = ConfigDict(extra="forbid") data: ApiKeyCreateResponse class SingleValueResponseApiKeyDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: ApiKeyDto class SingleValueResponseDekRotationResultDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: DekRotationResultDto class SingleValueResponseDeployLockDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: DeployLockDto class SingleValueResponseEnvironmentDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: EnvironmentDto class SingleValueResponseInviteDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: InviteDto class SingleValueResponseListUUID(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[UUID] class SingleValueResponseLong(BaseModel): + model_config = ConfigDict(extra="forbid") data: int class SingleValueResponseMaintenanceWindowDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: MaintenanceWindowDto class SingleValueResponseMonitorAuthDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: MonitorAuthDto class SingleValueResponseMonitorTestResultDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: MonitorTestResultDto class SingleValueResponseNotificationDispatchDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: NotificationDispatchDto class SingleValueResponseOrganizationDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: OrganizationDto class SingleValueResponseResourceGroupHealthDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: ResourceGroupHealthDto class SingleValueResponseResourceGroupMemberDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: ResourceGroupMemberDto class SingleValueResponseResultSummaryDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: ResultSummaryDto class SingleValueResponseSecretDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: SecretDto class SingleValueResponseServiceDayDetailDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: ServiceDayDetailDto class SingleValueResponseServiceLiveStatusDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: ServiceLiveStatusDto class SingleValueResponseServicePollSummaryDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: ServicePollSummaryDto class SingleValueResponseServiceSubscriptionDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: ServiceSubscriptionDto class SingleValueResponseString(BaseModel): + model_config = ConfigDict(extra="forbid") data: str @@ -4270,6 +4452,7 @@ class ChannelType5(StrEnum): class SlackChannelConfig(BaseModel): + model_config = ConfigDict(extra="forbid") channel_type: Annotated[ChannelType5, Field(alias="channelType")] webhook_url: Annotated[ str, @@ -4291,6 +4474,7 @@ class Type46(StrEnum): class SslExpiryAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type46 min_days_remaining: Annotated[ int, @@ -4306,6 +4490,7 @@ class Type47(StrEnum): class StatusCodeAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type47 expected: Annotated[ str, @@ -4323,6 +4508,7 @@ class StatusCodeAssertion(BaseModel): class StatusPageBranding(BaseModel): + model_config = ConfigDict(extra="forbid") logo_url: Annotated[ str | None, Field( @@ -4462,6 +4648,7 @@ class CurrentStatus1(StrEnum): class StatusPageComponentDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID status_page_id: Annotated[UUID, Field(alias="statusPageId")] group_id: Annotated[UUID | None, Field(alias="groupId")] = None @@ -4481,6 +4668,7 @@ class StatusPageComponentDto(BaseModel): class StatusPageComponentGroupDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID status_page_id: Annotated[UUID, Field(alias="statusPageId")] name: str @@ -4494,6 +4682,7 @@ class StatusPageComponentGroupDto(BaseModel): class StatusPageComponentUptimeDayDto(BaseModel): + model_config = ConfigDict(extra="forbid") date: Annotated[ AwareDatetime, Field(description="Start-of-day timestamp for this bucket (UTC midnight)"), @@ -4541,6 +4730,7 @@ class VerificationMethod(StrEnum): class StatusPageCustomDomainDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID hostname: str status: Status14 @@ -4565,6 +4755,7 @@ class OverallStatus(StrEnum): class StatusPageDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID organization_id: Annotated[int, Field(alias="organizationId")] workspace_id: Annotated[int, Field(alias="workspaceId")] @@ -4591,6 +4782,7 @@ class ComponentStatus(StrEnum): class StatusPageIncidentComponentDto(BaseModel): + model_config = ConfigDict(extra="forbid") status_page_component_id: Annotated[UUID, Field(alias="statusPageComponentId")] component_status: Annotated[ComponentStatus, Field(alias="componentStatus")] component_name: Annotated[str, Field(alias="componentName")] @@ -4609,6 +4801,7 @@ class CreatedBy1(StrEnum): class StatusPageIncidentUpdateDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID status: Status15 body: str @@ -4619,6 +4812,7 @@ class StatusPageIncidentUpdateDto(BaseModel): class StatusPageSubscriberDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID email: str confirmed: bool @@ -4626,12 +4820,14 @@ class StatusPageSubscriberDto(BaseModel): class Summary(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID name: Annotated[str, Field(min_length=1)] slug: Annotated[str, Field(min_length=1)] class TableValueResultAlertChannelDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[AlertChannelDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4640,6 +4836,7 @@ class TableValueResultAlertChannelDto(BaseModel): class TableValueResultAlertDeliveryDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[AlertDeliveryDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4648,6 +4845,7 @@ class TableValueResultAlertDeliveryDto(BaseModel): class TableValueResultApiKeyDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[ApiKeyDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4656,6 +4854,7 @@ class TableValueResultApiKeyDto(BaseModel): class TableValueResultAuditEventDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[AuditEventDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4664,6 +4863,7 @@ class TableValueResultAuditEventDto(BaseModel): class TableValueResultCategoryDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[CategoryDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4672,6 +4872,7 @@ class TableValueResultCategoryDto(BaseModel): class TableValueResultComponentUptimeDayDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[ComponentUptimeDayDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4680,6 +4881,7 @@ class TableValueResultComponentUptimeDayDto(BaseModel): class TableValueResultDeliveryAttemptDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[DeliveryAttemptDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4688,6 +4890,7 @@ class TableValueResultDeliveryAttemptDto(BaseModel): class TableValueResultEnvironmentDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[EnvironmentDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4696,6 +4899,7 @@ class TableValueResultEnvironmentDto(BaseModel): class TableValueResultIncidentDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[IncidentDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4704,6 +4908,7 @@ class TableValueResultIncidentDto(BaseModel): class TableValueResultInviteDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[InviteDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4712,6 +4917,7 @@ class TableValueResultInviteDto(BaseModel): class TableValueResultMaintenanceWindowDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[MaintenanceWindowDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4720,6 +4926,7 @@ class TableValueResultMaintenanceWindowDto(BaseModel): class TableValueResultMemberDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[MemberDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4728,6 +4935,7 @@ class TableValueResultMemberDto(BaseModel): class TableValueResultNotificationDispatchDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[NotificationDispatchDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4736,6 +4944,7 @@ class TableValueResultNotificationDispatchDto(BaseModel): class TableValueResultNotificationDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[NotificationDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4744,6 +4953,7 @@ class TableValueResultNotificationDto(BaseModel): class TableValueResultScheduledMaintenanceDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[ScheduledMaintenanceDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4752,6 +4962,7 @@ class TableValueResultScheduledMaintenanceDto(BaseModel): class TableValueResultSecretDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[SecretDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4760,6 +4971,7 @@ class TableValueResultSecretDto(BaseModel): class TableValueResultServiceComponentDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[ServiceComponentDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4768,6 +4980,7 @@ class TableValueResultServiceComponentDto(BaseModel): class TableValueResultServiceIncidentDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[ServiceIncidentDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4776,6 +4989,7 @@ class TableValueResultServiceIncidentDto(BaseModel): class TableValueResultServiceSubscriptionDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[ServiceSubscriptionDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4784,6 +4998,7 @@ class TableValueResultServiceSubscriptionDto(BaseModel): class TableValueResultStatusPageComponentDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[StatusPageComponentDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4792,6 +5007,7 @@ class TableValueResultStatusPageComponentDto(BaseModel): class TableValueResultStatusPageComponentGroupDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[StatusPageComponentGroupDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4800,6 +5016,7 @@ class TableValueResultStatusPageComponentGroupDto(BaseModel): class TableValueResultStatusPageComponentUptimeDayDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[StatusPageComponentUptimeDayDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4808,6 +5025,7 @@ class TableValueResultStatusPageComponentUptimeDayDto(BaseModel): class TableValueResultStatusPageCustomDomainDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[StatusPageCustomDomainDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4816,6 +5034,7 @@ class TableValueResultStatusPageCustomDomainDto(BaseModel): class TableValueResultStatusPageDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[StatusPageDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4824,6 +5043,7 @@ class TableValueResultStatusPageDto(BaseModel): class TableValueResultStatusPageSubscriberDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[StatusPageSubscriberDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -4832,6 +5052,7 @@ class TableValueResultStatusPageSubscriberDto(BaseModel): class TagDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique tag identifier")] organization_id: Annotated[ int, @@ -4859,6 +5080,7 @@ class CheckType4(StrEnum): class Tcp(BaseModel): + model_config = ConfigDict(extra="forbid") check_type: Literal["tcp"] host: Annotated[str, Field(description="Target host", examples=["db.example.com"])] port: Annotated[int, Field(description="Target port", examples=[5432])] @@ -4872,10 +5094,12 @@ class Type49(StrEnum): class TcpConnectsAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type49 class TcpMonitorConfig(BaseModel): + model_config = ConfigDict(extra="forbid") host: Annotated[ str, Field(description="Target hostname or IP address", min_length=1) ] @@ -4891,6 +5115,7 @@ class Type50(StrEnum): class TcpResponseTimeAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type50 max_ms: Annotated[ int, @@ -4906,6 +5131,7 @@ class Type51(StrEnum): class TcpResponseTimeWarnAssertion(BaseModel): + model_config = ConfigDict(extra="forbid") type: Type51 warn_ms: Annotated[ int, @@ -4921,6 +5147,7 @@ class ChannelType6(StrEnum): class TeamsChannelConfig(BaseModel): + model_config = ConfigDict(extra="forbid") channel_type: Annotated[ChannelType6, Field(alias="channelType")] webhook_url: Annotated[ str, @@ -4933,11 +5160,13 @@ class TeamsChannelConfig(BaseModel): class TestChannelResult(BaseModel): + model_config = ConfigDict(extra="forbid") success: bool message: str class TestMatchResult(BaseModel): + model_config = ConfigDict(extra="forbid") matched: Annotated[ bool, Field( @@ -4955,6 +5184,7 @@ class TestMatchResult(BaseModel): class TestNotificationPolicyRequest(BaseModel): + model_config = ConfigDict(extra="forbid") severity: Annotated[ str | None, Field( @@ -5012,6 +5242,7 @@ class TestNotificationPolicyRequest(BaseModel): class TestWebhookEndpointRequest(BaseModel): + model_config = ConfigDict(extra="forbid") event_type: Annotated[ str | None, Field( @@ -5022,6 +5253,7 @@ class TestWebhookEndpointRequest(BaseModel): class TlsInfoDto(BaseModel): + model_config = ConfigDict(extra="forbid") subject_cn: Annotated[ str | None, Field( @@ -5101,6 +5333,7 @@ class AggregationType(StrEnum): class TriggerRule(BaseModel): + model_config = ConfigDict(extra="forbid") type: Annotated[ Type52, Field( @@ -5142,6 +5375,7 @@ class TriggerRule(BaseModel): class UpdateAlertSensitivityRequest(BaseModel): + model_config = ConfigDict(extra="forbid") alert_sensitivity: Annotated[ str, Field( @@ -5154,6 +5388,7 @@ class UpdateAlertSensitivityRequest(BaseModel): class UpdateApiKeyRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str, Field(description="New name for this API key", max_length=200, min_length=0), @@ -5166,6 +5401,7 @@ class Severity8(StrEnum): class UpdateAssertionRequest(BaseModel): + model_config = ConfigDict(extra="forbid") config: ( BodyContainsAssertion | DnsExpectedCnameAssertion @@ -5216,6 +5452,7 @@ class UpdateAssertionRequest(BaseModel): class UpdateEnvironmentRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str | None, Field( @@ -5238,6 +5475,7 @@ class UpdateEnvironmentRequest(BaseModel): class UpdateIncidentPolicyRequest(BaseModel): + model_config = ConfigDict(extra="forbid") trigger_rules: Annotated[ list[TriggerRule], Field( @@ -5251,6 +5489,7 @@ class UpdateIncidentPolicyRequest(BaseModel): class UpdateMaintenanceWindowRequest(BaseModel): + model_config = ConfigDict(extra="forbid") monitor_id: Annotated[ UUID | None, Field( @@ -5287,10 +5526,12 @@ class UpdateMaintenanceWindowRequest(BaseModel): class UpdateMonitorAuthRequest(BaseModel): + model_config = ConfigDict(extra="forbid") config: ApiKeyAuthConfig | BasicAuthConfig | BearerAuthConfig | HeaderAuthConfig class UpdateOrgDetailsRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str, Field( @@ -5331,6 +5572,7 @@ class UpdateOrgDetailsRequest(BaseModel): class UpdateResourceGroupRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str, Field( @@ -5427,6 +5669,7 @@ class UpdateResourceGroupRequest(BaseModel): class UpdateSecretRequest(BaseModel): + model_config = ConfigDict(extra="forbid") value: Annotated[ str, Field( @@ -5438,6 +5681,7 @@ class UpdateSecretRequest(BaseModel): class UpdateStatusPageComponentGroupRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str | None, Field( @@ -5470,6 +5714,7 @@ class UpdateStatusPageComponentGroupRequest(BaseModel): class UpdateStatusPageComponentRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str | None, Field( @@ -5531,6 +5776,7 @@ class UpdateStatusPageComponentRequest(BaseModel): class UpdateStatusPageIncidentRequest(BaseModel): + model_config = ConfigDict(extra="forbid") title: Annotated[ str | None, Field( @@ -5572,6 +5818,7 @@ class UpdateStatusPageIncidentRequest(BaseModel): class UpdateStatusPageRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str | None, Field( @@ -5604,6 +5851,7 @@ class UpdateStatusPageRequest(BaseModel): class UpdateTagRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str | None, Field(description="New tag name", max_length=100, min_length=0) ] = None @@ -5617,6 +5865,7 @@ class UpdateTagRequest(BaseModel): class UpdateWebhookEndpointRequest(BaseModel): + model_config = ConfigDict(extra="forbid") url: Annotated[ str | None, Field( @@ -5647,12 +5896,14 @@ class UpdateWebhookEndpointRequest(BaseModel): class UpdateWorkspaceRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str, Field(description="New workspace name", max_length=200, min_length=0) ] class UptimeBucketDto(BaseModel): + model_config = ConfigDict(extra="forbid") timestamp: Annotated[ AwareDatetime, Field( @@ -5679,6 +5930,7 @@ class UptimeBucketDto(BaseModel): class UptimeDto(BaseModel): + model_config = ConfigDict(extra="forbid") uptime_percentage: Annotated[ float | None, Field( @@ -5726,6 +5978,7 @@ class ChannelType7(StrEnum): class WebhookChannelConfig(BaseModel): + model_config = ConfigDict(extra="forbid") channel_type: Annotated[ChannelType7, Field(alias="channelType")] url: Annotated[ str, @@ -5751,6 +6004,7 @@ class WebhookChannelConfig(BaseModel): class WebhookDeliveryDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID endpoint_id: Annotated[UUID, Field(alias="endpointId")] event_id: Annotated[str, Field(alias="eventId")] @@ -5768,6 +6022,7 @@ class WebhookDeliveryDto(BaseModel): class WebhookEndpointDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique webhook endpoint identifier")] url: Annotated[ str, Field(description="HTTPS endpoint URL that receives event payloads") @@ -5819,6 +6074,7 @@ class WebhookEndpointDto(BaseModel): class WebhookEventCatalogEntry(BaseModel): + model_config = ConfigDict(extra="forbid") type: Annotated[ str, Field(description='Dot-notation event type identifier, e.g. "monitor.created"'), @@ -5835,6 +6091,7 @@ class WebhookEventCatalogEntry(BaseModel): class WebhookEventCatalogResponse(BaseModel): + model_config = ConfigDict(extra="forbid") data: Annotated[ list[WebhookEventCatalogEntry], Field(description="List of all available webhook event types"), @@ -5842,11 +6099,13 @@ class WebhookEventCatalogResponse(BaseModel): class WebhookSigningSecretDto(BaseModel): + model_config = ConfigDict(extra="forbid") configured: bool masked_secret: Annotated[str | None, Field(alias="maskedSecret")] = None class WebhookTestResult(BaseModel): + model_config = ConfigDict(extra="forbid") success: bool status_code: Annotated[int | None, Field(alias="statusCode")] = None message: str @@ -5854,6 +6113,7 @@ class WebhookTestResult(BaseModel): class WorkspaceDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[int, Field(description="Unique workspace identifier")] created_at: Annotated[ AwareDatetime, @@ -5875,6 +6135,7 @@ class WorkspaceDto(BaseModel): class AddMonitorTagsRequest(BaseModel): + model_config = ConfigDict(extra="forbid") tag_ids: Annotated[ list[UUID] | None, Field(alias="tagIds", description="IDs of existing org tags to attach"), @@ -5889,6 +6150,7 @@ class AddMonitorTagsRequest(BaseModel): class AuthMeResponse(BaseModel): + model_config = ConfigDict(extra="forbid") key: KeyInfo organization: OrgInfo plan: PlanInfo @@ -5896,6 +6158,7 @@ class AuthMeResponse(BaseModel): class BatchComponentUptimeDto(BaseModel): + model_config = ConfigDict(extra="forbid") components: Annotated[ dict[str, list[ComponentUptimeDayDto]], Field( @@ -5905,6 +6168,7 @@ class BatchComponentUptimeDto(BaseModel): class BulkMonitorActionRequest(BaseModel): + model_config = ConfigDict(extra="forbid") monitor_ids: Annotated[ list[UUID], Field( @@ -5935,6 +6199,7 @@ class BulkMonitorActionRequest(BaseModel): class BulkMonitorActionResult(BaseModel): + model_config = ConfigDict(extra="forbid") succeeded: Annotated[ list[UUID], Field(description="IDs of monitors on which the action succeeded") ] @@ -5957,6 +6222,7 @@ class CheckTypeDetailsDto(RootModel[Http | Tcp | Icmp | Dns | McpServer]): class CreateAlertChannelRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str, Field( @@ -5977,6 +6243,7 @@ class CreateAlertChannelRequest(BaseModel): class CreateAssertionRequest(BaseModel): + model_config = ConfigDict(extra="forbid") config: ( BodyContainsAssertion | DnsExpectedCnameAssertion @@ -6030,6 +6297,7 @@ class CreateAssertionRequest(BaseModel): class CreateMonitorRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str, Field( @@ -6093,6 +6361,7 @@ class CreateMonitorRequest(BaseModel): class CreateResourceGroupRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str, Field( @@ -6186,6 +6455,7 @@ class CreateResourceGroupRequest(BaseModel): class CreateStatusPageRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str, Field( @@ -6231,6 +6501,7 @@ class CreateStatusPageRequest(BaseModel): class CursorPageServiceCatalogDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: Annotated[list[ServiceCatalogDto], Field(description="Items on this page")] next_cursor: Annotated[ str | None, @@ -6248,6 +6519,7 @@ class CursorPageServiceCatalogDto(BaseModel): class CursorPageServicePollResultDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: Annotated[list[ServicePollResultDto], Field(description="Items on this page")] next_cursor: Annotated[ str | None, @@ -6265,11 +6537,13 @@ class CursorPageServicePollResultDto(BaseModel): class DashboardOverviewDto(BaseModel): + model_config = ConfigDict(extra="forbid") monitors: MonitorsSummaryDto incidents: IncidentsSummaryDto class EscalationChain(BaseModel): + model_config = ConfigDict(extra="forbid") steps: Annotated[ list[EscalationStep], Field( @@ -6287,6 +6561,7 @@ class EscalationChain(BaseModel): class GlobalStatusSummaryDto(BaseModel): + model_config = ConfigDict(extra="forbid") total_services: Annotated[ int, Field( @@ -6350,6 +6625,7 @@ class GlobalStatusSummaryDto(BaseModel): class IncidentDetailDto(BaseModel): + model_config = ConfigDict(extra="forbid") incident: IncidentDto updates: list[IncidentUpdateDto] status_page_incidents: Annotated[ @@ -6358,6 +6634,7 @@ class IncidentDetailDto(BaseModel): class IncidentPolicyDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique incident policy identifier")] monitor_id: Annotated[ UUID, Field(alias="monitorId", description="Monitor this policy is attached to") @@ -6398,6 +6675,7 @@ class IncidentPolicyDto(BaseModel): class IntegrationConfigSchemaDto(BaseModel): + model_config = ConfigDict(extra="forbid") connection_fields: Annotated[ list[IntegrationFieldDto], Field(alias="connectionFields") ] @@ -6405,6 +6683,7 @@ class IntegrationConfigSchemaDto(BaseModel): class IntegrationDto(BaseModel): + model_config = ConfigDict(extra="forbid") type: str name: str description: str @@ -6417,6 +6696,7 @@ class IntegrationDto(BaseModel): class MonitorAssertionDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID monitor_id: Annotated[UUID, Field(alias="monitorId")] assertion_type: Annotated[AssertionType, Field(alias="assertionType")] @@ -6468,6 +6748,7 @@ class MonitorAssertionDto(BaseModel): class MonitorDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique monitor identifier")] organization_id: Annotated[ int, @@ -6543,6 +6824,7 @@ class MonitorDto(BaseModel): class MonitorTestRequest(BaseModel): + model_config = ConfigDict(extra="forbid") type: Annotated[Type38, Field(description="Monitor protocol type to test")] config: ( DnsMonitorConfig @@ -6559,6 +6841,7 @@ class MonitorTestRequest(BaseModel): class MonitorVersionDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique version record identifier")] monitor_id: Annotated[ UUID, Field(alias="monitorId", description="Monitor this version belongs to") @@ -6594,6 +6877,7 @@ class MonitorVersionDto(BaseModel): class NotificationPolicyDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique notification policy identifier")] organization_id: Annotated[ int, @@ -6629,6 +6913,7 @@ class NotificationPolicyDto(BaseModel): class ResourceGroupDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique resource group identifier")] organization_id: Annotated[ int, @@ -6728,6 +7013,7 @@ class ResourceGroupDto(BaseModel): class ServiceDetailDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID slug: str name: str @@ -6759,6 +7045,7 @@ class ServiceDetailDto(BaseModel): class ServiceIncidentDetailDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID title: str status: str @@ -6774,6 +7061,7 @@ class ServiceIncidentDetailDto(BaseModel): class ServiceUptimeResponse(BaseModel): + model_config = ConfigDict(extra="forbid") overall_uptime_pct: Annotated[ float | None, Field( @@ -6800,118 +7088,147 @@ class ServiceUptimeResponse(BaseModel): class SingleValueResponseAuthMeResponse(BaseModel): + model_config = ConfigDict(extra="forbid") data: AuthMeResponse class SingleValueResponseBatchComponentUptimeDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: BatchComponentUptimeDto class SingleValueResponseBulkMonitorActionResult(BaseModel): + model_config = ConfigDict(extra="forbid") data: BulkMonitorActionResult class SingleValueResponseDashboardOverviewDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: DashboardOverviewDto class SingleValueResponseGlobalStatusSummaryDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: GlobalStatusSummaryDto class SingleValueResponseIncidentDetailDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: IncidentDetailDto class SingleValueResponseIncidentPolicyDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: IncidentPolicyDto class SingleValueResponseMonitorAssertionDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: MonitorAssertionDto class SingleValueResponseMonitorDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: MonitorDto class SingleValueResponseMonitorVersionDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: MonitorVersionDto class SingleValueResponseNotificationPolicyDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: NotificationPolicyDto class SingleValueResponseResourceGroupDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: ResourceGroupDto class SingleValueResponseServiceDetailDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: ServiceDetailDto class SingleValueResponseServiceIncidentDetailDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: ServiceIncidentDetailDto class SingleValueResponseServiceUptimeResponse(BaseModel): + model_config = ConfigDict(extra="forbid") data: ServiceUptimeResponse class SingleValueResponseStatusPageComponentDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: StatusPageComponentDto class SingleValueResponseStatusPageComponentGroupDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: StatusPageComponentGroupDto class SingleValueResponseStatusPageCustomDomainDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: StatusPageCustomDomainDto class SingleValueResponseStatusPageDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: StatusPageDto class SingleValueResponseStatusPageSubscriberDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: StatusPageSubscriberDto class SingleValueResponseTagDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: TagDto class SingleValueResponseTestChannelResult(BaseModel): + model_config = ConfigDict(extra="forbid") data: TestChannelResult class SingleValueResponseTestMatchResult(BaseModel): + model_config = ConfigDict(extra="forbid") data: TestMatchResult class SingleValueResponseUptimeDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: UptimeDto class SingleValueResponseWebhookEndpointDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: WebhookEndpointDto class SingleValueResponseWebhookSigningSecretDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: WebhookSigningSecretDto class SingleValueResponseWebhookTestResult(BaseModel): + model_config = ConfigDict(extra="forbid") data: WebhookTestResult class SingleValueResponseWorkspaceDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: WorkspaceDto class StatusPageIncidentDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: UUID status_page_id: Annotated[UUID, Field(alias="statusPageId")] title: Annotated[str, Field(min_length=1)] @@ -6939,6 +7256,7 @@ class StatusPageIncidentDto(BaseModel): class TableValueResultIntegrationDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[IntegrationDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -6947,6 +7265,7 @@ class TableValueResultIntegrationDto(BaseModel): class TableValueResultMonitorDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[MonitorDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -6955,6 +7274,7 @@ class TableValueResultMonitorDto(BaseModel): class TableValueResultMonitorVersionDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[MonitorVersionDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -6963,6 +7283,7 @@ class TableValueResultMonitorVersionDto(BaseModel): class TableValueResultNotificationPolicyDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[NotificationPolicyDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -6971,6 +7292,7 @@ class TableValueResultNotificationPolicyDto(BaseModel): class TableValueResultResourceGroupDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[ResourceGroupDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -6979,6 +7301,7 @@ class TableValueResultResourceGroupDto(BaseModel): class TableValueResultStatusPageIncidentDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[StatusPageIncidentDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -6987,6 +7310,7 @@ class TableValueResultStatusPageIncidentDto(BaseModel): class TableValueResultTagDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[TagDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -6995,6 +7319,7 @@ class TableValueResultTagDto(BaseModel): class TableValueResultWebhookDeliveryDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[WebhookDeliveryDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -7003,6 +7328,7 @@ class TableValueResultWebhookDeliveryDto(BaseModel): class TableValueResultWebhookEndpointDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[WebhookEndpointDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -7011,6 +7337,7 @@ class TableValueResultWebhookEndpointDto(BaseModel): class TableValueResultWorkspaceDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: list[WorkspaceDto] has_next: Annotated[bool, Field(alias="hasNext")] has_prev: Annotated[bool, Field(alias="hasPrev")] @@ -7019,6 +7346,7 @@ class TableValueResultWorkspaceDto(BaseModel): class TestAlertChannelRequest(BaseModel): + model_config = ConfigDict(extra="forbid") config: ( DiscordChannelConfig | EmailChannelConfig @@ -7031,6 +7359,7 @@ class TestAlertChannelRequest(BaseModel): class UpdateAlertChannelRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str, Field( @@ -7051,6 +7380,7 @@ class UpdateAlertChannelRequest(BaseModel): class UpdateMonitorRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str | None, Field( @@ -7126,6 +7456,7 @@ class UpdateMonitorRequest(BaseModel): class UpdateNotificationPolicyRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str | None, Field( @@ -7155,6 +7486,7 @@ class UpdateNotificationPolicyRequest(BaseModel): class CheckResultDetailsDto(BaseModel): + model_config = ConfigDict(extra="forbid") status_code: Annotated[ int | None, Field( @@ -7208,6 +7540,7 @@ class CheckResultDetailsDto(BaseModel): class CheckResultDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: Annotated[UUID, Field(description="Unique identifier of the check result")] timestamp: Annotated[ AwareDatetime, @@ -7252,6 +7585,7 @@ class CheckResultDto(BaseModel): class CreateNotificationPolicyRequest(BaseModel): + model_config = ConfigDict(extra="forbid") name: Annotated[ str, Field( @@ -7280,6 +7614,7 @@ class CreateNotificationPolicyRequest(BaseModel): class CursorPageCheckResultDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: Annotated[list[CheckResultDto], Field(description="Items on this page")] next_cursor: Annotated[ str | None, @@ -7297,4 +7632,5 @@ class CursorPageCheckResultDto(BaseModel): class SingleValueResponseStatusPageIncidentDto(BaseModel): + model_config = ConfigDict(extra="forbid") data: StatusPageIncidentDto diff --git a/src/devhelm/_http.py b/src/devhelm/_http.py index 12e2abe..4ee7eb2 100644 --- a/src/devhelm/_http.py +++ b/src/devhelm/_http.py @@ -8,7 +8,11 @@ import httpx from pydantic import BaseModel -from devhelm._errors import DevhelmError, error_from_response +from devhelm._errors import ( + DevhelmTransportError, + DevhelmValidationError, + error_from_response, +) DEFAULT_BASE_URL = "https://api.devhelm.io" DEFAULT_PAGE_SIZE = 200 @@ -28,10 +32,8 @@ class DevhelmConfig: def _resolve(value: str | None, env_key: str, label: str) -> str: result = value or os.environ.get(env_key) if not result: - raise DevhelmError( - "VALIDATION", - f"{label} is required. Pass it to Devhelm() or set {env_key}.", - 0, + raise DevhelmValidationError( + f"{label} is required. Pass it to Devhelm() or set {env_key}." ) return result @@ -78,49 +80,108 @@ def _serialize_body( if isinstance(body, BaseModel): return body.model_dump(mode="json", by_alias=True, exclude_none=True) if isinstance(body, dict): - raise DevhelmError( - "VALIDATION", + raise DevhelmValidationError( "Raw dicts are not accepted as request bodies. " - "Use the generated Pydantic model instead.", - 0, + "Use the generated Pydantic model instead." ) return body +def _decode_body(response: httpx.Response) -> _JsonResponse: + """Narrow ``httpx.Response.json()`` (typed `Any`) into the SDK's + declared `_JsonResponse` shape at a single boundary. + + httpx never types its decoded body, so without this funnel every caller + site would have to suppress mypy's no-any-return diagnostic. Centralising + the narrowing lets the rest of the SDK keep mypy clean (P5: zero casts + outside generated files) while preserving honest semantics — a non-JSON + body still raises through `httpx`/`json` rather than being silently + re-typed. + """ + body = response.json() + if body is None or isinstance(body, (dict, list)): + return body + # The API contract is "JSON object, JSON array, or empty body". Anything + # else (a bare scalar) is a server-side bug we want to surface loudly, + # not silently reshape into an unknown. + raise DevhelmValidationError( + "Expected a JSON object, JSON array, or empty body from the server, " + f"got {type(body).__name__}." + ) + + def checked_fetch(response: httpx.Response) -> _JsonResponse: - """Check an httpx response and raise DevhelmError on failure.""" + """Check an httpx response and raise a typed DevhelmApiError on failure.""" if response.is_success: if response.status_code == 204: return None - return response.json() # type: ignore[no-any-return] + return _decode_body(response) raise error_from_response(response.status_code, response.text) +# --------------------------------------------------------------------------- +# Transport-error wrapping +# --------------------------------------------------------------------------- + + +def _wrap_transport_errors(send: Any) -> httpx.Response: + """Run ``send()`` and translate httpx transport failures into + `DevhelmTransportError`, preserving ``__cause__``. + + httpx-level exceptions that indicate the request never reached the server + (or the server's response never reached us) are wrapped here. We let + `httpx.HTTPStatusError`-style failures fall through unchanged because + those should not occur — `checked_fetch` reads `response.is_success` + explicitly rather than calling `.raise_for_status()`. + """ + try: + result = send() + except httpx.HTTPError as exc: + raise DevhelmTransportError(f"{type(exc).__name__}: {exc}", cause=exc) from exc + if not isinstance(result, httpx.Response): + # Defensive: every httpx.Client.{get,post,...} returns httpx.Response. + # Anyone who feeds `_wrap_transport_errors` a callable that returns + # something else has a bug we want to surface immediately, not bury. + raise TypeError( + f"Expected httpx.Response from transport call, got {type(result).__name__}" + ) + return result + + def api_get( client: httpx.Client, path: str, params: dict[str, Any] | None = None ) -> _JsonResponse: - return checked_fetch(client.get(path, params=params)) + return checked_fetch( + _wrap_transport_errors(lambda: client.get(path, params=params)) + ) def api_post( client: httpx.Client, path: str, body: BaseModel | dict[str, object] | None = None ) -> _JsonResponse: if body is None: - return checked_fetch(client.post(path)) - return checked_fetch(client.post(path, json=_serialize_body(body))) + return checked_fetch(_wrap_transport_errors(lambda: client.post(path))) + payload = _serialize_body(body) + return checked_fetch( + _wrap_transport_errors(lambda: client.post(path, json=payload)) + ) def api_put( client: httpx.Client, path: str, body: BaseModel | dict[str, object] | None ) -> _JsonResponse: - return checked_fetch(client.put(path, json=_serialize_body(body))) + payload = _serialize_body(body) + return checked_fetch(_wrap_transport_errors(lambda: client.put(path, json=payload))) def api_patch( client: httpx.Client, path: str, body: BaseModel | dict[str, object] | None ) -> _JsonResponse: - return checked_fetch(client.patch(path, json=_serialize_body(body))) + payload = _serialize_body(body) + return checked_fetch( + _wrap_transport_errors(lambda: client.patch(path, json=payload)) + ) def api_delete(client: httpx.Client, path: str) -> None: - checked_fetch(client.delete(path)) + checked_fetch(_wrap_transport_errors(lambda: client.delete(path))) diff --git a/src/devhelm/_pagination.py b/src/devhelm/_pagination.py index a5db2dc..f078bef 100644 --- a/src/devhelm/_pagination.py +++ b/src/devhelm/_pagination.py @@ -1,11 +1,12 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Generic, TypeVar, cast +from typing import Any, Generic, TypeVar import httpx -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, ValidationError +from devhelm._errors import DevhelmValidationError from devhelm._http import DEFAULT_PAGE_SIZE, api_get from devhelm._validation import parse_list @@ -33,6 +34,58 @@ class CursorPage(Generic[T]): has_more: bool = False +class _PageEnvelope(BaseModel): + """Server-side page metadata as a typed model. + + Items are validated separately via ``parse_list(model_class, ...)`` so the + envelope only describes the surrounding pagination shape; that keeps this + layer P5-clean (no casts) without forcing every model to be expressed + twice. ``extra="forbid"`` so unknown envelope keys surface as a typed + ``DevhelmValidationError`` (P1) rather than silently disappearing. + """ + + model_config = ConfigDict(extra="forbid") + + data: list[Any] = [] # validated separately + hasNext: bool = False + hasPrev: bool = False + totalElements: int | None = None + totalPages: int | None = None + + +class _CursorPageEnvelope(BaseModel): + """Cursor-page envelope. See ``_PageEnvelope`` for the rationale.""" + + model_config = ConfigDict(extra="forbid") + + data: list[Any] = [] + nextCursor: str | None = None + hasMore: bool = False + + +def _validate_page(resp: object) -> _PageEnvelope: + try: + return _PageEnvelope.model_validate(resp) + except ValidationError as e: + # Surface the structured Pydantic errors so callers can introspect + # the failed location instead of getting a string-summarised + # `value_error`. Non-`ValidationError` exceptions (network, IO, + # programmer mistake) intentionally propagate — wrapping them here + # would mask real bugs as fake "validation" failures. + raise DevhelmValidationError( + "Invalid paginated response envelope", errors=e.errors(), cause=e + ) from e + + +def _validate_cursor_page(resp: object) -> _CursorPageEnvelope: + try: + return _CursorPageEnvelope.model_validate(resp) + except ValidationError as e: + raise DevhelmValidationError( + "Invalid cursor-paginated response envelope", errors=e.errors(), cause=e + ) from e + + def fetch_all_pages( client: httpx.Client, path: str, @@ -45,10 +98,9 @@ def fetch_all_pages( while True: resp = api_get(client, path, params={"page": page, "size": page_size}) - raw_items = resp.get("data", []) if isinstance(resp, dict) else [] - items = parse_list(model_class, raw_items, f"GET {path}") - all_items.extend(items) - if not (isinstance(resp, dict) and resp.get("hasNext")): + envelope = _validate_page(resp) + all_items.extend(parse_list(model_class, envelope.data, f"GET {path}")) + if not envelope.hasNext: break page += 1 @@ -60,18 +112,13 @@ def fetch_page( ) -> Page[M]: """Fetch a single page from an offset-paginated endpoint with validation.""" resp = api_get(client, path, params={"page": page, "size": size}) - raw_items = resp.get("data", []) if isinstance(resp, dict) else [] - items = parse_list(model_class, raw_items, f"GET {path}") + envelope = _validate_page(resp) return Page( - data=items, - has_next=bool(resp.get("hasNext")) if isinstance(resp, dict) else False, - has_prev=bool(resp.get("hasPrev")) if isinstance(resp, dict) else False, - total_elements=cast(int | None, resp.get("totalElements")) - if isinstance(resp, dict) - else None, - total_pages=cast(int | None, resp.get("totalPages")) - if isinstance(resp, dict) - else None, + data=parse_list(model_class, envelope.data, f"GET {path}"), + has_next=envelope.hasNext, + has_prev=envelope.hasPrev, + total_elements=envelope.totalElements, + total_pages=envelope.totalPages, ) @@ -90,12 +137,9 @@ def fetch_cursor_page( params["limit"] = limit resp = api_get(client, path, params=params or None) - raw_items = resp.get("data", []) if isinstance(resp, dict) else [] - items = parse_list(model_class, raw_items, f"GET {path}") + envelope = _validate_cursor_page(resp) return CursorPage( - data=items, - next_cursor=cast(str | None, resp.get("nextCursor")) - if isinstance(resp, dict) - else None, - has_more=bool(resp.get("hasMore")) if isinstance(resp, dict) else False, + data=parse_list(model_class, envelope.data, f"GET {path}"), + next_cursor=envelope.nextCursor, + has_more=envelope.hasMore, ) diff --git a/src/devhelm/_validation.py b/src/devhelm/_validation.py index 408238a..a870c8b 100644 --- a/src/devhelm/_validation.py +++ b/src/devhelm/_validation.py @@ -11,7 +11,7 @@ from pydantic import BaseModel, TypeAdapter, ValidationError -from devhelm._errors import DevhelmError +from devhelm._errors import DevhelmValidationError M = TypeVar("M", bound=BaseModel) @@ -39,25 +39,23 @@ def validate_request( return model_class.model_validate(body) except ValidationError as e: ctx = f" ({context})" if context else "" - raise DevhelmError( - "VALIDATION", - f"Request validation failed{ctx}: {e.error_count()} error(s)", - 0, - str(e), + raise DevhelmValidationError( + f"Request validation failed{ctx}: {e.error_count()} error(s) — {e}", + errors=e.errors(), + cause=e, ) from e def parse_model(model_class: type[M], data: Any, context: str = "") -> M: - """Parse a raw dict/JSON through a Pydantic model, raising DevhelmError on failure.""" + """Parse a raw dict/JSON through a Pydantic model, raising on failure.""" try: return model_class.model_validate(data) except ValidationError as e: ctx = f" ({context})" if context else "" - raise DevhelmError( - "VALIDATION", - f"Response validation failed{ctx}: {e.error_count()} error(s)", - 0, - str(e), + raise DevhelmValidationError( + f"Response validation failed{ctx}: {e.error_count()} error(s) — {e}", + errors=e.errors(), + cause=e, ) from e @@ -68,22 +66,66 @@ def parse_single(model_class: type[M], data: Any, context: str = "") -> M: return parse_model(model_class, data, context) +def parse_strict_envelope( + model_class: type[M], data: Any, *, optional: bool = False, context: str = "" +) -> M | None: + """Parse a strict ``{"data": T}`` envelope, rejecting unknown top-level keys. + + Implements P1 (response extras forbidden) at the envelope layer for + endpoints whose ``data`` field can legitimately be ``null`` (e.g. + ``GET /api/v1/deploy/lock`` returns ``{"data": null}`` when no lock is + held). Unlike :func:`parse_single`, this helper: + + * Raises if the response is not a dict at all. + * Raises if any top-level key besides ``data`` is present (loud spec + drift detection — equivalent to ``ConfigDict(extra='forbid')`` on a + hand-rolled envelope ``BaseModel``, but without forcing the resource + module to declare a Pydantic class purely to get strictness). + * Returns ``None`` for ``{"data": null}`` only when ``optional=True``. + + Hand-rolled instead of declaring an envelope ``BaseModel`` because + Pydantic's mypy plugin synthesises ``__init__(**data: Any)`` on every + subclass, which would trip ``disallow_any_explicit`` in the resource + layer. + """ + ctx = f" ({context})" if context else "" + if not isinstance(data, dict): + raise DevhelmValidationError( + f"Expected envelope dict, got {type(data).__name__}{ctx}" + ) + extra = set(data.keys()) - {"data"} + if extra: + raise DevhelmValidationError( + f"Unknown envelope fields{ctx}: {sorted(extra)}", + errors=[ + { + "loc": (key,), + "msg": "Extra inputs are not permitted", + "type": "extra_forbidden", + } + for key in sorted(extra) + ], + ) + inner = data.get("data") + if inner is None: + if optional: + return None + raise DevhelmValidationError(f"Envelope missing required `data` field{ctx}") + return parse_model(model_class, inner, context) + + def parse_list(model_class: type[M], data: Any, context: str = "") -> list[M]: """Parse a list of items through a Pydantic model.""" if not isinstance(data, list): - raise DevhelmError( - "VALIDATION", - f"Expected list, got {type(data).__name__}{f' ({context})' if context else ''}", - 0, - ) + ctx = f" ({context})" if context else "" + raise DevhelmValidationError(f"Expected list, got {type(data).__name__}{ctx}") adapter: TypeAdapter[list[M]] = TypeAdapter(list[model_class]) # type: ignore[valid-type] try: return adapter.validate_python(data) except ValidationError as e: ctx = f" ({context})" if context else "" - raise DevhelmError( - "VALIDATION", - f"List validation failed{ctx}: {e.error_count()} error(s)", - 0, - str(e), + raise DevhelmValidationError( + f"List validation failed{ctx}: {e.error_count()} error(s) — {e}", + errors=e.errors(), + cause=e, ) from e diff --git a/src/devhelm/resources/deploy_lock.py b/src/devhelm/resources/deploy_lock.py index d95dd71..39d6e59 100644 --- a/src/devhelm/resources/deploy_lock.py +++ b/src/devhelm/resources/deploy_lock.py @@ -4,7 +4,12 @@ from devhelm._generated import AcquireDeployLockRequest, DeployLockDto from devhelm._http import api_delete, api_get, api_post, path_param -from devhelm._validation import RequestBody, parse_model, parse_single, validate_request +from devhelm._validation import ( + RequestBody, + parse_single, + parse_strict_envelope, + validate_request, +) class DeployLock: @@ -23,11 +28,17 @@ def acquire(self, body: RequestBody[AcquireDeployLockRequest]) -> DeployLockDto: ) def current(self) -> DeployLockDto | None: - """Get the current deploy lock, or None if unlocked.""" + """Get the current deploy lock, or ``None`` if unlocked. + + Uses :func:`parse_strict_envelope` so unknown top-level fields fail + loud (P1) — the API returns ``{"data": null}`` when no lock is held, + which is the only place in the SDK where ``data`` is legitimately + nullable. + """ resp = api_get(self._client, "/api/v1/deploy/lock") - if isinstance(resp, dict) and resp.get("data") is not None: - return parse_model(DeployLockDto, resp["data"], "GET /api/v1/deploy/lock") - return None + return parse_strict_envelope( + DeployLockDto, resp, optional=True, context="GET /api/v1/deploy/lock" + ) def release(self, lock_id: int | str) -> None: """Release a deploy lock by ID.""" diff --git a/tests/test_errors.py b/tests/test_errors.py index 1b33591..77673e6 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,60 +1,88 @@ -"""Tests for error_from_response and error hierarchy.""" +"""Tests for the typed error taxonomy.""" from __future__ import annotations import json -from devhelm._errors import AuthError, DevhelmError, error_from_response +from devhelm._errors import ( + DevhelmApiError, + DevhelmAuthError, + DevhelmConflictError, + DevhelmError, + DevhelmNotFoundError, + DevhelmRateLimitError, + DevhelmServerError, + DevhelmValidationError, + error_from_response, +) class TestErrorFromResponse: def test_401_returns_auth_error(self) -> None: err = error_from_response(401, json.dumps({"message": "Unauthorized"})) - assert isinstance(err, AuthError) - assert err.code == "AUTH" + assert isinstance(err, DevhelmAuthError) assert err.status == 401 assert err.message == "Unauthorized" def test_403_returns_auth_error(self) -> None: err = error_from_response(403, json.dumps({"message": "Forbidden"})) - assert isinstance(err, AuthError) - assert err.code == "AUTH" + assert isinstance(err, DevhelmAuthError) assert err.status == 403 def test_404_returns_not_found(self) -> None: err = error_from_response( 404, json.dumps({"message": "Not found", "detail": "Monitor 99"}) ) - assert isinstance(err, DevhelmError) - assert not isinstance(err, AuthError) - assert err.code == "NOT_FOUND" + assert isinstance(err, DevhelmNotFoundError) + assert not isinstance(err, DevhelmAuthError) assert err.detail == "Monitor 99" def test_409_returns_conflict(self) -> None: err = error_from_response(409, json.dumps({"message": "Already exists"})) - assert err.code == "CONFLICT" + assert isinstance(err, DevhelmConflictError) - def test_400_returns_validation(self) -> None: + def test_400_returns_api_error_not_validation(self) -> None: + # 4xx (other than 401/403/404/409/429) is plain DevhelmApiError; the + # framework no longer conflates server-side "VALIDATION" with our + # local DevhelmValidationError class (which is reserved for Pydantic + # failures we raise without ever talking to the server). err = error_from_response(400, json.dumps({"message": "Bad request"})) - assert err.code == "VALIDATION" + assert isinstance(err, DevhelmApiError) + assert not isinstance(err, DevhelmValidationError) + assert err.status == 400 - def test_422_returns_validation(self) -> None: + def test_422_returns_api_error_not_validation(self) -> None: err = error_from_response(422, json.dumps({"message": "Unprocessable"})) - assert err.code == "VALIDATION" + assert isinstance(err, DevhelmApiError) + assert not isinstance(err, DevhelmValidationError) + assert err.status == 422 + + def test_429_returns_rate_limit(self) -> None: + err = error_from_response(429, json.dumps({"message": "Slow down"})) + assert isinstance(err, DevhelmRateLimitError) + assert err.status == 429 - def test_500_returns_api(self) -> None: + def test_500_returns_server_error(self) -> None: err = error_from_response(500, json.dumps({"message": "Internal error"})) - assert err.code == "API" + assert isinstance(err, DevhelmServerError) assert err.status == 500 + def test_503_returns_server_error(self) -> None: + err = error_from_response(503, json.dumps({"message": "Unavailable"})) + assert isinstance(err, DevhelmServerError) + assert err.status == 503 + def test_non_json_body(self) -> None: err = error_from_response(502, "Bad Gateway") - assert err.code == "API" - assert err.message == "Bad Gateway" + assert isinstance(err, DevhelmServerError) + # Non-JSON bodies surface as DevhelmApiError with the raw text on + # `body` and the default `HTTP {status}` message. + assert err.message == "HTTP 502" + assert err.body == "Bad Gateway" def test_empty_body(self) -> None: err = error_from_response(503, "") - assert err.code == "API" + assert isinstance(err, DevhelmServerError) assert err.message == "HTTP 503" def test_json_with_error_field(self) -> None: @@ -73,15 +101,22 @@ def test_json_without_detail(self) -> None: class TestDevhelmErrorInheritance: - def test_is_exception(self) -> None: - err = DevhelmError("API", "test", 500) + def test_api_error_is_devhelm_error(self) -> None: + err = DevhelmApiError("test", status=500) + assert isinstance(err, DevhelmError) assert isinstance(err, Exception) - def test_auth_error_is_devhelm_error(self) -> None: - err = AuthError("test", 401) + def test_auth_error_is_api_error(self) -> None: + err = DevhelmAuthError("test", status=401) + assert isinstance(err, DevhelmApiError) assert isinstance(err, DevhelmError) assert isinstance(err, Exception) + def test_validation_error_is_devhelm_error_but_not_api_error(self) -> None: + err = DevhelmValidationError("nope") + assert isinstance(err, DevhelmError) + assert not isinstance(err, DevhelmApiError) + def test_str_shows_message(self) -> None: - err = DevhelmError("NOT_FOUND", "Monitor not found", 404) + err = DevhelmNotFoundError("Monitor not found", status=404) assert str(err) == "Monitor not found" diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 5888a8f..f947b86 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -15,6 +15,7 @@ from devhelm._generated import ( AddCustomDomainRequest, AdminAddSubscriberRequest, + CheckTypeDetailsDto, ComponentPosition, CreateManualIncidentRequest, CreateMonitorRequest, @@ -34,6 +35,7 @@ StatusPageComponentDto, StatusPageDto, StatusPageIncidentDto, + SubscribedEvent, WebhookTestResult, ) @@ -656,3 +658,55 @@ def test_invalid_component_type(self) -> None: CreateStatusPageComponentRequest.model_validate( {"name": "X", "type": "INVALID_TYPE"} ) + + +class TestRootModelStrictness: + """`scripts/inject_strict_config.py` deliberately skips RootModel classes + because Pydantic raises ``root-model-extra`` if you try to set + ``extra='forbid'`` on them. Strictness on ``RootModel[Inner]`` is + delegated to the *inner* type. These tests pin that contract — if the + inner variants ever lose their ``extra='forbid'`` config, ``RootModel`` + discriminated unions would silently accept unknown fields, which is the + exact spec-drift class we're guarding against (P1).""" + + def test_subscribed_event_accepts_valid_string(self) -> None: + # `SubscribedEvent(RootModel[str])` — the inner type is a scalar, so + # the strictness contract reduces to type/length validation. + ev = SubscribedEvent.model_validate("monitor.created") + assert ev.root == "monitor.created" + + def test_subscribed_event_rejects_non_string(self) -> None: + with pytest.raises(ValidationError): + SubscribedEvent.model_validate(42) + + def test_subscribed_event_rejects_empty_string(self) -> None: + # min_length=1 from the spec. + with pytest.raises(ValidationError): + SubscribedEvent.model_validate("") + + def test_check_type_details_routes_by_discriminator(self) -> None: + details = CheckTypeDetailsDto.model_validate({"check_type": "http"}) + assert details.root.check_type == "http" + + def test_check_type_details_rejects_unknown_discriminator(self) -> None: + with pytest.raises(ValidationError): + CheckTypeDetailsDto.model_validate({"check_type": "graphql"}) + + def test_check_type_details_inner_variant_rejects_extra_keys(self) -> None: + # The Http inner variant has `extra='forbid'`. If anyone ever drops + # it, this test fails — that's the whole point of having explicit + # coverage for RootModel-wrapped inners (P1). + with pytest.raises(ValidationError, match="extra"): + CheckTypeDetailsDto.model_validate( + {"check_type": "http", "totally_made_up_key": True} + ) + + def test_check_type_details_dns_variant_rejects_extra_keys(self) -> None: + with pytest.raises(ValidationError, match="extra"): + CheckTypeDetailsDto.model_validate( + {"check_type": "dns", "rogue_field": "x"} + ) + + def test_check_type_details_tcp_variant_rejects_extra_keys(self) -> None: + with pytest.raises(ValidationError, match="extra"): + CheckTypeDetailsDto.model_validate({"check_type": "tcp", "tls_extra": True}) diff --git a/tests/test_typing.py b/tests/test_typing.py index e8a91d3..a11992d 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -2,7 +2,13 @@ These tests ensure that: * mypy strict + ``disallow_any_explicit`` runs cleanly across the entire - package, including the previously-excluded ``_generated.py``. + package, including the previously-excluded ``_generated.py``. The + JSON-boundary modules (``_errors``, ``_http``, ``_pagination``, + ``_validation``, ``_generated``) are allowlisted in ``pyproject.toml`` + via ``tool.mypy.overrides`` because they intentionally model unparsed + JSON; every other module is ``Any``-free. + * The resource layer — everything customers import — does not use + ``typing.Any``. * The single justified ``# type: ignore`` comment is the only one in hand-written code (generated code may add ``[assignment]`` for the ``HealthThresholdType.count`` collision documented in ``scripts/typegen.sh``). @@ -40,27 +46,76 @@ def test_mypy_strict_passes_including_generated() -> None: ) +def test_resource_layer_is_any_free() -> None: + """The resource modules (everything customers import) must not use ``Any``. + + The five JSON-boundary modules are explicitly allowlisted because they + serialise unparsed JSON; *every other* module — and especially the + public ``resources/`` package — must stay ``Any``-free so end-user code + inherits the strict typing guarantees promised in ``pyproject.toml``. + """ + pattern = re.compile(r"\bAny\b") + boundary_modules = { + "_errors.py", + "_http.py", + "_pagination.py", + "_validation.py", + "_generated.py", + } + offenders: list[str] = [] + for path in SRC.rglob("*.py"): + if path.name in boundary_modules: + continue + text = path.read_text() + for lineno, line in enumerate(text.splitlines(), start=1): + stripped = line.strip() + if stripped.startswith("#"): + continue + if pattern.search(line): + offenders.append(f"{path.relative_to(ROOT)}:{lineno}: {line.strip()}") + assert not offenders, ( + "The resource layer must stay `Any`-free. If you genuinely need an " + "untyped JSON value here, prefer parsing it through a Pydantic model " + "instead of widening the public API:\n" + "\n".join(offenders) + ) + + def test_handwritten_modules_have_only_documented_type_ignores() -> None: """Catch sneaky `# type: ignore` additions outside the generated file.""" pattern = re.compile(r"#\s*type:\s*ignore") + # Allow-list entries are (relative_path, stripped_line) tuples so that + # incidental line-number shifts (e.g. ruff format reflowing nearby code) + # don't churn this list. Add a new entry only with a documented reason + # in a code comment alongside the suppression itself. + expected: set[tuple[str, str]] = { + # `TypeAdapter[list[M]] = TypeAdapter(list[model_class])` would + # require a higher-kinded type so mypy knows the constructor's + # value-time generic lines up with the annotation's type-time + # generic. There is no way to express that in PEP-484, so the + # ignore stays. (See `_validation.parse_list`.) + ( + "src/devhelm/_validation.py", + "adapter: TypeAdapter[list[M]] = TypeAdapter(list[model_class]) # type: ignore[valid-type]", + ) + } offenders: list[str] = [] + actual: set[tuple[str, str]] = set() for path in SRC.rglob("*.py"): if path.name == "_generated.py": continue text = path.read_text() for lineno, line in enumerate(text.splitlines(), start=1): - if pattern.search(line): - offenders.append(f"{path.relative_to(ROOT)}:{lineno}: {line.strip()}") - expected = { - "src/devhelm/_validation.py:79: adapter: TypeAdapter[list[M]] = " - "TypeAdapter(list[model_class]) # type: ignore[valid-type]", - "src/devhelm/_http.py:95: return response.json() # type: " - "ignore[no-any-return]", - } - actual = set(offenders) + if not pattern.search(line): + continue + rel = str(path.relative_to(ROOT)) + stripped = line.strip() + actual.add((rel, stripped)) + offenders.append(f"{rel}:{lineno}: {stripped}") extra = actual - expected assert not extra, ( "Unexpected `# type: ignore` comments outside the generated file. " "Each suppression must be documented and added to the test allow-list:\n" - + "\n".join(sorted(extra)) + + "\n".join(f"{rel}: {line}" for rel, line in sorted(extra)) + + "\n\nFull offender list with line numbers:\n" + + "\n".join(sorted(offenders)) ) diff --git a/tests/test_validation_helpers.py b/tests/test_validation_helpers.py new file mode 100644 index 0000000..ecbd5bc --- /dev/null +++ b/tests/test_validation_helpers.py @@ -0,0 +1,85 @@ +"""Tests for the public validation helpers in :mod:`devhelm._validation`. + +Focuses on :func:`parse_strict_envelope`, which guarantees P1 (response +extras forbidden) for envelopes whose ``data`` field can legitimately be +``null`` (today: only ``GET /api/v1/deploy/lock``). +""" + +from __future__ import annotations + +import pytest + +from devhelm._errors import DevhelmValidationError +from devhelm._generated import DeployLockDto +from devhelm._validation import parse_strict_envelope + + +class TestParseStrictEnvelope: + def _valid_lock(self) -> dict[str, str | int]: + return { + "id": "11111111-1111-1111-1111-111111111111", + "lockedBy": "ci-bot", + "lockedAt": "2026-01-01T00:00:00Z", + "expiresAt": "2026-01-01T00:30:00Z", + } + + def test_unwraps_data_into_typed_model(self) -> None: + lock = parse_strict_envelope( + DeployLockDto, {"data": self._valid_lock()}, context="ctx" + ) + assert lock is not None + assert lock.locked_by == "ci-bot" + + def test_returns_none_when_data_is_null_and_optional(self) -> None: + result = parse_strict_envelope( + DeployLockDto, {"data": None}, optional=True, context="ctx" + ) + assert result is None + + def test_raises_when_data_null_and_not_optional(self) -> None: + with pytest.raises(DevhelmValidationError, match="missing required `data`"): + parse_strict_envelope(DeployLockDto, {"data": None}, context="ctx") + + def test_rejects_unknown_top_level_keys(self) -> None: + with pytest.raises(DevhelmValidationError, match="Unknown envelope fields"): + parse_strict_envelope( + DeployLockDto, + {"data": self._valid_lock(), "metadata": {"hint": "drift"}}, + optional=True, + context="ctx", + ) + + def test_unknown_keys_surface_as_structured_errors(self) -> None: + with pytest.raises(DevhelmValidationError) as exc_info: + parse_strict_envelope( + DeployLockDto, {"data": None, "x": 1, "y": 2}, optional=True + ) + # Sorted by key for determinism — `x` first, `y` second. + assert exc_info.value.errors == [ + { + "loc": ("x",), + "msg": "Extra inputs are not permitted", + "type": "extra_forbidden", + }, + { + "loc": ("y",), + "msg": "Extra inputs are not permitted", + "type": "extra_forbidden", + }, + ] + + def test_rejects_non_dict_response(self) -> None: + with pytest.raises(DevhelmValidationError, match="Expected envelope dict"): + parse_strict_envelope(DeployLockDto, "not a dict", optional=True) + + def test_inner_validation_failure_propagates_pydantic_errors(self) -> None: + with pytest.raises(DevhelmValidationError) as exc_info: + parse_strict_envelope( + DeployLockDto, + {"data": {"id": "not-a-uuid"}}, + context="GET /api/v1/deploy/lock", + ) + # Pydantic-level errors were captured (not the hand-rolled + # extra_forbidden shape), so the failure points at the inner field. + assert exc_info.value.errors # non-empty + assert any(e["type"] != "extra_forbidden" for e in exc_info.value.errors)