Skip to content

Commit 1699cd5

Browse files
Merge branch 'main' into cursor/pydantic-checks-settings-f16d
2 parents 8cdaf12 + c583910 commit 1699cd5

8 files changed

Lines changed: 623 additions & 89 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Giskard is an open-source Python library for **testing and evaluating agentic sy
4444
| Status | Package | Description |
4545
| -------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
4646
| ✅ Beta | `giskard-checks` | Testing & evaluation — scenario API, built-in checks, LLM-as-judge |
47-
| 🚧 In progress | `giskard-scan` | Agent vulnerability scanner — red teaming, prompt injection, data leakage (successor of [v2 Scan](https://legacy-docs.giskard.ai/en/stable/open_source/scan/index.html)) |
47+
| ✅ Beta | `giskard-scan` | Agent vulnerability scanner — red teaming, prompt injection, data leakage (successor of [v2 Scan](https://legacy-docs.giskard.ai/en/stable/open_source/scan/index.html)) |
4848
| 📋 Planned | `giskard-rag` | RAG evaluation & synthetic data generation (successor of [v2 RAGET](https://legacy-docs.giskard.ai/en/stable/open_source/testset_generation/index.html)) |
4949

5050
## Giskard Checks — create and apply evals for testing agents

THIRD_PARTY_NOTICES.md

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ Find a list of packages below
1717
||aiohappyeyeballs|
1818
||aiohttp|
1919
||aiosignal|
20-
||annotated-doc|
2120
||annotated-types|
2221
||anthropic|
2322
||anyio|
@@ -78,13 +77,11 @@ Find a list of packages below
7877
||requests|
7978
||rich|
8079
||rpds-py|
81-
||shellingham|
8280
||sniffio|
8381
||tenacity|
8482
||tiktoken|
8583
||tokenizers|
8684
||tqdm|
87-
||typer|
8885
||typing-extensions|
8986
||typing-inspection|
9087
||urllib3|
@@ -113,13 +110,6 @@ Find a list of packages below
113110
- License: Apache Software License
114111
- Compatible: True
115112

116-
### annotated-doc-0.0.4
117-
118-
- HomePage:
119-
- Author: =?utf-8?q?Sebasti=C3=A1n_Ram=C3=ADrez?=
120-
- License: MIT
121-
- Compatible: True
122-
123113
### annotated-types-0.7.0
124114

125115
- HomePage:
@@ -540,13 +530,6 @@ Find a list of packages below
540530
- License: MIT
541531
- Compatible: True
542532

543-
### shellingham-1.5.4
544-
545-
- HomePage: https://github.com/sarugaku/shellingham
546-
- Author: Tzu-ping Chung
547-
- License: ISC License _ISCL_
548-
- Compatible: True
549-
550533
### sniffio-1.3.1
551534

552535
- HomePage:
@@ -582,13 +565,6 @@ Find a list of packages below
582565
- License: MIT;; MPL-2.0
583566
- Compatible: True
584567

585-
### typer-0.25.1
586-
587-
- HomePage:
588-
- Author: Sebastián Ramírez
589-
- License: MIT
590-
- Compatible: True
591-
592568
### typing-extensions-4.15.0
593569

594570
- HomePage:

libs/giskard-checks/src/giskard/checks/builtin/comparison.py

Lines changed: 142 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import warnings
22
from abc import ABC, abstractmethod
3-
from typing import Any, Self, override
3+
from typing import Any, Literal, Self, override
44

55
from pydantic import Field, model_validator
66
from pydantic.experimental.missing_sentinel import MISSING
@@ -11,6 +11,9 @@
1111
from ..core.result import CheckResult
1212
from ..utils.normalization import NormalizationForm, normalize_data
1313

14+
MatchMode = Literal["any", "all", "none"]
15+
type MatchCollection[T] = list[T] | set[T] | tuple[T, ...]
16+
1417

1518
class ComparisonCheck[InputType, OutputType, TraceType: Trace, ExpectedType]( # pyright: ignore[reportMissingTypeArgument]
1619
ABC, Check[InputType, OutputType, TraceType]
@@ -48,6 +51,15 @@ class ComparisonCheck[InputType, OutputType, TraceType: Trace, ExpectedType]( #
4851
default="NFKC",
4952
description="Unicode normalization form to apply before comparison. Defaults to NFKC.",
5053
)
54+
match: MatchMode | MISSING = Field(
55+
default=MISSING,
56+
description=(
57+
"How to apply the comparison when the resolved actual value is a collection. "
58+
"When omitted, the resolved value is compared directly. "
59+
"'any' passes if at least one item matches, 'all' if every item matches, "
60+
"'none' if no item matches. Requires a list, set, or tuple."
61+
),
62+
)
5163

5264
@abstractmethod
5365
def _compare(self, actual_value: Any, expected_value: ExpectedType) -> bool:
@@ -75,6 +87,125 @@ def validate_expected_value_or_expected_value_key(self) -> Self:
7587
)
7688
return self
7789

90+
def _compare_normalized(
91+
self, normalized_actual: Any, normalized_expected: ExpectedType
92+
) -> bool | None:
93+
try:
94+
return self._compare(normalized_actual, normalized_expected)
95+
except Exception:
96+
return None
97+
98+
def _try_compare(
99+
self, actual_value: Any, expected_value: ExpectedType
100+
) -> bool | None:
101+
return self._compare_normalized(
102+
normalize_data(actual_value, self.normalization_form),
103+
normalize_data(expected_value, self.normalization_form),
104+
)
105+
106+
def _try_compare_to_normalized_expected(
107+
self, actual_value: Any, normalized_expected: ExpectedType
108+
) -> bool | None:
109+
return self._compare_normalized(
110+
normalize_data(actual_value, self.normalization_form),
111+
normalized_expected,
112+
)
113+
114+
def _unsupported_comparison_message(
115+
self, actual_value: Any, expected_value: ExpectedType
116+
) -> str:
117+
return (
118+
f"Comparison not supported: items in {type(actual_value).__name__} "
119+
f"do not support {self._operator_symbol} comparison with "
120+
f"{type(expected_value).__name__}"
121+
)
122+
123+
def _collection_match_message(
124+
self,
125+
passed: bool,
126+
actual_value: Any,
127+
expected_value: ExpectedType,
128+
matched_items: list[Any] | None = None,
129+
) -> str:
130+
actual_repr = repr(actual_value)
131+
expected_repr = repr(expected_value)
132+
comparison = self._comparison_message
133+
if self.match == "any":
134+
if passed:
135+
return f"At least one value in {actual_repr} is {comparison} {expected_repr}."
136+
return (
137+
f"Expected at least one value {comparison} {expected_repr} "
138+
f"but none matched in {actual_repr}."
139+
)
140+
if self.match == "all":
141+
if passed:
142+
return f"All values in {actual_repr} are {comparison} {expected_repr}."
143+
return f"Expected all values {comparison} {expected_repr} but got {actual_repr}."
144+
if passed:
145+
return f"No value in {actual_repr} is {comparison} {expected_repr}."
146+
return (
147+
f"Expected no value {comparison} {expected_repr} "
148+
f"but found matches in {repr(matched_items)}."
149+
)
150+
151+
def _run_collection_match(
152+
self,
153+
actual_value: Any,
154+
expected_value: ExpectedType,
155+
details: dict[str, Any],
156+
) -> CheckResult:
157+
if not isinstance(actual_value, (list, set, tuple)):
158+
return CheckResult.failure(
159+
message=(
160+
f"Expected a list, set, or tuple at key '{self.key}' when match is "
161+
f"{self.match!r}, but got {type(actual_value).__name__}."
162+
),
163+
details=details,
164+
)
165+
166+
collection: MatchCollection[Any] = actual_value
167+
normalized_expected = normalize_data(expected_value, self.normalization_form)
168+
comparison_results: list[bool | None] = []
169+
matched_items: list[Any] = []
170+
for item in collection:
171+
result = self._try_compare_to_normalized_expected(item, normalized_expected)
172+
comparison_results.append(result)
173+
if result:
174+
matched_items.append(item)
175+
176+
if any(result is None for result in comparison_results):
177+
return CheckResult.failure(
178+
message=self._unsupported_comparison_message(
179+
actual_value, expected_value
180+
),
181+
details=details,
182+
)
183+
184+
if self.match == "any":
185+
passed = any(comparison_results)
186+
elif self.match == "all":
187+
passed = all(comparison_results)
188+
else:
189+
passed = not any(comparison_results)
190+
191+
if passed:
192+
return CheckResult.success(
193+
message=self._collection_match_message(
194+
passed, actual_value, expected_value
195+
),
196+
details=details,
197+
)
198+
199+
return CheckResult.failure(
200+
message=self._collection_match_message(
201+
passed,
202+
actual_value,
203+
expected_value,
204+
matched_items if self.match == "none" else None,
205+
),
206+
details=details,
207+
)
208+
78209
@override
79210
async def run(self, trace: TraceType) -> CheckResult:
80211
"""Execute the check against the provided trace."""
@@ -102,21 +233,20 @@ async def run(self, trace: TraceType) -> CheckResult:
102233
details=details,
103234
)
104235

105-
normalized_actual_value = normalize_data(actual_value, self.normalization_form)
106-
normalized_expected_value = normalize_data(
107-
expected_value, self.normalization_form
108-
)
109-
try:
110-
if self._compare(normalized_actual_value, normalized_expected_value):
111-
return CheckResult.success(
112-
message=f"The actual value {repr(actual_value)} is {self._comparison_message} the expected value {repr(expected_value)}.",
113-
details=details,
114-
)
115-
except Exception:
236+
if self.match is not MISSING:
237+
return self._run_collection_match(actual_value, expected_value, details)
238+
239+
compare_result = self._try_compare(actual_value, expected_value)
240+
if compare_result is None:
116241
return CheckResult.failure(
117242
message=f"Comparison not supported: {type(actual_value).__name__} does not support {self._operator_symbol} comparison with {type(expected_value).__name__}",
118243
details=details,
119244
)
245+
if compare_result:
246+
return CheckResult.success(
247+
message=f"The actual value {repr(actual_value)} is {self._comparison_message} the expected value {repr(expected_value)}.",
248+
details=details,
249+
)
120250

121251
return CheckResult.failure(
122252
message=f"Expected value {self._comparison_message} {repr(expected_value)} but got {repr(actual_value)}",

libs/giskard-checks/src/giskard/checks/builtin/json_valid.py

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ class JsonValid[InputType, OutputType, TraceType: Trace]( # pyright: ignore[rep
2121
):
2222
"""Check that validates whether a trace value is valid JSON.
2323
24-
The extracted value can be a JSON string or an already parsed JSON-compatible
25-
value such as a dict, list, string, number, boolean, or None.
24+
With ``parse=True`` (default), the extracted value must be a serialized
25+
JSON string, which is parsed with ``json.loads`` before validation. With
26+
``parse=False``, the value is treated as an already-parsed JSON value
27+
(dict, list, str, number, bool, or None) and only checked for JSON
28+
serializability and schema conformance.
2629
"""
2730

2831
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
@@ -31,6 +34,14 @@ class JsonValid[InputType, OutputType, TraceType: Trace]( # pyright: ignore[rep
3134
default="trace.last.outputs",
3235
description="JSONPath expression to extract the value to validate.",
3336
)
37+
parse: bool = Field(
38+
default=True,
39+
description=(
40+
"If True, the value is treated as a serialized JSON string and "
41+
"parsed before validation. If False, the value is treated as an "
42+
"already-parsed JSON value."
43+
),
44+
)
3445
expected_schema: dict[str, Any] | None = Field(
3546
default=None,
3647
alias="schema",
@@ -73,26 +84,35 @@ async def run(self, trace: TraceType) -> CheckResult:
7384
details=details,
7485
)
7586

76-
try:
77-
parsed_value = self._parse_json(value)
78-
except TypeError as err:
79-
details["error"] = str(err)
80-
return CheckResult.failure(
81-
message=f"Value at key '{self.key}' is not JSON serializable: {err}",
82-
details=details,
83-
)
84-
except json.JSONDecodeError as err:
85-
details["error"] = str(err)
86-
return CheckResult.failure(
87-
message=f"Value at key '{self.key}' is not valid JSON: {err}",
88-
details=details,
89-
)
87+
if self.parse:
88+
if not isinstance(value, str):
89+
return CheckResult.failure(
90+
message=f"Value at key '{self.key}' is not a string: {value!r}",
91+
details=details,
92+
)
93+
try:
94+
value = json.loads(value)
95+
except json.JSONDecodeError as err:
96+
details["error"] = str(err)
97+
return CheckResult.failure(
98+
message=f"Value at key '{self.key}' is not valid JSON: {err}",
99+
details=details,
100+
)
101+
else:
102+
try:
103+
json.dumps(value)
104+
except (TypeError, ValueError) as err:
105+
details["error"] = str(err)
106+
return CheckResult.failure(
107+
message=f"Value at key '{self.key}' is not JSON serializable: {err}",
108+
details=details,
109+
)
90110

91-
details["parsed_value"] = parsed_value
111+
details["parsed_value"] = value
92112

93113
if self.expected_schema is not None:
94114
try:
95-
self._validate_schema(parsed_value, self.expected_schema)
115+
self._validate_schema(value, self.expected_schema)
96116
except Unresolvable as err:
97117
details["error"] = str(err)
98118
return CheckResult.error(
@@ -102,7 +122,10 @@ async def run(self, trace: TraceType) -> CheckResult:
102122
except JsonSchemaValidationError as err:
103123
details["error"] = err.message
104124
return CheckResult.failure(
105-
message=f"JSON value at key '{self.key}' does not match the provided schema: {err.message}.",
125+
message=(
126+
f"JSON value at key '{self.key}' does not match the "
127+
f"provided schema: {err.message}."
128+
),
106129
details=details,
107130
)
108131

@@ -111,18 +134,6 @@ async def run(self, trace: TraceType) -> CheckResult:
111134
details=details,
112135
)
113136

114-
@staticmethod
115-
def _parse_json(value: Any) -> Any:
116-
if isinstance(value, str):
117-
return json.loads(value)
118-
119-
try:
120-
json.dumps(value)
121-
except (TypeError, ValueError) as err:
122-
raise TypeError(str(err)) from err
123-
124-
return value
125-
126137
@staticmethod
127138
def _validate_schema_definition(schema: dict[str, Any]) -> None:
128139
validator_for(schema).check_schema(schema)

0 commit comments

Comments
 (0)