quack-test 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Michael F.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.4
2
+ Name: quack-test
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: pytest>=7.0.0
9
+ Requires-Dist: openai>=1.0.0
10
+ Requires-Dist: python-dotenv>=1.0.0
11
+ Dynamic: license-file
12
+
13
+ # Quack Test
14
+
15
+ A plugin for pytest to evaluate non-deterministic agent components.
16
+
17
+ ## Installation
18
+
19
+ Simply pip install it
20
+
21
+ ```bash
22
+ pip install quack-test
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ You can simply specify `@nondeterministic` for components, which are not deterministic and should be run multiple times.
28
+
29
+ ```python
30
+ import random
31
+ from quack_test import nondeterministic_fixture, nondeterministic_test, judge
32
+
33
+ @nondeterministic_fixture(n=5)
34
+ def sample_text():
35
+ return f"I have {random.randint(10)} apples."
36
+
37
+ @nondeterministic_test(score=0.8)
38
+ def test_apples(sample_text):
39
+ return judge(sample_text, criterion="Has more than 1 apple")
40
+
41
+ @nondeterministic_test(score=0.8)
42
+ def test_coal(sample_text):
43
+ return judge(sample_text, criterion="Has only coal")
44
+ ```
@@ -0,0 +1,32 @@
1
+ # Quack Test
2
+
3
+ A plugin for pytest to evaluate non-deterministic agent components.
4
+
5
+ ## Installation
6
+
7
+ Simply pip install it
8
+
9
+ ```bash
10
+ pip install quack-test
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ You can simply specify `@nondeterministic` for components, which are not deterministic and should be run multiple times.
16
+
17
+ ```python
18
+ import random
19
+ from quack_test import nondeterministic_fixture, nondeterministic_test, judge
20
+
21
+ @nondeterministic_fixture(n=5)
22
+ def sample_text():
23
+ return f"I have {random.randint(10)} apples."
24
+
25
+ @nondeterministic_test(score=0.8)
26
+ def test_apples(sample_text):
27
+ return judge(sample_text, criterion="Has more than 1 apple")
28
+
29
+ @nondeterministic_test(score=0.8)
30
+ def test_coal(sample_text):
31
+ return judge(sample_text, criterion="Has only coal")
32
+ ```
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "quack-test"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "pytest>=7.0.0",
9
+ "openai>=1.0.0",
10
+ "python-dotenv>=1.0.0",
11
+ ]
12
+
13
+ [dependency-groups]
14
+ dev = [
15
+ "ty",
16
+ "ruff",
17
+ "pre-commit",
18
+ ]
@@ -0,0 +1,15 @@
1
+ """
2
+ Quack Test - Decorators for handling nondeterministic tests and fixtures.
3
+
4
+ Provides decorators for running fixtures and tests multiple times to handle
5
+ flaky behavior, particularly useful for LLM-based tests.
6
+ """
7
+
8
+ from quack_test.decorators import nondeterministic_fixture, nondeterministic_test
9
+ from quack_test.judge import judge
10
+
11
+ __all__ = [
12
+ "nondeterministic_fixture",
13
+ "nondeterministic_test",
14
+ "judge",
15
+ ]
@@ -0,0 +1,147 @@
1
+ """
2
+ Decorators for handling nondeterministic tests and fixtures.
3
+ """
4
+
5
+ import functools
6
+ import pytest
7
+ from typing import Callable, Any, List
8
+
9
+
10
+ def nondeterministic_fixture(n: int = 5):
11
+ """
12
+ Decorator for fixtures that should be executed multiple times.
13
+
14
+ The fixture function will be executed `n` times, and all results
15
+ will be collected into a list. This list is then passed to tests
16
+ that depend on this fixture.
17
+
18
+ Args:
19
+ n: Number of times to execute the fixture (default: 5)
20
+
21
+ Example:
22
+ @nondeterministic_fixture(n=10)
23
+ def random_data():
24
+ return random.randint(1, 100)
25
+
26
+ # The test will receive a list of 10 random integers
27
+ def test_values(random_data):
28
+ assert len(random_data) == 10
29
+ """
30
+
31
+ def decorator(func: Callable) -> Callable:
32
+ @functools.wraps(func)
33
+ def wrapper(*args, **kwargs) -> List[Any]:
34
+ results = []
35
+ for _ in range(n):
36
+ result = func(*args, **kwargs)
37
+ results.append(result)
38
+ return results
39
+
40
+ # Mark this as a pytest fixture
41
+ return pytest.fixture(wrapper)
42
+
43
+ return decorator
44
+
45
+
46
+ def nondeterministic_test(score: float = 0.8, n: int = -1, should_fail: bool = False):
47
+ """
48
+ Decorator for tests that should be executed multiple times with a success threshold.
49
+
50
+ The test function will be executed multiple times (determined by the length of
51
+ fixture data if available, or a default number). The test must return a score
52
+ which will be averaged. If the average score is higher than the given score, you pass.
53
+
54
+ Args:
55
+ score: Minimum average score that must be achieved in order to pass the test.
56
+ n: Number of times to run the test (if -1, uses fixture length)
57
+ should_fail: If True, the test passes when the score is BELOW the threshold (default: False)
58
+
59
+ Example:
60
+ @nondeterministic_fixture(n=10)
61
+ def llm_output():
62
+ return call_llm("Generate a greeting")
63
+
64
+ @nondeterministic_test(score=0.8)
65
+ def test_greeting(llm_output):
66
+ return judge(llm_output, criterion="Contains 'hello' or 'hi'")
67
+
68
+ @nondeterministic_test(score=0.8, should_fail=True)
69
+ def test_no_profanity(llm_output):
70
+ return judge(llm_output, criterion="Contains profanity")
71
+ """
72
+
73
+ def decorator(func: Callable) -> Callable:
74
+ @functools.wraps(func)
75
+ def wrapper(*args, **kwargs) -> None:
76
+ # n_runs can be either given by n or defined by the length of the fixture data in args or kwargs
77
+ # use any list found in args or kwargs as fixture data
78
+ fixture_data = None
79
+ for arg in args:
80
+ if isinstance(arg, list):
81
+ fixture_data = arg
82
+ break
83
+ for kwarg in kwargs.values():
84
+ if isinstance(kwarg, list):
85
+ fixture_data = kwarg
86
+ break
87
+ if n > 0:
88
+ n_runs = n
89
+ elif fixture_data is not None:
90
+ n_runs = len(fixture_data)
91
+ else:
92
+ raise RuntimeError(
93
+ "Cannot determine number of runs for nondeterministic_test. "
94
+ "Provide a fixture that returns a list or set n explicitly."
95
+ )
96
+
97
+ # Run the test n times
98
+ successes = 0
99
+ failures = 0
100
+ scores = []
101
+
102
+ for i in range(n_runs):
103
+ try:
104
+ # If we have fixture data, pass the i-th element
105
+ test_args = [
106
+ arg[i] if isinstance(arg, list) and len(arg) == n_runs else arg
107
+ for arg in args
108
+ ]
109
+ test_kwargs = {
110
+ k: (v[i] if isinstance(v, list) and len(v) == n_runs else v)
111
+ for k, v in kwargs.items()
112
+ }
113
+
114
+ result = float(func(*test_args, **test_kwargs))
115
+ scores.append(result)
116
+
117
+ if result > score:
118
+ successes += 1
119
+ else:
120
+ failures += 1
121
+ except Exception:
122
+ failures += 1
123
+ scores.append(0)
124
+
125
+ # Calculate success rate
126
+ success_rate = successes / n_runs
127
+ achieved_score = sum(scores) / n_runs
128
+
129
+ # Assert the success rate meets the threshold
130
+ if should_fail:
131
+ # For should_fail tests, we expect the score to be BELOW the threshold
132
+ assert achieved_score < score, (
133
+ f"Test expected to fail but succeeded. "
134
+ f"Score: {achieved_score:.2} (required: < {score:.2}), "
135
+ f"Success rate: {success_rate:.2%} ({successes}/{n_runs})"
136
+ )
137
+ else:
138
+ # Normal tests expect the score to be AT OR ABOVE the threshold
139
+ assert achieved_score >= score, (
140
+ f"Test failed to meet success threshold. "
141
+ f"Score: {achieved_score:.2} (required: {score:.2}), "
142
+ f"Success rate: {success_rate:.2%} ({successes}/{n_runs})"
143
+ )
144
+
145
+ return wrapper
146
+
147
+ return decorator
@@ -0,0 +1,115 @@
1
+ """
2
+ Judge function for evaluating test outputs against criteria.
3
+ """
4
+ import os
5
+ from functools import cache
6
+ from pathlib import Path
7
+ from typing import Optional
8
+ from dotenv import load_dotenv
9
+ from openai import OpenAI, AzureOpenAI
10
+
11
+ # Load environment variables
12
+ load_dotenv()
13
+
14
+ # Get the directory where this file is located
15
+ _PROMPTS_DIR = Path(__file__).parent
16
+
17
+
18
+ @cache
19
+ def _load_prompt_template(filepath: Path) -> str:
20
+ """Load a prompt template from a markdown file with caching."""
21
+ with open(filepath, 'r', encoding='utf-8') as f:
22
+ return f.read()
23
+
24
+
25
+ @cache
26
+ def _get_client():
27
+ """Create and cache the OpenAI or Azure OpenAI client."""
28
+ provider = os.getenv("OPENAI_PROVIDER", "OpenAI")
29
+
30
+ if provider == "AzureOpenAI":
31
+ return AzureOpenAI(
32
+ api_key=os.environ["OPENAI_API_KEY"],
33
+ base_url=os.environ["OPENAI_ENDPOINT"],
34
+ api_version=os.getenv("OPENAI_API_VERSION", "2024-10-21"),
35
+ )
36
+ else:
37
+ return OpenAI(
38
+ api_key=os.environ["OPENAI_API_KEY"],
39
+ base_url=os.environ["OPENAI_ENDPOINT"],
40
+ )
41
+
42
+
43
+ def judge(
44
+ text: str,
45
+ criterion: str = "",
46
+ gt: str = "",
47
+ prompt_template: Optional[str] = None
48
+ ) -> float:
49
+ """
50
+ Evaluate whether an text meets a given criterion or matches the ground truth.
51
+
52
+ Args:
53
+ text: The text to evaluate (typically a string)
54
+ criterion: (Optional) The criterion to check against
55
+ gt: (Optional) A ground truth which the text should match
56
+ prompt_template: (Optional) Custom prompt template string with {text}, {criterion}, and/or {gt} placeholders.
57
+ If not provided, loads from criterion_prompt.md or gt_prompt.md
58
+
59
+ Returns:
60
+ float: A score how well it matches the gt or criterion
61
+
62
+ Examples:
63
+ >>> judge("I have 5 apples", criterion="Has more than 1 apple")
64
+ 5.0
65
+
66
+ >>> judge("I have coal", gt="I have some coal")
67
+ 5.0
68
+
69
+ >>> judge("No apples here", criterion="Has more than 1 apple")
70
+ 0.0
71
+
72
+ >>> judge("I have 5 apples", criterion="Has apples",
73
+ ... prompt_template="Does '{text}' meet '{criterion}'? Score 0-1:")
74
+ 5.0
75
+ """
76
+ # Build the prompt based on what's provided
77
+ if prompt_template:
78
+ # Use custom prompt template
79
+ prompt = prompt_template.format(criterion=criterion, gt=gt)
80
+ elif criterion and gt:
81
+ raise ValueError("Criterion and gt cannot be set at the same time without a custom template.")
82
+ elif criterion:
83
+ # Load criterion prompt template
84
+ template = _load_prompt_template(_PROMPTS_DIR / "prompt_criterion.md")
85
+ prompt = template.format(criterion=criterion)
86
+ elif gt:
87
+ # Load ground truth prompt template
88
+ template = _load_prompt_template(_PROMPTS_DIR / "prompt_gt.md")
89
+ prompt = template.format(gt=gt)
90
+ else:
91
+ raise ValueError("Either criterion or gt must be provided")
92
+
93
+ # Make the API call
94
+ client = _get_client()
95
+ response = client.chat.completions.create(
96
+ model=os.getenv("OPENAI_MODEL_NAME", "gpt-4o"),
97
+ messages=[
98
+ {"role": "system", "content": prompt},
99
+ {"role": "user", "content": text}
100
+ ],
101
+ temperature=0.0,
102
+ )
103
+
104
+ # Extract and return the score
105
+ content = response.choices[0].message.content
106
+ if content is None:
107
+ raise ValueError("Received empty response from API")
108
+
109
+ score_text = content.strip()
110
+ try:
111
+ score = float(score_text)
112
+ # Clamp the score between 0.0 and 1.0
113
+ return score
114
+ except ValueError:
115
+ raise ValueError(f"Failed to parse score from response: {score_text}")
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.4
2
+ Name: quack-test
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: pytest>=7.0.0
9
+ Requires-Dist: openai>=1.0.0
10
+ Requires-Dist: python-dotenv>=1.0.0
11
+ Dynamic: license-file
12
+
13
+ # Quack Test
14
+
15
+ A plugin for pytest to evaluate non-deterministic agent components.
16
+
17
+ ## Installation
18
+
19
+ Simply pip install it
20
+
21
+ ```bash
22
+ pip install quack-test
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ You can simply specify `@nondeterministic` for components, which are not deterministic and should be run multiple times.
28
+
29
+ ```python
30
+ import random
31
+ from quack_test import nondeterministic_fixture, nondeterministic_test, judge
32
+
33
+ @nondeterministic_fixture(n=5)
34
+ def sample_text():
35
+ return f"I have {random.randint(10)} apples."
36
+
37
+ @nondeterministic_test(score=0.8)
38
+ def test_apples(sample_text):
39
+ return judge(sample_text, criterion="Has more than 1 apple")
40
+
41
+ @nondeterministic_test(score=0.8)
42
+ def test_coal(sample_text):
43
+ return judge(sample_text, criterion="Has only coal")
44
+ ```
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ quack_test/__init__.py
5
+ quack_test/decorators.py
6
+ quack_test/judge.py
7
+ quack_test.egg-info/PKG-INFO
8
+ quack_test.egg-info/SOURCES.txt
9
+ quack_test.egg-info/dependency_links.txt
10
+ quack_test.egg-info/requires.txt
11
+ quack_test.egg-info/top_level.txt
12
+ test/test_example.py
@@ -0,0 +1,3 @@
1
+ pytest>=7.0.0
2
+ openai>=1.0.0
3
+ python-dotenv>=1.0.0
@@ -0,0 +1 @@
1
+ quack_test
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,18 @@
1
+ import random
2
+ from quack_test import nondeterministic_fixture, nondeterministic_test, judge
3
+
4
+ @nondeterministic_fixture(n=5)
5
+ def sample_text():
6
+ return f"I have {random.randint(0, 10)} apples."
7
+
8
+ @nondeterministic_test(score=0.8)
9
+ def test_apples(sample_text):
10
+ return judge(sample_text, criterion="Has more than 1 apple")
11
+
12
+ @nondeterministic_test(score=0.8)
13
+ def test_apples_gt(sample_text):
14
+ return judge(sample_text, gt="I have N (0-10) apples")
15
+
16
+ @nondeterministic_test(score=0.8, should_fail=True)
17
+ def test_coal(sample_text):
18
+ return judge(sample_text, criterion="Has only coal")