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,367 @@
1
+ """Release doctor checks for DataForge public-surface gates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import tomllib
11
+ from dataclasses import asdict, dataclass
12
+ from importlib import metadata as importlib_metadata
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ EXPECTED_HF_USER = "Praneshrajan15"
17
+ DEFAULT_KAGGLE_CREDENTIALS = Path.home() / ".kaggle" / "credentials.json"
18
+ STALE_KAGGLE_JSON = Path.home() / ".kaggle" / "kaggle.json"
19
+ CORE_DISTRIBUTION = "dataforge_07"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class DoctorCheck:
24
+ """One release doctor check result."""
25
+
26
+ name: str
27
+ ok: bool
28
+ detail: str
29
+ metadata: dict[str, Any]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class DoctorReport:
34
+ """Machine-readable release doctor report."""
35
+
36
+ ok: bool
37
+ checks: list[DoctorCheck]
38
+ scopes: list[str]
39
+ secrets_printed: bool = False
40
+
41
+ def to_dict(self) -> dict[str, Any]:
42
+ """Return a JSON-serializable report."""
43
+ return asdict(self)
44
+
45
+
46
+ def _check_hugging_face() -> DoctorCheck:
47
+ try:
48
+ from huggingface_hub import HfApi, get_token
49
+ except ImportError as exc:
50
+ return DoctorCheck("hugging_face", False, f"huggingface_hub missing: {exc}", {})
51
+
52
+ token_present = bool(get_token())
53
+ if not token_present:
54
+ return DoctorCheck(
55
+ "hugging_face",
56
+ False,
57
+ "No cached Hugging Face token found.",
58
+ {"expected_user": EXPECTED_HF_USER, "token_present": False},
59
+ )
60
+ try:
61
+ info = HfApi(token=get_token()).whoami()
62
+ except Exception as exc:
63
+ return DoctorCheck(
64
+ "hugging_face",
65
+ False,
66
+ f"Could not resolve Hugging Face identity: {exc}",
67
+ {"expected_user": EXPECTED_HF_USER, "token_present": True},
68
+ )
69
+ user = str(info.get("name", ""))
70
+ return DoctorCheck(
71
+ "hugging_face",
72
+ user == EXPECTED_HF_USER,
73
+ "Authenticated with expected Hugging Face user."
74
+ if user == EXPECTED_HF_USER
75
+ else f"Authenticated as {user!r}, expected {EXPECTED_HF_USER!r}.",
76
+ {"user": user, "expected_user": EXPECTED_HF_USER, "token_present": True},
77
+ )
78
+
79
+
80
+ def _load_kaggle_oauth(path: Path) -> dict[str, Any]:
81
+ if path.name == "kaggle.json":
82
+ raise RuntimeError(
83
+ f"Refusing to read stale legacy Kaggle API key file: {path}. "
84
+ f"Use OAuth credentials at {DEFAULT_KAGGLE_CREDENTIALS}."
85
+ )
86
+ if not path.exists():
87
+ raise RuntimeError(f"Missing Kaggle OAuth credentials: {path}")
88
+ payload = json.loads(path.read_text(encoding="utf-8-sig"))
89
+ if not isinstance(payload, dict):
90
+ raise RuntimeError("Kaggle OAuth credentials must be a JSON object.")
91
+ required = {"refresh_token", "access_token", "access_token_expiration", "username", "scopes"}
92
+ missing = sorted(required - set(payload))
93
+ if missing:
94
+ raise RuntimeError("Kaggle OAuth credentials missing fields: " + ", ".join(missing))
95
+ if not isinstance(payload.get("username"), str) or not payload["username"]:
96
+ raise RuntimeError("Kaggle OAuth credentials are missing username.")
97
+ scopes = payload.get("scopes")
98
+ if not isinstance(scopes, list) or not scopes:
99
+ raise RuntimeError("Kaggle OAuth credentials are missing scopes.")
100
+ return payload
101
+
102
+
103
+ def _check_kaggle_oauth(credentials_path: Path = DEFAULT_KAGGLE_CREDENTIALS) -> DoctorCheck:
104
+ try:
105
+ payload = _load_kaggle_oauth(credentials_path)
106
+ except Exception as exc:
107
+ return DoctorCheck(
108
+ "kaggle_oauth", False, str(exc), {"credential_path": str(credentials_path)}
109
+ )
110
+ return DoctorCheck(
111
+ "kaggle_oauth",
112
+ True,
113
+ "Kaggle OAuth credentials are present and legacy key is ignored.",
114
+ {
115
+ "credential_path": str(credentials_path),
116
+ "username": payload["username"],
117
+ "scopes_count": len(payload["scopes"]),
118
+ "legacy_kaggle_json_exists": STALE_KAGGLE_JSON.exists(),
119
+ "legacy_kaggle_json_used": False,
120
+ "tokens_printed": False,
121
+ },
122
+ )
123
+
124
+
125
+ def _check_kaggle_cli_clean_config(
126
+ credentials_path: Path = DEFAULT_KAGGLE_CREDENTIALS,
127
+ ) -> DoctorCheck:
128
+ """Verify Kaggle CLI auth through OAuth under a clean config directory."""
129
+ script = _project_root() / "scripts" / "preflight" / "check_kaggle_auth.py"
130
+ if not script.exists():
131
+ return DoctorCheck(
132
+ "kaggle_cli_clean_config",
133
+ False,
134
+ f"Kaggle auth preflight script not found: {script}",
135
+ {"credential_path": str(credentials_path), "tokens_printed": False},
136
+ )
137
+ command = [
138
+ sys.executable,
139
+ str(script),
140
+ "--kaggle-json",
141
+ str(credentials_path),
142
+ "--check-cli",
143
+ ]
144
+ try:
145
+ result = subprocess.run(
146
+ command,
147
+ cwd=_project_root(),
148
+ capture_output=True,
149
+ text=True,
150
+ encoding="utf-8",
151
+ errors="replace",
152
+ timeout=90,
153
+ check=False,
154
+ )
155
+ except subprocess.TimeoutExpired:
156
+ return DoctorCheck(
157
+ "kaggle_cli_clean_config",
158
+ False,
159
+ "Kaggle CLI clean-config OAuth preflight timed out.",
160
+ {"credential_path": str(credentials_path), "tokens_printed": False},
161
+ )
162
+ ok = result.returncode == 0
163
+ detail = (
164
+ "Kaggle CLI read-only command succeeded with clean KAGGLE_CONFIG_DIR."
165
+ if ok
166
+ else "Kaggle CLI clean-config OAuth preflight failed."
167
+ )
168
+ return DoctorCheck(
169
+ "kaggle_cli_clean_config",
170
+ ok,
171
+ detail,
172
+ {
173
+ "credential_path": str(credentials_path),
174
+ "command": " ".join(command),
175
+ "tokens_printed": False,
176
+ },
177
+ )
178
+
179
+
180
+ def _check_cloudflare() -> DoctorCheck:
181
+ npx = shutil.which("npx") or shutil.which("npx.cmd")
182
+ if npx is None:
183
+ return DoctorCheck("cloudflare", False, "npx/wrangler not available on PATH.", {})
184
+ env_file_path = ""
185
+ try:
186
+ with tempfile.NamedTemporaryFile(delete=False) as env_file:
187
+ env_file_path = env_file.name
188
+ command = [npx, "wrangler", "whoami", "--env-file", env_file_path]
189
+ try:
190
+ result = subprocess.run(
191
+ command,
192
+ capture_output=True,
193
+ text=True,
194
+ encoding="utf-8",
195
+ errors="replace",
196
+ timeout=60,
197
+ check=False,
198
+ )
199
+ finally:
200
+ if env_file_path:
201
+ Path(env_file_path).unlink(missing_ok=True)
202
+ except FileNotFoundError:
203
+ return DoctorCheck("cloudflare", False, "npx/wrangler not available on PATH.", {})
204
+ except subprocess.TimeoutExpired:
205
+ return DoctorCheck("cloudflare", False, "wrangler whoami timed out.", {})
206
+
207
+ output = result.stdout + result.stderr
208
+ ok = result.returncode == 0 and "logged in" in output.lower()
209
+ has_route_scope = "workers_routes (write)" in output
210
+ return DoctorCheck(
211
+ "cloudflare",
212
+ ok and has_route_scope,
213
+ "Wrangler OAuth is logged in with Workers route scope."
214
+ if ok and has_route_scope
215
+ else "Wrangler is not logged in with the required Workers route scope.",
216
+ {
217
+ "wrangler_available": result.returncode == 0,
218
+ "logged_in": ok,
219
+ "workers_routes_write": has_route_scope,
220
+ "command": f"{npx} wrangler whoami --env-file <empty-env-file>",
221
+ "env_file_override": "empty",
222
+ },
223
+ )
224
+
225
+
226
+ def _project_root() -> Path:
227
+ """Return the repository root for local core release checks."""
228
+ return Path(__file__).resolve().parents[2]
229
+
230
+
231
+ def _check_package_boundary() -> DoctorCheck:
232
+ """Verify the core wheel only includes the public DataForge namespace."""
233
+ pyproject_path = _project_root() / "pyproject.toml"
234
+ if not pyproject_path.exists():
235
+ try:
236
+ distribution = importlib_metadata.distribution(CORE_DISTRIBUTION)
237
+ top_level = distribution.read_text("top_level.txt") or ""
238
+ except importlib_metadata.PackageNotFoundError as exc:
239
+ return DoctorCheck(
240
+ "core_package_boundary",
241
+ False,
242
+ f"Could not read installed package metadata: {exc}",
243
+ {"distribution": CORE_DISTRIBUTION},
244
+ )
245
+ top_level_packages = sorted(line.strip() for line in top_level.splitlines() if line.strip())
246
+ expected_top_level = ["dataforge"]
247
+ return DoctorCheck(
248
+ "core_package_boundary",
249
+ top_level_packages == expected_top_level,
250
+ "Installed wheel exposes only the dataforge top-level package."
251
+ if top_level_packages == expected_top_level
252
+ else (
253
+ "Installed wheel exposes top-level packages "
254
+ f"{top_level_packages!r}, expected {expected_top_level!r}."
255
+ ),
256
+ {"top_level": top_level_packages, "expected": expected_top_level},
257
+ )
258
+
259
+ try:
260
+ pyproject = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
261
+ include = pyproject["tool"]["setuptools"]["packages"]["find"]["include"]
262
+ except Exception as exc:
263
+ return DoctorCheck(
264
+ "core_package_boundary",
265
+ False,
266
+ f"Could not read package include contract: {exc}",
267
+ {"pyproject": str(pyproject_path)},
268
+ )
269
+ expected = ["dataforge", "dataforge.*"]
270
+ return DoctorCheck(
271
+ "core_package_boundary",
272
+ include == expected,
273
+ "Core wheel includes only the dataforge namespace."
274
+ if include == expected
275
+ else f"Core wheel package include list is {include!r}, expected {expected!r}.",
276
+ {"include": include, "expected": expected},
277
+ )
278
+
279
+
280
+ def _check_packaged_core_files() -> DoctorCheck:
281
+ """Verify files required by source-install and wheel smoke commands exist."""
282
+ root = _project_root()
283
+ required = [
284
+ root / "dataforge" / "py.typed",
285
+ root / "dataforge" / "fixtures" / "hospital_10rows.csv",
286
+ root / "dataforge" / "fixtures" / "hospital_schema.yaml",
287
+ root / "dataforge" / "safety" / "constitutions" / "default.yaml",
288
+ ]
289
+ missing = [str(path.relative_to(root)) for path in required if not path.exists()]
290
+ return DoctorCheck(
291
+ "core_packaged_files",
292
+ not missing,
293
+ "Core package data files required by smoke commands are present."
294
+ if not missing
295
+ else "Missing core package data files: " + ", ".join(missing),
296
+ {"missing": missing},
297
+ )
298
+
299
+
300
+ def _core_checks() -> list[DoctorCheck]:
301
+ """Return local OSS release checks that do not require personal accounts."""
302
+ return [_check_package_boundary(), _check_packaged_core_files()]
303
+
304
+
305
+ def _maintainer_deploy_checks(kaggle_credentials: Path) -> list[DoctorCheck]:
306
+ """Return maintainer-specific deploy/auth checks."""
307
+ return [
308
+ _check_hugging_face(),
309
+ _check_kaggle_oauth(kaggle_credentials),
310
+ _check_kaggle_cli_clean_config(kaggle_credentials),
311
+ _check_cloudflare(),
312
+ ]
313
+
314
+
315
+ def run_doctor(
316
+ *,
317
+ kaggle_credentials: Path = DEFAULT_KAGGLE_CREDENTIALS,
318
+ core: bool = True,
319
+ maintainer_deploy: bool = False,
320
+ ) -> DoctorReport:
321
+ """Run selected release doctor checks."""
322
+ checks: list[DoctorCheck] = []
323
+ scopes: list[str] = []
324
+ if core:
325
+ checks.extend(_core_checks())
326
+ scopes.append("core")
327
+ if maintainer_deploy:
328
+ checks.extend(_maintainer_deploy_checks(kaggle_credentials))
329
+ scopes.append("maintainer_deploy")
330
+ return DoctorReport(
331
+ ok=all(check.ok for check in checks),
332
+ checks=checks,
333
+ scopes=scopes,
334
+ )
335
+
336
+
337
+ def main(argv: list[str] | None = None) -> int:
338
+ """Script entrypoint used by CI and local release work."""
339
+ import argparse
340
+
341
+ parser = argparse.ArgumentParser(description=__doc__)
342
+ parser.add_argument("--json", action="store_true", help="Print JSON instead of text.")
343
+ parser.add_argument("--kaggle-credentials", type=Path, default=DEFAULT_KAGGLE_CREDENTIALS)
344
+ parser.add_argument("--core", action="store_true", help="Run OSS core release checks.")
345
+ parser.add_argument(
346
+ "--maintainer-deploy",
347
+ action="store_true",
348
+ help="Run maintainer-specific deploy/auth checks.",
349
+ )
350
+ args = parser.parse_args(argv)
351
+ core = args.core or not args.maintainer_deploy
352
+ report = run_doctor(
353
+ kaggle_credentials=args.kaggle_credentials,
354
+ core=core,
355
+ maintainer_deploy=args.maintainer_deploy,
356
+ )
357
+ if args.json:
358
+ print(json.dumps(report.to_dict(), indent=2, sort_keys=True))
359
+ else:
360
+ for check in report.checks:
361
+ status = "ok" if check.ok else "fail"
362
+ print(f"{status:4} {check.name}: {check.detail}")
363
+ return 0 if report.ok else 2
364
+
365
+
366
+ if __name__ == "__main__":
367
+ raise SystemExit(main(sys.argv[1:]))