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.
@@ -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,4 @@
1
+ """pytest-delta: Run only tests impacted by your code changes."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.1.0"
@@ -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}")