jeval 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.
jeval/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Evaluate JSON values against expected checks."""
2
+
3
+ from jeval.case import Case, Check, CaseError
4
+ from jeval.evaluate import evaluate, get_path, PathError
5
+ from jeval.result import CheckResult, Result
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = [
9
+ "Case",
10
+ "CaseError",
11
+ "Check",
12
+ "CheckResult",
13
+ "PathError",
14
+ "Result",
15
+ "evaluate",
16
+ "get_path",
17
+ "__version__",
18
+ ]
jeval/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from jeval.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
jeval/case.py ADDED
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ OPS = (
8
+ "eq",
9
+ "neq",
10
+ "contains",
11
+ "regex",
12
+ "exists",
13
+ "type",
14
+ "gt",
15
+ "gte",
16
+ "lt",
17
+ "lte",
18
+ "in",
19
+ "length",
20
+ )
21
+
22
+
23
+ class CaseError(ValueError):
24
+ """Raised when a case or check document is invalid."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Check:
29
+ """A single assertion against a path in the actual value."""
30
+
31
+ op: str
32
+ path: str = "$"
33
+ value: Any = None
34
+ message: str | None = None
35
+
36
+ def __post_init__(self) -> None:
37
+ if self.op not in OPS:
38
+ raise CaseError(f"unknown check op {self.op!r}; expected one of {', '.join(OPS)}")
39
+
40
+ @classmethod
41
+ def from_dict(cls, data: dict[str, Any]) -> Check:
42
+ if not isinstance(data, dict):
43
+ raise CaseError("check must be an object")
44
+ if "op" not in data:
45
+ raise CaseError("check is missing 'op'")
46
+ unknown = set(data) - {"op", "path", "value", "message"}
47
+ if unknown:
48
+ raise CaseError(f"check has unknown fields: {', '.join(sorted(unknown))}")
49
+ return cls(
50
+ op=str(data["op"]),
51
+ path=str(data.get("path", "$")),
52
+ value=data.get("value"),
53
+ message=data.get("message"),
54
+ )
55
+
56
+ def to_dict(self) -> dict[str, Any]:
57
+ payload: dict[str, Any] = {"op": self.op, "path": self.path}
58
+ if self.value is not None:
59
+ payload["value"] = self.value
60
+ if self.message:
61
+ payload["message"] = self.message
62
+ return payload
63
+
64
+
65
+ @dataclass
66
+ class Case:
67
+ """One evaluation case: optional expected value plus checks."""
68
+
69
+ id: str
70
+ checks: list[Check] = field(default_factory=list)
71
+ expected: Any = None
72
+ actual: Any = None
73
+ metadata: dict[str, Any] = field(default_factory=dict)
74
+
75
+ @classmethod
76
+ def from_dict(cls, data: dict[str, Any], default_id: str = "case") -> Case:
77
+ if not isinstance(data, dict):
78
+ raise CaseError("case must be an object")
79
+ raw_checks = data.get("checks", [])
80
+ if raw_checks is None:
81
+ raw_checks = []
82
+ if not isinstance(raw_checks, list):
83
+ raise CaseError("checks must be a list")
84
+ metadata = data.get("metadata") or {}
85
+ if not isinstance(metadata, dict):
86
+ raise CaseError("metadata must be an object")
87
+ unknown = set(data) - {"id", "checks", "expected", "actual", "metadata", "name"}
88
+ if unknown:
89
+ raise CaseError(f"case has unknown fields: {', '.join(sorted(unknown))}")
90
+ case_id = str(data.get("id") or data.get("name") or default_id)
91
+ return cls(
92
+ id=case_id,
93
+ checks=[Check.from_dict(item) for item in raw_checks],
94
+ expected=data.get("expected"),
95
+ actual=data.get("actual"),
96
+ metadata=metadata,
97
+ )
98
+
99
+ def to_dict(self) -> dict[str, Any]:
100
+ payload: dict[str, Any] = {"id": self.id}
101
+ if self.checks:
102
+ payload["checks"] = [check.to_dict() for check in self.checks]
103
+ if self.expected is not None:
104
+ payload["expected"] = self.expected
105
+ if self.actual is not None:
106
+ payload["actual"] = self.actual
107
+ if self.metadata:
108
+ payload["metadata"] = self.metadata
109
+ return payload
jeval/cli.py ADDED
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any, TextIO
8
+
9
+ from jeval import __version__
10
+ from jeval.case import Case, CaseError
11
+ from jeval.evaluate import evaluate
12
+ from jeval.result import Result
13
+
14
+
15
+ def main(argv: list[str] | None = None) -> int:
16
+ parser = argparse.ArgumentParser(
17
+ prog="jeval",
18
+ description="Evaluate one JSON case against an actual value.",
19
+ )
20
+ parser.add_argument("case", help="Path to a JSON case file")
21
+ parser.add_argument(
22
+ "--actual",
23
+ help="Actual JSON value (string). Overrides actual in the case file.",
24
+ )
25
+ parser.add_argument(
26
+ "--actual-file",
27
+ help="Path to a JSON file with the actual value.",
28
+ )
29
+ parser.add_argument(
30
+ "--json",
31
+ action="store_true",
32
+ dest="as_json",
33
+ help="Print the result as JSON.",
34
+ )
35
+ parser.add_argument("--version", action="version", version=f"jeval {__version__}")
36
+ args = parser.parse_args(argv)
37
+
38
+ try:
39
+ case = _load_case(Path(args.case))
40
+ actual = _load_actual(args.actual, args.actual_file)
41
+ result = evaluate(case, actual) if actual is not _UNSET else evaluate(case)
42
+ except (OSError, json.JSONDecodeError, CaseError, ValueError) as exc:
43
+ print(f"jeval: {exc}", file=sys.stderr)
44
+ return 2
45
+
46
+ _print_result(result, as_json=args.as_json, stream=sys.stdout)
47
+ return 0 if result.ok else 1
48
+
49
+
50
+ _UNSET = object()
51
+
52
+
53
+ def _load_case(path: Path) -> Case:
54
+ data = json.loads(path.read_text(encoding="utf-8"))
55
+ if isinstance(data, list):
56
+ raise CaseError("jeval expects a single case object, not a list; use jevals for suites")
57
+ return Case.from_dict(data, default_id=path.stem)
58
+
59
+
60
+ def _load_actual(actual: str | None, actual_file: str | None) -> Any:
61
+ if actual and actual_file:
62
+ raise ValueError("use either --actual or --actual-file, not both")
63
+ if actual is not None:
64
+ return json.loads(actual)
65
+ if actual_file is not None:
66
+ return json.loads(Path(actual_file).read_text(encoding="utf-8"))
67
+ return _UNSET
68
+
69
+
70
+ def _print_result(result: Result, as_json: bool, stream: TextIO) -> None:
71
+ if as_json:
72
+ json.dump(result.to_dict(), stream, indent=2)
73
+ stream.write("\n")
74
+ return
75
+ stream.write(result.summary() + "\n")
jeval/evaluate.py ADDED
@@ -0,0 +1,233 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from jeval.case import Case, Check
7
+ from jeval.result import CheckResult, Result
8
+
9
+ _MISSING = object()
10
+
11
+ _TYPE_NAMES = {
12
+ "string": str,
13
+ "str": str,
14
+ "number": (int, float),
15
+ "int": int,
16
+ "float": float,
17
+ "boolean": bool,
18
+ "bool": bool,
19
+ "null": type(None),
20
+ "array": list,
21
+ "list": list,
22
+ "object": dict,
23
+ "dict": dict,
24
+ }
25
+
26
+
27
+ class PathError(KeyError):
28
+ """Raised when a dotted path cannot be resolved."""
29
+
30
+
31
+ def get_path(data: Any, path: str) -> Any:
32
+ """Return the value at a dotted path (`$`, `$.a.b`, `items.0.name`)."""
33
+ if path in ("", "$", ".", "$."):
34
+ return data
35
+ if path.startswith("$."):
36
+ path = path[2:]
37
+ elif path.startswith("$"):
38
+ path = path[1:]
39
+ current = data
40
+ walked: list[str] = []
41
+ for part in path.split("."):
42
+ if part == "":
43
+ continue
44
+ walked.append(part)
45
+ current = _step(current, part, ".".join(walked))
46
+ return current
47
+
48
+
49
+ def _step(current: Any, part: str, display: str) -> Any:
50
+ if isinstance(current, dict):
51
+ if part not in current:
52
+ raise PathError(f"path {display!r} not found")
53
+ return current[part]
54
+ if isinstance(current, list):
55
+ if not part.isdigit():
56
+ raise PathError(f"path {display!r} is not a list index")
57
+ index = int(part)
58
+ if index >= len(current):
59
+ raise PathError(f"path {display!r} is out of range")
60
+ return current[index]
61
+ raise PathError(f"path {display!r} cannot be resolved on {type(current).__name__}")
62
+
63
+
64
+ def evaluate(case: Case, actual: Any | None = _MISSING) -> Result:
65
+ """Run a case's checks against `actual` (or `case.actual`)."""
66
+ if actual is _MISSING:
67
+ value = case.actual
68
+ else:
69
+ value = actual
70
+
71
+ checks = list(case.checks)
72
+ if not checks:
73
+ checks = [Check(op="eq", path="$", value=case.expected)]
74
+
75
+ results = [_run_check(check, value) for check in checks]
76
+ return Result(case_id=case.id, ok=all(item.ok for item in results), checks=results)
77
+
78
+
79
+ def _run_check(check: Check, actual_root: Any) -> CheckResult:
80
+ try:
81
+ if check.op == "exists":
82
+ try:
83
+ get_path(actual_root, check.path)
84
+ present = True
85
+ except PathError:
86
+ present = False
87
+ expected = True if check.value is None else bool(check.value)
88
+ ok = present is expected
89
+ message = (
90
+ f"{check.op} {check.path} {'present' if present else 'missing'}"
91
+ if ok
92
+ else f"{check.op} {check.path} expected {'present' if expected else 'missing'}, got {'present' if present else 'missing'}"
93
+ )
94
+ return CheckResult(
95
+ op=check.op,
96
+ path=check.path,
97
+ ok=ok,
98
+ message=check.message or message,
99
+ expected=expected,
100
+ actual=present,
101
+ )
102
+
103
+ observed = get_path(actual_root, check.path)
104
+ except PathError as exc:
105
+ return CheckResult(
106
+ op=check.op,
107
+ path=check.path,
108
+ ok=False,
109
+ message=check.message or str(exc),
110
+ expected=check.value,
111
+ actual=None,
112
+ )
113
+
114
+ ok, detail = _compare(check.op, observed, check.value)
115
+ if ok:
116
+ message = detail or f"{check.op} {check.path}"
117
+ else:
118
+ message = detail or (
119
+ f"{check.op} {check.path} expected {check.value!r}, got {observed!r}"
120
+ )
121
+ return CheckResult(
122
+ op=check.op,
123
+ path=check.path,
124
+ ok=ok,
125
+ message=check.message or message,
126
+ expected=check.value,
127
+ actual=observed,
128
+ )
129
+
130
+
131
+ def _compare(op: str, observed: Any, expected: Any) -> tuple[bool, str]:
132
+ if op == "eq":
133
+ ok = observed == expected
134
+ return ok, f"eq {observed!r} == {expected!r}" if ok else f"eq expected {expected!r}, got {observed!r}"
135
+ if op == "neq":
136
+ ok = observed != expected
137
+ return ok, f"neq {observed!r} != {expected!r}" if ok else f"neq expected not {expected!r}, got {observed!r}"
138
+ if op == "contains":
139
+ ok = _contains(observed, expected)
140
+ return ok, (
141
+ f"contains {expected!r} in {observed!r}"
142
+ if ok
143
+ else f"contains expected {expected!r} in {observed!r}"
144
+ )
145
+ if op == "in":
146
+ try:
147
+ ok = observed in expected
148
+ except TypeError:
149
+ ok = False
150
+ return ok, (
151
+ f"in {observed!r} in {expected!r}"
152
+ if ok
153
+ else f"in expected {observed!r} to be in {expected!r}"
154
+ )
155
+ if op == "regex":
156
+ if not isinstance(observed, str) or not isinstance(expected, str):
157
+ return False, "regex requires string actual and value"
158
+ ok = re.search(expected, observed) is not None
159
+ return ok, (
160
+ f"regex {expected!r} matched {observed!r}"
161
+ if ok
162
+ else f"regex {expected!r} did not match {observed!r}"
163
+ )
164
+ if op == "type":
165
+ name = str(expected).lower()
166
+ if name not in _TYPE_NAMES:
167
+ return False, f"type unknown type name {expected!r}"
168
+ ok = isinstance(observed, _TYPE_NAMES[name]) and not (
169
+ name in {"number", "int"} and isinstance(observed, bool)
170
+ )
171
+ if name == "number" and isinstance(observed, bool):
172
+ ok = False
173
+ return ok, (
174
+ f"type {check_type_name(observed)} is {name}"
175
+ if ok
176
+ else f"type expected {name}, got {check_type_name(observed)}"
177
+ )
178
+ if op in {"gt", "gte", "lt", "lte"}:
179
+ try:
180
+ ops = {
181
+ "gt": observed > expected,
182
+ "gte": observed >= expected,
183
+ "lt": observed < expected,
184
+ "lte": observed <= expected,
185
+ }
186
+ ok = bool(ops[op])
187
+ except TypeError:
188
+ return False, f"{op} cannot compare {observed!r} with {expected!r}"
189
+ return ok, (
190
+ f"{op} {observed!r} {op} {expected!r}"
191
+ if ok
192
+ else f"{op} expected {observed!r} {op} {expected!r}"
193
+ )
194
+ if op == "length":
195
+ try:
196
+ length = len(observed)
197
+ except TypeError:
198
+ return False, f"length {type(observed).__name__} has no length"
199
+ ok = length == expected
200
+ return ok, (
201
+ f"length {length} == {expected}"
202
+ if ok
203
+ else f"length expected {expected}, got {length}"
204
+ )
205
+ return False, f"unknown op {op!r}"
206
+
207
+
208
+ def check_type_name(value: Any) -> str:
209
+ if value is None:
210
+ return "null"
211
+ if isinstance(value, bool):
212
+ return "boolean"
213
+ if isinstance(value, int):
214
+ return "int"
215
+ if isinstance(value, float):
216
+ return "float"
217
+ if isinstance(value, str):
218
+ return "string"
219
+ if isinstance(value, list):
220
+ return "array"
221
+ if isinstance(value, dict):
222
+ return "object"
223
+ return type(value).__name__
224
+
225
+
226
+ def _contains(observed: Any, expected: Any) -> bool:
227
+ if isinstance(observed, str):
228
+ return str(expected) in observed
229
+ if isinstance(observed, dict):
230
+ return expected in observed
231
+ if isinstance(observed, list):
232
+ return expected in observed
233
+ return False
jeval/py.typed ADDED
File without changes
jeval/result.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class CheckResult:
9
+ op: str
10
+ path: str
11
+ ok: bool
12
+ message: str
13
+ expected: Any = None
14
+ actual: Any = None
15
+
16
+ def to_dict(self) -> dict[str, Any]:
17
+ return {
18
+ "op": self.op,
19
+ "path": self.path,
20
+ "ok": self.ok,
21
+ "message": self.message,
22
+ "expected": self.expected,
23
+ "actual": self.actual,
24
+ }
25
+
26
+
27
+ @dataclass
28
+ class Result:
29
+ case_id: str
30
+ ok: bool
31
+ checks: list[CheckResult] = field(default_factory=list)
32
+
33
+ @property
34
+ def failed(self) -> list[CheckResult]:
35
+ return [item for item in self.checks if not item.ok]
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ return {
39
+ "id": self.case_id,
40
+ "ok": self.ok,
41
+ "checks": [item.to_dict() for item in self.checks],
42
+ "failed": [item.to_dict() for item in self.failed],
43
+ }
44
+
45
+ def summary(self) -> str:
46
+ if self.ok:
47
+ return f"PASS {self.case_id}"
48
+ lines = [f"FAIL {self.case_id}"]
49
+ for item in self.failed:
50
+ lines.append(f" {item.message}")
51
+ return "\n".join(lines)
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.5
2
+ Name: jeval
3
+ Version: 0.1.0
4
+ Summary: Evaluate JSON values against expected checks.
5
+ Project-URL: Homepage, https://pypi.org/project/jeval/
6
+ Project-URL: Issues, https://pypi.org/project/jeval/
7
+ Author-email: Gabriel Tinoco <gabriel@openlayer.com>
8
+ License-Expression: MIT
9
+ Keywords: assertions,eval,evaluation,json,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
+ Description-Content-Type: text/markdown
21
+
22
+ # jeval
23
+
24
+ Evaluate a JSON value against expected checks. One case, one result.
25
+
26
+ ```bash
27
+ pip install jeval
28
+ ```
29
+
30
+ ```python
31
+ from jeval import Case, Check, evaluate
32
+
33
+ result = evaluate(
34
+ Case(
35
+ id="order-total",
36
+ checks=[Check(path="total", op="eq", value=21)],
37
+ ),
38
+ actual={"total": 21, "currency": "USD"},
39
+ )
40
+ assert result.ok
41
+ ```
42
+
43
+ ```bash
44
+ jeval examples/case.json --actual '{"total": 21}'
45
+ ```
46
+
47
+ A case with no `checks` defaults to deep equality against `expected`.
48
+
49
+ See **jevals** if you want to run a directory or file of cases and get a score.
@@ -0,0 +1,11 @@
1
+ jeval/__init__.py,sha256=eq2eYz2yyGQtuj77r1ED-izIZkhgM9W0IMh1N0ysALs,381
2
+ jeval/__main__.py,sha256=ziduDfLD69A0J6gnaIW9iwmk_SgFXUDiXmQOo--CNjw,84
3
+ jeval/case.py,sha256=3LdPFOr67pmZMR7QHxRBYgyEmVCWcHCopTarQTrJMO4,3347
4
+ jeval/cli.py,sha256=Vwn6bbX06yXfTPBdBhNBEOVdkDd-S5vJUyDLRu2OEcE,2358
5
+ jeval/evaluate.py,sha256=rPl9xAd1iyUWXlpKEINOGHZJob7nZBA5-7EDomzci2Q,7347
6
+ jeval/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ jeval/result.py,sha256=MqxX6PblDu_eEF1gOxTHPv7-0eaxoXNkgyUpMZtagQs,1257
8
+ jeval-0.1.0.dist-info/METADATA,sha256=ftJSEb-mESPfB-Cd_8FB0Amnt63NwOLYZSWWkdEYB6E,1411
9
+ jeval-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
10
+ jeval-0.1.0.dist-info/entry_points.txt,sha256=rDC_Rtrn5u8J7vPBTagnWtBjMqHFm1TetYXaRfpALDA,41
11
+ jeval-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jeval = jeval.cli:main