jevals 0.1.0__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.
@@ -0,0 +1,26 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ *.egg-info/
8
+ .eggs/
9
+ dist/
10
+ build/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .coverage
15
+ htmlcov/
16
+ .venv/
17
+ venv/
18
+
19
+ # Tools
20
+ *.log
21
+
22
+ # Editors
23
+ .idea/
24
+ .vscode/
25
+ *.swp
26
+ .DS_Store
jevals-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.5
2
+ Name: jevals
3
+ Version: 0.1.0
4
+ Summary: Run suites of JSON evaluations and report a score.
5
+ Project-URL: Homepage, https://pypi.org/project/jevals/
6
+ Project-URL: Issues, https://pypi.org/project/jevals/
7
+ Author-email: Gabriel Tinoco <gabriel@openlayer.com>
8
+ License-Expression: MIT
9
+ Keywords: eval,evaluation,jeval,json,suite,testing
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Testing
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: jeval>=0.1.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # jevals
24
+
25
+ Run a suite of JSON evaluation cases and report a score.
26
+
27
+ ```bash
28
+ pip install jevals
29
+ ```
30
+
31
+ `jevals` depends on [`jeval`](https://pypi.org/project/jeval/) for the per-case checks.
32
+
33
+ ```bash
34
+ jevals run examples/suite.json
35
+ jevals run examples/cases
36
+ ```
37
+
38
+ A suite is a JSON object with `cases`, a JSON list of cases, or a directory of `.json` files:
39
+
40
+ ```json
41
+ {
42
+ "name": "checkout",
43
+ "cases": [
44
+ {
45
+ "id": "order-total",
46
+ "actual": {"total": 21, "currency": "USD"},
47
+ "checks": [
48
+ {"path": "total", "op": "eq", "value": 21},
49
+ {"path": "currency", "op": "eq", "value": "USD"}
50
+ ]
51
+ }
52
+ ]
53
+ }
54
+ ```
55
+
56
+ Exit codes: `0` all passed, `1` some failed, `2` usage or file error.
57
+
58
+ ```bash
59
+ jevals run examples/suite.json --json
60
+ jevals run examples/suite.json --fail-under 1.0
61
+ ```
jevals-0.1.0/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # jevals
2
+
3
+ Run a suite of JSON evaluation cases and report a score.
4
+
5
+ ```bash
6
+ pip install jevals
7
+ ```
8
+
9
+ `jevals` depends on [`jeval`](https://pypi.org/project/jeval/) for the per-case checks.
10
+
11
+ ```bash
12
+ jevals run examples/suite.json
13
+ jevals run examples/cases
14
+ ```
15
+
16
+ A suite is a JSON object with `cases`, a JSON list of cases, or a directory of `.json` files:
17
+
18
+ ```json
19
+ {
20
+ "name": "checkout",
21
+ "cases": [
22
+ {
23
+ "id": "order-total",
24
+ "actual": {"total": 21, "currency": "USD"},
25
+ "checks": [
26
+ {"path": "total", "op": "eq", "value": 21},
27
+ {"path": "currency", "op": "eq", "value": "USD"}
28
+ ]
29
+ }
30
+ ]
31
+ }
32
+ ```
33
+
34
+ Exit codes: `0` all passed, `1` some failed, `2` usage or file error.
35
+
36
+ ```bash
37
+ jevals run examples/suite.json --json
38
+ jevals run examples/suite.json --fail-under 1.0
39
+ ```
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "jevals"
7
+ version = "0.1.0"
8
+ description = "Run suites of JSON evaluations and report a score."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Gabriel Tinoco", email = "gabriel@openlayer.com" },
14
+ ]
15
+ keywords = ["eval", "evaluation", "json", "testing", "suite", "jeval"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Software Development :: Testing",
26
+ ]
27
+ dependencies = [
28
+ "jeval>=0.1.0",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://pypi.org/project/jevals/"
33
+ Issues = "https://pypi.org/project/jevals/"
34
+
35
+ [project.scripts]
36
+ jevals = "jevals.cli:main"
@@ -0,0 +1,6 @@
1
+ """Run suites of JSON evaluations and report a score."""
2
+
3
+ from jevals.suite import Suite, SuiteResult, load_suite
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["Suite", "SuiteResult", "load_suite", "__version__"]
@@ -0,0 +1,4 @@
1
+ from jevals.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import TextIO
8
+
9
+ from jeval import CaseError
10
+
11
+ from jevals import __version__
12
+ from jevals.suite import load_actuals, load_suite
13
+
14
+
15
+ def main(argv: list[str] | None = None) -> int:
16
+ parser = argparse.ArgumentParser(
17
+ prog="jevals",
18
+ description="Run a suite of JSON evaluation cases and report a score.",
19
+ )
20
+ sub = parser.add_subparsers(dest="command")
21
+
22
+ run = sub.add_parser("run", help="Run a suite file or directory of cases")
23
+ run.add_argument("path", help="Suite JSON file or directory of .json cases")
24
+ run.add_argument(
25
+ "--actuals",
26
+ help="JSON file mapping case id to actual value (overrides case actuals).",
27
+ )
28
+ run.add_argument("--json", action="store_true", dest="as_json", help="Print JSON.")
29
+ run.add_argument(
30
+ "--fail-under",
31
+ type=float,
32
+ default=1.0,
33
+ help="Fail unless score (passed/total) is at least this value. Default: 1.0",
34
+ )
35
+
36
+ parser.add_argument("--version", action="version", version=f"jevals {__version__}")
37
+ args = parser.parse_args(argv)
38
+
39
+ if args.command != "run":
40
+ parser.print_help()
41
+ return 2
42
+
43
+ try:
44
+ suite = load_suite(Path(args.path))
45
+ actuals = load_actuals(Path(args.actuals)) if args.actuals else None
46
+ result = suite.run(actuals)
47
+ except (OSError, json.JSONDecodeError, CaseError, ValueError) as exc:
48
+ print(f"jevals: {exc}", file=sys.stderr)
49
+ return 2
50
+
51
+ _print(result, as_json=args.as_json, stream=sys.stdout)
52
+ if result.total == 0:
53
+ return 2
54
+ if result.score < args.fail_under:
55
+ return 1
56
+ return 0
57
+
58
+
59
+ def _print(result, as_json: bool, stream: TextIO) -> None:
60
+ if as_json:
61
+ json.dump(result.to_dict(), stream, indent=2)
62
+ stream.write("\n")
63
+ return
64
+ stream.write(result.summary() + "\n")
File without changes
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from jeval import Case, CaseError, Result, evaluate
9
+
10
+
11
+ @dataclass
12
+ class Suite:
13
+ name: str
14
+ cases: list[Case]
15
+
16
+ def run(self, actuals: dict[str, Any] | None = None) -> SuiteResult:
17
+ results: list[Result] = []
18
+ for case in self.cases:
19
+ if actuals is None or case.id not in actuals:
20
+ results.append(evaluate(case))
21
+ else:
22
+ results.append(evaluate(case, actuals[case.id]))
23
+ return SuiteResult(name=self.name, results=results)
24
+
25
+
26
+ @dataclass
27
+ class SuiteResult:
28
+ name: str
29
+ results: list[Result] = field(default_factory=list)
30
+
31
+ @property
32
+ def passed(self) -> int:
33
+ return sum(1 for item in self.results if item.ok)
34
+
35
+ @property
36
+ def failed(self) -> int:
37
+ return sum(1 for item in self.results if not item.ok)
38
+
39
+ @property
40
+ def total(self) -> int:
41
+ return len(self.results)
42
+
43
+ @property
44
+ def ok(self) -> bool:
45
+ return self.total > 0 and self.failed == 0
46
+
47
+ @property
48
+ def score(self) -> float:
49
+ if self.total == 0:
50
+ return 0.0
51
+ return self.passed / self.total
52
+
53
+ def to_dict(self) -> dict[str, Any]:
54
+ return {
55
+ "name": self.name,
56
+ "ok": self.ok,
57
+ "passed": self.passed,
58
+ "failed": self.failed,
59
+ "total": self.total,
60
+ "score": self.score,
61
+ "results": [item.to_dict() for item in self.results],
62
+ }
63
+
64
+ def summary(self) -> str:
65
+ if self.total == 0:
66
+ return f"{self.name}: no cases"
67
+ status = "PASS" if self.ok else "FAIL"
68
+ lines = [
69
+ f"{status} {self.name} {self.passed} passed, {self.failed} failed ({self.score:.0%})"
70
+ ]
71
+ for item in self.results:
72
+ if item.ok:
73
+ lines.append(f" PASS {item.case_id}")
74
+ else:
75
+ lines.append(f" FAIL {item.case_id}")
76
+ for check in item.failed:
77
+ lines.append(f" {check.message}")
78
+ return "\n".join(lines)
79
+
80
+
81
+ def load_suite(path: Path) -> Suite:
82
+ path = path.resolve()
83
+ if path.is_dir():
84
+ cases = _load_dir(path)
85
+ return Suite(name=path.name, cases=cases)
86
+ data = json.loads(path.read_text(encoding="utf-8"))
87
+ return _suite_from_data(data, default_name=path.stem, source=path)
88
+
89
+
90
+ def _load_dir(path: Path) -> list[Case]:
91
+ files = sorted(item for item in path.glob("*.json") if item.is_file())
92
+ if not files:
93
+ raise CaseError(f"no .json cases in {path}")
94
+ cases: list[Case] = []
95
+ for file in files:
96
+ data = json.loads(file.read_text(encoding="utf-8"))
97
+ cases.extend(_cases_from_data(data, default_id=file.stem, source=file))
98
+ return cases
99
+
100
+
101
+ def _suite_from_data(data: Any, default_name: str, source: Path) -> Suite:
102
+ if isinstance(data, list):
103
+ return Suite(
104
+ name=default_name,
105
+ cases=_cases_from_data(data, default_id=default_name, source=source),
106
+ )
107
+ if not isinstance(data, dict):
108
+ raise CaseError(f"{source} must be a suite object or a list of cases")
109
+ if "cases" in data:
110
+ name = str(data.get("name") or data.get("id") or default_name)
111
+ raw_cases = data["cases"]
112
+ if not isinstance(raw_cases, list):
113
+ raise CaseError("cases must be a list")
114
+ cases = [
115
+ Case.from_dict(item, default_id=f"{name}-{index}")
116
+ if isinstance(item, dict)
117
+ else _reject_case(item)
118
+ for index, item in enumerate(raw_cases, start=1)
119
+ ]
120
+ return Suite(name=name, cases=cases)
121
+ return Suite(name=default_name, cases=[Case.from_dict(data, default_id=default_name)])
122
+
123
+
124
+ def _cases_from_data(data: Any, default_id: str, source: Path) -> list[Case]:
125
+ if isinstance(data, list):
126
+ return [
127
+ Case.from_dict(item, default_id=f"{default_id}-{index}")
128
+ if isinstance(item, dict)
129
+ else _reject_case(item)
130
+ for index, item in enumerate(data, start=1)
131
+ ]
132
+ if isinstance(data, dict) and "cases" in data:
133
+ return _suite_from_data(data, default_name=default_id, source=source).cases
134
+ if isinstance(data, dict):
135
+ return [Case.from_dict(data, default_id=default_id)]
136
+ raise CaseError(f"{source} is not a valid case or suite")
137
+
138
+
139
+ def _reject_case(item: Any) -> Case:
140
+ raise CaseError(f"case must be an object, got {type(item).__name__}")
141
+
142
+
143
+ def load_actuals(path: Path) -> dict[str, Any]:
144
+ data = json.loads(path.read_text(encoding="utf-8"))
145
+ if isinstance(data, dict):
146
+ return data
147
+ if isinstance(data, list):
148
+ actuals: dict[str, Any] = {}
149
+ for item in data:
150
+ if not isinstance(item, dict) or "id" not in item:
151
+ raise CaseError("actuals list items must be objects with an 'id'")
152
+ case_id = str(item["id"])
153
+ if "actual" in item:
154
+ actuals[case_id] = item["actual"]
155
+ elif "value" in item:
156
+ actuals[case_id] = item["value"]
157
+ else:
158
+ raise CaseError(f"actuals item {case_id!r} is missing 'actual'")
159
+ return actuals
160
+ raise CaseError("actuals must be an object mapping case id to value, or a list")
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from jevals.cli import main
7
+
8
+
9
+ def test_cli_run(tmp_path: Path) -> None:
10
+ suite = tmp_path / "suite.json"
11
+ suite.write_text(
12
+ json.dumps(
13
+ {
14
+ "name": "demo",
15
+ "cases": [
16
+ {
17
+ "id": "ok",
18
+ "actual": {"n": 1},
19
+ "checks": [{"path": "n", "op": "eq", "value": 1}],
20
+ }
21
+ ],
22
+ }
23
+ ),
24
+ encoding="utf-8",
25
+ )
26
+ assert main(["run", str(suite)]) == 0
27
+ assert main(["run", str(suite), "--json"]) == 0
28
+
29
+
30
+ def test_cli_fail_under(tmp_path: Path) -> None:
31
+ suite = tmp_path / "suite.json"
32
+ suite.write_text(
33
+ json.dumps(
34
+ {
35
+ "cases": [
36
+ {"id": "a", "actual": 1, "expected": 1},
37
+ {"id": "b", "actual": 1, "expected": 2},
38
+ ]
39
+ }
40
+ ),
41
+ encoding="utf-8",
42
+ )
43
+ assert main(["run", str(suite)]) == 1
44
+ assert main(["run", str(suite), "--fail-under", "0.4"]) == 0
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from jevals.suite import Suite, load_actuals, load_suite
7
+ from jeval import Case, Check
8
+
9
+
10
+ def test_suite_score(tmp_path: Path) -> None:
11
+ suite_path = tmp_path / "suite.json"
12
+ suite_path.write_text(
13
+ json.dumps(
14
+ {
15
+ "name": "demo",
16
+ "cases": [
17
+ {
18
+ "id": "ok",
19
+ "actual": {"n": 1},
20
+ "checks": [{"path": "n", "op": "eq", "value": 1}],
21
+ },
22
+ {
23
+ "id": "bad",
24
+ "actual": {"n": 2},
25
+ "checks": [{"path": "n", "op": "eq", "value": 1}],
26
+ },
27
+ ],
28
+ }
29
+ ),
30
+ encoding="utf-8",
31
+ )
32
+ result = load_suite(suite_path).run()
33
+ assert result.passed == 1
34
+ assert result.failed == 1
35
+ assert result.score == 0.5
36
+ assert not result.ok
37
+
38
+
39
+ def test_directory_suite(tmp_path: Path) -> None:
40
+ cases = tmp_path / "cases"
41
+ cases.mkdir()
42
+ (cases / "one.json").write_text(
43
+ json.dumps({"id": "one", "actual": {"ok": True}, "expected": {"ok": True}}),
44
+ encoding="utf-8",
45
+ )
46
+ result = load_suite(cases).run()
47
+ assert result.ok
48
+ assert result.total == 1
49
+
50
+
51
+ def test_actuals_override() -> None:
52
+ suite = Suite(
53
+ name="override",
54
+ cases=[Case(id="n", checks=[Check(path="$", op="eq", value=3)], actual=1)],
55
+ )
56
+ assert not suite.run().ok
57
+ assert suite.run({"n": 3}).ok
58
+
59
+
60
+ def test_load_actuals_list(tmp_path: Path) -> None:
61
+ path = tmp_path / "actuals.json"
62
+ path.write_text(json.dumps([{"id": "n", "actual": 3}]), encoding="utf-8")
63
+ assert load_actuals(path) == {"n": 3}