ezvals 0.2.0__py3-none-win_amd64.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.
ezvals/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ from typing import Any, Callable, TypedDict
2
+
3
+ from ezvals.cli import run
4
+ from ezvals.context import EvalContext, EvalResult, TraceData
5
+ from ezvals.evals import eval, run_evals
6
+
7
+
8
+ class EvalCase(TypedDict, total=False):
9
+ """An item of @eval(cases=[...]). Fields override the decorator's values for that case."""
10
+ id: str
11
+ input: Any
12
+ reference: Any
13
+ metadata: dict
14
+ dataset: str
15
+ labels: list
16
+ default_score_key: str
17
+ timeout: float
18
+ target: Callable
19
+ evaluators: list
20
+ trials: int
21
+
22
+
23
+ __all__ = ["eval", "EvalContext", "EvalResult", "TraceData", "EvalCase", "run_evals", "run"]
ezvals/bin/ezvals.exe ADDED
Binary file
ezvals/cli.py ADDED
@@ -0,0 +1,33 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ BINARY = Path(__file__).parent / "bin" / ("ezvals.exe" if os.name == "nt" else "ezvals")
9
+
10
+
11
+ def main():
12
+ """`ezvals` console script: hand off to the bundled host binary."""
13
+ env = {**os.environ, "EZVALS_PYTHON": sys.executable}
14
+ if os.name == "nt":
15
+ sys.exit(subprocess.run([str(BINARY), *sys.argv[1:]], env=env).returncode)
16
+ os.execve(BINARY, [str(BINARY), *sys.argv[1:]], env)
17
+
18
+
19
+ def run(path: str, dataset: Optional[str] = None, labels: Optional[list] = None, limit: Optional[int] = None,
20
+ output: Optional[str] = None, concurrency: Optional[int] = None, timeout: Optional[float] = None,
21
+ trials: Optional[int] = None, session: Optional[str] = None, run_name: Optional[str] = None, no_save: bool = False,
22
+ config: Optional[str] = None) -> dict:
23
+ """Programmatic `ezvals run`. Returns the run (results, totals, ids) plus `saved_path`."""
24
+ flags = {"--dataset": dataset, "--limit": limit, "--output": output, "--concurrency": concurrency,
25
+ "--timeout": timeout, "--trials": trials, "--session": session, "--run-name": run_name, "--config": config}
26
+ args = [str(BINARY), "run", path, "--json"]
27
+ args += [arg for flag, value in flags.items() if value is not None for arg in (flag, str(value))]
28
+ args += [arg for label in labels or [] for arg in ("--label", label)]
29
+ args += ["--no-save"] if no_save else []
30
+ proc = subprocess.run(args, capture_output=True, text=True, env={**os.environ, "EZVALS_PYTHON": sys.executable})
31
+ if proc.returncode not in (0, 4): # 4: no evals matched, which still returns the (empty) run
32
+ raise ValueError(proc.stderr.strip())
33
+ return json.loads(proc.stdout)
ezvals/context.py ADDED
@@ -0,0 +1,134 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any, Optional
3
+
4
+
5
+ class TraceData(dict):
6
+ """Trace/debug data. A dict with attribute access; `messages` and `trace_url` are always readable."""
7
+
8
+ def __getattr__(self, key):
9
+ if key == "messages":
10
+ return self.setdefault("messages", [])
11
+ if key == "trace_url":
12
+ return self.get("trace_url")
13
+ try:
14
+ return self[key]
15
+ except KeyError:
16
+ raise AttributeError(key) from None
17
+
18
+ def __setattr__(self, key, value):
19
+ self[key] = list(value) if key == "messages" else value
20
+
21
+ def add_messages(self, messages: list) -> "TraceData":
22
+ """Replace messages (does not append)."""
23
+ self["messages"] = list(messages)
24
+ return self
25
+
26
+
27
+ def normalize_score(score: Any, default_key: Optional[str]) -> dict:
28
+ if isinstance(score, bool):
29
+ score = {"passed": score}
30
+ elif isinstance(score, (int, float)):
31
+ score = {"value": score}
32
+ elif not isinstance(score, dict) or score.get("value") is None and score.get("passed") is None:
33
+ raise ValueError(f"Invalid score {score!r}: use True/False, a number, "
34
+ "or {'key': ..., 'passed'/'value': ..., 'notes': ...}")
35
+ score = dict(score)
36
+ if "key" not in score:
37
+ if default_key is None:
38
+ raise ValueError("Must specify score key or set default_score_key")
39
+ score["key"] = default_key
40
+ return score
41
+
42
+
43
+ def normalize_scores(scores: Any, default_key: Optional[str]) -> list:
44
+ """A score, a list of scores, or None, as a list of score dicts."""
45
+ scores = scores if isinstance(scores, list) else [] if scores is None else [scores]
46
+ normalized = [normalize_score(s, default_key) for s in scores]
47
+ if sum(not (isinstance(s, dict) and "key" in s) for s in scores) > 1:
48
+ raise ValueError(f"Scores {scores!r} would share the key {default_key!r}: give each a 'key'")
49
+ return normalized
50
+
51
+
52
+ @dataclass
53
+ class EvalResult:
54
+ input: Any
55
+ output: Any
56
+ reference: Any = None
57
+ scores: list = field(default_factory=list)
58
+ error: Optional[str] = None
59
+ latency: Optional[float] = None
60
+ metadata: dict = field(default_factory=dict)
61
+ trace_data: TraceData = field(default_factory=TraceData)
62
+
63
+ def __post_init__(self):
64
+ self.scores = normalize_scores(self.scores, "pass")
65
+ self.metadata = self.metadata or {}
66
+ self.trace_data = TraceData(self.trace_data or {})
67
+
68
+
69
+ class EvalContext:
70
+ """Mutable builder for an EvalResult. Injected into any eval parameter annotated as EvalContext."""
71
+
72
+ def __init__(self, input=None, output=None, reference=None, default_score_key: Optional[str] = "pass",
73
+ metadata=None, trace_data=None, latency=None, function_name=None, dataset=None, labels=None,
74
+ **run_info):
75
+ self.input = input
76
+ self.output = output
77
+ self.reference = reference
78
+ self.default_score_key = default_score_key
79
+ self.metadata = dict(metadata or {})
80
+ self.trace_data = TraceData(trace_data or {})
81
+ self.latency = latency
82
+ self.scores: list = []
83
+ self.error: Optional[str] = None
84
+ self.function_name = function_name
85
+ self.dataset = dataset
86
+ self.labels = labels
87
+ self._run = run_info
88
+
89
+ def store(self, input=None, output=None, reference=None, latency=None, scores=None, messages=None,
90
+ trace_url=None, metadata=None, trace_data=None) -> "EvalContext":
91
+ """Set any fields passed. Scores with an existing key overwrite it; metadata and trace_data merge."""
92
+ if input is not None:
93
+ self.input = input
94
+ if output is not None:
95
+ self.output = output
96
+ if reference is not None:
97
+ self.reference = reference
98
+ if latency is not None:
99
+ self.latency = latency
100
+ if trace_data is not None:
101
+ self.trace_data.update(trace_data)
102
+ if messages is not None:
103
+ self.trace_data.messages = messages
104
+ if trace_url is not None:
105
+ self.trace_data.trace_url = trace_url
106
+ if metadata is not None:
107
+ self.metadata.update(metadata)
108
+ for score in normalize_scores(scores, self.default_score_key):
109
+ keys = [s["key"] for s in self.scores]
110
+ if score["key"] in keys:
111
+ self.scores[keys.index(score["key"])] = score
112
+ else:
113
+ self.scores.append(score)
114
+ return self
115
+
116
+ def build(self) -> EvalResult:
117
+ scores = self.scores or ([] if self.error else [{"key": self.default_score_key or "pass", "passed": True}])
118
+ return EvalResult(input=self.input, output=self.output, reference=self.reference, scores=scores,
119
+ error=self.error, latency=self.latency, metadata=self.metadata, trace_data=self.trace_data)
120
+
121
+ def build_with_error(self, error: str) -> EvalResult:
122
+ self.error = error
123
+ return self.build()
124
+
125
+ def __enter__(self):
126
+ return self
127
+
128
+ def __exit__(self, *exc):
129
+ return False
130
+
131
+
132
+ for _name in ("run_id", "session_name", "run_name", "eval_path"):
133
+ setattr(EvalContext, _name, property(lambda self, n=_name: self._run.get(n)))
134
+ EvalContext.config = property(lambda self: self._run.get("config") or {})
ezvals/evals.py ADDED
@@ -0,0 +1,287 @@
1
+ import asyncio
2
+ import contextlib
3
+ import contextvars
4
+ import functools
5
+ import importlib.util
6
+ import inspect
7
+ import os
8
+ import sys
9
+ import threading
10
+ import time
11
+ import traceback
12
+ import typing
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any, Callable, Optional
17
+
18
+ from ezvals.context import EvalContext, EvalResult, normalize_scores
19
+
20
+ # Parameters that file defaults and individual cases can set.
21
+ PARAMS = {"input", "reference", "dataset", "labels", "metadata", "default_score_key", "timeout", "target", "evaluators",
22
+ "trials"}
23
+ _UNSET = object()
24
+
25
+
26
+ class EvalFunction:
27
+ def __init__(self, func: Callable, params: dict):
28
+ self.func = func
29
+ self.params = params
30
+ self.ctx_param = next((name for name, hint in _hints(func).items() if _is_context(hint)), None)
31
+ needs_ctx = {"target", "input_loader", "cases"} & params.keys()
32
+ if needs_ctx and self.ctx_param is None:
33
+ raise ValueError(f"{sorted(needs_ctx)[0]} requires the evaluation function to accept a context parameter")
34
+ if "input_loader" in params and {"input", "reference", "cases"} & params.keys():
35
+ raise ValueError("input_loader cannot be used with input=, reference= or cases=")
36
+ functools.update_wrapper(self, func)
37
+
38
+ def __call__(self):
39
+ return _run_sync(self.call_async())
40
+
41
+ async def call_async(self):
42
+ results = [r for e in expand(self) for r in await e.run()]
43
+ return results[0] if len(results) == 1 else results
44
+
45
+
46
+ def eval(func=None, *, input=_UNSET, reference=_UNSET, dataset=_UNSET, labels=_UNSET, metadata=_UNSET,
47
+ default_score_key=_UNSET, timeout=_UNSET, target=_UNSET, evaluators=_UNSET, input_loader=_UNSET,
48
+ cases=_UNSET, trials=_UNSET):
49
+ """Mark a function as an evaluation. Usable as @eval or @eval(...). `ezvals run` runs it `trials` times."""
50
+ params = {k: v for k, v in locals().items() if k != "func" and v is not _UNSET}
51
+ if func is not None:
52
+ return EvalFunction(func, params)
53
+ return lambda f: EvalFunction(f, params)
54
+
55
+
56
+ @dataclass
57
+ class Eval:
58
+ """One runnable evaluation: an EvalFunction resolved against file defaults and a single case."""
59
+ id: str
60
+ name: str
61
+ func: Callable
62
+ ctx_param: Optional[str]
63
+ params: dict
64
+ error: Optional[str] = None
65
+
66
+ async def run(self, run_info: Optional[dict] = None, grade: Optional[dict] = None) -> list:
67
+ """Run the eval. With `grade` (a stored result), skip the target and score that result's output again."""
68
+ run_info = run_info or {}
69
+ p = self.params
70
+ if self.error:
71
+ return [EvalResult(input=None, output=None, error=self.error)]
72
+ ctx = EvalContext(input=p.get("input"), reference=p.get("reference"), metadata=p.get("metadata"),
73
+ default_score_key=p["default_score_key"], function_name=self.name,
74
+ dataset=p["dataset"], labels=p["labels"], **run_info) if self.ctx_param else None
75
+ if grade:
76
+ ctx.store(input=grade.get("input"), output=grade.get("output"), latency=grade.get("latency"),
77
+ metadata=grade.get("metadata"), trace_data=grade.get("trace_data"))
78
+ timeout = run_info.get("timeout") or p.get("timeout")
79
+ start = time.perf_counter()
80
+ try:
81
+ returned = await asyncio.wait_for(self._call_with_target(ctx, skip_target=bool(grade)), timeout)
82
+ if returned is None and ctx is not None:
83
+ returned = ctx
84
+ if isinstance(returned, EvalContext):
85
+ returned = returned.build()
86
+ results = returned if isinstance(returned, list) else [returned]
87
+ if not all(isinstance(r, EvalResult) for r in results):
88
+ raise ValueError("Evaluation function must return EvalResult, List[EvalResult], EvalContext, "
89
+ f"or None (with a context parameter), got {type(returned).__name__}")
90
+ except asyncio.TimeoutError:
91
+ results = [_errored(ctx, f"TimeoutError: Evaluation timed out after {float(timeout)}s")]
92
+ except AssertionError as e:
93
+ if ctx is None:
94
+ results = [_errored(None, describe(e))]
95
+ else: # a bare `assert` has no message, so its source line explains the failure
96
+ notes = str(e) or traceback.extract_tb(e.__traceback__)[-1].line or "Assertion failed"
97
+ ctx.store(scores={"passed": False, "notes": notes})
98
+ results = [ctx.build()]
99
+ except Exception as e:
100
+ results = [_errored(ctx, describe(e))]
101
+ if ctx is not None and inspect.iscoroutine(ctx.output):
102
+ ctx.output.close()
103
+ ctx.output = None
104
+ results = [ctx.build_with_error("TypeError: ctx.output was never awaited. Did you forget await?")]
105
+ try: # evaluators score every finished result, including ones a failed assertion scored
106
+ for i in range(len(results)):
107
+ for evaluator in [] if results[i].error else p.get("evaluators") or []:
108
+ scored = await _call(evaluator, results[i])
109
+ if isinstance(scored, EvalResult):
110
+ results[i] = scored
111
+ elif scored is not None:
112
+ results[i].scores += normalize_scores(scored, "pass")
113
+ except Exception as e:
114
+ results = [_errored(ctx, describe(e))]
115
+ for result in results:
116
+ if not result.scores and not result.error:
117
+ result.scores = [{"key": p["default_score_key"], "passed": True}]
118
+ if result.latency is None:
119
+ result.latency = (time.perf_counter() - start) / len(results)
120
+ return results
121
+
122
+ async def _call_with_target(self, ctx, skip_target):
123
+ if self.params.get("target") and not skip_target:
124
+ out = await _call(self.params["target"], ctx)
125
+ if isinstance(out, EvalResult):
126
+ ctx.store(output=out.output, latency=out.latency, trace_data=out.trace_data, metadata=out.metadata)
127
+ elif out is not None and not isinstance(out, EvalContext):
128
+ ctx.store(output=out)
129
+ return await _call(self.func, **({self.ctx_param: ctx} if self.ctx_param else {}))
130
+
131
+
132
+ def expand(fn: EvalFunction, file_defaults: Optional[dict] = None, file: Optional[str] = None) -> list:
133
+ """Resolve an EvalFunction into runnable Evals: builtins < file defaults < decorator < each case."""
134
+ file = file or inspect.getfile(fn.func)
135
+ unknown = set(file_defaults or {}) - PARAMS
136
+ if unknown:
137
+ raise ValueError(f"Unknown keys in ezvals_defaults: {', '.join(sorted(unknown))}")
138
+ builtins = {"dataset": Path(file).stem, "labels": [], "metadata": {}, "default_score_key": "pass"}
139
+ base = _layer(_layer(builtins, file_defaults or {}, merge_labels=False),
140
+ {k: v for k, v in fn.params.items() if k in PARAMS}, merge_labels=False)
141
+ name = fn.func.__name__
142
+
143
+ def make(case_name, params, error=None):
144
+ return Eval(f"{file}::{case_name}", case_name, fn.func, fn.ctx_param, params, error)
145
+
146
+ if "input_loader" in fn.params:
147
+ try:
148
+ examples = _run_sync(_call(fn.params["input_loader"]))
149
+ except Exception as e:
150
+ return [make(name, base, describe(e, f"Input loader failed: {e}"))]
151
+ cases = [ex if isinstance(ex, dict) else {k: getattr(ex, k) for k in PARAMS | {"id"} if hasattr(ex, k)} for ex in examples]
152
+ elif "cases" in fn.params:
153
+ cases = fn.params["cases"]
154
+ else:
155
+ return [make(name, base)]
156
+
157
+ evals = []
158
+ for i, case in enumerate(cases):
159
+ unknown = set(case) - PARAMS - {"id"}
160
+ if unknown:
161
+ raise ValueError(f"Unknown case keys: {', '.join(sorted(unknown))}")
162
+ overrides = {k: v for k, v in case.items() if k != "id"}
163
+ evals.append(make(f"{name}[{case.get('id', i)}]", _layer(base, overrides, merge_labels=True)))
164
+ return evals
165
+
166
+
167
+ def discover(files: list) -> list:
168
+ """Import eval files and return their Evals in source order."""
169
+ evals = []
170
+ for file in files:
171
+ sys.path.insert(0, str(Path(file).resolve().parent))
172
+ spec = importlib.util.spec_from_file_location(Path(file).stem, file)
173
+ module = importlib.util.module_from_spec(spec)
174
+ sys.modules[spec.name] = module
175
+ spec.loader.exec_module(module)
176
+ found = [obj for obj in vars(module).values()
177
+ if isinstance(obj, EvalFunction) and obj.func.__module__ == module.__name__]
178
+ for fn in sorted(found, key=lambda f: f.func.__code__.co_firstlineno):
179
+ evals += expand(fn, getattr(module, "ezvals_defaults", None), file)
180
+ return evals
181
+
182
+
183
+ def run_evals(evals: list, concurrency: int = 1, timeout: Optional[float] = None, dataset: Optional[str] = None,
184
+ labels: Optional[list] = None, limit: Optional[int] = None) -> list:
185
+ """Run eval functions and/or paths in-process and return their EvalResults (nothing is saved)."""
186
+ resolved = []
187
+ for item in evals:
188
+ resolved += expand(item) if isinstance(item, EvalFunction) else discover(_eval_files(item))
189
+ if dataset:
190
+ resolved = [e for e in resolved if e.params["dataset"] in dataset.split(",")]
191
+ if labels:
192
+ resolved = [e for e in resolved if set(labels) & set(e.params["labels"])]
193
+ semaphore = asyncio.Semaphore(concurrency)
194
+
195
+ async def one(e):
196
+ async with semaphore:
197
+ return await e.run({"timeout": timeout})
198
+
199
+ async def all_():
200
+ return await asyncio.gather(*(one(e) for e in resolved[:limit]))
201
+
202
+ return [r for results in _run_sync(all_()) for r in results]
203
+
204
+
205
+ def _eval_files(path: str) -> list:
206
+ p = Path(path)
207
+ if p.is_file():
208
+ return [str(p)]
209
+ return [str(f) for f in sorted(p.rglob("*.py"))
210
+ if not f.name.startswith("_") and not any(part.startswith((".", "__")) for part in f.relative_to(p).parts)]
211
+
212
+
213
+ def _layer(base: dict, over: dict, merge_labels: bool) -> dict:
214
+ """Overlay params. Metadata merges (None clears); case labels merge with base labels (None/[] clears)."""
215
+ out = {**base, **over}
216
+ if over.get("metadata") is not None:
217
+ out["metadata"] = {**(base.get("metadata") or {}), **over["metadata"]}
218
+ if merge_labels and "labels" in over:
219
+ out["labels"] = base["labels"] + [l for l in over["labels"] or [] if l not in base["labels"]] if over["labels"] else []
220
+ return out
221
+
222
+
223
+ _INTERNAL = (os.path.dirname(__file__), os.path.dirname(asyncio.__file__), threading.__file__, "<frozen")
224
+
225
+
226
+ def describe(error: BaseException, headline: Optional[str] = None) -> str:
227
+ """The error, then its traceback through the user's own code only (not ezvals', asyncio's or threading's)."""
228
+ frames = [f for f in traceback.extract_tb(error.__traceback__) if not f.filename.startswith(_INTERNAL)]
229
+ for f in frames:
230
+ if f.filename.startswith(os.getcwd() + os.sep):
231
+ f.filename = os.path.relpath(f.filename)
232
+ where = "Traceback (most recent call last):\n" + "".join(traceback.format_list(frames)) if frames else ""
233
+ return (headline + "\n" if headline else "".join(traceback.format_exception_only(error))) + where
234
+
235
+
236
+ def _errored(ctx: Optional[EvalContext], error: str) -> EvalResult:
237
+ return ctx.build_with_error(error) if ctx else EvalResult(input=None, output=None, error=error)
238
+
239
+
240
+ async def _call(fn, *args, **kwargs):
241
+ """Await async callables; run sync ones on a daemon thread so a timeout can abandon them."""
242
+ if inspect.iscoroutinefunction(fn):
243
+ return await fn(*args, **kwargs)
244
+ loop = asyncio.get_running_loop()
245
+ future = loop.create_future()
246
+ call = functools.partial(contextvars.copy_context().run, fn, *args, **kwargs)
247
+
248
+ def settle(result=None, error=None):
249
+ if not future.done():
250
+ future.set_exception(error) if error else future.set_result(result)
251
+
252
+ def target():
253
+ try:
254
+ outcome = (call(), None)
255
+ except BaseException as e:
256
+ outcome = (None, e)
257
+ with contextlib.suppress(RuntimeError): # the loop is gone if this call was abandoned after a timeout
258
+ loop.call_soon_threadsafe(settle, *outcome)
259
+
260
+ threading.Thread(target=target, daemon=True).start()
261
+ return await future
262
+
263
+
264
+ def _run_sync(coro):
265
+ try:
266
+ asyncio.get_running_loop()
267
+ except RuntimeError:
268
+ return asyncio.run(coro)
269
+ with ThreadPoolExecutor(1) as pool:
270
+ return pool.submit(asyncio.run, coro).result()
271
+
272
+
273
+ def _hints(func) -> dict:
274
+ params = inspect.signature(func).parameters
275
+ try:
276
+ hints = typing.get_type_hints(func)
277
+ except Exception:
278
+ hints = {}
279
+ return {name: hints.get(name, p.annotation) for name, p in params.items()}
280
+
281
+
282
+ def _is_context(hint) -> bool:
283
+ if hint is EvalContext:
284
+ return True
285
+ if isinstance(hint, (str, typing.ForwardRef)):
286
+ return str(getattr(hint, "__forward_arg__", hint)).split(".")[-1] == "EvalContext"
287
+ return any(_is_context(arg) for arg in typing.get_args(hint))
ezvals/worker.py ADDED
@@ -0,0 +1,66 @@
1
+ """Worker process the ezvals host spawns to run Python evals.
2
+
3
+ Protocol (JSON lines): the worker writes {"type": "evals"} once discovery finishes, then reads
4
+ {"id", "run", "grade"?} requests from stdin and answers each with {"type": "result", "id"}. Evals run
5
+ concurrently; the worker exits once stdin closes and in-flight evals finish. User output goes to stderr.
6
+ """
7
+ import asyncio
8
+ import dataclasses
9
+ import json
10
+ import os
11
+ import sys
12
+
13
+ from ezvals.evals import describe, discover
14
+
15
+
16
+ def main():
17
+ protocol = os.fdopen(os.dup(1), "w", buffering=1)
18
+ os.dup2(2, 1)
19
+ sys.stdout.reconfigure(line_buffering=True) # user prints show up as they happen with --verbose
20
+
21
+ def send(message):
22
+ try:
23
+ line = json.dumps(message, default=_jsonable, allow_nan=False)
24
+ except ValueError: # NaN/Infinity aren't JSON: send them as null
25
+ line = json.dumps(json.loads(json.dumps(message, default=_jsonable), parse_constant=lambda _: None))
26
+ protocol.write(line + "\n")
27
+
28
+ run_info = json.loads(os.environ.get("EZVALS_RUN") or "{}")
29
+ try:
30
+ evals = {e.id: e for e in discover(sys.argv[1:])}
31
+ except Exception as error:
32
+ send({"type": "error", "error": describe(error)})
33
+ sys.exit(1)
34
+ send({"type": "evals", "evals": [{
35
+ "id": e.id, "function": e.name, "dataset": e.params["dataset"], "labels": e.params["labels"],
36
+ "input": e.params.get("input"), "reference": e.params.get("reference"), "metadata": e.params.get("metadata"),
37
+ "trials": e.params.get("trials"), "target": bool(e.params.get("target")),
38
+ } for e in evals.values()]})
39
+
40
+ async def run(request):
41
+ try:
42
+ results = await evals[request["run"]].run(run_info, request.get("grade"))
43
+ send({"type": "result", "id": request["id"], "results": [vars(r) for r in results]})
44
+ except Exception as error: # the host waits for every result, so one that can't be reported becomes an error
45
+ send({"type": "result", "id": request["id"], "results": [{"error": describe(error)}]})
46
+
47
+ async def serve():
48
+ loop = asyncio.get_running_loop()
49
+ tasks = set()
50
+ while line := await loop.run_in_executor(None, sys.stdin.readline):
51
+ tasks.add(asyncio.create_task(run(json.loads(line))))
52
+ await asyncio.gather(*tasks)
53
+
54
+ asyncio.run(serve())
55
+
56
+
57
+ def _jsonable(obj):
58
+ if hasattr(obj, "model_dump"):
59
+ return obj.model_dump()
60
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
61
+ return dataclasses.asdict(obj)
62
+ return str(obj)
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.5
2
+ Name: ezvals
3
+ Version: 0.2.0
4
+ Summary: A lightweight, code-first evaluation framework for testing AI agents and LLM applications
5
+ Project-URL: Homepage, https://github.com/camronh/EZVals
6
+ Project-URL: Repository, https://github.com/camronh/EZVals
7
+ License: MIT
8
+ Keywords: agents,benchmark,cli,evaluation,llm,testing
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Topic :: Software Development :: Testing
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+
16
+ # EZVals
17
+
18
+ Unit testing for AI agents and LLM apps. Write evals like tests, in Python or TypeScript; EZVals runs them, stores every result, and gives you a web UI to review, annotate and compare runs.
19
+
20
+ ![UI screenshot](https://raw.githubusercontent.com/camronh/EZVals/main/assets/ui.png)
21
+
22
+ ## Install
23
+
24
+ EZVals is a development dependency.
25
+
26
+ ```bash
27
+ uv add --dev ezvals # or: pip install ezvals
28
+ npm install --save-dev ezvals # TypeScript (Node >= 22.18)
29
+ ```
30
+
31
+ ## Write an eval
32
+
33
+ ```python
34
+ # evals/support.py
35
+ from ezvals import eval, EvalContext
36
+
37
+ @eval(input="I want a refund", dataset="customer_service")
38
+ async def test_refund(ctx: EvalContext):
39
+ ctx.output = await run_agent(ctx.input)
40
+ assert "refund" in ctx.output.lower(), "Should acknowledge refund"
41
+ ```
42
+
43
+ ```ts
44
+ // evals/support.eval.ts
45
+ import assert from "node:assert";
46
+ import { evaluate } from "ezvals";
47
+
48
+ evaluate("refund", { input: "I want a refund", dataset: "customer_service" }, async (ctx) => {
49
+ ctx.output = await runAgent(ctx.input);
50
+ assert(String(ctx.output).toLowerCase().includes("refund"), "Should acknowledge refund");
51
+ });
52
+ ```
53
+
54
+ A failed assertion is a failing score, not an error, and whatever the eval stored before it is kept. Use `cases=` to run one eval over many inputs, `ctx.store(scores=...)` for named or numeric scores, and `target=` to separate calling your agent from scoring it. See the [docs](https://github.com/camronh/EZVals/tree/main/docs) and [examples](https://github.com/camronh/EZVals/tree/main/examples).
55
+
56
+ ## Run it
57
+
58
+ ```bash
59
+ ezvals run evals/ # headless; saves to .ezvals/sessions/
60
+ ezvals run evals/ --json # ...and print the results as JSON (handy for agents)
61
+ ezvals serve evals/ # web UI: run, filter, annotate, compare runs
62
+ ezvals run evals/support.py::test_refund -c 8 --timeout 30
63
+ ezvals run evals/ --trials 5 # run each eval 5 times; reports pass@5 and pass^5
64
+ ezvals regrade a1b2c3d4 # re-score a run's outputs after changing a grader, without re-running the agent
65
+ ezvals query "SELECT run_name, total_passed FROM runs" # SQL over every saved run (see: ezvals query --schema)
66
+ ```
67
+
68
+ TypeScript projects run the same commands through `npx ezvals`. A directory with both languages runs as one.
69
+
70
+ ## Agent skill
71
+
72
+ EZVals ships a skill that teaches coding agents to write and analyze evals:
73
+
74
+ ```bash
75
+ ezvals skills add --claude # or --codex, --cursor, --windsurf, --kiro, --roo, --agents
76
+ ```
77
+
78
+ ## How it works
79
+
80
+ `ezvals` is a single Go binary (`cmd/ezvals/`) that owns the CLI, the web UI (`ui/`, embedded at build time) and storage. To run evals it spawns a small worker from the language SDK (`python/`, `typescript/`) that imports your files and executes evals on request; results stream back and are appended to a per-run event log. Adding a language means writing an SDK against [the SDK spec](https://github.com/camronh/EZVals/blob/main/docs/.spec/EXPERIENCE_SPEC_SDK.md) and passing the shared `conformance/` fixtures.
81
+
82
+ ## Development
83
+
84
+ Requires Go, Node, and uv.
85
+
86
+ ```bash
87
+ make build # UI + host binary (into python/ezvals/bin) + TypeScript SDK
88
+ make test # Go (incl. cross-language conformance), Python and TypeScript tests
89
+ make test-e2e # browser tests for the web UI against a real server
90
+ make storybook # every UI component and page in its states (also run as tests by `make test`)
91
+ uv run --project python ezvals serve examples
92
+ ```
93
+
94
+ Specs in `docs/.spec/` are the source of truth for behavior.
@@ -0,0 +1,10 @@
1
+ ezvals/__init__.py,sha256=ANt0TZK8B8JuwGB_gSC1gYTTq7fJ_VNUnWORhhVWHfY,610
2
+ ezvals/cli.py,sha256=XAzIrl7qZ6u88qJlYW2gZjTKACiLQrZUARLW9R90I6M,1775
3
+ ezvals/context.py,sha256=jz_26oBUMu2pI1LXJ0WsjlgALpfssNCpkh_7B646L3Q,5317
4
+ ezvals/evals.py,sha256=2kHIMQuSn_20FpelO1hVElyUIzY6bHBaiB0rmS-dPGw,13096
5
+ ezvals/worker.py,sha256=TuL38t8Jg69zH3IYt4PUKwSX3iHqEEMGdcvuGJ_wCXw,2609
6
+ ezvals/bin/ezvals.exe,sha256=lKAtvh4zDlUPjnrVW6NQ_Ck6RS2F3IbP0MdXv-qsnJk,18387456
7
+ ezvals-0.2.0.dist-info/METADATA,sha256=0O1_3Vm7EcLFLYxhGjduezJASU5Fh9Xsp6uKPKjOSYY,4145
8
+ ezvals-0.2.0.dist-info/WHEEL,sha256=A7YmR1UrJozxyeLCzQFwQUdR7CRUwkFKasEDmnrjVxY,94
9
+ ezvals-0.2.0.dist-info/entry_points.txt,sha256=HgjFjE8IGlHpiVID1_NF3vqwcwxcml_MW4u7OH1cCqo,43
10
+ ezvals-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ezvals = ezvals.cli:main