erdo 0.1.7__py3-none-any.whl → 0.1.9__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.

Potentially problematic release.


This version of erdo might be problematic. Click here for more details.

erdo/test/evaluate.py ADDED
@@ -0,0 +1,272 @@
1
+ """Evaluation functions for agent test assertions."""
2
+
3
+ import json
4
+ import re
5
+ from typing import Any, Dict, List, Optional, Union
6
+
7
+
8
+ def text_contains(value: Any, expected: str, case_sensitive: bool = True) -> bool:
9
+ """Check if text contains the expected substring.
10
+
11
+ Args:
12
+ value: The value to check (will be converted to string)
13
+ expected: The substring to look for
14
+ case_sensitive: Whether the match should be case-sensitive
15
+
16
+ Returns:
17
+ True if the text contains the expected substring
18
+
19
+ Example:
20
+ >>> text_contains("Hello World", "World")
21
+ True
22
+ >>> text_contains("Hello World", "world", case_sensitive=False)
23
+ True
24
+ >>> text_contains("Hello World", "Goodbye")
25
+ False
26
+ """
27
+ text = str(value)
28
+ if not case_sensitive:
29
+ text = text.lower()
30
+ expected = expected.lower()
31
+ return expected in text
32
+
33
+
34
+ def text_equals(value: Any, expected: str, case_sensitive: bool = True) -> bool:
35
+ """Check if text exactly equals the expected value.
36
+
37
+ Args:
38
+ value: The value to check (will be converted to string)
39
+ expected: The exact string to match
40
+ case_sensitive: Whether the match should be case-sensitive
41
+
42
+ Returns:
43
+ True if the text exactly matches
44
+
45
+ Example:
46
+ >>> text_equals("Hello", "Hello")
47
+ True
48
+ >>> text_equals("Hello", "hello", case_sensitive=False)
49
+ True
50
+ >>> text_equals("Hello World", "Hello")
51
+ False
52
+ """
53
+ text = str(value)
54
+ if not case_sensitive:
55
+ text = text.lower()
56
+ expected = expected.lower()
57
+ return text == expected
58
+
59
+
60
+ def text_matches(value: Any, pattern: str, flags: int = 0) -> bool:
61
+ """Check if text matches a regular expression pattern.
62
+
63
+ Args:
64
+ value: The value to check (will be converted to string)
65
+ pattern: The regex pattern to match
66
+ flags: Optional regex flags (e.g., re.IGNORECASE)
67
+
68
+ Returns:
69
+ True if the text matches the pattern
70
+
71
+ Example:
72
+ >>> text_matches("Hello 123", r"Hello \\d+")
73
+ True
74
+ >>> text_matches("hello world", r"^hello", re.IGNORECASE)
75
+ True
76
+ >>> text_matches("abc", r"\\d+")
77
+ False
78
+ """
79
+ text = str(value)
80
+ return re.search(pattern, text, flags) is not None
81
+
82
+
83
+ def json_path_equals(data: Union[Dict, str], path: str, expected: Any) -> bool:
84
+ """Check if a JSON path in the data equals the expected value.
85
+
86
+ Args:
87
+ data: Dictionary or JSON string to search
88
+ path: Dot-notation path (e.g., "user.name" or "items[0].id")
89
+ expected: The expected value at that path
90
+
91
+ Returns:
92
+ True if the value at the path equals expected
93
+
94
+ Example:
95
+ >>> data = {"user": {"name": "Alice", "age": 30}}
96
+ >>> json_path_equals(data, "user.name", "Alice")
97
+ True
98
+ >>> json_path_equals(data, "user.age", 30)
99
+ True
100
+ >>> json_path_equals(data, "user.name", "Bob")
101
+ False
102
+
103
+ Note:
104
+ Supports simple paths like "a.b.c" and array access like "items[0]"
105
+ For complex JSONPath queries, consider using a dedicated JSONPath library
106
+ """
107
+ # Convert string to dict if needed
108
+ if isinstance(data, str):
109
+ try:
110
+ data = json.loads(data)
111
+ except json.JSONDecodeError:
112
+ return False
113
+
114
+ # Navigate the path
115
+ value = _get_json_path_value(data, path)
116
+ return value == expected
117
+
118
+
119
+ def json_path_exists(data: Union[Dict, str], path: str) -> bool:
120
+ """Check if a JSON path exists in the data.
121
+
122
+ Args:
123
+ data: Dictionary or JSON string to search
124
+ path: Dot-notation path (e.g., "user.name" or "items[0].id")
125
+
126
+ Returns:
127
+ True if the path exists (even if value is None)
128
+
129
+ Example:
130
+ >>> data = {"user": {"name": "Alice"}}
131
+ >>> json_path_exists(data, "user.name")
132
+ True
133
+ >>> json_path_exists(data, "user.email")
134
+ False
135
+ """
136
+ # Convert string to dict if needed
137
+ if isinstance(data, str):
138
+ try:
139
+ data = json.loads(data)
140
+ except json.JSONDecodeError:
141
+ return False
142
+
143
+ try:
144
+ _get_json_path_value(data, path)
145
+ return True
146
+ except (KeyError, IndexError, TypeError):
147
+ return False
148
+
149
+
150
+ def has_dataset(
151
+ response: Any, dataset_id: Optional[str] = None, dataset_key: Optional[str] = None
152
+ ) -> bool:
153
+ """Check if a response includes a specific dataset.
154
+
155
+ Args:
156
+ response: The invocation response
157
+ dataset_id: Optional dataset ID to check for
158
+ dataset_key: Optional dataset key to check for
159
+
160
+ Returns:
161
+ True if the dataset is present in the response
162
+
163
+ Example:
164
+ >>> # Check if any dataset is present
165
+ >>> has_dataset(response)
166
+ True
167
+ >>> # Check for specific dataset by ID
168
+ >>> has_dataset(response, dataset_id="abc-123")
169
+ True
170
+ >>> # Check for dataset by key
171
+ >>> has_dataset(response, dataset_key="sales_data")
172
+ True
173
+ """
174
+ # Try to extract datasets from common response formats
175
+ datasets = []
176
+
177
+ # Check if response has datasets attribute
178
+ if hasattr(response, "datasets"):
179
+ datasets = response.datasets
180
+ # Check if response has events with dataset info
181
+ elif hasattr(response, "events"):
182
+ for event in response.events:
183
+ if isinstance(event, dict):
184
+ payload = event.get("payload", {})
185
+ if "datasets" in payload:
186
+ datasets.extend(payload["datasets"])
187
+ if "dataset" in payload:
188
+ datasets.append(payload["dataset"])
189
+
190
+ # Check if specific dataset is present
191
+ if dataset_id or dataset_key:
192
+ for dataset in datasets:
193
+ if isinstance(dataset, dict):
194
+ if dataset_id and dataset.get("id") == dataset_id:
195
+ return True
196
+ if dataset_key and dataset.get("key") == dataset_key:
197
+ return True
198
+ elif isinstance(dataset, str):
199
+ # Dataset might just be an ID string
200
+ if dataset_id and dataset == dataset_id:
201
+ return True
202
+ return False
203
+
204
+ # Check if any dataset is present
205
+ return len(datasets) > 0
206
+
207
+
208
+ def _get_json_path_value(data: Any, path: str) -> Any:
209
+ """Navigate a JSON path and return the value.
210
+
211
+ Supports:
212
+ - Dot notation: "user.name"
213
+ - Array access: "items[0]"
214
+ - Mixed: "users[0].name"
215
+
216
+ Raises:
217
+ KeyError, IndexError, TypeError if path doesn't exist
218
+ """
219
+ if not path:
220
+ return data
221
+
222
+ parts = _parse_path(path)
223
+ current = data
224
+
225
+ for part in parts:
226
+ if isinstance(part, int):
227
+ # Array index
228
+ current = current[part]
229
+ else:
230
+ # Object key
231
+ current = current[part]
232
+
233
+ return current
234
+
235
+
236
+ def _parse_path(path: str) -> List[Union[str, int]]:
237
+ """Parse a JSON path into parts.
238
+
239
+ Examples:
240
+ "user.name" → ["user", "name"]
241
+ "items[0]" → ["items", 0]
242
+ "users[0].name" → ["users", 0, "name"]
243
+ """
244
+ parts: List[Union[str, int]] = []
245
+ current = ""
246
+
247
+ i = 0
248
+ while i < len(path):
249
+ char = path[i]
250
+
251
+ if char == ".":
252
+ if current:
253
+ parts.append(current)
254
+ current = ""
255
+ elif char == "[":
256
+ if current:
257
+ parts.append(current)
258
+ current = ""
259
+ # Find closing bracket
260
+ j = path.index("]", i)
261
+ index = int(path[i + 1 : j])
262
+ parts.append(index)
263
+ i = j
264
+ else:
265
+ current += char
266
+
267
+ i += 1
268
+
269
+ if current:
270
+ parts.append(current)
271
+
272
+ return parts
erdo/test/runner.py ADDED
@@ -0,0 +1,263 @@
1
+ """Agent test runner that discovers and runs agent_test_* functions in parallel.
2
+
3
+ This module provides a test runner that:
4
+ 1. Discovers all agent_test_*() functions in a Python file
5
+ 2. Invokes all agents in parallel (fast)
6
+ 3. Runs assertions sequentially (already fast)
7
+ 4. Reports results in a clean format
8
+
9
+ Usage:
10
+ python -m erdo.test.runner path/to/test_file.py
11
+
12
+ Or via CLI:
13
+ erdo agent-test path/to/test_file.py
14
+ """
15
+
16
+ import concurrent.futures
17
+ import importlib.util
18
+ import inspect
19
+ import sys
20
+ import time
21
+ import traceback
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Callable, List, Optional, Tuple
25
+
26
+
27
+ @dataclass
28
+ class TestResult:
29
+ """Result from running a single test."""
30
+
31
+ name: str
32
+ passed: bool
33
+ duration: float
34
+ error: Optional[str] = None
35
+ assertion_error: Optional[str] = None
36
+
37
+
38
+ @dataclass
39
+ class TestSummary:
40
+ """Summary of all test results."""
41
+
42
+ total: int
43
+ passed: int
44
+ failed: int
45
+ duration: float
46
+ results: List[TestResult]
47
+
48
+
49
+ def discover_tests(file_path: str) -> List[Tuple[str, Callable]]:
50
+ """Discover all agent_test_* functions in a Python file.
51
+
52
+ Args:
53
+ file_path: Path to the Python file
54
+
55
+ Returns:
56
+ List of (test_name, test_function) tuples
57
+ """
58
+ # Load the module
59
+ spec = importlib.util.spec_from_file_location("test_module", file_path)
60
+ if not spec or not spec.loader:
61
+ raise ImportError(f"Cannot load module from {file_path}")
62
+
63
+ module = importlib.util.module_from_spec(spec)
64
+ sys.modules["test_module"] = module
65
+ spec.loader.exec_module(module)
66
+
67
+ # Find all agent_test_* functions
68
+ tests = []
69
+ for name, obj in inspect.getmembers(module):
70
+ if name.startswith("agent_test_") and callable(obj):
71
+ tests.append((name, obj))
72
+
73
+ return tests
74
+
75
+
76
+ def run_test(test_name: str, test_func: Callable) -> TestResult:
77
+ """Run a single test function.
78
+
79
+ Args:
80
+ test_name: Name of the test
81
+ test_func: Test function to run
82
+
83
+ Returns:
84
+ TestResult with outcome
85
+ """
86
+ start_time = time.time()
87
+
88
+ try:
89
+ # Run the test function
90
+ test_func()
91
+
92
+ # If we get here, test passed
93
+ duration = time.time() - start_time
94
+ return TestResult(name=test_name, passed=True, duration=duration)
95
+
96
+ except AssertionError as e:
97
+ # Assertion failed
98
+ duration = time.time() - start_time
99
+ return TestResult(
100
+ name=test_name,
101
+ passed=False,
102
+ duration=duration,
103
+ assertion_error=str(e),
104
+ )
105
+
106
+ except Exception:
107
+ # Other error (invocation failed, etc.)
108
+ duration = time.time() - start_time
109
+ tb = traceback.format_exc()
110
+ return TestResult(name=test_name, passed=False, duration=duration, error=tb)
111
+
112
+
113
+ def run_tests_parallel(
114
+ tests: List[Tuple[str, Callable]], max_workers: Optional[int] = None
115
+ ) -> TestSummary:
116
+ """Run all tests in parallel.
117
+
118
+ Args:
119
+ tests: List of (test_name, test_function) tuples
120
+ max_workers: Maximum number of parallel workers
121
+
122
+ Returns:
123
+ TestSummary with all results
124
+ """
125
+ start_time = time.time()
126
+ results = []
127
+
128
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
129
+ # Submit all tests
130
+ futures = {executor.submit(run_test, name, func): name for name, func in tests}
131
+
132
+ # Collect results as they complete
133
+ for future in concurrent.futures.as_completed(futures):
134
+ result = future.result()
135
+ results.append(result)
136
+
137
+ duration = time.time() - start_time
138
+ passed = sum(1 for r in results if r.passed)
139
+ failed = len(results) - passed
140
+
141
+ return TestSummary(
142
+ total=len(results),
143
+ passed=passed,
144
+ failed=failed,
145
+ duration=duration,
146
+ results=sorted(
147
+ results, key=lambda r: r.name
148
+ ), # Sort by name for consistent output
149
+ )
150
+
151
+
152
+ def print_summary(summary: TestSummary, verbose: bool = False):
153
+ """Print test results summary.
154
+
155
+ Args:
156
+ summary: TestSummary to print
157
+ verbose: Whether to show detailed error messages
158
+ """
159
+ print()
160
+ print("=" * 70)
161
+ print("AGENT TEST RESULTS")
162
+ print("=" * 70)
163
+ print()
164
+
165
+ # Print each test result
166
+ for result in summary.results:
167
+ if result.passed:
168
+ print(f"✅ {result.name} ({result.duration:.2f}s)")
169
+ else:
170
+ print(f"❌ {result.name} ({result.duration:.2f}s)")
171
+ if verbose:
172
+ if result.assertion_error:
173
+ print(f" Assertion: {result.assertion_error}")
174
+ if result.error:
175
+ print(f" Error:\n{result.error}")
176
+
177
+ # Print summary
178
+ print()
179
+ print("-" * 70)
180
+ print(
181
+ f"Total: {summary.total} | "
182
+ f"Passed: {summary.passed} | "
183
+ f"Failed: {summary.failed} | "
184
+ f"Duration: {summary.duration:.2f}s"
185
+ )
186
+ print("-" * 70)
187
+ print()
188
+
189
+ # Print failed test details if not verbose
190
+ if not verbose and summary.failed > 0:
191
+ print("Failed tests:")
192
+ for result in summary.results:
193
+ if not result.passed:
194
+ print(f"\n{result.name}:")
195
+ if result.assertion_error:
196
+ print(f" {result.assertion_error}")
197
+ if result.error:
198
+ # Print just the last line of the error
199
+ error_lines = result.error.strip().split("\n")
200
+ print(f" {error_lines[-1]}")
201
+ print("\n(Use --verbose for full error traces)")
202
+ print()
203
+
204
+
205
+ def main(
206
+ file_path: str, verbose: bool = False, max_workers: Optional[int] = None
207
+ ) -> int:
208
+ """Main entry point for the test runner.
209
+
210
+ Args:
211
+ file_path: Path to the test file
212
+ verbose: Whether to show detailed output
213
+ max_workers: Maximum number of parallel workers
214
+
215
+ Returns:
216
+ Exit code (0 for success, 1 for failure)
217
+ """
218
+ try:
219
+ # Check file exists
220
+ if not Path(file_path).exists():
221
+ print(f"Error: File not found: {file_path}")
222
+ return 1
223
+
224
+ # Discover tests
225
+ print(f"Discovering tests in {file_path}...")
226
+ tests = discover_tests(file_path)
227
+
228
+ if not tests:
229
+ print("No tests found (looking for agent_test_* functions)")
230
+ return 1
231
+
232
+ print(f"Found {len(tests)} tests")
233
+ print()
234
+
235
+ # Run tests
236
+ print("Running tests in parallel...")
237
+ summary = run_tests_parallel(tests, max_workers=max_workers)
238
+
239
+ # Print results
240
+ print_summary(summary, verbose=verbose)
241
+
242
+ # Return exit code
243
+ return 0 if summary.failed == 0 else 1
244
+
245
+ except Exception as e:
246
+ print(f"Error running tests: {e}")
247
+ traceback.print_exc()
248
+ return 1
249
+
250
+
251
+ if __name__ == "__main__":
252
+ import argparse
253
+
254
+ parser = argparse.ArgumentParser(description="Run agent tests")
255
+ parser.add_argument("file", help="Path to test file")
256
+ parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
257
+ parser.add_argument(
258
+ "-j", "--jobs", type=int, default=None, help="Number of parallel jobs"
259
+ )
260
+
261
+ args = parser.parse_args()
262
+
263
+ sys.exit(main(args.file, verbose=args.verbose, max_workers=args.jobs))