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.
- {devhelm-0.6.2 → devhelm-0.6.3}/PKG-INFO +1 -1
- {devhelm-0.6.2 → devhelm-0.6.3}/docs/openapi/monitoring-api.json +15 -7
- {devhelm-0.6.2 → devhelm-0.6.3}/pyproject.toml +1 -1
- devhelm-0.6.3/scripts/inject_strict_config.py +321 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/__init__.py +13 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/_generated.py +383 -369
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/_pagination.py +28 -5
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/monitors.py +90 -6
- {devhelm-0.6.2 → devhelm-0.6.3}/tests/test_client.py +100 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/uv.lock +1 -1
- devhelm-0.6.2/scripts/inject_strict_config.py +0 -134
- {devhelm-0.6.2 → devhelm-0.6.3}/.github/workflows/ci.yml +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/.github/workflows/release.yml +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/.github/workflows/spec-check.yml +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/.gitignore +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/LICENSE +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/Makefile +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/README.md +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/scripts/regen-from.sh +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/scripts/release.sh +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/scripts/typegen.sh +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/_errors.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/_http.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/_validation.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/client.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/py.typed +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/__init__.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/alert_channels.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/api_keys.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/dependencies.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/deploy_lock.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/environments.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/forensics.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/incidents.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/notification_policies.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/resource_groups.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/secrets.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/status.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/status_pages.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/tags.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/resources/webhooks.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/src/devhelm/types.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/tests/__init__.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/tests/run_sdk.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/tests/test_errors.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/tests/test_http.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/tests/test_negative_validation.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/tests/test_schemas.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/tests/test_spec_parity.py +0 -0
- {devhelm-0.6.2 → devhelm-0.6.3}/tests/test_typing.py +0 -0
- {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.
|
|
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": "
|
|
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": "
|
|
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
|
|
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": {
|
|
@@ -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
|