smartselect 0.1.3__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,269 @@
1
+ Metadata-Version: 2.4
2
+ Name: smartselect
3
+ Version: 0.1.3
4
+ Summary: LLM-powered test selection for CI/CD pipelines
5
+ Project-URL: Homepage, https://github.com/mattfrautnick/testwise
6
+ Project-URL: Repository, https://github.com/mattfrautnick/testwise
7
+ Project-URL: Issues, https://github.com/mattfrautnick/testwise/issues
8
+ Author: Matthew Frautnick
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai,cd,ci,llm,test-selection,testing
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Testing
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: click>=8.0
23
+ Requires-Dist: litellm>=1.40.0
24
+ Requires-Dist: pydantic>=2.0
25
+ Requires-Dist: pyyaml>=6.0
26
+ Requires-Dist: tiktoken>=0.7.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: mypy; extra == 'dev'
29
+ Requires-Dist: pytest-cov; extra == 'dev'
30
+ Requires-Dist: pytest-mock; extra == 'dev'
31
+ Requires-Dist: pytest>=8.0; extra == 'dev'
32
+ Requires-Dist: ruff; extra == 'dev'
33
+ Requires-Dist: types-pyyaml; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ <p align="center">
37
+ <h1 align="center">Testwise</h1>
38
+ <p align="center">
39
+ LLM-powered test selection for CI/CD pipelines
40
+ <br />
41
+ <em>Run only the tests that matter. Save CI time without sacrificing coverage.</em>
42
+ </p>
43
+ <p align="center">
44
+ <a href="https://github.com/mattfrautnick/testwise/actions/workflows/ci.yml"><img src="https://github.com/mattfrautnick/testwise/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
45
+ <a href="https://pypi.org/project/testwise/"><img src="https://img.shields.io/pypi/v/testwise.svg" alt="PyPI"></a>
46
+ <a href="https://pypi.org/project/testwise/"><img src="https://img.shields.io/pypi/pyversions/testwise.svg" alt="Python"></a>
47
+ <a href="https://github.com/mattfrautnick/testwise/blob/main/LICENSE"><img src="https://img.shields.io/github/license/mattfrautnick/testwise" alt="License"></a>
48
+ </p>
49
+ </p>
50
+
51
+ ---
52
+
53
+ Testwise analyzes your git diff and uses an LLM to classify every test as `must_run`, `should_run`, or `skip` — then executes only what's needed. It supports **test-level granularity** for languages with parser plugins and falls back to file-level selection for everything else.
54
+
55
+ ## Why Testwise?
56
+
57
+ Large test suites slow down CI. Most changes only affect a fraction of your tests, but running the full suite every time wastes minutes (or hours). Existing static-analysis approaches miss indirect dependencies and cross-cutting concerns. Testwise uses an LLM that actually understands your code changes and test structure to make smarter decisions — with a safe fallback to run everything if it's ever uncertain.
58
+
59
+ ## How It Works
60
+
61
+ ```
62
+ git diff ─> Discover Tests ─> Parse with Plugins ─> LLM Classifies ─> Run Selected ─> Report
63
+ ```
64
+
65
+ 1. **Diff Analysis** — Extracts the git diff between base and head refs
66
+ 2. **Test Discovery** — Finds all test files and parses individual test functions via parser plugins
67
+ 3. **LLM Classification** — Sends diff + test inventory to an LLM with structured output
68
+ 4. **Selective Execution** — Runs only selected tests and reports results with GitHub annotations
69
+
70
+ ## Features
71
+
72
+ - **Hybrid Granularity** — Test-level selection for languages with parser plugins (pytest built-in), file-level fallback for others
73
+ - **Plugin Architecture** — Extensible parser system via Python entry points. [Write a parser](#writing-a-parser-plugin) for any test framework.
74
+ - **Any LLM Provider** — Uses [litellm](https://github.com/BerriAI/litellm) to support Claude, GPT, Gemini, and 100+ other models
75
+ - **GitHub Actions** — Ships as a composite action with step summary, annotations, and outputs
76
+ - **Safe Fallback** — If the LLM fails or is uncertain, falls back to running all tests
77
+ - **Test Annotations** — Supports `@pytest.mark.covers()` to explicitly map tests to code areas
78
+
79
+ ## Quick Start
80
+
81
+ ### Install
82
+
83
+ ```bash
84
+ pip install smartselect
85
+ ```
86
+
87
+ ### Configure
88
+
89
+ Create `.testwise.yml` in your repo root:
90
+
91
+ ```yaml
92
+ runners:
93
+ - name: pytest
94
+ command: pytest
95
+ args: ["-v", "--tb=short"]
96
+ test_patterns: ["tests/**/*.py", "test_*.py"]
97
+ parser: pytest
98
+ select_mode: test
99
+
100
+ llm:
101
+ model: anthropic/claude-sonnet-4-20250514
102
+ api_key_env: ANTHROPIC_API_KEY
103
+ ```
104
+
105
+ ### Run
106
+
107
+ ```bash
108
+ # Dry run — see what the LLM would select
109
+ testwise --dry-run
110
+
111
+ # Run selected tests
112
+ testwise
113
+
114
+ # Force all tests (bypass LLM)
115
+ testwise --fallback
116
+ ```
117
+
118
+ ### GitHub Actions
119
+
120
+ ```yaml
121
+ jobs:
122
+ test:
123
+ runs-on: ubuntu-latest
124
+ steps:
125
+ - uses: actions/checkout@v4
126
+ with:
127
+ fetch-depth: 0 # Full history needed for diff
128
+
129
+ - uses: mattfrautnick/testwise@v1
130
+ with:
131
+ api-key: ${{ secrets.ANTHROPIC_API_KEY }}
132
+ run-level: should_run
133
+ ```
134
+
135
+ The action writes a Markdown summary to `$GITHUB_STEP_SUMMARY` and emits `::error::` annotations for failing tests inline in your PR diff.
136
+
137
+ ## Test Annotations
138
+
139
+ Testwise's pytest parser understands standard markers and a custom `@covers` annotation that explicitly maps tests to code areas:
140
+
141
+ ```python
142
+ import pytest
143
+
144
+ @pytest.mark.covers("auth_module", "user.login")
145
+ def test_login_success(client, db):
146
+ """Verify successful login flow."""
147
+ ...
148
+
149
+ @pytest.mark.integration
150
+ @pytest.mark.covers("payment_service")
151
+ def test_checkout_flow(client):
152
+ ...
153
+
154
+ @pytest.mark.parametrize("role", ["admin", "user", "guest"])
155
+ def test_permissions(role):
156
+ ...
157
+ ```
158
+
159
+ The parser also extracts imports and fixture references automatically — no annotation required for basic dependency mapping.
160
+
161
+ ## Parser Plugins
162
+
163
+ Testwise uses a plugin architecture for language-specific test parsing. Plugins are registered via Python entry points.
164
+
165
+ ### Built-in Parsers
166
+
167
+ | Parser | Language | Granularity | Features |
168
+ |--------|----------|-------------|----------|
169
+ | `pytest` | Python | Test-level | Markers, covers, parametrize, fixtures, imports |
170
+ | `generic` | Any | File-level | Fallback for unsupported languages |
171
+
172
+ ### Writing a Parser Plugin
173
+
174
+ Implement `BaseParser` and register it as an entry point:
175
+
176
+ ```python
177
+ from testwise.parsers import BaseParser
178
+ from testwise.models import ParsedTest, ParsedTestFile, RunnerConfig
179
+ from pathlib import Path
180
+
181
+ class JestParser(BaseParser):
182
+ name = "jest"
183
+ languages = ["javascript", "typescript"]
184
+ file_patterns = ["*.test.ts", "*.test.js", "*.spec.ts", "*.spec.js"]
185
+
186
+ def parse_test_file(self, file_path: Path, content: str) -> ParsedTestFile:
187
+ # Parse describe/it blocks, extract test names
188
+ ...
189
+
190
+ def build_run_command(self, tests, runner_config, repo_root):
191
+ # Build jest --testNamePattern command
192
+ ...
193
+ ```
194
+
195
+ ```toml
196
+ # pyproject.toml
197
+ [project.entry-points."testwise.parsers"]
198
+ jest = "my_package.jest_parser:JestParser"
199
+ ```
200
+
201
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for a full guide on writing and testing parser plugins.
202
+
203
+ ## CLI Reference
204
+
205
+ ```
206
+ testwise [OPTIONS]
207
+
208
+ Options:
209
+ -c, --config PATH Path to .testwise.yml
210
+ -b, --base-ref TEXT Base git ref to diff against
211
+ --head-ref TEXT Head git ref (default: HEAD)
212
+ -o, --output [text|json|github] Output format (default: text)
213
+ --output-file PATH Write JSON report to file
214
+ --dry-run Show selections without running tests
215
+ --fallback Skip LLM, run all tests
216
+ --run-level [must_run|should_run|all] Minimum classification to run
217
+ -v, --verbose Verbose logging
218
+ --version Show version
219
+ --help Show this message
220
+ ```
221
+
222
+ ## Configuration Reference
223
+
224
+ See [`.testwise.example.yml`](.testwise.example.yml) for a fully commented example.
225
+
226
+ | Key | Type | Default | Description |
227
+ |-----|------|---------|-------------|
228
+ | `runners[].name` | string | required | Runner identifier |
229
+ | `runners[].command` | string | required | Test runner command |
230
+ | `runners[].args` | list | `[]` | Additional arguments |
231
+ | `runners[].test_patterns` | list | `[]` | Glob patterns for test files |
232
+ | `runners[].parser` | string | `"generic"` | Parser plugin name |
233
+ | `runners[].select_mode` | string | `"file"` | `"test"` or `"file"` |
234
+ | `runners[].timeout_seconds` | int | `300` | Per-runner timeout |
235
+ | `llm.model` | string | `"anthropic/claude-sonnet-4-20250514"` | LLM model ([litellm format](https://docs.litellm.ai/docs/providers)) |
236
+ | `llm.api_key_env` | string | `"ANTHROPIC_API_KEY"` | Env var containing API key |
237
+ | `llm.max_context_tokens` | int | `100000` | Token budget for context |
238
+ | `llm.temperature` | float | `0.0` | LLM temperature |
239
+ | `fallback_on_error` | bool | `true` | Run all tests if LLM fails |
240
+ | `run_should_run` | bool | `true` | Also run "should_run" tests |
241
+
242
+ ## Roadmap
243
+
244
+ Testwise is in early development. Here's what's planned:
245
+
246
+ - [ ] Jest/Vitest parser plugin
247
+ - [ ] Go test parser plugin
248
+ - [ ] Caching layer — skip LLM call for identical diffs
249
+ - [ ] Cost tracking — log token usage and estimated cost per run
250
+ - [ ] Confidence threshold — auto-fallback below a configurable confidence
251
+ - [ ] Test impact analysis — learn from historical runs which tests fail for which changes
252
+ - [ ] GitLab CI integration
253
+
254
+ Have an idea? [Open an issue](https://github.com/mattfrautnick/testwise/issues) or [start a discussion](https://github.com/mattfrautnick/testwise/discussions).
255
+
256
+ ## Contributing
257
+
258
+ Contributions are welcome! Whether it's a bug fix, a new parser plugin, or documentation improvements — all contributions help.
259
+
260
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, architecture overview, and the full guide to writing parser plugins.
261
+
262
+ ## Community
263
+
264
+ - [GitHub Issues](https://github.com/mattfrautnick/testwise/issues) — Bug reports and feature requests
265
+ - [GitHub Discussions](https://github.com/mattfrautnick/testwise/discussions) — Questions, ideas, and show & tell
266
+
267
+ ## License
268
+
269
+ [MIT](LICENSE)
@@ -0,0 +1,19 @@
1
+ testwise/__init__.py,sha256=XMM2xTN3zWPtFivFCrsL8RYwDvcSBTa0Nu970-kwWag,88
2
+ testwise/cli.py,sha256=wg5a_1shcrjjN7b5aFX18uz6r7hDEzhIZ1SE3AQMK5I,6910
3
+ testwise/config.py,sha256=TlkgWnUgbnZf_yX6VQGuPymsr66LSMvnYpBGF0V4aZY,4301
4
+ testwise/context_builder.py,sha256=To8cCECLJvSLnNKuDM0PAzewrp5hNsbMYrr2W3QJ3yc,6438
5
+ testwise/diff_analyzer.py,sha256=dixnZS7q89FadFMyBXuON5aEMyzu-3kRrgn-c1wQFao,8517
6
+ testwise/exceptions.py,sha256=e8_5INl5641uPfzkVbd9A7ZusqXpTcjNfXkNsNhVdW4,683
7
+ testwise/llm_selector.py,sha256=uMqJ5DE594k5x1nF1mYFyG6x8lDQpe0rsftqc9bdQzI,7998
8
+ testwise/models.py,sha256=_YaKkVxdZzVRYIrmSSEywkt7dVx-KU2YHWF2RJgaSGA,4795
9
+ testwise/reporter.py,sha256=RYA038VusjWFWw9ZPdQ2_OXv2i7F7Rms1sKgQaIQfvI,6905
10
+ testwise/test_discovery.py,sha256=PgDxcDZjDX-BqQUokNkwgrWO-Io_yzpVkMG89aaGHFE,4981
11
+ testwise/test_runner.py,sha256=kmf_UjAFDqWvkh5ePEsGmJeTlvUviTKKa7ruC6q6Q0M,6107
12
+ testwise/parsers/__init__.py,sha256=jKAthPsrcU88Cc8K5D_4rjCFp9MLUkfnXrNF_9oOndw,2174
13
+ testwise/parsers/generic_parser.py,sha256=jEA_1_ggOKwiFpLKqJcJvGhA9FUqOb849QMYDjtbKIY,2074
14
+ testwise/parsers/pytest_parser.py,sha256=T-Nrs2zj6IGjNHRbMKB_Flxs1_Bj3X0DAwcVo_YsXVQ,6536
15
+ smartselect-0.1.3.dist-info/METADATA,sha256=hh2VAciBA2aNQnd7dEKlRgPAVDendQ8fJGxFFW8GumM,10182
16
+ smartselect-0.1.3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
17
+ smartselect-0.1.3.dist-info/entry_points.txt,sha256=FgsKwPmLYN5GPIIotb1i87qVqVvm4OHs20DtoQxtO-E,176
18
+ smartselect-0.1.3.dist-info/licenses/LICENSE,sha256=3H4rvlve13gMeaLQf8iewTht1Gh3DDQIi61XgPquRCY,1074
19
+ smartselect-0.1.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,6 @@
1
+ [console_scripts]
2
+ testwise = testwise.cli:main
3
+
4
+ [testwise.parsers]
5
+ generic = testwise.parsers.generic_parser:GenericParser
6
+ pytest = testwise.parsers.pytest_parser:PytestParser
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthew Frautnick
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.
testwise/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Testwise - LLM-powered test selection for CI/CD pipelines."""
2
+
3
+ __version__ = "0.1.0"
testwise/cli.py ADDED
@@ -0,0 +1,204 @@
1
+ """CLI entry point and orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from testwise.config import get_repo_root, load_config
13
+ from testwise.context_builder import build_context
14
+ from testwise.diff_analyzer import filter_diff_files, get_diff, truncate_diff
15
+ from testwise.exceptions import LLMError, TestwiseError
16
+ from testwise.llm_selector import fallback_all_tests, select_tests
17
+ from testwise.models import RunReport, TestClassification
18
+ from testwise.reporter import report_results
19
+ from testwise.test_discovery import discover_tests, parse_test_files
20
+ from testwise.test_runner import run_selected_tests
21
+
22
+
23
+ @click.command()
24
+ @click.option(
25
+ "--config",
26
+ "-c",
27
+ "config_path",
28
+ type=click.Path(exists=True, path_type=Path),
29
+ help="Path to .testwise.yml",
30
+ )
31
+ @click.option("--base-ref", "-b", help="Base git ref to diff against")
32
+ @click.option("--head-ref", help="Head git ref (default: HEAD)")
33
+ @click.option(
34
+ "--output",
35
+ "-o",
36
+ "output_format",
37
+ type=click.Choice(["text", "json", "github"]),
38
+ default="text",
39
+ help="Output format",
40
+ )
41
+ @click.option("--output-file", type=click.Path(path_type=Path), help="Write JSON report to file")
42
+ @click.option("--dry-run", is_flag=True, help="Show selections without running tests")
43
+ @click.option("--fallback", is_flag=True, help="Skip LLM, run all tests")
44
+ @click.option(
45
+ "--run-level",
46
+ type=click.Choice(["must_run", "should_run", "all"]),
47
+ default="should_run",
48
+ help="Minimum classification to execute",
49
+ )
50
+ @click.option("--verbose", "-v", is_flag=True, help="Verbose logging")
51
+ @click.version_option(package_name="smartselect")
52
+ def main(
53
+ config_path: Path | None,
54
+ base_ref: str | None,
55
+ head_ref: str | None,
56
+ output_format: str,
57
+ output_file: Path | None,
58
+ dry_run: bool,
59
+ fallback: bool,
60
+ run_level: str,
61
+ verbose: bool,
62
+ ) -> None:
63
+ """Testwise - LLM-powered test selection for CI/CD pipelines."""
64
+ logging.basicConfig(
65
+ level=logging.DEBUG if verbose else logging.INFO,
66
+ format="%(name)s: %(message)s",
67
+ )
68
+
69
+ start_time = time.monotonic()
70
+
71
+ try:
72
+ # 1. Load config
73
+ config = load_config(config_path)
74
+
75
+ # 2. Get repo root
76
+ repo_root = get_repo_root()
77
+
78
+ # 3. Get diff
79
+ diff = get_diff(base_ref=base_ref, head_ref=head_ref, repo_path=repo_root)
80
+
81
+ if not diff.files:
82
+ click.echo("No changes detected.")
83
+ sys.exit(0)
84
+
85
+ # Filter diff files
86
+ diff.files = filter_diff_files(
87
+ diff.files,
88
+ include=config.include_patterns,
89
+ exclude=config.exclude_patterns,
90
+ )
91
+
92
+ # Truncate if needed
93
+ diff = truncate_diff(diff, config.context.max_diff_lines)
94
+
95
+ click.echo(
96
+ f"Changes: {len(diff.files)} files (+{diff.total_additions}/-{diff.total_deletions})"
97
+ )
98
+
99
+ # 4. Discover and parse test files
100
+ test_files = discover_tests(repo_root, config.runners)
101
+ if not test_files:
102
+ click.echo("No test files found. Check your runner patterns in .testwise.yml", err=True)
103
+ sys.exit(2)
104
+
105
+ parsed_files = parse_test_files(test_files, config.runners, repo_root)
106
+ total_tests = sum(len(pf.tests) for pf in parsed_files)
107
+ click.echo(f"Discovered: {len(test_files)} test files, {total_tests} individual tests")
108
+
109
+ # 5. Get test selections
110
+ llm_latency = 0.0
111
+ fallback_triggered = False
112
+
113
+ if fallback:
114
+ # User forced fallback
115
+ llm_response = fallback_all_tests(parsed_files, "User requested --fallback")
116
+ fallback_triggered = True
117
+ else:
118
+ # Build context and call LLM
119
+ messages = build_context(
120
+ diff=diff,
121
+ parsed_files=parsed_files,
122
+ runners=config.runners,
123
+ max_context_tokens=config.llm.max_context_tokens,
124
+ model=config.llm.model,
125
+ )
126
+
127
+ try:
128
+ llm_response, llm_latency = select_tests(messages, config.llm)
129
+
130
+ if llm_response.fallback_recommended:
131
+ click.echo("LLM recommended fallback — running all tests")
132
+ llm_response = fallback_all_tests(parsed_files, "LLM recommended fallback")
133
+ fallback_triggered = True
134
+
135
+ except LLMError as e:
136
+ click.echo(f"LLM error: {e}", err=True)
137
+ if config.fallback_on_error:
138
+ click.echo("Falling back to running all tests")
139
+ llm_response = fallback_all_tests(parsed_files, str(e))
140
+ fallback_triggered = True
141
+ else:
142
+ sys.exit(2)
143
+
144
+ # 6. Filter by run level
145
+ min_classifications = {
146
+ "must_run": {TestClassification.must_run},
147
+ "should_run": {TestClassification.must_run, TestClassification.should_run},
148
+ "all": {
149
+ TestClassification.must_run,
150
+ TestClassification.should_run,
151
+ TestClassification.skip,
152
+ },
153
+ }
154
+ allowed = min_classifications[run_level]
155
+
156
+ active_selections = [s for s in llm_response.selections if s.classification in allowed]
157
+ skipped_selections = [s for s in llm_response.selections if s.classification not in allowed]
158
+
159
+ click.echo(f"Selected: {len(active_selections)} tests (skipping {len(skipped_selections)})")
160
+
161
+ # 7. Execute tests (unless dry run)
162
+ results = []
163
+ if not dry_run and active_selections:
164
+ click.echo("Running selected tests...")
165
+ results = run_selected_tests(
166
+ selections=active_selections,
167
+ parsed_files=parsed_files,
168
+ runners=config.runners,
169
+ repo_root=repo_root,
170
+ )
171
+
172
+ # 8. Build report
173
+ total_duration = time.monotonic() - start_time
174
+ passed = sum(1 for r in results if r.passed)
175
+ failed = sum(1 for r in results if not r.passed)
176
+
177
+ report = RunReport(
178
+ total_tests_discovered=total_tests,
179
+ tests_selected=len(active_selections),
180
+ tests_skipped=len(skipped_selections),
181
+ tests_passed=passed,
182
+ tests_failed=failed,
183
+ llm_model_used=config.llm.model,
184
+ llm_latency_seconds=llm_latency,
185
+ total_duration_seconds=total_duration,
186
+ results=results,
187
+ selections=llm_response.selections,
188
+ fallback_triggered=fallback_triggered,
189
+ )
190
+
191
+ # 9. Report
192
+ report_results(report, output_format, output_file)
193
+
194
+ # 10. Exit code
195
+ if failed > 0:
196
+ sys.exit(1)
197
+
198
+ except TestwiseError as e:
199
+ click.echo(f"Error: {e}", err=True)
200
+ sys.exit(2)
201
+
202
+
203
+ if __name__ == "__main__":
204
+ main()
testwise/config.py ADDED
@@ -0,0 +1,135 @@
1
+ """Configuration loading and validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+ from testwise.exceptions import ConfigError
12
+ from testwise.models import TestwiseConfig
13
+
14
+
15
+ def get_repo_root() -> Path:
16
+ """Get the root of the current git repository."""
17
+ try:
18
+ result = subprocess.run(
19
+ ["git", "rev-parse", "--show-toplevel"],
20
+ capture_output=True,
21
+ text=True,
22
+ check=True,
23
+ )
24
+ return Path(result.stdout.strip())
25
+ except (subprocess.CalledProcessError, FileNotFoundError) as e:
26
+ raise ConfigError(f"Not in a git repository: {e}") from e
27
+
28
+
29
+ def find_config_file(repo_root: Path) -> Path | None:
30
+ """Look for .testwise.yml or .testwise.yaml in the repo root."""
31
+ for name in (".testwise.yml", ".testwise.yaml"):
32
+ path = repo_root / name
33
+ if path.exists():
34
+ return path
35
+ return None
36
+
37
+
38
+ def load_config(
39
+ config_path: Path | None = None,
40
+ overrides: dict[str, object] | None = None,
41
+ ) -> TestwiseConfig:
42
+ """Load configuration from file and environment variables.
43
+
44
+ Resolution order:
45
+ 1. Explicit config_path argument
46
+ 2. TESTWISE_CONFIG environment variable
47
+ 3. Auto-discover in repo root
48
+ 4. Defaults
49
+ """
50
+ raw: dict[str, object] = {}
51
+
52
+ # Find config file
53
+ if config_path is None:
54
+ env_path = os.environ.get("TESTWISE_CONFIG")
55
+ if env_path:
56
+ config_path = Path(env_path)
57
+
58
+ if config_path is None:
59
+ try:
60
+ repo_root = get_repo_root()
61
+ config_path = find_config_file(repo_root)
62
+ except ConfigError:
63
+ pass
64
+
65
+ # Load YAML
66
+ if config_path is not None:
67
+ if not config_path.exists():
68
+ raise ConfigError(f"Config file not found: {config_path}")
69
+ try:
70
+ with open(config_path) as f:
71
+ raw = yaml.safe_load(f) or {}
72
+ except yaml.YAMLError as e:
73
+ raise ConfigError(f"Invalid YAML in {config_path}: {e}") from e
74
+
75
+ # Apply environment variable overrides
76
+ _apply_env_overrides(raw)
77
+
78
+ # Apply explicit overrides
79
+ if overrides:
80
+ _deep_merge(raw, overrides)
81
+
82
+ # Validate and return
83
+ try:
84
+ return TestwiseConfig.model_validate(raw)
85
+ except Exception as e:
86
+ raise ConfigError(f"Invalid configuration: {e}") from e
87
+
88
+
89
+ def _apply_env_overrides(raw: dict[str, object]) -> None:
90
+ """Apply TESTWISE_* environment variables as config overrides."""
91
+ env_map = {
92
+ "TESTWISE_LLM_MODEL": ("llm", "model"),
93
+ "TESTWISE_LLM_TEMPERATURE": ("llm", "temperature"),
94
+ "TESTWISE_LLM_MAX_CONTEXT_TOKENS": ("llm", "max_context_tokens"),
95
+ "TESTWISE_LLM_TIMEOUT": ("llm", "timeout_seconds"),
96
+ "TESTWISE_FALLBACK_ON_ERROR": ("fallback_on_error",),
97
+ "TESTWISE_RUN_SHOULD_RUN": ("run_should_run",),
98
+ }
99
+
100
+ for env_var, path in env_map.items():
101
+ value = os.environ.get(env_var)
102
+ if value is None:
103
+ continue
104
+
105
+ # Coerce types
106
+ if path[-1] in ("temperature",):
107
+ value = float(value) # type: ignore[assignment]
108
+ elif path[-1] in ("max_context_tokens", "timeout_seconds"):
109
+ value = int(value) # type: ignore[assignment]
110
+ elif path[-1] in ("fallback_on_error", "run_should_run"):
111
+ value = value.lower() in ("true", "1", "yes") # type: ignore[assignment]
112
+
113
+ # Set in raw dict
114
+ target: dict[str, object] = raw
115
+ for key in path[:-1]:
116
+ nested = target.setdefault(key, {})
117
+ assert isinstance(nested, dict)
118
+ target = nested
119
+ target[path[-1]] = value
120
+
121
+ # API key env var name override
122
+ api_key_env = os.environ.get("TESTWISE_API_KEY_ENV")
123
+ if api_key_env:
124
+ llm = raw.setdefault("llm", {})
125
+ assert isinstance(llm, dict)
126
+ llm["api_key_env"] = api_key_env
127
+
128
+
129
+ def _deep_merge(base: dict[str, object], override: dict[str, object]) -> None:
130
+ """Merge override into base, modifying base in place."""
131
+ for key, value in override.items():
132
+ if key in base and isinstance(base[key], dict) and isinstance(value, dict):
133
+ _deep_merge(base[key], value) # type: ignore[arg-type]
134
+ else:
135
+ base[key] = value