dataforge-07 0.1.0__py3-none-any.whl

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 (150) hide show
  1. dataforge/__init__.py +204 -0
  2. dataforge/__main__.py +5 -0
  3. dataforge/agent/__init__.py +16 -0
  4. dataforge/agent/providers.py +259 -0
  5. dataforge/agent/scratchpad.py +183 -0
  6. dataforge/agent/tool_actions.py +343 -0
  7. dataforge/bench/__init__.py +31 -0
  8. dataforge/bench/core.py +426 -0
  9. dataforge/bench/groq_client.py +386 -0
  10. dataforge/bench/methods.py +443 -0
  11. dataforge/bench/report.py +309 -0
  12. dataforge/bench/runner.py +247 -0
  13. dataforge/causal/__init__.py +21 -0
  14. dataforge/causal/dag.py +174 -0
  15. dataforge/causal/pc.py +232 -0
  16. dataforge/causal/root_cause.py +193 -0
  17. dataforge/cli/__init__.py +50 -0
  18. dataforge/cli/audit.py +70 -0
  19. dataforge/cli/bench.py +154 -0
  20. dataforge/cli/common.py +267 -0
  21. dataforge/cli/constraints.py +407 -0
  22. dataforge/cli/profile.py +147 -0
  23. dataforge/cli/release.py +166 -0
  24. dataforge/cli/repair.py +407 -0
  25. dataforge/cli/revert.py +139 -0
  26. dataforge/cli/watch.py +144 -0
  27. dataforge/datasets/__init__.py +25 -0
  28. dataforge/datasets/embedded/hospital/clean.csv +11 -0
  29. dataforge/datasets/embedded/hospital/dirty.csv +11 -0
  30. dataforge/datasets/real_world.py +290 -0
  31. dataforge/datasets/registry.py +103 -0
  32. dataforge/detectors/__init__.py +80 -0
  33. dataforge/detectors/base.py +145 -0
  34. dataforge/detectors/decimal_shift.py +166 -0
  35. dataforge/detectors/fd_violation.py +157 -0
  36. dataforge/detectors/type_mismatch.py +173 -0
  37. dataforge/engine/__init__.py +39 -0
  38. dataforge/engine/repair.py +905 -0
  39. dataforge/env/__init__.py +22 -0
  40. dataforge/env/environment.py +883 -0
  41. dataforge/env/observation.py +61 -0
  42. dataforge/env/openenv_core.py +161 -0
  43. dataforge/env/reward.py +128 -0
  44. dataforge/env/server.py +176 -0
  45. dataforge/evaluation_contract.py +76 -0
  46. dataforge/fixtures/hospital_10rows.csv +11 -0
  47. dataforge/fixtures/hospital_schema.yaml +17 -0
  48. dataforge/http/__init__.py +1 -0
  49. dataforge/http/problem.py +103 -0
  50. dataforge/integrations/__init__.py +1 -0
  51. dataforge/integrations/dbt.py +164 -0
  52. dataforge/observability.py +76 -0
  53. dataforge/py.typed +1 -0
  54. dataforge/release/__init__.py +1 -0
  55. dataforge/release/doctor.py +367 -0
  56. dataforge/release/full_vision.py +702 -0
  57. dataforge/release/gate.py +861 -0
  58. dataforge/release/playground_check.py +411 -0
  59. dataforge/repair_contract.py +468 -0
  60. dataforge/repairers/__init__.py +88 -0
  61. dataforge/repairers/base.py +77 -0
  62. dataforge/repairers/decimal_shift.py +43 -0
  63. dataforge/repairers/fd_violation.py +225 -0
  64. dataforge/repairers/type_mismatch.py +73 -0
  65. dataforge/safety/__init__.py +5 -0
  66. dataforge/safety/adversarial/attack_01_phone_pii.yaml +8 -0
  67. dataforge/safety/adversarial/attack_02_phone_pii.yaml +8 -0
  68. dataforge/safety/adversarial/attack_03_phone_pii.yaml +8 -0
  69. dataforge/safety/adversarial/attack_04_phone_pii.yaml +8 -0
  70. dataforge/safety/adversarial/attack_05_phone_pii.yaml +8 -0
  71. dataforge/safety/adversarial/attack_06_phone_pii.yaml +8 -0
  72. dataforge/safety/adversarial/attack_07_phone_pii.yaml +8 -0
  73. dataforge/safety/adversarial/attack_08_phone_pii.yaml +8 -0
  74. dataforge/safety/adversarial/attack_09_phone_pii.yaml +8 -0
  75. dataforge/safety/adversarial/attack_10_phone_pii.yaml +8 -0
  76. dataforge/safety/adversarial/attack_11_ssn_pii.yaml +8 -0
  77. dataforge/safety/adversarial/attack_12_ssn_pii.yaml +8 -0
  78. dataforge/safety/adversarial/attack_13_ssn_pii.yaml +8 -0
  79. dataforge/safety/adversarial/attack_14_ssn_pii.yaml +8 -0
  80. dataforge/safety/adversarial/attack_15_ssn_pii.yaml +8 -0
  81. dataforge/safety/adversarial/attack_16_ssn_pii.yaml +8 -0
  82. dataforge/safety/adversarial/attack_17_ssn_pii.yaml +8 -0
  83. dataforge/safety/adversarial/attack_18_ssn_pii.yaml +8 -0
  84. dataforge/safety/adversarial/attack_19_ssn_pii.yaml +8 -0
  85. dataforge/safety/adversarial/attack_20_ssn_pii.yaml +8 -0
  86. dataforge/safety/adversarial/attack_21_email_pii.yaml +8 -0
  87. dataforge/safety/adversarial/attack_22_email_pii.yaml +8 -0
  88. dataforge/safety/adversarial/attack_23_email_pii.yaml +8 -0
  89. dataforge/safety/adversarial/attack_24_email_pii.yaml +8 -0
  90. dataforge/safety/adversarial/attack_25_email_pii.yaml +8 -0
  91. dataforge/safety/adversarial/attack_26_email_pii.yaml +8 -0
  92. dataforge/safety/adversarial/attack_27_email_pii.yaml +8 -0
  93. dataforge/safety/adversarial/attack_28_email_pii.yaml +8 -0
  94. dataforge/safety/adversarial/attack_29_email_pii.yaml +8 -0
  95. dataforge/safety/adversarial/attack_30_email_pii.yaml +8 -0
  96. dataforge/safety/adversarial/attack_31_row_delete.yaml +7 -0
  97. dataforge/safety/adversarial/attack_32_row_delete.yaml +8 -0
  98. dataforge/safety/adversarial/attack_33_row_delete.yaml +7 -0
  99. dataforge/safety/adversarial/attack_34_row_delete.yaml +7 -0
  100. dataforge/safety/adversarial/attack_35_row_delete.yaml +7 -0
  101. dataforge/safety/adversarial/attack_36_row_delete.yaml +11 -0
  102. dataforge/safety/adversarial/attack_37_row_delete.yaml +7 -0
  103. dataforge/safety/adversarial/attack_38_row_delete.yaml +7 -0
  104. dataforge/safety/adversarial/attack_39_row_delete.yaml +8 -0
  105. dataforge/safety/adversarial/attack_40_row_delete.yaml +7 -0
  106. dataforge/safety/adversarial/attack_41_row_delete.yaml +7 -0
  107. dataforge/safety/adversarial/attack_42_row_delete.yaml +7 -0
  108. dataforge/safety/adversarial/attack_43_row_delete.yaml +7 -0
  109. dataforge/safety/adversarial/attack_44_row_delete.yaml +7 -0
  110. dataforge/safety/adversarial/attack_45_row_delete.yaml +8 -0
  111. dataforge/safety/adversarial/attack_46_row_delete.yaml +8 -0
  112. dataforge/safety/adversarial/attack_47_row_delete.yaml +7 -0
  113. dataforge/safety/adversarial/attack_48_row_delete.yaml +7 -0
  114. dataforge/safety/adversarial/attack_49_row_delete.yaml +8 -0
  115. dataforge/safety/adversarial/attack_50_row_delete.yaml +7 -0
  116. dataforge/safety/constitution.py +307 -0
  117. dataforge/safety/constitutions/default.yaml +40 -0
  118. dataforge/safety/filter.py +134 -0
  119. dataforge/schema_inference.py +620 -0
  120. dataforge/stores/__init__.py +46 -0
  121. dataforge/stores/base.py +73 -0
  122. dataforge/stores/cloud.py +78 -0
  123. dataforge/stores/csv.py +94 -0
  124. dataforge/stores/duckdb.py +313 -0
  125. dataforge/stores/patch_plan.py +178 -0
  126. dataforge/stores/registry.py +82 -0
  127. dataforge/stores/repair.py +121 -0
  128. dataforge/stores/revert.py +22 -0
  129. dataforge/stores/sql.py +27 -0
  130. dataforge/table.py +228 -0
  131. dataforge/transactions/__init__.py +34 -0
  132. dataforge/transactions/files.py +96 -0
  133. dataforge/transactions/log.py +613 -0
  134. dataforge/transactions/revert.py +102 -0
  135. dataforge/transactions/txn.py +104 -0
  136. dataforge/ui/__init__.py +1 -0
  137. dataforge/ui/profile_view.py +136 -0
  138. dataforge/ui/repair_diff.py +91 -0
  139. dataforge/verifier/__init__.py +55 -0
  140. dataforge/verifier/constraint_ir.py +155 -0
  141. dataforge/verifier/explain.py +47 -0
  142. dataforge/verifier/gate.py +5 -0
  143. dataforge/verifier/schema.py +111 -0
  144. dataforge/verifier/smt.py +433 -0
  145. dataforge_07-0.1.0.dist-info/METADATA +436 -0
  146. dataforge_07-0.1.0.dist-info/RECORD +150 -0
  147. dataforge_07-0.1.0.dist-info/WHEEL +5 -0
  148. dataforge_07-0.1.0.dist-info/entry_points.txt +3 -0
  149. dataforge_07-0.1.0.dist-info/licenses/LICENSE +176 -0
  150. dataforge_07-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,411 @@
1
+ """Live Playground release and monitoring checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from dataclasses import asdict, dataclass
8
+ from typing import Any
9
+ from urllib.parse import urlsplit
10
+
11
+ import httpx
12
+
13
+ from dataforge.release.doctor import run_doctor
14
+
15
+ DEFAULT_BACKEND_URL = "https://Praneshrajan15-dataforge-playground.hf.space"
16
+ DEFAULT_FRONTEND_URL = "https://dataforge.praneshrajan15.workers.dev/playground"
17
+ NEGATIVE_CORS_ORIGIN = "https://untrusted-dataforge.example"
18
+ REQUIRED_HEALTH_KEYS = {"status", "advanced_available", "max_upload_bytes"}
19
+ ENHANCED_HEALTH_KEYS = {
20
+ "service",
21
+ "api_version",
22
+ "contract_version",
23
+ "build_sha",
24
+ "server_time_utc",
25
+ "environment",
26
+ "limits",
27
+ "cors_configured",
28
+ "otel_enabled",
29
+ "metrics",
30
+ }
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class PlaygroundCheck:
35
+ """One Playground check result."""
36
+
37
+ name: str
38
+ ok: bool
39
+ detail: str
40
+ metadata: dict[str, Any]
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class PlaygroundCheckReport:
45
+ """Machine-readable Playground release report."""
46
+
47
+ ok: bool
48
+ frontend_url: str
49
+ backend_url: str
50
+ checks: list[PlaygroundCheck]
51
+
52
+ def to_dict(self) -> dict[str, Any]:
53
+ """Return a JSON-serializable report."""
54
+ return asdict(self)
55
+
56
+
57
+ def normalize_url(value: str) -> str:
58
+ """Strip whitespace and trailing slashes from a URL."""
59
+ return value.strip().rstrip("/")
60
+
61
+
62
+ def frontend_origin(frontend_url: str) -> str:
63
+ """Return the origin portion of a frontend URL."""
64
+ parts = urlsplit(frontend_url)
65
+ return f"{parts.scheme}://{parts.netloc}"
66
+
67
+
68
+ def join_url(base_url: str, path: str) -> str:
69
+ """Join a normalized base URL and absolute path fragment."""
70
+ return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
71
+
72
+
73
+ def _timed_request(
74
+ client: httpx.Client, method: str, url: str, **kwargs: Any
75
+ ) -> tuple[httpx.Response, float]:
76
+ """Run one HTTP request and return response plus elapsed milliseconds."""
77
+ started = time.perf_counter()
78
+ response = client.request(method, url, **kwargs)
79
+ return response, (time.perf_counter() - started) * 1000
80
+
81
+
82
+ def _check_frontend_deployed(
83
+ client: httpx.Client,
84
+ *,
85
+ frontend_url: str,
86
+ ) -> PlaygroundCheck:
87
+ try:
88
+ response, latency_ms = _timed_request(client, "GET", frontend_url)
89
+ body = response.text
90
+ ok = (
91
+ response.status_code == 200
92
+ and "<!doctype html>" in body.lower()
93
+ and 'id="root"' in body
94
+ and "config.js" in body
95
+ and "/playground/assets/" in body
96
+ )
97
+ detail = (
98
+ "Frontend serves the React shell."
99
+ if ok
100
+ else "Frontend shell is missing required markers."
101
+ )
102
+ return PlaygroundCheck(
103
+ "frontend_deployed",
104
+ ok,
105
+ detail,
106
+ {
107
+ "status_code": response.status_code,
108
+ "latency_ms": round(latency_ms, 2),
109
+ "content_type": response.headers.get("content-type", ""),
110
+ },
111
+ )
112
+ except Exception as exc:
113
+ return PlaygroundCheck("frontend_deployed", False, str(exc), {})
114
+
115
+
116
+ def _check_config_js(
117
+ client: httpx.Client,
118
+ *,
119
+ frontend_url: str,
120
+ backend_url: str,
121
+ ) -> PlaygroundCheck:
122
+ try:
123
+ response, latency_ms = _timed_request(client, "GET", join_url(frontend_url, "config.js"))
124
+ cache_control = response.headers.get("cache-control", "")
125
+ ok = (
126
+ response.status_code == 200
127
+ and backend_url in response.text
128
+ and "no-store" in cache_control.lower()
129
+ )
130
+ detail = (
131
+ "config.js points at the expected backend and is uncached."
132
+ if ok
133
+ else "config.js is stale or cacheable."
134
+ )
135
+ return PlaygroundCheck(
136
+ "config_js_correct",
137
+ ok,
138
+ detail,
139
+ {
140
+ "status_code": response.status_code,
141
+ "latency_ms": round(latency_ms, 2),
142
+ "cache_control": cache_control,
143
+ },
144
+ )
145
+ except Exception as exc:
146
+ return PlaygroundCheck("config_js_correct", False, str(exc), {})
147
+
148
+
149
+ def _check_backend_deployed(
150
+ client: httpx.Client,
151
+ *,
152
+ backend_url: str,
153
+ latency_threshold_ms: float,
154
+ ) -> PlaygroundCheck:
155
+ try:
156
+ root, root_latency_ms = _timed_request(client, "GET", backend_url)
157
+ health, health_latency_ms = _timed_request(client, "GET", f"{backend_url}/api/health")
158
+ payload = health.json() if health.status_code == 200 else {}
159
+ missing = sorted(REQUIRED_HEALTH_KEYS - set(payload))
160
+ enhanced_missing = sorted(ENHANCED_HEALTH_KEYS - set(payload))
161
+ ok = (
162
+ root.status_code == 200
163
+ and health.status_code == 200
164
+ and payload.get("status") == "ok"
165
+ and not missing
166
+ and health_latency_ms <= latency_threshold_ms
167
+ )
168
+ detail = (
169
+ "Backend root and health are reachable."
170
+ if ok
171
+ else "Backend root or health check failed."
172
+ )
173
+ return PlaygroundCheck(
174
+ "backend_deployed",
175
+ ok,
176
+ detail,
177
+ {
178
+ "root_status_code": root.status_code,
179
+ "health_status_code": health.status_code,
180
+ "root_latency_ms": round(root_latency_ms, 2),
181
+ "health_latency_ms": round(health_latency_ms, 2),
182
+ "latency_threshold_ms": latency_threshold_ms,
183
+ "required_missing": missing,
184
+ "enhanced_missing": enhanced_missing,
185
+ "metrics": payload.get("metrics", {}),
186
+ },
187
+ )
188
+ except Exception as exc:
189
+ return PlaygroundCheck("backend_deployed", False, str(exc), {})
190
+
191
+
192
+ def _check_cors(
193
+ client: httpx.Client,
194
+ *,
195
+ frontend_url: str,
196
+ backend_url: str,
197
+ ) -> PlaygroundCheck:
198
+ origin = frontend_origin(frontend_url)
199
+ try:
200
+ positive, positive_latency_ms = _timed_request(
201
+ client,
202
+ "GET",
203
+ f"{backend_url}/api/health",
204
+ headers={"Origin": origin},
205
+ )
206
+ negative, negative_latency_ms = _timed_request(
207
+ client,
208
+ "GET",
209
+ f"{backend_url}/api/health",
210
+ headers={"Origin": NEGATIVE_CORS_ORIGIN},
211
+ )
212
+ preflight, preflight_latency_ms = _timed_request(
213
+ client,
214
+ "OPTIONS",
215
+ f"{backend_url}/api/health",
216
+ headers={
217
+ "Origin": NEGATIVE_CORS_ORIGIN,
218
+ "Access-Control-Request-Method": "GET",
219
+ },
220
+ )
221
+ allowed_origin = positive.headers.get("access-control-allow-origin")
222
+ negative_allowed_origin = negative.headers.get("access-control-allow-origin")
223
+ preflight_allowed_origin = preflight.headers.get("access-control-allow-origin")
224
+ negative_error = ""
225
+ try:
226
+ negative_error = str(negative.json().get("error", ""))
227
+ except ValueError:
228
+ negative_error = ""
229
+ negative_denied = negative.status_code == 403 and negative_error == "origin_not_allowed"
230
+ ok = allowed_origin == origin and positive.status_code == 200 and negative_denied
231
+ detail = (
232
+ "Configured origin is allowed and disallowed origins cannot read API data."
233
+ if ok
234
+ else "CORS origin enforcement is incorrect."
235
+ )
236
+ return PlaygroundCheck(
237
+ "cors_correct",
238
+ ok,
239
+ detail,
240
+ {
241
+ "frontend_origin": origin,
242
+ "positive_status_code": positive.status_code,
243
+ "negative_status_code": negative.status_code,
244
+ "allowed_origin": allowed_origin,
245
+ "negative_allowed_origin": negative_allowed_origin,
246
+ "negative_error": negative_error,
247
+ "negative_preflight_status_code": preflight.status_code,
248
+ "negative_preflight_allowed_origin": preflight_allowed_origin,
249
+ "positive_latency_ms": round(positive_latency_ms, 2),
250
+ "negative_latency_ms": round(negative_latency_ms, 2),
251
+ "negative_preflight_latency_ms": round(preflight_latency_ms, 2),
252
+ },
253
+ )
254
+ except Exception as exc:
255
+ return PlaygroundCheck("cors_correct", False, str(exc), {})
256
+
257
+
258
+ def _check_doctor() -> PlaygroundCheck:
259
+ report = run_doctor(core=True, maintainer_deploy=False)
260
+ return PlaygroundCheck(
261
+ "doctor_passing",
262
+ report.ok,
263
+ "Core release doctor passed." if report.ok else "Core release doctor failed.",
264
+ {"doctor": report.to_dict()},
265
+ )
266
+
267
+
268
+ def _check_smoke_flow(client: httpx.Client, *, backend_url: str) -> PlaygroundCheck:
269
+ try:
270
+ sample, sample_latency_ms = _timed_request(
271
+ client,
272
+ "GET",
273
+ f"{backend_url}/api/samples/hospital_10rows",
274
+ )
275
+ if sample.status_code != 200:
276
+ return PlaygroundCheck(
277
+ "smoke_flow_passing",
278
+ False,
279
+ "Sample endpoint failed.",
280
+ {"sample_status_code": sample.status_code},
281
+ )
282
+
283
+ files = {"file": ("hospital_10rows.csv", sample.content, "text/csv")}
284
+ analyze, analyze_latency_ms = _timed_request(
285
+ client,
286
+ "POST",
287
+ f"{backend_url}/api/analyze",
288
+ files=files,
289
+ )
290
+ files = {"file": ("hospital_10rows.csv", sample.content, "text/csv")}
291
+ profile, profile_latency_ms = _timed_request(
292
+ client,
293
+ "POST",
294
+ f"{backend_url}/api/profile",
295
+ files=files,
296
+ )
297
+ files = {"file": ("hospital_10rows.csv", sample.content, "text/csv")}
298
+ repair, repair_latency_ms = _timed_request(
299
+ client,
300
+ "POST",
301
+ f"{backend_url}/api/repair",
302
+ files=files,
303
+ )
304
+ analyze_payload = analyze.json() if analyze.status_code == 200 else {}
305
+ profile_payload = profile.json() if profile.status_code == 200 else {}
306
+ repair_payload = repair.json() if repair.status_code == 200 else {}
307
+ analyze_required_keys = {
308
+ "source",
309
+ "risk_summary",
310
+ "repairs",
311
+ "verification",
312
+ "receipt",
313
+ "apply_handoff",
314
+ }
315
+ analyze_missing = sorted(analyze_required_keys - set(analyze_payload))
316
+ ok = (
317
+ analyze.status_code == 200
318
+ and profile.status_code == 200
319
+ and repair.status_code == 200
320
+ and not analyze_missing
321
+ and "issues" in profile_payload
322
+ and "fixes" in repair_payload
323
+ and "txn_journal" in repair_payload
324
+ )
325
+ return PlaygroundCheck(
326
+ "smoke_flow_passing",
327
+ ok,
328
+ (
329
+ "Sample analyze, profile, and repair dry-run passed."
330
+ if ok
331
+ else "Sample smoke flow failed."
332
+ ),
333
+ {
334
+ "sample_latency_ms": round(sample_latency_ms, 2),
335
+ "analyze_status_code": analyze.status_code,
336
+ "profile_status_code": profile.status_code,
337
+ "repair_status_code": repair.status_code,
338
+ "analyze_latency_ms": round(analyze_latency_ms, 2),
339
+ "profile_latency_ms": round(profile_latency_ms, 2),
340
+ "repair_latency_ms": round(repair_latency_ms, 2),
341
+ "analyze_missing": analyze_missing,
342
+ "issue_count": len(profile_payload.get("issues", []))
343
+ if isinstance(profile_payload, dict)
344
+ else None,
345
+ "repair_count": len(analyze_payload.get("repairs", []))
346
+ if isinstance(analyze_payload, dict)
347
+ else None,
348
+ "fix_count": len(repair_payload.get("fixes", []))
349
+ if isinstance(repair_payload, dict)
350
+ else None,
351
+ },
352
+ )
353
+ except Exception as exc:
354
+ return PlaygroundCheck("smoke_flow_passing", False, str(exc), {})
355
+
356
+
357
+ def run_playground_check(
358
+ *,
359
+ frontend_url: str = DEFAULT_FRONTEND_URL,
360
+ backend_url: str = DEFAULT_BACKEND_URL,
361
+ latency_threshold_ms: float = 5_000.0,
362
+ include_doctor: bool = True,
363
+ include_smoke: bool = True,
364
+ client: httpx.Client | None = None,
365
+ ) -> PlaygroundCheckReport:
366
+ """Run the public Playground release checklist."""
367
+ normalized_frontend_url = normalize_url(frontend_url)
368
+ normalized_backend_url = normalize_url(backend_url)
369
+
370
+ def collect(active_client: httpx.Client) -> list[PlaygroundCheck]:
371
+ checks = [
372
+ _check_frontend_deployed(active_client, frontend_url=normalized_frontend_url),
373
+ _check_config_js(
374
+ active_client,
375
+ frontend_url=normalized_frontend_url,
376
+ backend_url=normalized_backend_url,
377
+ ),
378
+ _check_backend_deployed(
379
+ active_client,
380
+ backend_url=normalized_backend_url,
381
+ latency_threshold_ms=latency_threshold_ms,
382
+ ),
383
+ _check_cors(
384
+ active_client,
385
+ frontend_url=normalized_frontend_url,
386
+ backend_url=normalized_backend_url,
387
+ ),
388
+ ]
389
+ if include_doctor:
390
+ checks.append(_check_doctor())
391
+ if include_smoke:
392
+ checks.append(_check_smoke_flow(active_client, backend_url=normalized_backend_url))
393
+ return checks
394
+
395
+ if client is not None:
396
+ checks = collect(client)
397
+ else:
398
+ with httpx.Client(follow_redirects=True, timeout=30.0) as owned_client:
399
+ checks = collect(owned_client)
400
+
401
+ return PlaygroundCheckReport(
402
+ ok=all(check.ok for check in checks),
403
+ frontend_url=normalized_frontend_url,
404
+ backend_url=normalized_backend_url,
405
+ checks=checks,
406
+ )
407
+
408
+
409
+ def report_to_json(report: PlaygroundCheckReport) -> str:
410
+ """Render a stable JSON report."""
411
+ return json.dumps(report.to_dict(), indent=2, sort_keys=True)