econeval 0.3.1__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.
econeval/__init__.py ADDED
@@ -0,0 +1,85 @@
1
+ """EconEval: CI/CD checks for economic and policy models."""
2
+
3
+ from .cli import main, run_cli
4
+ from .config import (
5
+ DriftTest,
6
+ EconEvalConfig,
7
+ EconomicCheck,
8
+ EconomicDriftTest,
9
+ FairnessConfig,
10
+ InvariantRule,
11
+ StressTest,
12
+ load_config,
13
+ )
14
+ from .errors import ExecutionIssue
15
+ from .interop import ModelRuntime, describe_model_interface, resolve_model_runtime
16
+ from .invariants import (
17
+ InvariantResult,
18
+ evaluate_expression,
19
+ run_invariant,
20
+ run_invariant_suite,
21
+ suite_passed,
22
+ )
23
+ from .reporting import (
24
+ build_json_report,
25
+ write_dashboard_report,
26
+ write_html_report,
27
+ write_json_report,
28
+ write_markdown_report,
29
+ )
30
+ from .scenarios import (
31
+ DriftResult,
32
+ EconomicCheckResult,
33
+ EconomicDriftResult,
34
+ FairnessResult,
35
+ ScenarioResult,
36
+ economic_drift_suite_passed,
37
+ economic_suite_passed,
38
+ run_drift_suite,
39
+ run_economic_drift_suite,
40
+ run_economic_suite,
41
+ run_fairness_checks,
42
+ run_stress_suite,
43
+ )
44
+
45
+ __all__ = [
46
+ "__version__",
47
+ "build_json_report",
48
+ "write_dashboard_report",
49
+ "EconEvalConfig",
50
+ "EconomicCheck",
51
+ "InvariantResult",
52
+ "InvariantRule",
53
+ "ExecutionIssue",
54
+ "ModelRuntime",
55
+ "DriftResult",
56
+ "DriftTest",
57
+ "EconomicCheckResult",
58
+ "EconomicDriftResult",
59
+ "EconomicDriftTest",
60
+ "FairnessConfig",
61
+ "FairnessResult",
62
+ "ScenarioResult",
63
+ "StressTest",
64
+ "main",
65
+ "load_config",
66
+ "evaluate_expression",
67
+ "describe_model_interface",
68
+ "run_invariant",
69
+ "run_invariant_suite",
70
+ "run_cli",
71
+ "resolve_model_runtime",
72
+ "run_drift_suite",
73
+ "run_economic_drift_suite",
74
+ "run_economic_suite",
75
+ "run_fairness_checks",
76
+ "run_stress_suite",
77
+ "economic_suite_passed",
78
+ "economic_drift_suite_passed",
79
+ "suite_passed",
80
+ "write_json_report",
81
+ "write_markdown_report",
82
+ "write_html_report",
83
+ ]
84
+
85
+ __version__ = "0.3.0"
econeval/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Module entrypoint for `python -m econeval`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ raise SystemExit(main(sys.argv[1:]))
econeval/cli.py ADDED
@@ -0,0 +1,284 @@
1
+ """Command line interface for EconEval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib.util
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+ from time import perf_counter
11
+
12
+ from .config import load_config
13
+ from .errors import ExecutionIssue
14
+ from .invariants import run_invariant_suite, suite_passed
15
+ from .reporting import (
16
+ build_json_report,
17
+ write_dashboard_report,
18
+ write_github_step_summary,
19
+ write_html_report,
20
+ write_json_report,
21
+ write_junit_report,
22
+ write_markdown_report,
23
+ )
24
+ from .scenarios import (
25
+ drift_suite_passed,
26
+ economic_drift_suite_passed,
27
+ economic_suite_passed,
28
+ fairness_suite_passed,
29
+ run_drift_suite,
30
+ run_economic_drift_suite,
31
+ run_economic_suite,
32
+ run_fairness_checks,
33
+ run_stress_suite,
34
+ )
35
+ from .scenarios import (
36
+ suite_passed as stress_suite_passed,
37
+ )
38
+
39
+
40
+ def build_parser() -> argparse.ArgumentParser:
41
+ parser = argparse.ArgumentParser(
42
+ prog="econeval",
43
+ description="Run EconEval checks against a model.",
44
+ )
45
+ parser.add_argument(
46
+ "--config",
47
+ required=True,
48
+ help="Path to the EconEval YAML config.",
49
+ )
50
+ parser.add_argument(
51
+ "--model",
52
+ required=True,
53
+ help="Path to the Python file that defines the model.",
54
+ )
55
+ parser.add_argument(
56
+ "--class",
57
+ dest="class_name",
58
+ default="DemoModel",
59
+ help="Model class name to instantiate from the model file.",
60
+ )
61
+ parser.add_argument(
62
+ "--report",
63
+ default="econeval-report.json",
64
+ help="Where to write the JSON report.",
65
+ )
66
+ parser.add_argument(
67
+ "--format",
68
+ choices=("json", "junit", "markdown", "html", "dashboard"),
69
+ default="json",
70
+ help="Report format to write.",
71
+ )
72
+ parser.add_argument("--verbose", action="store_true", help="Print detailed progress messages.")
73
+ parser.add_argument("--quiet", action="store_true", help="Suppress non-error output.")
74
+ return parser
75
+
76
+
77
+ def load_model_class(model_path: str | Path, class_name: str):
78
+ """Load a model class from a Python file path."""
79
+
80
+ module_path = Path(model_path).resolve()
81
+ if not module_path.exists():
82
+ raise FileNotFoundError(f"model file not found: {module_path}")
83
+ module_name = f"econeval_model_{module_path.stem}"
84
+ spec = importlib.util.spec_from_file_location(module_name, module_path)
85
+ if spec is None or spec.loader is None:
86
+ raise ValueError(f"could not load model file: {module_path}")
87
+
88
+ module = importlib.util.module_from_spec(spec)
89
+ sys.modules[module_name] = module
90
+ spec.loader.exec_module(module)
91
+
92
+ try:
93
+ return getattr(module, class_name)
94
+ except AttributeError as exc:
95
+ available_classes = sorted(
96
+ name for name, value in vars(module).items() if isinstance(value, type)
97
+ )
98
+ suffix = f" available classes: {', '.join(available_classes)}" if available_classes else ""
99
+ raise ValueError(f"class {class_name!r} not found in {module_path}.{suffix}") from exc
100
+
101
+
102
+ def run_cli(argv: list[str] | None = None) -> int:
103
+ args = build_parser().parse_args(argv)
104
+
105
+ issues: list[ExecutionIssue] = []
106
+ log = _build_logger(args.verbose, args.quiet)
107
+
108
+ try:
109
+ log("loading config")
110
+ config = load_config(args.config)
111
+ except Exception as exc:
112
+ issues.append(ExecutionIssue(stage="config", message=str(exc), detail=str(args.config)))
113
+ report = build_json_report(
114
+ config=_placeholder_config(),
115
+ results=[],
116
+ scenario_results=[],
117
+ drift_results=[],
118
+ fairness_results=[],
119
+ issues=issues,
120
+ )
121
+ _write_report(args.format, args.report, report)
122
+ _write_step_summary(report)
123
+ _print_failure("config load", exc, args.quiet)
124
+ return 1
125
+
126
+ try:
127
+ log("loading model")
128
+ model_class = load_model_class(args.model, args.class_name)
129
+ model = model_class()
130
+ except Exception as exc:
131
+ issues.append(ExecutionIssue(stage="model", message=str(exc), detail=str(args.model)))
132
+ report = build_json_report(
133
+ config=config,
134
+ results=[],
135
+ scenario_results=[],
136
+ drift_results=[],
137
+ fairness_results=[],
138
+ issues=issues,
139
+ )
140
+ _write_report(args.format, args.report, report)
141
+ _write_step_summary(report)
142
+ _print_failure("model load", exc, args.quiet)
143
+ return 1
144
+
145
+ log("running invariants")
146
+ started_at = perf_counter()
147
+ results = run_invariant_suite(model, config.invariants)
148
+ _log_stage_summary(log, "invariants", results, perf_counter() - started_at)
149
+ log("running economic checks")
150
+ started_at = perf_counter()
151
+ economic_results = run_economic_suite(model, config.economic_checks)
152
+ _log_stage_summary(log, "economic checks", economic_results, perf_counter() - started_at)
153
+ log("running stress tests")
154
+ started_at = perf_counter()
155
+ scenario_results = run_stress_suite(
156
+ model,
157
+ config.stress_tests,
158
+ base_path=Path(args.config).parent,
159
+ )
160
+ _log_stage_summary(log, "stress tests", scenario_results, perf_counter() - started_at)
161
+ log("running drift checks")
162
+ started_at = perf_counter()
163
+ drift_results = run_drift_suite(config.drift_tests, base_path=Path(args.config).parent)
164
+ _log_stage_summary(log, "drift checks", drift_results, perf_counter() - started_at)
165
+ log("running economic drift checks")
166
+ started_at = perf_counter()
167
+ economic_drift_results = run_economic_drift_suite(
168
+ model,
169
+ config.economic_drift_tests,
170
+ base_path=Path(args.config).parent,
171
+ )
172
+ _log_stage_summary(
173
+ log,
174
+ "economic drift checks",
175
+ economic_drift_results,
176
+ perf_counter() - started_at,
177
+ )
178
+ fairness_results = []
179
+ if config.fairness.enabled and config.fairness.dataset:
180
+ log("running fairness checks")
181
+ started_at = perf_counter()
182
+ fairness_results = run_fairness_checks(
183
+ model,
184
+ config.fairness.dataset,
185
+ config.fairness.metrics,
186
+ group_column=config.fairness.group_column,
187
+ positive_threshold=config.fairness.positive_threshold,
188
+ actual_threshold=config.fairness.actual_threshold,
189
+ base_path=Path(args.config).parent,
190
+ )
191
+ _log_stage_summary(log, "fairness checks", fairness_results, perf_counter() - started_at)
192
+
193
+ report = build_json_report(
194
+ config,
195
+ results,
196
+ scenario_results,
197
+ drift_results,
198
+ fairness_results,
199
+ economic_results=economic_results,
200
+ economic_drift_results=economic_drift_results,
201
+ )
202
+ _write_report(args.format, args.report, report)
203
+ _write_step_summary(report)
204
+ log(f"wrote report to {args.report}")
205
+
206
+ status = report["summary"]["status"]
207
+ _print_summary(
208
+ status,
209
+ report["summary"]["passed"],
210
+ report["summary"]["total"],
211
+ args.quiet,
212
+ args.format,
213
+ )
214
+
215
+ return (
216
+ 0
217
+ if (
218
+ suite_passed(results)
219
+ and economic_suite_passed(economic_results)
220
+ and stress_suite_passed(scenario_results)
221
+ and drift_suite_passed(drift_results)
222
+ and economic_drift_suite_passed(economic_drift_results)
223
+ and fairness_suite_passed(fairness_results)
224
+ )
225
+ else 1
226
+ )
227
+
228
+
229
+ def _placeholder_config():
230
+ from .config import EconEvalConfig
231
+
232
+ return EconEvalConfig(project="unavailable")
233
+
234
+
235
+ def _write_report(report_format: str, path: str | Path, report: dict[str, object]) -> None:
236
+ writers = {
237
+ "json": write_json_report,
238
+ "junit": write_junit_report,
239
+ "markdown": write_markdown_report,
240
+ "html": write_html_report,
241
+ "dashboard": write_dashboard_report,
242
+ }
243
+ writers[report_format](path, report)
244
+
245
+
246
+ def _write_step_summary(report: dict[str, object]) -> None:
247
+ summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
248
+ if not summary_path:
249
+ return
250
+
251
+ write_github_step_summary(summary_path, report)
252
+
253
+
254
+ def _build_logger(verbose: bool, quiet: bool):
255
+ if quiet:
256
+ return lambda message: None
257
+ if not verbose:
258
+ return lambda message: None
259
+ return lambda message: print(f"[econeval] {message}")
260
+
261
+
262
+ def _log_stage_summary(log, stage: str, results, elapsed: float) -> None:
263
+ if not results:
264
+ log(f"{stage}: no checks configured ({elapsed:.2f}s)")
265
+ return
266
+ passed = sum(1 for result in results if getattr(result, "passed", False))
267
+ total = len(results)
268
+ log(f"{stage}: {passed}/{total} passed ({elapsed:.2f}s)")
269
+
270
+
271
+ def _print_summary(status: str, passed: int, total: int, quiet: bool, report_format: str) -> None:
272
+ if quiet:
273
+ return
274
+ print(f"EconEval: {status} ({passed}/{total} checks passed, format={report_format})")
275
+
276
+
277
+ def _print_failure(stage: str, exc: Exception, quiet: bool) -> None:
278
+ if quiet:
279
+ return
280
+ print(f"EconEval: fail ({stage}: {exc})")
281
+
282
+
283
+ def main(argv: list[str] | None = None) -> int:
284
+ return run_cli(argv)