gwc-pybundle 2.1.2__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.
Potentially problematic release.
This version of gwc-pybundle might be problematic. Click here for more details.
- gwc_pybundle-2.1.2.dist-info/METADATA +903 -0
- gwc_pybundle-2.1.2.dist-info/RECORD +82 -0
- gwc_pybundle-2.1.2.dist-info/WHEEL +5 -0
- gwc_pybundle-2.1.2.dist-info/entry_points.txt +2 -0
- gwc_pybundle-2.1.2.dist-info/licenses/LICENSE.md +25 -0
- gwc_pybundle-2.1.2.dist-info/top_level.txt +1 -0
- pybundle/__init__.py +0 -0
- pybundle/__main__.py +4 -0
- pybundle/cli.py +546 -0
- pybundle/context.py +404 -0
- pybundle/doctor.py +148 -0
- pybundle/filters.py +228 -0
- pybundle/manifest.py +77 -0
- pybundle/packaging.py +45 -0
- pybundle/policy.py +132 -0
- pybundle/profiles.py +454 -0
- pybundle/roadmap_model.py +42 -0
- pybundle/roadmap_scan.py +328 -0
- pybundle/root_detect.py +14 -0
- pybundle/runner.py +180 -0
- pybundle/steps/__init__.py +26 -0
- pybundle/steps/ai_context.py +791 -0
- pybundle/steps/api_docs.py +219 -0
- pybundle/steps/asyncio_analysis.py +358 -0
- pybundle/steps/bandit.py +72 -0
- pybundle/steps/base.py +20 -0
- pybundle/steps/blocking_call_detection.py +291 -0
- pybundle/steps/call_graph.py +219 -0
- pybundle/steps/compileall.py +76 -0
- pybundle/steps/config_docs.py +319 -0
- pybundle/steps/config_validation.py +302 -0
- pybundle/steps/container_image.py +294 -0
- pybundle/steps/context_expand.py +272 -0
- pybundle/steps/copy_pack.py +293 -0
- pybundle/steps/coverage.py +101 -0
- pybundle/steps/cprofile_step.py +166 -0
- pybundle/steps/dependency_sizes.py +136 -0
- pybundle/steps/django_checks.py +214 -0
- pybundle/steps/dockerfile_lint.py +282 -0
- pybundle/steps/dockerignore.py +311 -0
- pybundle/steps/duplication.py +103 -0
- pybundle/steps/env_completeness.py +269 -0
- pybundle/steps/env_var_usage.py +253 -0
- pybundle/steps/error_refs.py +204 -0
- pybundle/steps/event_loop_patterns.py +280 -0
- pybundle/steps/exception_patterns.py +190 -0
- pybundle/steps/fastapi_integration.py +250 -0
- pybundle/steps/flask_debugging.py +312 -0
- pybundle/steps/git_analytics.py +315 -0
- pybundle/steps/handoff_md.py +176 -0
- pybundle/steps/import_time.py +175 -0
- pybundle/steps/interrogate.py +106 -0
- pybundle/steps/license_scan.py +96 -0
- pybundle/steps/line_profiler.py +117 -0
- pybundle/steps/link_validation.py +287 -0
- pybundle/steps/logging_analysis.py +233 -0
- pybundle/steps/memory_profile.py +176 -0
- pybundle/steps/migration_history.py +336 -0
- pybundle/steps/mutation_testing.py +141 -0
- pybundle/steps/mypy.py +103 -0
- pybundle/steps/orm_optimization.py +316 -0
- pybundle/steps/pip_audit.py +45 -0
- pybundle/steps/pipdeptree.py +62 -0
- pybundle/steps/pylance.py +562 -0
- pybundle/steps/pytest.py +66 -0
- pybundle/steps/query_pattern_analysis.py +334 -0
- pybundle/steps/radon.py +161 -0
- pybundle/steps/repro_md.py +161 -0
- pybundle/steps/rg_scans.py +78 -0
- pybundle/steps/roadmap.py +153 -0
- pybundle/steps/ruff.py +117 -0
- pybundle/steps/secrets_detection.py +235 -0
- pybundle/steps/security_headers.py +309 -0
- pybundle/steps/shell.py +74 -0
- pybundle/steps/slow_tests.py +178 -0
- pybundle/steps/sqlalchemy_validation.py +269 -0
- pybundle/steps/test_flakiness.py +184 -0
- pybundle/steps/tree.py +116 -0
- pybundle/steps/type_coverage.py +277 -0
- pybundle/steps/unused_deps.py +211 -0
- pybundle/steps/vulture.py +167 -0
- pybundle/tools.py +63 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Step: Container Image Analysis
|
|
3
|
+
Analyze Docker images for size and layer optimization.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from .base import Step, StepResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ContainerImageStep(Step):
|
|
15
|
+
"""Analyze Docker images for size and layer information."""
|
|
16
|
+
|
|
17
|
+
name = "container image analysis"
|
|
18
|
+
|
|
19
|
+
def run(self, ctx: "BundleContext") -> StepResult: # type: ignore[name-defined]
|
|
20
|
+
"""Analyze Docker images from docker-compose or specified images."""
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
start = time.time()
|
|
24
|
+
|
|
25
|
+
root = ctx.root
|
|
26
|
+
|
|
27
|
+
# Try to get image name from options or docker-compose
|
|
28
|
+
image_name = getattr(ctx.options, "docker_image", None)
|
|
29
|
+
|
|
30
|
+
if not image_name:
|
|
31
|
+
# Try to find docker-compose.yml
|
|
32
|
+
compose_files = [
|
|
33
|
+
root / "docker-compose.yml",
|
|
34
|
+
root / "docker-compose.yaml",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
image_name = None
|
|
38
|
+
for compose_file in compose_files:
|
|
39
|
+
if compose_file.exists():
|
|
40
|
+
image_name = self._extract_image_from_compose(compose_file)
|
|
41
|
+
if image_name:
|
|
42
|
+
break
|
|
43
|
+
|
|
44
|
+
if not image_name:
|
|
45
|
+
elapsed = int(time.time() - start)
|
|
46
|
+
return StepResult(
|
|
47
|
+
self.name, "SKIP", elapsed, "No docker image specified or found"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Check if Docker is available
|
|
51
|
+
docker_available = self._check_docker()
|
|
52
|
+
if not docker_available:
|
|
53
|
+
elapsed = int(time.time() - start)
|
|
54
|
+
return StepResult(self.name, "SKIP", elapsed, "Docker not available")
|
|
55
|
+
|
|
56
|
+
# Generate report
|
|
57
|
+
lines = [
|
|
58
|
+
"=" * 80,
|
|
59
|
+
"CONTAINER IMAGE ANALYSIS",
|
|
60
|
+
"=" * 80,
|
|
61
|
+
"",
|
|
62
|
+
f"Target image: {image_name}",
|
|
63
|
+
"",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
# Get image info
|
|
67
|
+
image_info = self._inspect_image(image_name)
|
|
68
|
+
if not image_info:
|
|
69
|
+
lines.extend(
|
|
70
|
+
[
|
|
71
|
+
"⚠ Could not inspect image. Image may not be built or available.",
|
|
72
|
+
"",
|
|
73
|
+
"Build the image first with:",
|
|
74
|
+
f" docker build -t {image_name} .",
|
|
75
|
+
"",
|
|
76
|
+
]
|
|
77
|
+
)
|
|
78
|
+
else:
|
|
79
|
+
# Image details
|
|
80
|
+
lines.extend(
|
|
81
|
+
[
|
|
82
|
+
"=" * 80,
|
|
83
|
+
"IMAGE INFORMATION",
|
|
84
|
+
"=" * 80,
|
|
85
|
+
"",
|
|
86
|
+
f"Image ID: {image_info.get('Id', 'N/A')[:19]}...",
|
|
87
|
+
f"Created: {image_info.get('Created', 'N/A')}",
|
|
88
|
+
f"Architecture: {image_info.get('Architecture', 'N/A')}",
|
|
89
|
+
f"OS: {image_info.get('Os', 'N/A')}",
|
|
90
|
+
f"Docker Version: {image_info.get('DockerVersion', 'N/A')}",
|
|
91
|
+
"",
|
|
92
|
+
]
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Size information
|
|
96
|
+
size = image_info.get("Size", 0)
|
|
97
|
+
virtual_size = image_info.get("VirtualSize", 0)
|
|
98
|
+
|
|
99
|
+
lines.extend(
|
|
100
|
+
[
|
|
101
|
+
"=" * 80,
|
|
102
|
+
"SIZE INFORMATION",
|
|
103
|
+
"=" * 80,
|
|
104
|
+
"",
|
|
105
|
+
f"Compressed size: {self._format_size(size)}",
|
|
106
|
+
f"Uncompressed size: {self._format_size(virtual_size)}",
|
|
107
|
+
"",
|
|
108
|
+
]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Layer analysis
|
|
112
|
+
layers = self._get_image_history(image_name)
|
|
113
|
+
if layers:
|
|
114
|
+
lines.extend(
|
|
115
|
+
[
|
|
116
|
+
"=" * 80,
|
|
117
|
+
"LAYER ANALYSIS (top 20 by size)",
|
|
118
|
+
"=" * 80,
|
|
119
|
+
"",
|
|
120
|
+
]
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Sort by size descending
|
|
124
|
+
sorted_layers = sorted(
|
|
125
|
+
layers, key=lambda x: x.get("Size", 0), reverse=True
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
total_layer_size = 0
|
|
129
|
+
for i, layer in enumerate(sorted_layers[:20], 1):
|
|
130
|
+
size = layer.get("Size", 0)
|
|
131
|
+
total_layer_size += size
|
|
132
|
+
cmd = layer.get("CreatedBy", "").strip()
|
|
133
|
+
|
|
134
|
+
# Truncate long commands
|
|
135
|
+
if len(cmd) > 70:
|
|
136
|
+
cmd = cmd[:67] + "..."
|
|
137
|
+
|
|
138
|
+
size_str = self._format_size(size)
|
|
139
|
+
lines.append(f"{i:2}. {size_str:12} {cmd}")
|
|
140
|
+
|
|
141
|
+
if len(sorted_layers) > 20:
|
|
142
|
+
lines.append(f"\n... and {len(sorted_layers) - 20} more layers")
|
|
143
|
+
|
|
144
|
+
lines.append("")
|
|
145
|
+
|
|
146
|
+
# Recommendations for optimization
|
|
147
|
+
lines.extend(
|
|
148
|
+
[
|
|
149
|
+
"=" * 80,
|
|
150
|
+
"OPTIMIZATION RECOMMENDATIONS",
|
|
151
|
+
"=" * 80,
|
|
152
|
+
"",
|
|
153
|
+
]
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Check for common issues
|
|
157
|
+
has_large_run = any(
|
|
158
|
+
layer.get("Size", 0) > 50 * 1024 * 1024 for layer in layers
|
|
159
|
+
)
|
|
160
|
+
layer_count = len(layers)
|
|
161
|
+
|
|
162
|
+
if has_large_run:
|
|
163
|
+
lines.append(" - Large RUN layers detected (>50MB)")
|
|
164
|
+
lines.append(" Consider combining RUN commands and cleaning up in same layer")
|
|
165
|
+
|
|
166
|
+
if layer_count > 30:
|
|
167
|
+
lines.append(
|
|
168
|
+
f" - Many layers detected ({layer_count})"
|
|
169
|
+
)
|
|
170
|
+
lines.append(" Consider combining related RUN instructions")
|
|
171
|
+
|
|
172
|
+
if total_layer_size > 1024 * 1024 * 1024:
|
|
173
|
+
size_gb = total_layer_size / (1024 * 1024 * 1024)
|
|
174
|
+
lines.append(f" - Large total size ({size_gb:.1f}GB)")
|
|
175
|
+
lines.append(" Consider using multi-stage builds or base image optimization")
|
|
176
|
+
|
|
177
|
+
lines.append("")
|
|
178
|
+
|
|
179
|
+
# Recommendations
|
|
180
|
+
if not image_info:
|
|
181
|
+
lines.extend(
|
|
182
|
+
[
|
|
183
|
+
"=" * 80,
|
|
184
|
+
"NEXT STEPS",
|
|
185
|
+
"=" * 80,
|
|
186
|
+
"",
|
|
187
|
+
f"1. Build the Docker image:",
|
|
188
|
+
f" docker build -t {image_name} .",
|
|
189
|
+
"",
|
|
190
|
+
f"2. Re-run pybundle to analyze the built image:",
|
|
191
|
+
f" pybundle run analysis --docker-image {image_name}",
|
|
192
|
+
"",
|
|
193
|
+
]
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Write report
|
|
197
|
+
output = "\n".join(lines)
|
|
198
|
+
dest = ctx.workdir / "meta" / "107_container_image.txt"
|
|
199
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
200
|
+
dest.write_text(output, encoding="utf-8")
|
|
201
|
+
|
|
202
|
+
elapsed = int(time.time() - start)
|
|
203
|
+
return StepResult(self.name, "OK", elapsed, "")
|
|
204
|
+
|
|
205
|
+
def _check_docker(self) -> bool:
|
|
206
|
+
"""Check if Docker is available and running."""
|
|
207
|
+
try:
|
|
208
|
+
result = subprocess.run(
|
|
209
|
+
["docker", "version"],
|
|
210
|
+
capture_output=True,
|
|
211
|
+
timeout=5,
|
|
212
|
+
)
|
|
213
|
+
return result.returncode == 0
|
|
214
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
def _extract_image_from_compose(self, compose_file: Path) -> Optional[str]:
|
|
218
|
+
"""Extract image name from docker-compose file."""
|
|
219
|
+
try:
|
|
220
|
+
content = compose_file.read_text(encoding="utf-8", errors="ignore")
|
|
221
|
+
# Simple regex to find image references
|
|
222
|
+
match = re.search(r'image:\s*([^\s\n]+)', content)
|
|
223
|
+
if match:
|
|
224
|
+
return match.group(1)
|
|
225
|
+
except (OSError, UnicodeDecodeError):
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
def _inspect_image(self, image_name: str) -> dict:
|
|
231
|
+
"""Use docker inspect to get image information."""
|
|
232
|
+
try:
|
|
233
|
+
result = subprocess.run(
|
|
234
|
+
["docker", "image", "inspect", image_name],
|
|
235
|
+
capture_output=True,
|
|
236
|
+
text=True,
|
|
237
|
+
timeout=10,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if result.returncode == 0:
|
|
241
|
+
import json
|
|
242
|
+
|
|
243
|
+
data = json.loads(result.stdout)
|
|
244
|
+
if data and len(data) > 0:
|
|
245
|
+
return data[0]
|
|
246
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
return {}
|
|
250
|
+
|
|
251
|
+
def _get_image_history(self, image_name: str) -> List[dict]:
|
|
252
|
+
"""Get layer history from docker history."""
|
|
253
|
+
layers = []
|
|
254
|
+
try:
|
|
255
|
+
result = subprocess.run(
|
|
256
|
+
["docker", "history", "--no-trunc", "--human", image_name],
|
|
257
|
+
capture_output=True,
|
|
258
|
+
text=True,
|
|
259
|
+
timeout=10,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if result.returncode == 0:
|
|
263
|
+
import json
|
|
264
|
+
|
|
265
|
+
# Try to get JSON format
|
|
266
|
+
result = subprocess.run(
|
|
267
|
+
["docker", "history", "--no-trunc", image_name, "--format", "json"],
|
|
268
|
+
capture_output=True,
|
|
269
|
+
text=True,
|
|
270
|
+
timeout=10,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if result.returncode == 0:
|
|
274
|
+
for line in result.stdout.strip().split("\n"):
|
|
275
|
+
if line:
|
|
276
|
+
try:
|
|
277
|
+
layer = json.loads(line)
|
|
278
|
+
layers.append(layer)
|
|
279
|
+
except json.JSONDecodeError:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
283
|
+
pass
|
|
284
|
+
|
|
285
|
+
return layers
|
|
286
|
+
|
|
287
|
+
def _format_size(self, size_bytes: float) -> str:
|
|
288
|
+
"""Format bytes into human readable size."""
|
|
289
|
+
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
|
290
|
+
if size_bytes < 1024:
|
|
291
|
+
return f"{size_bytes:.1f}{unit}"
|
|
292
|
+
size_bytes /= 1024
|
|
293
|
+
|
|
294
|
+
return f"{size_bytes:.1f}PB"
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .base import StepResult
|
|
9
|
+
from ..context import BundleContext
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _read_lines(p: Path) -> list[str]:
|
|
13
|
+
if not p.is_file():
|
|
14
|
+
return []
|
|
15
|
+
return [
|
|
16
|
+
ln.strip()
|
|
17
|
+
for ln in p.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
18
|
+
if ln.strip()
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _is_under(root: Path, p: Path) -> bool:
|
|
23
|
+
try:
|
|
24
|
+
p.resolve().relative_to(root.resolve())
|
|
25
|
+
return True
|
|
26
|
+
except Exception:
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _copy_file(src: Path, dst: Path) -> bool:
|
|
31
|
+
try:
|
|
32
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
dst.write_bytes(src.read_bytes())
|
|
34
|
+
return True
|
|
35
|
+
except Exception:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _find_repo_candidates(root: Path) -> list[Path]:
|
|
40
|
+
cands = [root]
|
|
41
|
+
if (root / "src").is_dir():
|
|
42
|
+
cands.append(root / "src")
|
|
43
|
+
return cands
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _module_to_path(roots: list[Path], module: str) -> Path | None:
|
|
47
|
+
"""
|
|
48
|
+
Resolve an absolute module like 'pkg.sub' to a file inside repo roots:
|
|
49
|
+
- <root>/pkg/sub.py
|
|
50
|
+
- <root>/pkg/sub/__init__.py
|
|
51
|
+
"""
|
|
52
|
+
parts = module.split(".")
|
|
53
|
+
for r in roots:
|
|
54
|
+
f1 = r.joinpath(*parts).with_suffix(".py")
|
|
55
|
+
if f1.is_file():
|
|
56
|
+
return f1
|
|
57
|
+
f2 = r.joinpath(*parts) / "__init__.py"
|
|
58
|
+
if f2.is_file():
|
|
59
|
+
return f2
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _relative_module_to_path(
|
|
64
|
+
roots: list[Path], base_file: Path, module: str | None, level: int
|
|
65
|
+
) -> Path | None:
|
|
66
|
+
"""
|
|
67
|
+
Resolve relative imports like:
|
|
68
|
+
from . import x (level=1, module=None)
|
|
69
|
+
from ..foo import y (level=2, module="foo")
|
|
70
|
+
"""
|
|
71
|
+
# Find the package directory for base_file
|
|
72
|
+
base_dir = base_file.parent
|
|
73
|
+
|
|
74
|
+
# Move up `level` package levels
|
|
75
|
+
rel_dir = base_dir
|
|
76
|
+
for _ in range(level):
|
|
77
|
+
rel_dir = rel_dir.parent
|
|
78
|
+
|
|
79
|
+
if module:
|
|
80
|
+
target = rel_dir.joinpath(*module.split("."))
|
|
81
|
+
else:
|
|
82
|
+
target = rel_dir
|
|
83
|
+
|
|
84
|
+
# Try module as file or package
|
|
85
|
+
f1 = target.with_suffix(".py")
|
|
86
|
+
if f1.is_file():
|
|
87
|
+
return f1
|
|
88
|
+
f2 = target / "__init__.py"
|
|
89
|
+
if f2.is_file():
|
|
90
|
+
return f2
|
|
91
|
+
|
|
92
|
+
# If relative resolution fails, try absolute resolution as fallback
|
|
93
|
+
if module:
|
|
94
|
+
return _module_to_path(roots, module)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _extract_import_modules(py_file: Path) -> set[tuple[str | None, int]]:
|
|
99
|
+
"""
|
|
100
|
+
Returns a set of (module, level) pairs:
|
|
101
|
+
- absolute imports: (module, 0)
|
|
102
|
+
- relative imports: (module, level>=1)
|
|
103
|
+
module can be None for `from . import x` style.
|
|
104
|
+
"""
|
|
105
|
+
out: set[tuple[str | None, int]] = set()
|
|
106
|
+
try:
|
|
107
|
+
src = py_file.read_text(encoding="utf-8", errors="replace")
|
|
108
|
+
tree = ast.parse(src)
|
|
109
|
+
except Exception:
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
for node in ast.walk(tree):
|
|
113
|
+
if isinstance(node, ast.Import):
|
|
114
|
+
for alias in node.names:
|
|
115
|
+
if alias.name:
|
|
116
|
+
out.add((alias.name, 0))
|
|
117
|
+
elif isinstance(node, ast.ImportFrom):
|
|
118
|
+
# node.module can be None (from . import x)
|
|
119
|
+
lvl = int(getattr(node, "level", 0) or 0)
|
|
120
|
+
mod = node.module
|
|
121
|
+
if mod:
|
|
122
|
+
out.add((mod, lvl))
|
|
123
|
+
else:
|
|
124
|
+
# still useful; we can resolve to the package itself for context
|
|
125
|
+
out.add((None, lvl))
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _add_package_chain(files: set[Path], root: Path, py: Path) -> None:
|
|
130
|
+
"""
|
|
131
|
+
Add __init__.py from file's dir up to repo root.
|
|
132
|
+
"""
|
|
133
|
+
cur = py.parent
|
|
134
|
+
root_res = root.resolve()
|
|
135
|
+
while True:
|
|
136
|
+
initp = cur / "__init__.py"
|
|
137
|
+
if initp.is_file():
|
|
138
|
+
files.add(initp)
|
|
139
|
+
if cur.resolve() == root_res:
|
|
140
|
+
break
|
|
141
|
+
if not _is_under(root, cur):
|
|
142
|
+
break
|
|
143
|
+
cur = cur.parent
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _add_conftest_chain(files: set[Path], root: Path, py: Path) -> None:
|
|
147
|
+
"""
|
|
148
|
+
Add conftest.py from file's dir up to repo root (pytest glue).
|
|
149
|
+
"""
|
|
150
|
+
cur = py.parent
|
|
151
|
+
root_res = root.resolve()
|
|
152
|
+
while True:
|
|
153
|
+
cp = cur / "conftest.py"
|
|
154
|
+
if cp.is_file():
|
|
155
|
+
files.add(cp)
|
|
156
|
+
if cur.resolve() == root_res:
|
|
157
|
+
break
|
|
158
|
+
if not _is_under(root, cur):
|
|
159
|
+
break
|
|
160
|
+
cur = cur.parent
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class ErrorContextExpandStep:
|
|
165
|
+
name: str = "expand error context"
|
|
166
|
+
depth: int = 2
|
|
167
|
+
max_files: int = 600
|
|
168
|
+
# reads this list produced by step 8
|
|
169
|
+
source_list_file: str = "error_files_from_logs.txt"
|
|
170
|
+
|
|
171
|
+
def run(self, ctx: BundleContext) -> StepResult:
|
|
172
|
+
start = time.time()
|
|
173
|
+
roots = _find_repo_candidates(ctx.root)
|
|
174
|
+
|
|
175
|
+
list_path = ctx.workdir / self.source_list_file
|
|
176
|
+
rels = _read_lines(list_path)
|
|
177
|
+
|
|
178
|
+
dest_root = ctx.srcdir / "_error_context"
|
|
179
|
+
dest_root.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
|
|
181
|
+
to_copy: set[Path] = set()
|
|
182
|
+
queue: list[tuple[Path, int]] = []
|
|
183
|
+
|
|
184
|
+
# Seed with referenced python files only
|
|
185
|
+
for rel_str in rels:
|
|
186
|
+
p = (ctx.root / rel_str).resolve()
|
|
187
|
+
if p.is_file() and p.suffix == ".py" and _is_under(ctx.root, p):
|
|
188
|
+
queue.append((p, 0))
|
|
189
|
+
to_copy.add(p)
|
|
190
|
+
|
|
191
|
+
visited: set[Path] = set()
|
|
192
|
+
|
|
193
|
+
while queue and len(to_copy) < self.max_files:
|
|
194
|
+
py_file, d = queue.pop(0)
|
|
195
|
+
if py_file in visited:
|
|
196
|
+
continue
|
|
197
|
+
visited.add(py_file)
|
|
198
|
+
|
|
199
|
+
# add pytest + package glue around this file
|
|
200
|
+
_add_package_chain(to_copy, ctx.root, py_file)
|
|
201
|
+
_add_conftest_chain(to_copy, ctx.root, py_file)
|
|
202
|
+
|
|
203
|
+
if d >= self.depth:
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
# parse imports and resolve to repo files
|
|
207
|
+
for mod, level in _extract_import_modules(py_file):
|
|
208
|
+
target: Path | None
|
|
209
|
+
if level and level > 0:
|
|
210
|
+
target = _relative_module_to_path(roots, py_file, mod, level)
|
|
211
|
+
else:
|
|
212
|
+
if not mod:
|
|
213
|
+
continue
|
|
214
|
+
target = _module_to_path(roots, mod)
|
|
215
|
+
|
|
216
|
+
if target and target.is_file() and _is_under(ctx.root, target):
|
|
217
|
+
if target.suffix == ".py":
|
|
218
|
+
if target not in to_copy:
|
|
219
|
+
to_copy.add(target)
|
|
220
|
+
queue.append((target, d + 1))
|
|
221
|
+
|
|
222
|
+
if len(to_copy) >= self.max_files:
|
|
223
|
+
break
|
|
224
|
+
|
|
225
|
+
# Always include top-level config files if present (small but high value)
|
|
226
|
+
for cfg in [
|
|
227
|
+
"pyproject.toml",
|
|
228
|
+
"mypy.ini",
|
|
229
|
+
"ruff.toml",
|
|
230
|
+
".ruff.toml",
|
|
231
|
+
"pytest.ini",
|
|
232
|
+
"setup.cfg",
|
|
233
|
+
"requirements.txt",
|
|
234
|
+
]:
|
|
235
|
+
p = ctx.root / cfg
|
|
236
|
+
if p.is_file():
|
|
237
|
+
to_copy.add(p)
|
|
238
|
+
|
|
239
|
+
copied = 0
|
|
240
|
+
for p in sorted(to_copy):
|
|
241
|
+
if copied >= self.max_files:
|
|
242
|
+
break
|
|
243
|
+
# copy under src/_error_context/<repo-relative-path>
|
|
244
|
+
try:
|
|
245
|
+
rel_path = p.resolve().relative_to(ctx.root.resolve())
|
|
246
|
+
except Exception:
|
|
247
|
+
continue
|
|
248
|
+
dst = dest_root / rel_path
|
|
249
|
+
if _copy_file(p, dst):
|
|
250
|
+
copied += 1
|
|
251
|
+
|
|
252
|
+
report = ctx.metadir / "61_error_context_report.txt"
|
|
253
|
+
report.write_text(
|
|
254
|
+
"\n".join(
|
|
255
|
+
[
|
|
256
|
+
f"seed_files={len([r for r in rels if r.endswith('.py')])}",
|
|
257
|
+
f"depth={self.depth}",
|
|
258
|
+
f"max_files={self.max_files}",
|
|
259
|
+
f"resolved_total={len(to_copy)}",
|
|
260
|
+
f"copied={copied}",
|
|
261
|
+
"dest=src/_error_context",
|
|
262
|
+
]
|
|
263
|
+
)
|
|
264
|
+
+ "\n",
|
|
265
|
+
encoding="utf-8",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
dur = int(time.time() - start)
|
|
269
|
+
note = f"resolved={len(to_copy)} copied={copied}"
|
|
270
|
+
if copied >= self.max_files:
|
|
271
|
+
note += " (HIT MAX)"
|
|
272
|
+
return StepResult(self.name, "PASS", dur, note)
|