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.
- smartselect-0.1.3.dist-info/METADATA +269 -0
- smartselect-0.1.3.dist-info/RECORD +19 -0
- smartselect-0.1.3.dist-info/WHEEL +4 -0
- smartselect-0.1.3.dist-info/entry_points.txt +6 -0
- smartselect-0.1.3.dist-info/licenses/LICENSE +21 -0
- testwise/__init__.py +3 -0
- testwise/cli.py +204 -0
- testwise/config.py +135 -0
- testwise/context_builder.py +185 -0
- testwise/diff_analyzer.py +273 -0
- testwise/exceptions.py +33 -0
- testwise/llm_selector.py +239 -0
- testwise/models.py +190 -0
- testwise/parsers/__init__.py +78 -0
- testwise/parsers/generic_parser.py +81 -0
- testwise/parsers/pytest_parser.py +204 -0
- testwise/reporter.py +200 -0
- testwise/test_discovery.py +165 -0
- testwise/test_runner.py +188 -0
|
@@ -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,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
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
|