evalwise 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.
@@ -0,0 +1,102 @@
1
+ """Dataset loading and management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Iterator
8
+
9
+
10
+ class Dataset:
11
+ """
12
+ A collection of test inputs for evaluation.
13
+
14
+ Example:
15
+ # From list
16
+ dataset = Dataset([
17
+ {"response": "Hello", "expected": "greeting"},
18
+ {"response": "Bye", "expected": "farewell"},
19
+ ])
20
+
21
+ # From file
22
+ dataset = Dataset.from_file("./golden_set.json")
23
+
24
+ # From CSV
25
+ dataset = Dataset.from_csv("./test_cases.csv")
26
+ """
27
+
28
+ def __init__(self, items: list[dict[str, Any]]):
29
+ self.items = items
30
+
31
+ def __len__(self) -> int:
32
+ return len(self.items)
33
+
34
+ def __iter__(self) -> Iterator[dict[str, Any]]:
35
+ return iter(self.items)
36
+
37
+ def __getitem__(self, idx: int) -> dict[str, Any]:
38
+ return self.items[idx]
39
+
40
+ @classmethod
41
+ def from_file(cls, path: str | Path) -> Dataset:
42
+ """Load dataset from JSON or YAML file."""
43
+ p = Path(path)
44
+
45
+ if p.suffix == ".json":
46
+ with open(p) as f:
47
+ data = json.load(f)
48
+ elif p.suffix in (".yaml", ".yml"):
49
+ try:
50
+ import yaml
51
+ with open(p) as f:
52
+ data = yaml.safe_load(f)
53
+ except ImportError:
54
+ raise ImportError("PyYAML required: pip install pyyaml")
55
+ else:
56
+ raise ValueError(f"Unsupported format: {p.suffix}")
57
+
58
+ if isinstance(data, list):
59
+ return cls(data)
60
+ elif isinstance(data, dict) and "tests" in data:
61
+ return cls(data["tests"])
62
+ else:
63
+ raise ValueError("File must contain a list or object with 'tests' key")
64
+
65
+ @classmethod
66
+ def from_csv(cls, path: str | Path, **kwargs: Any) -> Dataset:
67
+ """Load dataset from CSV file."""
68
+ import csv
69
+
70
+ items = []
71
+ with open(path, newline="") as f:
72
+ reader = csv.DictReader(f, **kwargs)
73
+ for row in reader:
74
+ items.append(dict(row))
75
+
76
+ return cls(items)
77
+
78
+ @classmethod
79
+ def from_jsonl(cls, path: str | Path) -> Dataset:
80
+ """Load dataset from JSON Lines file."""
81
+ items = []
82
+ with open(path) as f:
83
+ for line in f:
84
+ if line.strip():
85
+ items.append(json.loads(line))
86
+ return cls(items)
87
+
88
+ def filter(self, fn: callable) -> Dataset:
89
+ """Filter items by predicate function."""
90
+ return Dataset([item for item in self.items if fn(item)])
91
+
92
+ def sample(self, n: int, seed: int | None = None) -> Dataset:
93
+ """Random sample of n items."""
94
+ import random
95
+ if seed is not None:
96
+ random.seed(seed)
97
+ sampled = random.sample(self.items, min(n, len(self.items)))
98
+ return Dataset(sampled)
99
+
100
+ def to_list(self) -> list[dict[str, Any]]:
101
+ """Convert to list of dicts."""
102
+ return self.items.copy()
@@ -0,0 +1,142 @@
1
+ """Result types for evaluations."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from enum import Enum
6
+ from typing import Any
7
+
8
+
9
+ class Status(Enum):
10
+ PASS = "pass"
11
+ FAIL = "fail"
12
+ ERROR = "error"
13
+ SKIP = "skip"
14
+
15
+
16
+ @dataclass
17
+ class AssertionResult:
18
+ """Result of a single assertion."""
19
+
20
+ name: str
21
+ status: Status
22
+ message: str | None = None
23
+ expected: Any = None
24
+ actual: Any = None
25
+ duration_ms: float = 0.0
26
+
27
+ @property
28
+ def passed(self) -> bool:
29
+ return self.status == Status.PASS
30
+
31
+ def __repr__(self) -> str:
32
+ icon = "✓" if self.passed else "✗"
33
+ return f"{icon} {self.name}: {self.status.value}"
34
+
35
+
36
+ @dataclass
37
+ class TestResult:
38
+ """Result of a single test case."""
39
+
40
+ test_name: str
41
+ input_id: str | None = None
42
+ assertions: list[AssertionResult] = field(default_factory=list)
43
+ duration_ms: float = 0.0
44
+ error: str | None = None
45
+ metadata: dict[str, Any] = field(default_factory=dict)
46
+
47
+ @property
48
+ def status(self) -> Status:
49
+ if self.error:
50
+ return Status.ERROR
51
+ if not self.assertions:
52
+ return Status.SKIP
53
+ if all(a.passed for a in self.assertions):
54
+ return Status.PASS
55
+ return Status.FAIL
56
+
57
+ @property
58
+ def passed(self) -> bool:
59
+ return self.status == Status.PASS
60
+
61
+ @property
62
+ def pass_count(self) -> int:
63
+ return sum(1 for a in self.assertions if a.passed)
64
+
65
+ @property
66
+ def fail_count(self) -> int:
67
+ return sum(1 for a in self.assertions if not a.passed)
68
+
69
+
70
+ @dataclass
71
+ class SuiteResult:
72
+ """Result of running a full test suite."""
73
+
74
+ suite_name: str
75
+ tests: list[TestResult] = field(default_factory=list)
76
+ started_at: datetime = field(default_factory=datetime.now)
77
+ completed_at: datetime | None = None
78
+ metadata: dict[str, Any] = field(default_factory=dict)
79
+
80
+ @property
81
+ def total_tests(self) -> int:
82
+ return len(self.tests)
83
+
84
+ @property
85
+ def passed_tests(self) -> int:
86
+ return sum(1 for t in self.tests if t.passed)
87
+
88
+ @property
89
+ def failed_tests(self) -> int:
90
+ return sum(1 for t in self.tests if t.status == Status.FAIL)
91
+
92
+ @property
93
+ def error_tests(self) -> int:
94
+ return sum(1 for t in self.tests if t.status == Status.ERROR)
95
+
96
+ @property
97
+ def pass_rate(self) -> float:
98
+ if not self.tests:
99
+ return 0.0
100
+ return self.passed_tests / self.total_tests
101
+
102
+ @property
103
+ def duration_ms(self) -> float:
104
+ return sum(t.duration_ms for t in self.tests)
105
+
106
+ def assert_pass_rate(self, threshold: float = 0.95) -> None:
107
+ """Raise AssertionError if pass rate is below threshold."""
108
+ if self.pass_rate < threshold:
109
+ raise AssertionError(
110
+ f"Pass rate {self.pass_rate:.1%} is below threshold {threshold:.1%}. "
111
+ f"({self.passed_tests}/{self.total_tests} tests passed)"
112
+ )
113
+
114
+ def to_dict(self) -> dict[str, Any]:
115
+ """Convert to dictionary for JSON serialization."""
116
+ return {
117
+ "suite_name": self.suite_name,
118
+ "pass_rate": self.pass_rate,
119
+ "total_tests": self.total_tests,
120
+ "passed": self.passed_tests,
121
+ "failed": self.failed_tests,
122
+ "errors": self.error_tests,
123
+ "duration_ms": self.duration_ms,
124
+ "started_at": self.started_at.isoformat(),
125
+ "completed_at": self.completed_at.isoformat() if self.completed_at else None,
126
+ "tests": [
127
+ {
128
+ "name": t.test_name,
129
+ "input_id": t.input_id,
130
+ "status": t.status.value,
131
+ "assertions": [
132
+ {
133
+ "name": a.name,
134
+ "status": a.status.value,
135
+ "message": a.message,
136
+ }
137
+ for a in t.assertions
138
+ ],
139
+ }
140
+ for t in self.tests
141
+ ],
142
+ }
evalwise/core/suite.py ADDED
@@ -0,0 +1,243 @@
1
+ """Test suite definition and runner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import traceback
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from typing import Any, Callable
10
+
11
+ from evalwise.core.result import AssertionResult, Status, SuiteResult, TestResult
12
+ from evalwise.core.context import EvalContext, get_context, set_context
13
+
14
+
15
+ @dataclass
16
+ class TestCase:
17
+ """A single test case definition."""
18
+
19
+ name: str
20
+ fn: Callable[..., None]
21
+ tags: list[str] = field(default_factory=list)
22
+
23
+
24
+ class Suite:
25
+ """
26
+ A collection of evaluation tests.
27
+
28
+ Example:
29
+ suite = Suite("summarization")
30
+
31
+ @suite.test
32
+ def test_format(response, context):
33
+ Assert.bullet_count(response, exactly=3)
34
+ Assert.word_count(response, max=200)
35
+
36
+ results = suite.run(dataset="./golden.json")
37
+ """
38
+
39
+ def __init__(self, name: str):
40
+ self.name = name
41
+ self.tests: list[TestCase] = []
42
+ self._setup_fn: Callable[[], None] | None = None
43
+ self._teardown_fn: Callable[[], None] | None = None
44
+
45
+ def test(
46
+ self,
47
+ fn: Callable[..., None] | None = None,
48
+ *,
49
+ tags: list[str] | None = None,
50
+ ) -> Callable[..., None]:
51
+ """
52
+ Decorator to register a test function.
53
+
54
+ Example:
55
+ @suite.test
56
+ def test_something(response):
57
+ Assert.contains(response, "hello")
58
+
59
+ @suite.test(tags=["slow"])
60
+ def test_expensive(response):
61
+ Assert.llm_judge(response, "Is helpful")
62
+ """
63
+ def decorator(f: Callable[..., None]) -> Callable[..., None]:
64
+ self.tests.append(TestCase(
65
+ name=f.__name__,
66
+ fn=f,
67
+ tags=tags or [],
68
+ ))
69
+ return f
70
+
71
+ if fn is not None:
72
+ return decorator(fn)
73
+ return decorator
74
+
75
+ def setup(self, fn: Callable[[], None]) -> Callable[[], None]:
76
+ """Register a setup function to run before all tests."""
77
+ self._setup_fn = fn
78
+ return fn
79
+
80
+ def teardown(self, fn: Callable[[], None]) -> Callable[[], None]:
81
+ """Register a teardown function to run after all tests."""
82
+ self._teardown_fn = fn
83
+ return fn
84
+
85
+ def run(
86
+ self,
87
+ dataset: list[dict[str, Any]] | str | None = None,
88
+ *,
89
+ tags: list[str] | None = None,
90
+ exclude_tags: list[str] | None = None,
91
+ fail_fast: bool = False,
92
+ ) -> SuiteResult:
93
+ """
94
+ Run all tests in the suite.
95
+
96
+ Args:
97
+ dataset: List of test inputs, or path to JSON/YAML file
98
+ tags: Only run tests with these tags
99
+ exclude_tags: Skip tests with these tags
100
+ fail_fast: Stop on first failure
101
+
102
+ Returns:
103
+ SuiteResult with all test outcomes
104
+ """
105
+ result = SuiteResult(
106
+ suite_name=self.name,
107
+ started_at=datetime.now(),
108
+ )
109
+
110
+ # Load dataset if path provided
111
+ if isinstance(dataset, str):
112
+ dataset = self._load_dataset(dataset)
113
+
114
+ # Default to single empty input if no dataset
115
+ if not dataset:
116
+ dataset = [{}]
117
+
118
+ # Filter tests by tags
119
+ tests_to_run = self._filter_tests(tags, exclude_tags)
120
+
121
+ # Run setup
122
+ if self._setup_fn:
123
+ self._setup_fn()
124
+
125
+ try:
126
+ for test_case in tests_to_run:
127
+ for i, input_data in enumerate(dataset):
128
+ test_result = self._run_single_test(
129
+ test_case,
130
+ input_data,
131
+ input_id=str(i),
132
+ )
133
+ result.tests.append(test_result)
134
+
135
+ if fail_fast and not test_result.passed:
136
+ break
137
+
138
+ if fail_fast and result.failed_tests > 0:
139
+ break
140
+ finally:
141
+ if self._teardown_fn:
142
+ self._teardown_fn()
143
+
144
+ result.completed_at = datetime.now()
145
+ return result
146
+
147
+ def _run_single_test(
148
+ self,
149
+ test_case: TestCase,
150
+ input_data: dict[str, Any],
151
+ input_id: str,
152
+ ) -> TestResult:
153
+ """Run a single test case with given input."""
154
+ import inspect
155
+
156
+ context = EvalContext()
157
+ set_context(context)
158
+
159
+ start_time = time.perf_counter()
160
+ error: str | None = None
161
+
162
+ try:
163
+ # Get function signature to filter kwargs
164
+ sig = inspect.signature(test_case.fn)
165
+ params = sig.parameters
166
+
167
+ # Check if function accepts **kwargs
168
+ has_var_keyword = any(
169
+ p.kind == inspect.Parameter.VAR_KEYWORD
170
+ for p in params.values()
171
+ )
172
+
173
+ if has_var_keyword:
174
+ # Function accepts any kwargs
175
+ filtered_data = input_data
176
+ else:
177
+ # Only pass kwargs that match function parameters
178
+ filtered_data = {
179
+ k: v for k, v in input_data.items()
180
+ if k in params
181
+ }
182
+
183
+ # Call test function with filtered kwargs
184
+ test_case.fn(**filtered_data)
185
+ except AssertionError as e:
186
+ # Assertion failures are expected
187
+ pass
188
+ except Exception as e:
189
+ error = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
190
+
191
+ duration_ms = (time.perf_counter() - start_time) * 1000
192
+
193
+ return TestResult(
194
+ test_name=test_case.name,
195
+ input_id=input_id,
196
+ assertions=context.assertions,
197
+ duration_ms=duration_ms,
198
+ error=error,
199
+ metadata=input_data,
200
+ )
201
+
202
+ def _filter_tests(
203
+ self,
204
+ tags: list[str] | None,
205
+ exclude_tags: list[str] | None,
206
+ ) -> list[TestCase]:
207
+ """Filter tests by tags."""
208
+ result = self.tests
209
+
210
+ if tags:
211
+ result = [t for t in result if any(tag in t.tags for tag in tags)]
212
+
213
+ if exclude_tags:
214
+ result = [t for t in result if not any(tag in t.tags for tag in exclude_tags)]
215
+
216
+ return result
217
+
218
+ def _load_dataset(self, path: str) -> list[dict[str, Any]]:
219
+ """Load dataset from file."""
220
+ import json
221
+ from pathlib import Path
222
+
223
+ p = Path(path)
224
+
225
+ if p.suffix == ".json":
226
+ with open(p) as f:
227
+ data = json.load(f)
228
+ elif p.suffix in (".yaml", ".yml"):
229
+ try:
230
+ import yaml
231
+ with open(p) as f:
232
+ data = yaml.safe_load(f)
233
+ except ImportError:
234
+ raise ImportError("PyYAML required for YAML datasets: pip install pyyaml")
235
+ else:
236
+ raise ValueError(f"Unsupported dataset format: {p.suffix}")
237
+
238
+ if isinstance(data, list):
239
+ return data
240
+ elif isinstance(data, dict) and "tests" in data:
241
+ return data["tests"]
242
+ else:
243
+ raise ValueError("Dataset must be a list or object with 'tests' key")
@@ -0,0 +1,5 @@
1
+ """Image assertion module - deterministic checks for generated images."""
2
+
3
+ from evalwise.image.assertions import ImageAssert
4
+
5
+ __all__ = ["ImageAssert"]