pytest-delta 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.
- pytest_delta-0.1.0/LICENSE +21 -0
- pytest_delta-0.1.0/PKG-INFO +241 -0
- pytest_delta-0.1.0/README.md +220 -0
- pytest_delta-0.1.0/pyproject.toml +36 -0
- pytest_delta-0.1.0/src/pytest_delta/__init__.py +4 -0
- pytest_delta-0.1.0/src/pytest_delta/delta_manager.py +65 -0
- pytest_delta-0.1.0/src/pytest_delta/dependency_analyzer.py +272 -0
- pytest_delta-0.1.0/src/pytest_delta/plugin.py +313 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 CemAlpturk
|
|
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,241 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pytest-delta
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run only tests impacted by your code changes (delta-based selection) for pytest.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: pytest,plugin,selective,impact,test,ci,graph
|
|
7
|
+
Author: Cem Alptürk
|
|
8
|
+
Author-email: cem.alpturk@gmail.com
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Classifier: Framework :: Pytest
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Dist: gitpython (>=3.1.0)
|
|
16
|
+
Requires-Dist: pytest (>=7.0)
|
|
17
|
+
Project-URL: Homepage, https://github.com/CemAlpturk/pytest-delta
|
|
18
|
+
Project-URL: Repository, https://github.com/CemAlpturk/pytest-delta
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# pytest-delta
|
|
22
|
+
|
|
23
|
+
Run only tests impacted by your code changes (delta-based selection) for pytest.
|
|
24
|
+
|
|
25
|
+
## Overview
|
|
26
|
+
|
|
27
|
+
pytest-delta is a pytest plugin that reduces test execution time by running only the tests that are potentially affected by your code changes. It creates a directional dependency graph based on Python imports and selects tests intelligently based on what files have changed since the last successful test run.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- **Smart Test Selection**: Only runs tests affected by changed files
|
|
32
|
+
- **Dependency Tracking**: Creates a dependency graph based on Python imports
|
|
33
|
+
- **Git Integration**: Compares against the last successful test run commit
|
|
34
|
+
- **Uncommitted Changes Support**: Includes both staged and unstaged changes
|
|
35
|
+
- **Force Regeneration**: Option to force running all tests and regenerate metadata
|
|
36
|
+
- **File-based Mapping**: Assumes test files follow standard naming conventions
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install pytest-delta
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or for development:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
git clone https://github.com/CemAlpturk/pytest-delta
|
|
48
|
+
cd pytest-delta
|
|
49
|
+
pip install -e .
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
### Basic Usage
|
|
55
|
+
|
|
56
|
+
Run tests with delta selection:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pytest --delta
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
On first run, it will execute all tests and create a `.delta.json` file with metadata.
|
|
63
|
+
|
|
64
|
+
### Command Line Options
|
|
65
|
+
|
|
66
|
+
- `--delta`: Enable delta-based test selection
|
|
67
|
+
- `--delta-filename NAME`: Specify filename for delta metadata file (default: `.delta`, `.json` extension added automatically)
|
|
68
|
+
- `--delta-dir PATH`: Specify directory for delta metadata file (default: current directory)
|
|
69
|
+
- `--delta-force`: Force regeneration of delta file and run all tests
|
|
70
|
+
- `--delta-ignore PATTERN`: Ignore file patterns during dependency analysis (can be used multiple times)
|
|
71
|
+
|
|
72
|
+
### Examples
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# Run only affected tests
|
|
76
|
+
pytest --delta
|
|
77
|
+
|
|
78
|
+
# Force run all tests and regenerate metadata
|
|
79
|
+
pytest --delta --delta-force
|
|
80
|
+
|
|
81
|
+
# Use custom delta filename (will become custom-delta.json)
|
|
82
|
+
pytest --delta --delta-filename custom-delta
|
|
83
|
+
|
|
84
|
+
# Use custom directory for delta file
|
|
85
|
+
pytest --delta --delta-dir .metadata
|
|
86
|
+
|
|
87
|
+
# Combine custom filename and directory
|
|
88
|
+
pytest --delta --delta-filename my-tests --delta-dir /tmp/deltas
|
|
89
|
+
|
|
90
|
+
# Combine with other pytest options
|
|
91
|
+
pytest --delta -v --tb=short
|
|
92
|
+
|
|
93
|
+
# Ignore generated files during analysis
|
|
94
|
+
pytest --delta --delta-ignore "*generated*"
|
|
95
|
+
|
|
96
|
+
# Ignore multiple patterns
|
|
97
|
+
pytest --delta --delta-ignore "*generated*" --delta-ignore "vendor/*"
|
|
98
|
+
|
|
99
|
+
# Ignore test files from dependency analysis (useful for complex test hierarchies)
|
|
100
|
+
pytest --delta --delta-ignore "tests/integration/*"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Migration from Previous Versions
|
|
104
|
+
|
|
105
|
+
If you were using the old `--delta-file` option, you can migrate as follows:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# Old way (no longer supported):
|
|
109
|
+
# pytest --delta --delta-file /path/to/custom.json
|
|
110
|
+
|
|
111
|
+
# New way:
|
|
112
|
+
pytest --delta --delta-filename custom --delta-dir /path/to
|
|
113
|
+
# This creates: /path/to/custom.json
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## How It Works
|
|
117
|
+
|
|
118
|
+
1. **First Run**: On the first run (or when the delta file doesn't exist), all tests are executed and a delta metadata file is created containing the current Git commit hash.
|
|
119
|
+
|
|
120
|
+
2. **Change Detection**: On subsequent runs, the plugin:
|
|
121
|
+
- Compares current Git state with the last successful run
|
|
122
|
+
- Identifies changed Python files (both committed and uncommitted)
|
|
123
|
+
- Builds a dependency graph based on Python imports
|
|
124
|
+
- Finds all files transitively affected by the changes
|
|
125
|
+
|
|
126
|
+
3. **Test Selection**: The plugin selects tests based on:
|
|
127
|
+
- Direct test files that were modified
|
|
128
|
+
- Test files that test the modified source files
|
|
129
|
+
- Test files that test files affected by the changes (transitive dependencies)
|
|
130
|
+
|
|
131
|
+
4. **File Mapping**: Test files are mapped to source files using naming conventions:
|
|
132
|
+
- `tests/test_module.py` ↔ `src/module.py`
|
|
133
|
+
- `tests/subdir/test_module.py` ↔ `src/subdir/module.py`
|
|
134
|
+
|
|
135
|
+
## Project Structure Assumptions
|
|
136
|
+
|
|
137
|
+
The plugin works best with projects that follow these conventions:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
project/
|
|
141
|
+
├── src/ # Source code
|
|
142
|
+
│ ├── module1.py
|
|
143
|
+
│ └── package/
|
|
144
|
+
│ └── module2.py
|
|
145
|
+
├── tests/ # Test files
|
|
146
|
+
│ ├── test_module1.py
|
|
147
|
+
│ └── package/
|
|
148
|
+
│ └── test_module2.py
|
|
149
|
+
└── .delta.json # Delta metadata (auto-generated, default location)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Configuration
|
|
153
|
+
|
|
154
|
+
### Ignoring Files
|
|
155
|
+
|
|
156
|
+
The `--delta-ignore` option allows you to exclude certain files from dependency analysis. This is useful for:
|
|
157
|
+
|
|
158
|
+
- **Generated files**: Auto-generated code that shouldn't trigger test runs
|
|
159
|
+
- **Vendor/third-party code**: External dependencies that don't need analysis
|
|
160
|
+
- **Temporary files**: Files that are frequently modified but don't affect tests
|
|
161
|
+
- **Documentation**: Markdown, text files that might be mixed with Python code
|
|
162
|
+
|
|
163
|
+
The ignore patterns support:
|
|
164
|
+
- **Glob patterns**: `*generated*`, `*.tmp`, `vendor/*`
|
|
165
|
+
- **Path matching**: Both relative and absolute paths are checked
|
|
166
|
+
- **Multiple patterns**: Use the option multiple times for different patterns
|
|
167
|
+
|
|
168
|
+
Examples:
|
|
169
|
+
```bash
|
|
170
|
+
# Ignore all generated files
|
|
171
|
+
pytest --delta --delta-ignore "*generated*"
|
|
172
|
+
|
|
173
|
+
# Ignore vendor directory and any temp files
|
|
174
|
+
pytest --delta --delta-ignore "vendor/*" --delta-ignore "*.tmp"
|
|
175
|
+
|
|
176
|
+
# Ignore specific test subdirectories from analysis
|
|
177
|
+
pytest --delta --delta-ignore "tests/integration/*" --delta-ignore "tests/e2e/*"
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Default Configuration
|
|
181
|
+
|
|
182
|
+
The plugin requires no configuration for basic usage. It automatically:
|
|
183
|
+
|
|
184
|
+
- Finds Python files in `src/` and `tests/` directories
|
|
185
|
+
- Excludes virtual environments, `__pycache__`, and other irrelevant directories
|
|
186
|
+
- Creates dependency graphs based on import statements
|
|
187
|
+
- Maps test files to source files using naming conventions
|
|
188
|
+
|
|
189
|
+
## Error Handling
|
|
190
|
+
|
|
191
|
+
The plugin includes robust error handling:
|
|
192
|
+
|
|
193
|
+
- **No Git Repository**: Falls back to running all tests
|
|
194
|
+
- **Invalid Delta File**: Regenerates metadata and runs all tests
|
|
195
|
+
- **Git Errors**: Falls back to running all tests with warnings
|
|
196
|
+
- **Import Analysis Errors**: Continues with partial dependency graph
|
|
197
|
+
|
|
198
|
+
## Example Output
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
$ pytest --delta -v
|
|
202
|
+
================ test session starts ================
|
|
203
|
+
plugins: delta-0.1.0
|
|
204
|
+
[pytest-delta] Selected 3/10 tests based on code changes
|
|
205
|
+
[pytest-delta] Affected files: src/calculator.py, tests/test_calculator.py
|
|
206
|
+
|
|
207
|
+
tests/test_calculator.py::test_add PASSED
|
|
208
|
+
tests/test_calculator.py::test_multiply PASSED
|
|
209
|
+
tests/test_math_utils.py::test_area PASSED
|
|
210
|
+
|
|
211
|
+
[pytest-delta] Delta metadata updated successfully
|
|
212
|
+
================ 3 passed in 0.02s ================
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Development
|
|
216
|
+
|
|
217
|
+
To set up for development:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
git clone https://github.com/CemAlpturk/pytest-delta
|
|
221
|
+
cd pytest-delta
|
|
222
|
+
python -m venv .venv
|
|
223
|
+
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
|
224
|
+
pip install -e .
|
|
225
|
+
pip install pytest gitpython
|
|
226
|
+
|
|
227
|
+
# Run tests
|
|
228
|
+
pytest tests/
|
|
229
|
+
|
|
230
|
+
# Test the plugin
|
|
231
|
+
pytest --delta
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Contributing
|
|
235
|
+
|
|
236
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
237
|
+
|
|
238
|
+
## License
|
|
239
|
+
|
|
240
|
+
This project is licensed under the MIT License - see the LICENSE file for details.
|
|
241
|
+
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# pytest-delta
|
|
2
|
+
|
|
3
|
+
Run only tests impacted by your code changes (delta-based selection) for pytest.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
pytest-delta is a pytest plugin that reduces test execution time by running only the tests that are potentially affected by your code changes. It creates a directional dependency graph based on Python imports and selects tests intelligently based on what files have changed since the last successful test run.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Smart Test Selection**: Only runs tests affected by changed files
|
|
12
|
+
- **Dependency Tracking**: Creates a dependency graph based on Python imports
|
|
13
|
+
- **Git Integration**: Compares against the last successful test run commit
|
|
14
|
+
- **Uncommitted Changes Support**: Includes both staged and unstaged changes
|
|
15
|
+
- **Force Regeneration**: Option to force running all tests and regenerate metadata
|
|
16
|
+
- **File-based Mapping**: Assumes test files follow standard naming conventions
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install pytest-delta
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or for development:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
git clone https://github.com/CemAlpturk/pytest-delta
|
|
28
|
+
cd pytest-delta
|
|
29
|
+
pip install -e .
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
### Basic Usage
|
|
35
|
+
|
|
36
|
+
Run tests with delta selection:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pytest --delta
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
On first run, it will execute all tests and create a `.delta.json` file with metadata.
|
|
43
|
+
|
|
44
|
+
### Command Line Options
|
|
45
|
+
|
|
46
|
+
- `--delta`: Enable delta-based test selection
|
|
47
|
+
- `--delta-filename NAME`: Specify filename for delta metadata file (default: `.delta`, `.json` extension added automatically)
|
|
48
|
+
- `--delta-dir PATH`: Specify directory for delta metadata file (default: current directory)
|
|
49
|
+
- `--delta-force`: Force regeneration of delta file and run all tests
|
|
50
|
+
- `--delta-ignore PATTERN`: Ignore file patterns during dependency analysis (can be used multiple times)
|
|
51
|
+
|
|
52
|
+
### Examples
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Run only affected tests
|
|
56
|
+
pytest --delta
|
|
57
|
+
|
|
58
|
+
# Force run all tests and regenerate metadata
|
|
59
|
+
pytest --delta --delta-force
|
|
60
|
+
|
|
61
|
+
# Use custom delta filename (will become custom-delta.json)
|
|
62
|
+
pytest --delta --delta-filename custom-delta
|
|
63
|
+
|
|
64
|
+
# Use custom directory for delta file
|
|
65
|
+
pytest --delta --delta-dir .metadata
|
|
66
|
+
|
|
67
|
+
# Combine custom filename and directory
|
|
68
|
+
pytest --delta --delta-filename my-tests --delta-dir /tmp/deltas
|
|
69
|
+
|
|
70
|
+
# Combine with other pytest options
|
|
71
|
+
pytest --delta -v --tb=short
|
|
72
|
+
|
|
73
|
+
# Ignore generated files during analysis
|
|
74
|
+
pytest --delta --delta-ignore "*generated*"
|
|
75
|
+
|
|
76
|
+
# Ignore multiple patterns
|
|
77
|
+
pytest --delta --delta-ignore "*generated*" --delta-ignore "vendor/*"
|
|
78
|
+
|
|
79
|
+
# Ignore test files from dependency analysis (useful for complex test hierarchies)
|
|
80
|
+
pytest --delta --delta-ignore "tests/integration/*"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Migration from Previous Versions
|
|
84
|
+
|
|
85
|
+
If you were using the old `--delta-file` option, you can migrate as follows:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# Old way (no longer supported):
|
|
89
|
+
# pytest --delta --delta-file /path/to/custom.json
|
|
90
|
+
|
|
91
|
+
# New way:
|
|
92
|
+
pytest --delta --delta-filename custom --delta-dir /path/to
|
|
93
|
+
# This creates: /path/to/custom.json
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## How It Works
|
|
97
|
+
|
|
98
|
+
1. **First Run**: On the first run (or when the delta file doesn't exist), all tests are executed and a delta metadata file is created containing the current Git commit hash.
|
|
99
|
+
|
|
100
|
+
2. **Change Detection**: On subsequent runs, the plugin:
|
|
101
|
+
- Compares current Git state with the last successful run
|
|
102
|
+
- Identifies changed Python files (both committed and uncommitted)
|
|
103
|
+
- Builds a dependency graph based on Python imports
|
|
104
|
+
- Finds all files transitively affected by the changes
|
|
105
|
+
|
|
106
|
+
3. **Test Selection**: The plugin selects tests based on:
|
|
107
|
+
- Direct test files that were modified
|
|
108
|
+
- Test files that test the modified source files
|
|
109
|
+
- Test files that test files affected by the changes (transitive dependencies)
|
|
110
|
+
|
|
111
|
+
4. **File Mapping**: Test files are mapped to source files using naming conventions:
|
|
112
|
+
- `tests/test_module.py` ↔ `src/module.py`
|
|
113
|
+
- `tests/subdir/test_module.py` ↔ `src/subdir/module.py`
|
|
114
|
+
|
|
115
|
+
## Project Structure Assumptions
|
|
116
|
+
|
|
117
|
+
The plugin works best with projects that follow these conventions:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
project/
|
|
121
|
+
├── src/ # Source code
|
|
122
|
+
│ ├── module1.py
|
|
123
|
+
│ └── package/
|
|
124
|
+
│ └── module2.py
|
|
125
|
+
├── tests/ # Test files
|
|
126
|
+
│ ├── test_module1.py
|
|
127
|
+
│ └── package/
|
|
128
|
+
│ └── test_module2.py
|
|
129
|
+
└── .delta.json # Delta metadata (auto-generated, default location)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Configuration
|
|
133
|
+
|
|
134
|
+
### Ignoring Files
|
|
135
|
+
|
|
136
|
+
The `--delta-ignore` option allows you to exclude certain files from dependency analysis. This is useful for:
|
|
137
|
+
|
|
138
|
+
- **Generated files**: Auto-generated code that shouldn't trigger test runs
|
|
139
|
+
- **Vendor/third-party code**: External dependencies that don't need analysis
|
|
140
|
+
- **Temporary files**: Files that are frequently modified but don't affect tests
|
|
141
|
+
- **Documentation**: Markdown, text files that might be mixed with Python code
|
|
142
|
+
|
|
143
|
+
The ignore patterns support:
|
|
144
|
+
- **Glob patterns**: `*generated*`, `*.tmp`, `vendor/*`
|
|
145
|
+
- **Path matching**: Both relative and absolute paths are checked
|
|
146
|
+
- **Multiple patterns**: Use the option multiple times for different patterns
|
|
147
|
+
|
|
148
|
+
Examples:
|
|
149
|
+
```bash
|
|
150
|
+
# Ignore all generated files
|
|
151
|
+
pytest --delta --delta-ignore "*generated*"
|
|
152
|
+
|
|
153
|
+
# Ignore vendor directory and any temp files
|
|
154
|
+
pytest --delta --delta-ignore "vendor/*" --delta-ignore "*.tmp"
|
|
155
|
+
|
|
156
|
+
# Ignore specific test subdirectories from analysis
|
|
157
|
+
pytest --delta --delta-ignore "tests/integration/*" --delta-ignore "tests/e2e/*"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Default Configuration
|
|
161
|
+
|
|
162
|
+
The plugin requires no configuration for basic usage. It automatically:
|
|
163
|
+
|
|
164
|
+
- Finds Python files in `src/` and `tests/` directories
|
|
165
|
+
- Excludes virtual environments, `__pycache__`, and other irrelevant directories
|
|
166
|
+
- Creates dependency graphs based on import statements
|
|
167
|
+
- Maps test files to source files using naming conventions
|
|
168
|
+
|
|
169
|
+
## Error Handling
|
|
170
|
+
|
|
171
|
+
The plugin includes robust error handling:
|
|
172
|
+
|
|
173
|
+
- **No Git Repository**: Falls back to running all tests
|
|
174
|
+
- **Invalid Delta File**: Regenerates metadata and runs all tests
|
|
175
|
+
- **Git Errors**: Falls back to running all tests with warnings
|
|
176
|
+
- **Import Analysis Errors**: Continues with partial dependency graph
|
|
177
|
+
|
|
178
|
+
## Example Output
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
$ pytest --delta -v
|
|
182
|
+
================ test session starts ================
|
|
183
|
+
plugins: delta-0.1.0
|
|
184
|
+
[pytest-delta] Selected 3/10 tests based on code changes
|
|
185
|
+
[pytest-delta] Affected files: src/calculator.py, tests/test_calculator.py
|
|
186
|
+
|
|
187
|
+
tests/test_calculator.py::test_add PASSED
|
|
188
|
+
tests/test_calculator.py::test_multiply PASSED
|
|
189
|
+
tests/test_math_utils.py::test_area PASSED
|
|
190
|
+
|
|
191
|
+
[pytest-delta] Delta metadata updated successfully
|
|
192
|
+
================ 3 passed in 0.02s ================
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Development
|
|
196
|
+
|
|
197
|
+
To set up for development:
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
git clone https://github.com/CemAlpturk/pytest-delta
|
|
201
|
+
cd pytest-delta
|
|
202
|
+
python -m venv .venv
|
|
203
|
+
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
|
204
|
+
pip install -e .
|
|
205
|
+
pip install pytest gitpython
|
|
206
|
+
|
|
207
|
+
# Run tests
|
|
208
|
+
pytest tests/
|
|
209
|
+
|
|
210
|
+
# Test the plugin
|
|
211
|
+
pytest --delta
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Contributing
|
|
215
|
+
|
|
216
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
This project is licensed under the MIT License - see the LICENSE file for details.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "pytest-delta"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Run only tests impacted by your code changes (delta-based selection) for pytest."
|
|
5
|
+
authors = ["Cem Alptürk <cem.alpturk@gmail.com>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
packages = [{ include = "pytest_delta", from = "src" }]
|
|
9
|
+
keywords = ["pytest", "plugin", "selective", "impact", "test", "ci", "graph"]
|
|
10
|
+
homepage = "https://github.com/CemAlpturk/pytest-delta"
|
|
11
|
+
repository = "https://github.com/CemAlpturk/pytest-delta"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Framework :: Pytest",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[tool.poetry.dependencies]
|
|
19
|
+
python = ">=3.12"
|
|
20
|
+
pytest = ">=7.0"
|
|
21
|
+
gitpython = ">=3.1.0"
|
|
22
|
+
|
|
23
|
+
[tool.poetry.plugins."pytest11"]
|
|
24
|
+
pytest-delta = "pytest_delta.plugin"
|
|
25
|
+
|
|
26
|
+
[tool.ruff]
|
|
27
|
+
line-length = 100
|
|
28
|
+
target-version = "py312"
|
|
29
|
+
|
|
30
|
+
[tool.mypy]
|
|
31
|
+
python_version = "3.12"
|
|
32
|
+
strict = true
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["poetry-core>=1.0.0"]
|
|
36
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Delta metadata manager for pytest-delta plugin.
|
|
3
|
+
|
|
4
|
+
Handles saving and loading metadata about the last test run,
|
|
5
|
+
including the git commit hash and other relevant information.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
from git import Repo
|
|
13
|
+
from git.exc import GitCommandError, InvalidGitRepositoryError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DeltaManager:
|
|
17
|
+
"""Manages delta metadata file operations."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, delta_file: Path):
|
|
20
|
+
self.delta_file = delta_file
|
|
21
|
+
|
|
22
|
+
def load_metadata(self) -> Optional[Dict[str, Any]]:
|
|
23
|
+
"""Load metadata from the delta file."""
|
|
24
|
+
if not self.delta_file.exists():
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
with open(self.delta_file, "r", encoding="utf-8") as f:
|
|
29
|
+
return json.load(f)
|
|
30
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
31
|
+
raise ValueError(f"Failed to load delta metadata: {e}") from e
|
|
32
|
+
|
|
33
|
+
def save_metadata(self, metadata: Dict[str, Any]) -> None:
|
|
34
|
+
"""Save metadata to the delta file."""
|
|
35
|
+
try:
|
|
36
|
+
# Ensure parent directory exists
|
|
37
|
+
self.delta_file.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
|
|
39
|
+
with open(self.delta_file, "w", encoding="utf-8") as f:
|
|
40
|
+
json.dump(metadata, f, indent=2, sort_keys=True)
|
|
41
|
+
except OSError as e:
|
|
42
|
+
raise ValueError(f"Failed to save delta metadata: {e}") from e
|
|
43
|
+
|
|
44
|
+
def update_metadata(self, root_dir: Path) -> None:
|
|
45
|
+
"""Update metadata with current git state."""
|
|
46
|
+
try:
|
|
47
|
+
repo = Repo(root_dir)
|
|
48
|
+
except InvalidGitRepositoryError as e:
|
|
49
|
+
raise ValueError("Not a Git repository") from e
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
# Get current commit hash
|
|
53
|
+
current_commit = repo.head.commit.hexsha
|
|
54
|
+
|
|
55
|
+
# Create metadata
|
|
56
|
+
metadata = {
|
|
57
|
+
"last_commit": current_commit,
|
|
58
|
+
"last_successful_run": True,
|
|
59
|
+
"version": "0.1.0",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
self.save_metadata(metadata)
|
|
63
|
+
|
|
64
|
+
except GitCommandError as e:
|
|
65
|
+
raise ValueError(f"Failed to get Git information: {e}") from e
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dependency analyzer for pytest-delta plugin.
|
|
3
|
+
|
|
4
|
+
Creates a directional dependency graph based on Python imports
|
|
5
|
+
and determines which files are affected by changes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
import fnmatch
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, List, Set
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DependencyAnalyzer:
|
|
15
|
+
"""Analyzes Python file dependencies based on imports."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, root_dir: Path, ignore_patterns: List[str] | None = None):
|
|
18
|
+
self.root_dir = root_dir
|
|
19
|
+
self.ignore_patterns = ignore_patterns or []
|
|
20
|
+
|
|
21
|
+
def build_dependency_graph(self) -> Dict[Path, Set[Path]]:
|
|
22
|
+
"""
|
|
23
|
+
Build a dependency graph where keys are files and values are sets of files they depend on.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
A dictionary mapping file paths to their dependencies.
|
|
27
|
+
"""
|
|
28
|
+
dependency_graph = {}
|
|
29
|
+
python_files = self._find_python_files()
|
|
30
|
+
|
|
31
|
+
for file_path in python_files:
|
|
32
|
+
dependencies = self._extract_dependencies(file_path, python_files)
|
|
33
|
+
dependency_graph[file_path] = dependencies
|
|
34
|
+
|
|
35
|
+
return dependency_graph
|
|
36
|
+
|
|
37
|
+
def find_affected_files(
|
|
38
|
+
self, changed_files: Set[Path], dependency_graph: Dict[Path, Set[Path]]
|
|
39
|
+
) -> Set[Path]:
|
|
40
|
+
"""
|
|
41
|
+
Find all files affected by the given changed files.
|
|
42
|
+
|
|
43
|
+
This includes:
|
|
44
|
+
1. The changed files themselves
|
|
45
|
+
2. Files that directly depend on changed files
|
|
46
|
+
3. Files that transitively depend on changed files
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
changed_files: Set of files that have been modified
|
|
50
|
+
dependency_graph: Dependency graph from build_dependency_graph()
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Set of all files that are potentially affected by the changes
|
|
54
|
+
"""
|
|
55
|
+
affected = set(changed_files)
|
|
56
|
+
|
|
57
|
+
# Build reverse dependency graph (who depends on whom)
|
|
58
|
+
reverse_deps = self._build_reverse_dependency_graph(dependency_graph)
|
|
59
|
+
|
|
60
|
+
# Use BFS to find all files affected transitively
|
|
61
|
+
to_process = list(changed_files)
|
|
62
|
+
processed = set()
|
|
63
|
+
|
|
64
|
+
while to_process:
|
|
65
|
+
current_file = to_process.pop(0)
|
|
66
|
+
if current_file in processed:
|
|
67
|
+
continue
|
|
68
|
+
processed.add(current_file)
|
|
69
|
+
|
|
70
|
+
# Find files that depend on the current file
|
|
71
|
+
dependents = reverse_deps.get(current_file, set())
|
|
72
|
+
for dependent in dependents:
|
|
73
|
+
if dependent not in processed and dependent not in to_process:
|
|
74
|
+
affected.add(dependent)
|
|
75
|
+
to_process.append(dependent)
|
|
76
|
+
|
|
77
|
+
return affected
|
|
78
|
+
|
|
79
|
+
def _find_python_files(self) -> Set[Path]:
|
|
80
|
+
"""Find all Python files in the project."""
|
|
81
|
+
python_files = set()
|
|
82
|
+
|
|
83
|
+
# Search in common Python directories
|
|
84
|
+
search_dirs = [
|
|
85
|
+
self.root_dir / "src",
|
|
86
|
+
self.root_dir / "tests",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
# Also check for Python files in the root directory (but not recursively)
|
|
90
|
+
for file_path in self.root_dir.glob("*.py"):
|
|
91
|
+
if file_path.is_file():
|
|
92
|
+
python_files.add(file_path.resolve())
|
|
93
|
+
|
|
94
|
+
for search_dir in search_dirs:
|
|
95
|
+
if search_dir.is_dir():
|
|
96
|
+
python_files.update(search_dir.rglob("*.py"))
|
|
97
|
+
|
|
98
|
+
# Filter out __pycache__, .venv, and other irrelevant files
|
|
99
|
+
filtered_files = set()
|
|
100
|
+
exclude_patterns = [
|
|
101
|
+
"__pycache__",
|
|
102
|
+
".venv",
|
|
103
|
+
"venv",
|
|
104
|
+
".git",
|
|
105
|
+
"node_modules",
|
|
106
|
+
".pytest_cache",
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
for file_path in python_files:
|
|
110
|
+
path_str = str(file_path)
|
|
111
|
+
relative_path_str = str(file_path.relative_to(self.root_dir))
|
|
112
|
+
|
|
113
|
+
# Skip if any exclude pattern is in the path
|
|
114
|
+
if any(pattern in path_str for pattern in exclude_patterns):
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
# Skip if matches any user-provided ignore patterns
|
|
118
|
+
if self._should_ignore_file(file_path, relative_path_str):
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
if file_path.is_file():
|
|
122
|
+
filtered_files.add(file_path.resolve())
|
|
123
|
+
|
|
124
|
+
return filtered_files
|
|
125
|
+
|
|
126
|
+
def _extract_dependencies(self, file_path: Path, all_files: Set[Path]) -> Set[Path]:
|
|
127
|
+
"""Extract dependencies (imports) from a Python file."""
|
|
128
|
+
dependencies = set()
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
132
|
+
content = f.read()
|
|
133
|
+
except (OSError, UnicodeDecodeError):
|
|
134
|
+
# Skip files that can't be read
|
|
135
|
+
return dependencies
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
tree = ast.parse(content)
|
|
139
|
+
except SyntaxError:
|
|
140
|
+
# Skip files with syntax errors
|
|
141
|
+
return dependencies
|
|
142
|
+
|
|
143
|
+
for node in ast.walk(tree):
|
|
144
|
+
if isinstance(node, ast.Import):
|
|
145
|
+
for alias in node.names:
|
|
146
|
+
dep_path = self._resolve_import_to_file(alias.name, all_files)
|
|
147
|
+
if dep_path:
|
|
148
|
+
dependencies.add(dep_path)
|
|
149
|
+
|
|
150
|
+
elif isinstance(node, ast.ImportFrom):
|
|
151
|
+
if node.module:
|
|
152
|
+
# Handle relative imports
|
|
153
|
+
if node.level > 0: # Relative import
|
|
154
|
+
module_name = self._resolve_relative_import(
|
|
155
|
+
file_path, node.module, node.level
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
module_name = node.module
|
|
159
|
+
|
|
160
|
+
if module_name:
|
|
161
|
+
dep_path = self._resolve_import_to_file(module_name, all_files)
|
|
162
|
+
if dep_path:
|
|
163
|
+
dependencies.add(dep_path)
|
|
164
|
+
|
|
165
|
+
# Also handle individual imports from modules
|
|
166
|
+
for alias in node.names:
|
|
167
|
+
if node.module:
|
|
168
|
+
full_name = f"{node.module}.{alias.name}"
|
|
169
|
+
else:
|
|
170
|
+
full_name = alias.name
|
|
171
|
+
|
|
172
|
+
dep_path = self._resolve_import_to_file(full_name, all_files)
|
|
173
|
+
if dep_path:
|
|
174
|
+
dependencies.add(dep_path)
|
|
175
|
+
|
|
176
|
+
return dependencies
|
|
177
|
+
|
|
178
|
+
def _resolve_import_to_file(
|
|
179
|
+
self, import_name: str, all_files: Set[Path]
|
|
180
|
+
) -> Path | None:
|
|
181
|
+
"""Resolve an import name to an actual file path."""
|
|
182
|
+
# Convert module name to potential file paths
|
|
183
|
+
parts = import_name.split(".")
|
|
184
|
+
|
|
185
|
+
# Try different combinations to find the file
|
|
186
|
+
potential_paths = []
|
|
187
|
+
|
|
188
|
+
# Try as a direct module file
|
|
189
|
+
potential_paths.append(Path(*parts).with_suffix(".py"))
|
|
190
|
+
|
|
191
|
+
# Try as a package with __init__.py
|
|
192
|
+
potential_paths.append(Path(*parts) / "__init__.py")
|
|
193
|
+
|
|
194
|
+
# Try in src/ directory
|
|
195
|
+
potential_paths.append(Path("src") / Path(*parts) / "__init__.py")
|
|
196
|
+
potential_paths.append((Path("src") / Path(*parts)).with_suffix(".py"))
|
|
197
|
+
|
|
198
|
+
# Search for matches in all known files
|
|
199
|
+
for potential_path in potential_paths:
|
|
200
|
+
for file_path in all_files:
|
|
201
|
+
try:
|
|
202
|
+
# Check if the file path ends with our potential path
|
|
203
|
+
if file_path.relative_to(self.root_dir) == potential_path:
|
|
204
|
+
return file_path
|
|
205
|
+
except ValueError:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
# Also check suffix matching for nested structures
|
|
209
|
+
if str(file_path).endswith(str(potential_path)):
|
|
210
|
+
return file_path
|
|
211
|
+
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
def _resolve_relative_import(
|
|
215
|
+
self, file_path: Path, module_name: str | None, level: int
|
|
216
|
+
) -> str | None:
|
|
217
|
+
"""Resolve relative imports to absolute module names."""
|
|
218
|
+
try:
|
|
219
|
+
file_rel_path = file_path.relative_to(self.root_dir)
|
|
220
|
+
except ValueError:
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
# Remove the file name to get the directory
|
|
224
|
+
current_dir_parts = list(file_rel_path.parent.parts)
|
|
225
|
+
|
|
226
|
+
# Remove 'level' number of directories
|
|
227
|
+
if level > len(current_dir_parts):
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
base_parts = current_dir_parts[:-level] if level > 0 else current_dir_parts
|
|
231
|
+
|
|
232
|
+
if module_name:
|
|
233
|
+
module_parts = module_name.split(".")
|
|
234
|
+
full_parts = base_parts + module_parts
|
|
235
|
+
else:
|
|
236
|
+
full_parts = base_parts
|
|
237
|
+
|
|
238
|
+
return ".".join(full_parts) if full_parts else None
|
|
239
|
+
|
|
240
|
+
def _build_reverse_dependency_graph(
|
|
241
|
+
self, dependency_graph: Dict[Path, Set[Path]]
|
|
242
|
+
) -> Dict[Path, Set[Path]]:
|
|
243
|
+
"""Build reverse dependency graph (who depends on whom)."""
|
|
244
|
+
reverse_deps = {}
|
|
245
|
+
|
|
246
|
+
for file_path, dependencies in dependency_graph.items():
|
|
247
|
+
for dependency in dependencies:
|
|
248
|
+
if dependency not in reverse_deps:
|
|
249
|
+
reverse_deps[dependency] = set()
|
|
250
|
+
reverse_deps[dependency].add(file_path)
|
|
251
|
+
|
|
252
|
+
return reverse_deps
|
|
253
|
+
|
|
254
|
+
def _should_ignore_file(self, file_path: Path, relative_path_str: str) -> bool:
|
|
255
|
+
"""Check if a file should be ignored based on user-provided patterns."""
|
|
256
|
+
if not self.ignore_patterns:
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
# Check against both absolute path and relative path
|
|
260
|
+
absolute_path_str = str(file_path)
|
|
261
|
+
|
|
262
|
+
for pattern in self.ignore_patterns:
|
|
263
|
+
# Use fnmatch for glob-style pattern matching
|
|
264
|
+
if fnmatch.fnmatch(relative_path_str, pattern):
|
|
265
|
+
return True
|
|
266
|
+
if fnmatch.fnmatch(absolute_path_str, pattern):
|
|
267
|
+
return True
|
|
268
|
+
# Also check if pattern is simply contained in the path
|
|
269
|
+
if pattern in relative_path_str or pattern in absolute_path_str:
|
|
270
|
+
return True
|
|
271
|
+
|
|
272
|
+
return False
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pytest-delta plugin for running only tests impacted by code changes.
|
|
3
|
+
|
|
4
|
+
This plugin creates a directional dependency graph based on imports and selects
|
|
5
|
+
only the tests that are potentially affected by the changed files.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Set
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from git import Repo
|
|
13
|
+
from git.exc import GitCommandError, InvalidGitRepositoryError
|
|
14
|
+
|
|
15
|
+
from .dependency_analyzer import DependencyAnalyzer
|
|
16
|
+
from .delta_manager import DeltaManager
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
20
|
+
"""Add command line options for pytest-delta."""
|
|
21
|
+
group = parser.getgroup("delta", "pytest-delta options")
|
|
22
|
+
group.addoption(
|
|
23
|
+
"--delta",
|
|
24
|
+
action="store_true",
|
|
25
|
+
default=False,
|
|
26
|
+
help="Run only tests impacted by code changes since last successful run",
|
|
27
|
+
)
|
|
28
|
+
group.addoption(
|
|
29
|
+
"--delta-filename",
|
|
30
|
+
action="store",
|
|
31
|
+
default=".delta",
|
|
32
|
+
help="Filename for the delta metadata file (default: .delta, .json extension added automatically)",
|
|
33
|
+
)
|
|
34
|
+
group.addoption(
|
|
35
|
+
"--delta-dir",
|
|
36
|
+
action="store",
|
|
37
|
+
default=".",
|
|
38
|
+
help="Directory to store the delta metadata file (default: current directory)",
|
|
39
|
+
)
|
|
40
|
+
group.addoption(
|
|
41
|
+
"--delta-force",
|
|
42
|
+
action="store_true",
|
|
43
|
+
default=False,
|
|
44
|
+
help="Force regeneration of the delta file and run all tests",
|
|
45
|
+
)
|
|
46
|
+
group.addoption(
|
|
47
|
+
"--delta-ignore",
|
|
48
|
+
action="append",
|
|
49
|
+
default=[],
|
|
50
|
+
help="Ignore file patterns during dependency analysis (can be used multiple times)",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
55
|
+
"""Configure the plugin if --delta flag is used."""
|
|
56
|
+
if config.getoption("--delta"):
|
|
57
|
+
config.pluginmanager.register(DeltaPlugin(config), "delta-plugin")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DeltaPlugin:
|
|
61
|
+
"""Main plugin class for pytest-delta functionality."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, config: pytest.Config):
|
|
64
|
+
self.config = config
|
|
65
|
+
# Construct delta file path from filename and directory
|
|
66
|
+
delta_filename = config.getoption("--delta-filename")
|
|
67
|
+
delta_dir = config.getoption("--delta-dir")
|
|
68
|
+
|
|
69
|
+
# Ensure filename has .json extension
|
|
70
|
+
if not delta_filename.endswith(".json"):
|
|
71
|
+
delta_filename += ".json"
|
|
72
|
+
|
|
73
|
+
self.delta_file = Path(delta_dir) / delta_filename
|
|
74
|
+
self.force_regenerate = config.getoption("--delta-force")
|
|
75
|
+
self.ignore_patterns = config.getoption("--delta-ignore")
|
|
76
|
+
self.root_dir = Path.cwd()
|
|
77
|
+
self.delta_manager = DeltaManager(self.delta_file)
|
|
78
|
+
self.dependency_analyzer = DependencyAnalyzer(
|
|
79
|
+
self.root_dir, ignore_patterns=self.ignore_patterns
|
|
80
|
+
)
|
|
81
|
+
self.affected_files: Set[Path] = set()
|
|
82
|
+
self.should_run_all = False
|
|
83
|
+
|
|
84
|
+
def pytest_collection_modifyitems(
|
|
85
|
+
self, config: pytest.Config, items: List[pytest.Item]
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Modify the collected test items to only include affected tests."""
|
|
88
|
+
try:
|
|
89
|
+
# Try to determine which files are affected
|
|
90
|
+
self._analyze_changes()
|
|
91
|
+
|
|
92
|
+
if self.should_run_all:
|
|
93
|
+
# Run all tests and regenerate delta file
|
|
94
|
+
self._print_info("Running all tests (regenerating delta file)")
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
if not self.affected_files:
|
|
98
|
+
# No changes detected, skip all tests
|
|
99
|
+
self._print_info("No changes detected, skipping all tests")
|
|
100
|
+
items.clear()
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
# Filter tests based on affected files
|
|
104
|
+
original_count = len(items)
|
|
105
|
+
items[:] = self._filter_affected_tests(items)
|
|
106
|
+
filtered_count = len(items)
|
|
107
|
+
|
|
108
|
+
self._print_info(
|
|
109
|
+
f"Selected {filtered_count}/{original_count} tests based on code changes"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if filtered_count > 0:
|
|
113
|
+
affected_files_str = ", ".join(
|
|
114
|
+
str(f.relative_to(self.root_dir))
|
|
115
|
+
for f in sorted(self.affected_files)
|
|
116
|
+
)
|
|
117
|
+
self._print_info(f"Affected files: {affected_files_str}")
|
|
118
|
+
|
|
119
|
+
except Exception as e:
|
|
120
|
+
self._print_warning(f"Error in delta analysis: {e}")
|
|
121
|
+
self._print_warning("Running all tests as fallback")
|
|
122
|
+
self.should_run_all = True
|
|
123
|
+
|
|
124
|
+
def pytest_sessionfinish(self, session: pytest.Session, exitstatus: int) -> None:
|
|
125
|
+
"""Update delta metadata after test session completion."""
|
|
126
|
+
if exitstatus == 0: # Tests passed successfully
|
|
127
|
+
try:
|
|
128
|
+
self.delta_manager.update_metadata(self.root_dir)
|
|
129
|
+
self._print_info("Delta metadata updated successfully")
|
|
130
|
+
except Exception as e:
|
|
131
|
+
self._print_warning(f"Failed to update delta metadata: {e}")
|
|
132
|
+
|
|
133
|
+
def _analyze_changes(self) -> None:
|
|
134
|
+
"""Analyze what files have changed and determine affected files."""
|
|
135
|
+
try:
|
|
136
|
+
repo = Repo(self.root_dir)
|
|
137
|
+
except InvalidGitRepositoryError:
|
|
138
|
+
self._print_warning("Not a Git repository, running all tests")
|
|
139
|
+
self.should_run_all = True
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
if self.force_regenerate or not self.delta_file.exists():
|
|
143
|
+
self._print_info("Delta file not found or force regeneration requested")
|
|
144
|
+
self.should_run_all = True
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
# Load previous metadata
|
|
149
|
+
metadata = self.delta_manager.load_metadata()
|
|
150
|
+
if not metadata or "last_commit" not in metadata:
|
|
151
|
+
self._print_warning("Invalid delta metadata, running all tests")
|
|
152
|
+
self.should_run_all = True
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
last_commit = metadata["last_commit"]
|
|
156
|
+
|
|
157
|
+
# Get changed files since last commit
|
|
158
|
+
try:
|
|
159
|
+
changed_files = self._get_changed_files(repo, last_commit)
|
|
160
|
+
except GitCommandError as e:
|
|
161
|
+
self._print_warning(f"Git error: {e}")
|
|
162
|
+
self._print_warning("Running all tests")
|
|
163
|
+
self.should_run_all = True
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
if not changed_files:
|
|
167
|
+
# No changes detected
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
# Build dependency graph and find affected files
|
|
171
|
+
dependency_graph = self.dependency_analyzer.build_dependency_graph()
|
|
172
|
+
self.affected_files = self.dependency_analyzer.find_affected_files(
|
|
173
|
+
changed_files, dependency_graph
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
except Exception as e:
|
|
177
|
+
self._print_warning(f"Error analyzing changes: {e}")
|
|
178
|
+
self.should_run_all = True
|
|
179
|
+
|
|
180
|
+
def _get_changed_files(self, repo: Repo, last_commit: str) -> Set[Path]:
|
|
181
|
+
"""Get list of files changed since the last commit."""
|
|
182
|
+
changed_files = set()
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
# Get committed changes
|
|
186
|
+
diff = repo.commit(last_commit).diff("HEAD")
|
|
187
|
+
for item in diff:
|
|
188
|
+
if item.a_path:
|
|
189
|
+
file_path = self.root_dir / item.a_path
|
|
190
|
+
if file_path.suffix == ".py":
|
|
191
|
+
changed_files.add(file_path)
|
|
192
|
+
if item.b_path:
|
|
193
|
+
file_path = self.root_dir / item.b_path
|
|
194
|
+
if file_path.suffix == ".py":
|
|
195
|
+
changed_files.add(file_path)
|
|
196
|
+
except GitCommandError:
|
|
197
|
+
# Last commit might not exist, compare with HEAD
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
# Get uncommitted changes (staged and unstaged)
|
|
201
|
+
try:
|
|
202
|
+
# Staged changes
|
|
203
|
+
diff_staged = repo.index.diff("HEAD")
|
|
204
|
+
for item in diff_staged:
|
|
205
|
+
if item.a_path:
|
|
206
|
+
file_path = self.root_dir / item.a_path
|
|
207
|
+
if file_path.suffix == ".py":
|
|
208
|
+
changed_files.add(file_path)
|
|
209
|
+
if item.b_path:
|
|
210
|
+
file_path = self.root_dir / item.b_path
|
|
211
|
+
if file_path.suffix == ".py":
|
|
212
|
+
changed_files.add(file_path)
|
|
213
|
+
|
|
214
|
+
# Unstaged changes
|
|
215
|
+
diff_unstaged = repo.index.diff(None)
|
|
216
|
+
for item in diff_unstaged:
|
|
217
|
+
if item.a_path:
|
|
218
|
+
file_path = self.root_dir / item.a_path
|
|
219
|
+
if file_path.suffix == ".py":
|
|
220
|
+
changed_files.add(file_path)
|
|
221
|
+
if item.b_path:
|
|
222
|
+
file_path = self.root_dir / item.b_path
|
|
223
|
+
if file_path.suffix == ".py":
|
|
224
|
+
changed_files.add(file_path)
|
|
225
|
+
except GitCommandError:
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
return changed_files
|
|
229
|
+
|
|
230
|
+
def _filter_affected_tests(self, items: List[pytest.Item]) -> List[pytest.Item]:
|
|
231
|
+
"""Filter test items to only include those affected by changes."""
|
|
232
|
+
affected_tests = []
|
|
233
|
+
|
|
234
|
+
for item in items:
|
|
235
|
+
test_file = Path(item.fspath)
|
|
236
|
+
|
|
237
|
+
# Check if the test file itself is affected
|
|
238
|
+
if test_file in self.affected_files:
|
|
239
|
+
affected_tests.append(item)
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
# Check if the test file tests any affected source files
|
|
243
|
+
if self._test_covers_affected_files(test_file):
|
|
244
|
+
affected_tests.append(item)
|
|
245
|
+
|
|
246
|
+
return affected_tests
|
|
247
|
+
|
|
248
|
+
def _test_covers_affected_files(self, test_file: Path) -> bool:
|
|
249
|
+
"""Check if a test file covers any of the affected source files."""
|
|
250
|
+
# Simple heuristic: match test file path with source file path
|
|
251
|
+
# test_something.py -> something.py
|
|
252
|
+
# tests/test_module.py -> src/module.py or module.py
|
|
253
|
+
|
|
254
|
+
test_name = test_file.name
|
|
255
|
+
if test_name.startswith("test_"):
|
|
256
|
+
source_name = test_name[5:] # Remove 'test_' prefix
|
|
257
|
+
else:
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
# Look for corresponding source files in affected files
|
|
261
|
+
for affected_file in self.affected_files:
|
|
262
|
+
if affected_file.name == source_name:
|
|
263
|
+
return True
|
|
264
|
+
# Also check if the test directory structure matches source structure
|
|
265
|
+
if self._paths_match(test_file, affected_file):
|
|
266
|
+
return True
|
|
267
|
+
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
def _paths_match(self, test_file: Path, source_file: Path) -> bool:
|
|
271
|
+
"""Check if test file path corresponds to source file path."""
|
|
272
|
+
# Convert paths to relative and normalize
|
|
273
|
+
try:
|
|
274
|
+
test_rel = test_file.relative_to(self.root_dir)
|
|
275
|
+
source_rel = source_file.relative_to(self.root_dir)
|
|
276
|
+
except ValueError:
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
# Simple matching logic:
|
|
280
|
+
# tests/test_module.py matches src/module.py
|
|
281
|
+
# tests/subdir/test_module.py matches src/subdir/module.py
|
|
282
|
+
test_parts = list(test_rel.parts)
|
|
283
|
+
source_parts = list(source_rel.parts)
|
|
284
|
+
|
|
285
|
+
if len(test_parts) != len(source_parts):
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
for i, (test_part, source_part) in enumerate(zip(test_parts, source_parts)):
|
|
289
|
+
if i == 0: # First part: tests vs src
|
|
290
|
+
if test_part == "tests" and source_part == "src":
|
|
291
|
+
continue
|
|
292
|
+
elif test_part == source_part:
|
|
293
|
+
continue
|
|
294
|
+
else:
|
|
295
|
+
return False
|
|
296
|
+
elif i == len(test_parts) - 1: # Last part: filename
|
|
297
|
+
if test_part.startswith("test_") and test_part[5:] == source_part:
|
|
298
|
+
return True
|
|
299
|
+
else:
|
|
300
|
+
return False
|
|
301
|
+
else: # Middle parts: should match exactly
|
|
302
|
+
if test_part != source_part:
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
def _print_info(self, message: str) -> None:
|
|
308
|
+
"""Print informational message."""
|
|
309
|
+
print(f"[pytest-delta] {message}")
|
|
310
|
+
|
|
311
|
+
def _print_warning(self, message: str) -> None:
|
|
312
|
+
"""Print warning message."""
|
|
313
|
+
print(f"[pytest-delta] WARNING: {message}")
|