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.

Files changed (82) hide show
  1. gwc_pybundle-2.1.2.dist-info/METADATA +903 -0
  2. gwc_pybundle-2.1.2.dist-info/RECORD +82 -0
  3. gwc_pybundle-2.1.2.dist-info/WHEEL +5 -0
  4. gwc_pybundle-2.1.2.dist-info/entry_points.txt +2 -0
  5. gwc_pybundle-2.1.2.dist-info/licenses/LICENSE.md +25 -0
  6. gwc_pybundle-2.1.2.dist-info/top_level.txt +1 -0
  7. pybundle/__init__.py +0 -0
  8. pybundle/__main__.py +4 -0
  9. pybundle/cli.py +546 -0
  10. pybundle/context.py +404 -0
  11. pybundle/doctor.py +148 -0
  12. pybundle/filters.py +228 -0
  13. pybundle/manifest.py +77 -0
  14. pybundle/packaging.py +45 -0
  15. pybundle/policy.py +132 -0
  16. pybundle/profiles.py +454 -0
  17. pybundle/roadmap_model.py +42 -0
  18. pybundle/roadmap_scan.py +328 -0
  19. pybundle/root_detect.py +14 -0
  20. pybundle/runner.py +180 -0
  21. pybundle/steps/__init__.py +26 -0
  22. pybundle/steps/ai_context.py +791 -0
  23. pybundle/steps/api_docs.py +219 -0
  24. pybundle/steps/asyncio_analysis.py +358 -0
  25. pybundle/steps/bandit.py +72 -0
  26. pybundle/steps/base.py +20 -0
  27. pybundle/steps/blocking_call_detection.py +291 -0
  28. pybundle/steps/call_graph.py +219 -0
  29. pybundle/steps/compileall.py +76 -0
  30. pybundle/steps/config_docs.py +319 -0
  31. pybundle/steps/config_validation.py +302 -0
  32. pybundle/steps/container_image.py +294 -0
  33. pybundle/steps/context_expand.py +272 -0
  34. pybundle/steps/copy_pack.py +293 -0
  35. pybundle/steps/coverage.py +101 -0
  36. pybundle/steps/cprofile_step.py +166 -0
  37. pybundle/steps/dependency_sizes.py +136 -0
  38. pybundle/steps/django_checks.py +214 -0
  39. pybundle/steps/dockerfile_lint.py +282 -0
  40. pybundle/steps/dockerignore.py +311 -0
  41. pybundle/steps/duplication.py +103 -0
  42. pybundle/steps/env_completeness.py +269 -0
  43. pybundle/steps/env_var_usage.py +253 -0
  44. pybundle/steps/error_refs.py +204 -0
  45. pybundle/steps/event_loop_patterns.py +280 -0
  46. pybundle/steps/exception_patterns.py +190 -0
  47. pybundle/steps/fastapi_integration.py +250 -0
  48. pybundle/steps/flask_debugging.py +312 -0
  49. pybundle/steps/git_analytics.py +315 -0
  50. pybundle/steps/handoff_md.py +176 -0
  51. pybundle/steps/import_time.py +175 -0
  52. pybundle/steps/interrogate.py +106 -0
  53. pybundle/steps/license_scan.py +96 -0
  54. pybundle/steps/line_profiler.py +117 -0
  55. pybundle/steps/link_validation.py +287 -0
  56. pybundle/steps/logging_analysis.py +233 -0
  57. pybundle/steps/memory_profile.py +176 -0
  58. pybundle/steps/migration_history.py +336 -0
  59. pybundle/steps/mutation_testing.py +141 -0
  60. pybundle/steps/mypy.py +103 -0
  61. pybundle/steps/orm_optimization.py +316 -0
  62. pybundle/steps/pip_audit.py +45 -0
  63. pybundle/steps/pipdeptree.py +62 -0
  64. pybundle/steps/pylance.py +562 -0
  65. pybundle/steps/pytest.py +66 -0
  66. pybundle/steps/query_pattern_analysis.py +334 -0
  67. pybundle/steps/radon.py +161 -0
  68. pybundle/steps/repro_md.py +161 -0
  69. pybundle/steps/rg_scans.py +78 -0
  70. pybundle/steps/roadmap.py +153 -0
  71. pybundle/steps/ruff.py +117 -0
  72. pybundle/steps/secrets_detection.py +235 -0
  73. pybundle/steps/security_headers.py +309 -0
  74. pybundle/steps/shell.py +74 -0
  75. pybundle/steps/slow_tests.py +178 -0
  76. pybundle/steps/sqlalchemy_validation.py +269 -0
  77. pybundle/steps/test_flakiness.py +184 -0
  78. pybundle/steps/tree.py +116 -0
  79. pybundle/steps/type_coverage.py +277 -0
  80. pybundle/steps/unused_deps.py +211 -0
  81. pybundle/steps/vulture.py +167 -0
  82. 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)