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 +18 -0
- jeval/__main__.py +4 -0
- jeval/case.py +109 -0
- jeval/cli.py +75 -0
- jeval/evaluate.py +233 -0
- jeval/py.typed +0 -0
- jeval/result.py +51 -0
- jeval-0.1.0.dist-info/METADATA +49 -0
- jeval-0.1.0.dist-info/RECORD +11 -0
- jeval-0.1.0.dist-info/WHEEL +4 -0
- jeval-0.1.0.dist-info/entry_points.txt +2 -0
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
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,,
|