devhelm 0.6.2__tar.gz → 0.6.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. {devhelm-0.6.2 → devhelm-0.6.3}/PKG-INFO +1 -1
  2. {devhelm-0.6.2 → devhelm-0.6.3}/docs/openapi/monitoring-api.json +15 -7
  3. {devhelm-0.6.2 → devhelm-0.6.3}/pyproject.toml +1 -1
  4. devhelm-0.6.3/scripts/inject_strict_config.py +321 -0
  5. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/__init__.py +13 -0
  6. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/_generated.py +383 -369
  7. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/_pagination.py +28 -5
  8. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/monitors.py +90 -6
  9. {devhelm-0.6.2 → devhelm-0.6.3}/tests/test_client.py +100 -0
  10. {devhelm-0.6.2 → devhelm-0.6.3}/uv.lock +1 -1
  11. devhelm-0.6.2/scripts/inject_strict_config.py +0 -134
  12. {devhelm-0.6.2 → devhelm-0.6.3}/.github/workflows/ci.yml +0 -0
  13. {devhelm-0.6.2 → devhelm-0.6.3}/.github/workflows/release.yml +0 -0
  14. {devhelm-0.6.2 → devhelm-0.6.3}/.github/workflows/spec-check.yml +0 -0
  15. {devhelm-0.6.2 → devhelm-0.6.3}/.gitignore +0 -0
  16. {devhelm-0.6.2 → devhelm-0.6.3}/LICENSE +0 -0
  17. {devhelm-0.6.2 → devhelm-0.6.3}/Makefile +0 -0
  18. {devhelm-0.6.2 → devhelm-0.6.3}/README.md +0 -0
  19. {devhelm-0.6.2 → devhelm-0.6.3}/scripts/regen-from.sh +0 -0
  20. {devhelm-0.6.2 → devhelm-0.6.3}/scripts/release.sh +0 -0
  21. {devhelm-0.6.2 → devhelm-0.6.3}/scripts/typegen.sh +0 -0
  22. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/_errors.py +0 -0
  23. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/_http.py +0 -0
  24. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/_validation.py +0 -0
  25. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/client.py +0 -0
  26. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/py.typed +0 -0
  27. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/__init__.py +0 -0
  28. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/alert_channels.py +0 -0
  29. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/api_keys.py +0 -0
  30. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/dependencies.py +0 -0
  31. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/deploy_lock.py +0 -0
  32. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/environments.py +0 -0
  33. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/forensics.py +0 -0
  34. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/incidents.py +0 -0
  35. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/notification_policies.py +0 -0
  36. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/resource_groups.py +0 -0
  37. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/secrets.py +0 -0
  38. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/status.py +0 -0
  39. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/status_pages.py +0 -0
  40. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/tags.py +0 -0
  41. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/webhooks.py +0 -0
  42. {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/types.py +0 -0
  43. {devhelm-0.6.2 → devhelm-0.6.3}/tests/__init__.py +0 -0
  44. {devhelm-0.6.2 → devhelm-0.6.3}/tests/run_sdk.py +0 -0
  45. {devhelm-0.6.2 → devhelm-0.6.3}/tests/test_errors.py +0 -0
  46. {devhelm-0.6.2 → devhelm-0.6.3}/tests/test_http.py +0 -0
  47. {devhelm-0.6.2 → devhelm-0.6.3}/tests/test_negative_validation.py +0 -0
  48. {devhelm-0.6.2 → devhelm-0.6.3}/tests/test_schemas.py +0 -0
  49. {devhelm-0.6.2 → devhelm-0.6.3}/tests/test_spec_parity.py +0 -0
  50. {devhelm-0.6.2 → devhelm-0.6.3}/tests/test_typing.py +0 -0
  51. {devhelm-0.6.2 → devhelm-0.6.3}/tests/test_validation_helpers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devhelm
3
- Version: 0.6.2
3
+ Version: 0.6.3
4
4
  Summary: DevHelm SDK for Python — typed client for monitors, incidents, alerting, and more
5
5
  Project-URL: Homepage, https://github.com/devhelmhq/sdk-python
6
6
  Project-URL: Repository, https://github.com/devhelmhq/sdk-python.git
@@ -6412,7 +6412,9 @@
6412
6412
  "enum": [
6413
6413
  "DASHBOARD",
6414
6414
  "CLI",
6415
- "TERRAFORM"
6415
+ "TERRAFORM",
6416
+ "MCP",
6417
+ "API"
6416
6418
  ]
6417
6419
  }
6418
6420
  },
@@ -23307,11 +23309,13 @@
23307
23309
  },
23308
23310
  "managedBy": {
23309
23311
  "type": "string",
23310
- "description": "Who manages this monitor: DASHBOARD or CLI",
23312
+ "description": "Source that created/owns this monitor: DASHBOARD, CLI, TERRAFORM, MCP, or API. Use the value matching your surface so audit logs, drift detection, and analytics attribute correctly.",
23311
23313
  "enum": [
23312
23314
  "DASHBOARD",
23313
23315
  "CLI",
23314
- "TERRAFORM"
23316
+ "TERRAFORM",
23317
+ "MCP",
23318
+ "API"
23315
23319
  ]
23316
23320
  },
23317
23321
  "environmentId": {
@@ -27194,11 +27198,13 @@
27194
27198
  },
27195
27199
  "managedBy": {
27196
27200
  "type": "string",
27197
- "description": "Management source: DASHBOARD or CLI",
27201
+ "description": "Source that created/owns this monitor: DASHBOARD, CLI, TERRAFORM, MCP, or API",
27198
27202
  "enum": [
27199
27203
  "DASHBOARD",
27200
27204
  "CLI",
27201
- "TERRAFORM"
27205
+ "TERRAFORM",
27206
+ "MCP",
27207
+ "API"
27202
27208
  ]
27203
27209
  },
27204
27210
  "createdAt": {
@@ -33345,12 +33351,14 @@
33345
33351
  },
33346
33352
  "managedBy": {
33347
33353
  "type": "string",
33348
- "description": "New management source; null preserves current",
33354
+ "description": "New ownership source: DASHBOARD, CLI, TERRAFORM, MCP, or API; null preserves current value",
33349
33355
  "nullable": true,
33350
33356
  "enum": [
33351
33357
  "DASHBOARD",
33352
33358
  "CLI",
33353
- "TERRAFORM"
33359
+ "TERRAFORM",
33360
+ "MCP",
33361
+ "API"
33354
33362
  ]
33355
33363
  },
33356
33364
  "environmentId": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devhelm"
3
- version = "0.6.2"
3
+ version = "0.6.3"
4
4
  description = "DevHelm SDK for Python — typed client for monitors, incidents, alerting, and more"
5
5
  authors = [{ name = "DevHelm", email = "hello@devhelm.io" }]
6
6
  license = "MIT"
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env python3
2
+ """Inject ``model_config = ConfigDict(extra='forbid', populate_by_name=True)``
3
+ into every generated Pydantic BaseModel class, and add Pydantic v2
4
+ ``Field(discriminator=...)`` annotations on tagged-union fields.
5
+
6
+ datamodel-code-generator does not emit a config block when the source
7
+ OpenAPI spec lacks ``additionalProperties: false``. Springdoc never emits
8
+ that key, so we patch every generated class here.
9
+
10
+ Why ``populate_by_name=True``?
11
+ ==============================
12
+ Without it, models with ``validation_alias=camelCase`` reject snake_case
13
+ kwargs because ``extra='forbid'`` treats them as unknown keys. Setting
14
+ ``populate_by_name=True`` lets callers pass *either* the wire alias
15
+ (``frequencySeconds=60``) *or* the Python field name
16
+ (``frequency_seconds=60``), which makes the SDK feel like a proper
17
+ Python library instead of a thin JSON wrapper. Implements P1.Bug5 from
18
+ the round-3 DevEx audit.
19
+
20
+ Why discriminator injection?
21
+ ============================
22
+ Many request bodies (``CreateAssertionRequest.config``, alert channel
23
+ configs, etc.) are *tagged unions*: every member has a
24
+ ``type: Literal["..."]`` (or ``channel_type``, ``check_type``, …) field
25
+ that uniquely identifies which subtype applies. Without
26
+ ``Field(discriminator='type')``, Pydantic tries every union arm in turn
27
+ and emits an error per arm — for the 41-member assertion union, that's
28
+ **161 errors** for a single bad ``operator`` field. With the
29
+ discriminator, Pydantic routes to the correct subtype based on the tag
30
+ value and reports only that subtype's errors (typically 1).
31
+ Implements P0.Bug4 from the round-3 DevEx audit.
32
+
33
+ This implements policies P1 (response extras forbidden) and P2 (request
34
+ extras forbidden) from `mini/cowork/design/040-codegen-policies.md` plus
35
+ the two DevEx fixes above.
36
+
37
+ The transform is purely syntactic so we can run it on the codegen output
38
+ without parsing Python AST. Idempotent: re-runs upgrade an existing
39
+ ``model_config`` line in place if it's missing the populate_by_name flag
40
+ and skip unions already wrapped in ``Annotated[..., Field(...)]``.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import re
46
+ import sys
47
+ from pathlib import Path
48
+
49
+ # RootModel subclasses cannot set `extra='forbid'` (Pydantic raises
50
+ # `root-model-extra`), so skip them. Their behavior is governed by the
51
+ # inner type, which on its own enforces strict validation.
52
+ CLASS_RE = re.compile(r"^class\s+([A-Za-z_][\w]*)\s*\(\s*(BaseModel)\s*\)\s*:\s*$")
53
+ CONFIG_LINE = " model_config = ConfigDict(extra='forbid', populate_by_name=True)"
54
+
55
+ # Doc-banner injections keyed by class name. Inserted as a leading docstring
56
+ # inside the target class so the note shows up in IDE hovers and stays put
57
+ # across regeneration. Keep messages short and actionable; long-form
58
+ # documentation belongs in the API reference, not the generated source.
59
+ CLASS_BANNERS: dict[str, str] = {
60
+ "MonitorDto": (
61
+ "Note: ``currentStatus`` was removed from this DTO. "
62
+ "Inspect ``enabled`` and the incident-policy API to derive a "
63
+ "live status for a monitor instead."
64
+ ),
65
+ }
66
+
67
+
68
+ # StrEnum members that shadow inherited str methods need a `# type: ignore`
69
+ # because mypy thinks they're overriding the base method with an incompatible
70
+ # type. Listed explicitly so we get failures (instead of silent no-ops) when
71
+ # datamodel-codegen renames things.
72
+ STR_ENUM_COLLISIONS = {
73
+ # member name -> mypy ignore code
74
+ "count": "assignment",
75
+ "index": "assignment",
76
+ "title": "assignment",
77
+ "lower": "assignment",
78
+ "upper": "assignment",
79
+ "format": "assignment",
80
+ }
81
+
82
+ STR_ENUM_RE = re.compile(r"^class\s+([A-Za-z_][\w]*)\s*\(\s*StrEnum\s*\)\s*:\s*$")
83
+ STR_ENUM_MEMBER_RE = re.compile(r"^(\s+)([a-z_][\w]*)\s*=\s*(.+?)\s*$")
84
+
85
+
86
+ def inject(source: str) -> tuple[str, int]:
87
+ """Return (new_source, count_of_classes_modified)."""
88
+ if "from pydantic import" in source and "ConfigDict" not in source:
89
+ source = source.replace(
90
+ "from pydantic import",
91
+ "from pydantic import ConfigDict, ",
92
+ 1,
93
+ )
94
+ source = source.replace("ConfigDict, ConfigDict, ", "ConfigDict, ", 1)
95
+
96
+ lines = source.splitlines(keepends=True)
97
+ out: list[str] = []
98
+ i = 0
99
+ modified = 0
100
+ in_str_enum = False
101
+ while i < len(lines):
102
+ line = lines[i]
103
+ # Handle StrEnum-member collisions before the BaseModel pass below.
104
+ # We track whether we're inside a StrEnum body and patch any member
105
+ # whose name shadows an inherited str method.
106
+ if STR_ENUM_RE.match(line.rstrip("\n")):
107
+ in_str_enum = True
108
+ out.append(line)
109
+ i += 1
110
+ continue
111
+ if in_str_enum:
112
+ stripped = line.lstrip()
113
+ # End of class body: dedented non-blank line.
114
+ if stripped and not line.startswith((" ", "\t")):
115
+ in_str_enum = False
116
+ else:
117
+ m_member = STR_ENUM_MEMBER_RE.match(line.rstrip("\n"))
118
+ if m_member and m_member.group(2) in STR_ENUM_COLLISIONS:
119
+ code = STR_ENUM_COLLISIONS[m_member.group(2)]
120
+ if "type: ignore" not in line:
121
+ line = line.rstrip("\n") + f" # type: ignore[{code}]\n"
122
+ modified += 1
123
+ out.append(line)
124
+ i += 1
125
+ continue
126
+
127
+ out.append(line)
128
+ m = CLASS_RE.match(line.rstrip("\n"))
129
+ if not m:
130
+ i += 1
131
+ continue
132
+ class_name = m.group(1)
133
+ # Look at the very next line. If it's already model_config or pass,
134
+ # leave the class alone (idempotency / empty class).
135
+ next_idx = i + 1
136
+ next_line = lines[next_idx] if next_idx < len(lines) else ""
137
+ # Inject the class-level docstring banner if requested. Skip if a
138
+ # docstring is already present (idempotent on partial reruns).
139
+ banner = CLASS_BANNERS.get(class_name)
140
+ if banner and not next_line.lstrip().startswith(('"""', "'''")):
141
+ out.append(f' """{banner}"""\n')
142
+ modified += 1
143
+ if "model_config" in next_line:
144
+ # Upgrade the existing config line to include populate_by_name=True
145
+ # if it isn't already there. Idempotent across re-runs.
146
+ if "populate_by_name" not in next_line:
147
+ out.append(CONFIG_LINE + "\n")
148
+ i += 2 # replace the existing model_config line
149
+ modified += 1
150
+ continue
151
+ i += 1
152
+ continue
153
+ # Replace bare `pass` (empty class body) with model_config. Use
154
+ # exact match (NOT startswith) — fields like `passed: Annotated[...]`
155
+ # also start with "pass" but are not empty class markers.
156
+ if next_line.strip() in ("pass", "pass\n"):
157
+ out.append(CONFIG_LINE + "\n")
158
+ i += 2 # skip the pass
159
+ modified += 1
160
+ continue
161
+ out.append(CONFIG_LINE + "\n")
162
+ modified += 1
163
+ i += 1
164
+ return "".join(out), modified
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Discriminator injection on tagged unions
169
+ # ---------------------------------------------------------------------------
170
+
171
+ # Regex for the first line of a parenthesized union field declaration.
172
+ # Matches lines like `` config: (`` (with arbitrary indentation) and
173
+ # captures the indentation + field name so the closing paren is matched at
174
+ # the same level.
175
+ UNION_OPEN_RE = re.compile(r"^(\s+)(\w+): \(\s*$")
176
+ # Regex for the first concrete field in a class body that's a
177
+ # ``type: Literal[...]``-style discriminator tag. Captures the field name so
178
+ # we can reuse it as the Pydantic discriminator key (matches ``type``,
179
+ # ``channel_type``, ``check_type``, etc. — whichever the upstream OpenAPI
180
+ # spec used to mark the polymorphic tag).
181
+ DISC_FIELD_RE = re.compile(
182
+ r"^ (\w+): (?:Annotated\[\s*)?Literal\[[^\]]+\]"
183
+ r"(?:\s*,\s*Field\([^)]*\))?\s*\]?\s*=\s*"
184
+ )
185
+
186
+
187
+ def find_discriminators(source: str) -> dict[str, str]:
188
+ """Build ``{class_name: discriminator_field}`` for classes whose first
189
+ payload field is a single-member ``Literal[...]`` (the codegen pattern
190
+ for OpenAPI ``type``-style tags).
191
+
192
+ Only the *first* field after ``model_config`` counts: if a class doesn't
193
+ lead with a discriminator we treat it as untagged and skip it later.
194
+ This matches how the API actually models its sealed unions — every
195
+ polymorphic subtype starts with the tag field.
196
+ """
197
+ result: dict[str, str] = {}
198
+ lines = source.splitlines()
199
+ for i, line in enumerate(lines):
200
+ m = re.match(r"^class\s+(\w+)\s*\(\s*BaseModel\s*\)\s*:\s*$", line)
201
+ if not m:
202
+ continue
203
+ class_name = m.group(1)
204
+ # Walk class body looking for the first concrete field after
205
+ # ``model_config``. Skip blank lines and the model_config line
206
+ # itself; if the first real field is a Literal, that's the tag.
207
+ j = i + 1
208
+ while j < len(lines):
209
+ ln = lines[j]
210
+ if not ln.strip():
211
+ j += 1
212
+ continue
213
+ if ln.strip().startswith("model_config"):
214
+ j += 1
215
+ continue
216
+ mf = DISC_FIELD_RE.match(ln)
217
+ if mf:
218
+ result[class_name] = mf.group(1)
219
+ break
220
+ return result
221
+
222
+
223
+ def patch_unions(source: str, discriminators: dict[str, str]) -> tuple[str, int]:
224
+ """Wrap parenthesized union fields whose members all share the same
225
+ discriminator tag in ``Annotated[Union[...], Field(discriminator=...)]``.
226
+
227
+ Leaves untagged unions and mixed-tag unions alone — better to keep
228
+ permissive validation than to silently mis-route. ``ruff format``
229
+ re-flows the rewritten line afterwards so the file still satisfies
230
+ the formatter's line-length rules.
231
+ """
232
+ lines = source.splitlines(keepends=True)
233
+ out: list[str] = []
234
+ i = 0
235
+ modified = 0
236
+ while i < len(lines):
237
+ line = lines[i]
238
+ m = UNION_OPEN_RE.match(line)
239
+ if not m:
240
+ out.append(line)
241
+ i += 1
242
+ continue
243
+ indent = m.group(1)
244
+ field_name = m.group(2)
245
+ # Find the matching closing paren at the same indentation.
246
+ body: list[str] = []
247
+ j = i + 1
248
+ close_line: str | None = None
249
+ while j < len(lines):
250
+ ln = lines[j]
251
+ if ln.startswith(indent + ")"):
252
+ close_line = ln
253
+ break
254
+ body.append(ln)
255
+ j += 1
256
+ if close_line is None:
257
+ out.append(line)
258
+ i += 1
259
+ continue
260
+ # Parse union members: each line is like `` Foo`` or `` | Foo``.
261
+ members: list[str] = []
262
+ has_none = False
263
+ for bl in body:
264
+ content = bl.strip()
265
+ if content.startswith("|"):
266
+ content = content[1:].strip()
267
+ if not content:
268
+ continue
269
+ if content == "None":
270
+ has_none = True
271
+ continue
272
+ members.append(content)
273
+ # All members must be in our discriminator map and agree on the
274
+ # tag name. Otherwise leave the union untagged.
275
+ discs = {discriminators.get(name) for name in members}
276
+ if not members or None in discs or len(discs) != 1:
277
+ out.append(line)
278
+ i += 1
279
+ continue
280
+ disc_field = next(iter(discs))
281
+ union_str = " | ".join(members)
282
+ if has_none:
283
+ union_str += " | None"
284
+ new_annotation = (
285
+ f"{indent}{field_name}: Annotated[{union_str}, "
286
+ f"Field(discriminator={disc_field!r})]"
287
+ )
288
+ # Preserve any default value or trailing whitespace on the close line.
289
+ close_suffix = close_line[len(indent) + 1 :].rstrip("\n")
290
+ if close_suffix:
291
+ new_annotation += close_suffix
292
+ new_annotation += "\n"
293
+ out.append(new_annotation)
294
+ modified += 1
295
+ i = j + 1
296
+ return "".join(out), modified
297
+
298
+
299
+ def main() -> int:
300
+ if len(sys.argv) != 2:
301
+ print("usage: inject_strict_config.py <path-to-_generated.py>", file=sys.stderr)
302
+ return 1
303
+ path = Path(sys.argv[1])
304
+ if not path.exists():
305
+ print(f"error: file not found: {path}", file=sys.stderr)
306
+ return 1
307
+ src = path.read_text()
308
+ new_src, modified = inject(src)
309
+ discriminators = find_discriminators(new_src)
310
+ new_src, union_count = patch_unions(new_src, discriminators)
311
+ if new_src != src:
312
+ path.write_text(new_src)
313
+ print(
314
+ f"inject_strict_config: patched {modified} class(es) and "
315
+ f"{union_count} discriminated union(s) in {path}"
316
+ )
317
+ return 0
318
+
319
+
320
+ if __name__ == "__main__":
321
+ sys.exit(main())
@@ -1,5 +1,8 @@
1
1
  """DevHelm SDK for Python — typed client for monitors, incidents, alerting, and more."""
2
2
 
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _pkg_version
5
+
3
6
  from devhelm._errors import (
4
7
  DevhelmApiError,
5
8
  DevhelmAuthError,
@@ -133,7 +136,17 @@ from devhelm.types import (
133
136
  WebhookTestResult,
134
137
  )
135
138
 
139
+ try:
140
+ __version__ = _pkg_version("devhelm")
141
+ except PackageNotFoundError:
142
+ # Editable / source-tree install without dist-info — fall back to
143
+ # ``"unknown"`` rather than raising so downstream tooling that relies
144
+ # on ``devhelm.__version__`` keeps working in local development.
145
+ __version__ = "unknown"
146
+
136
147
  __all__ = [
148
+ # Version
149
+ "__version__",
137
150
  # Client
138
151
  "Devhelm",
139
152
  # Errors