deadpush 0.2.0__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.
deadpush/deps.py ADDED
@@ -0,0 +1,323 @@
1
+ """
2
+ Dependency Diff Review — detects new dependencies added in a session.
3
+
4
+ AI agents frequently add unnecessary dependencies. This module compares
5
+ current dependency files with the committed versions, showing what's new
6
+ with registry metadata (package age, downloads) to help evaluate risk.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import subprocess
14
+ import time
15
+ import urllib.request
16
+ import urllib.error
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+
22
+ DEPS_CACHE_FILE = Path.home() / ".deadpush" / "deps_cache.json"
23
+ DEPS_CACHE_MAX_AGE = 86400 # 24 hours
24
+ REGISTRY_TIMEOUT = 5
25
+
26
+
27
+ @dataclass
28
+ class Dependency:
29
+ """A single dependency entry."""
30
+ name: str
31
+ version: str
32
+ source_file: str # e.g., "pyproject.toml", "package.json"
33
+
34
+
35
+ @dataclass
36
+ class DepDiff:
37
+ """Result of comparing dependency files."""
38
+ added: list[Dependency] = field(default_factory=list)
39
+ removed: list[Dependency] = field(default_factory=list)
40
+ changed: list[tuple[Dependency, Dependency]] = field(default_factory=list) # (old, new)
41
+
42
+
43
+ class DepsReviewer:
44
+ """Reviews dependencies by comparing current files with HEAD and checking registries."""
45
+
46
+ def __init__(self, repo_root: Path):
47
+ self.repo_root = repo_root
48
+ self._cache: dict[str, Any] = {}
49
+ self._load_cache()
50
+
51
+ # ------------------------------------------------------------------
52
+ # Cache for registry lookups
53
+ # ------------------------------------------------------------------
54
+ def _load_cache(self):
55
+ if DEPS_CACHE_FILE.exists():
56
+ try:
57
+ data = json.loads(DEPS_CACHE_FILE.read_text(encoding="utf-8"))
58
+ now = time.time()
59
+ self._cache = {
60
+ k: v for k, v in data.items()
61
+ if now - v.get("cached_at", 0) < DEPS_CACHE_MAX_AGE
62
+ }
63
+ except Exception:
64
+ self._cache = {}
65
+
66
+ def _save_cache(self):
67
+ try:
68
+ DEPS_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
69
+ DEPS_CACHE_FILE.write_text(
70
+ json.dumps(self._cache, indent=2, default=str),
71
+ encoding="utf-8",
72
+ )
73
+ except Exception:
74
+ pass
75
+
76
+ # ------------------------------------------------------------------
77
+ # Parsing dependency files
78
+ # ------------------------------------------------------------------
79
+ def _parse_pyproject(self, content: str) -> list[Dependency]:
80
+ """Parse pyproject.toml dependencies."""
81
+ deps: list[Dependency] = []
82
+ try:
83
+ import tomllib
84
+ data = tomllib.loads(content)
85
+ except Exception:
86
+ # Fallback to regex
87
+ for m in re.finditer(r'"([^"]+)"\s*=\s*"([^"]*)"', content):
88
+ deps.append(Dependency(name=m.group(1), version=m.group(2), source_file="pyproject.toml"))
89
+ return deps
90
+
91
+ for section in ("project.dependencies", "project.optional-dependencies"):
92
+ keys = section.split(".")
93
+ d = data
94
+ for k in keys:
95
+ d = d.get(k, {}) if isinstance(d, dict) else {}
96
+ if isinstance(d, list):
97
+ for dep_str in d:
98
+ if isinstance(dep_str, str):
99
+ m = re.match(r'^([\w.-]+)\s*(.+)?', dep_str)
100
+ if m:
101
+ deps.append(Dependency(name=m.group(1), version=(m.group(2) or "").strip(), source_file="pyproject.toml"))
102
+ return deps
103
+
104
+ def _parse_requirements(self, content: str) -> list[Dependency]:
105
+ """Parse requirements.txt."""
106
+ deps: list[Dependency] = []
107
+ for line in content.splitlines():
108
+ line = line.strip()
109
+ if not line or line.startswith("#") or line.startswith("-"):
110
+ continue
111
+ m = re.match(r'^([\w.-]+)\s*([><=!]+\s*\S+)?', line)
112
+ if m:
113
+ deps.append(Dependency(name=m.group(1), version=(m.group(2) or "").strip(), source_file="requirements.txt"))
114
+ return deps
115
+
116
+ def _parse_package_json(self, content: str) -> list[Dependency]:
117
+ """Parse package.json dependencies."""
118
+ deps: list[Dependency] = []
119
+ try:
120
+ data = json.loads(content)
121
+ except Exception:
122
+ return deps
123
+
124
+ for section in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
125
+ for name, version in data.get(section, {}).items():
126
+ deps.append(Dependency(name=name, version=str(version), source_file="package.json"))
127
+ return deps
128
+
129
+ def _parse_cargo(self, content: str) -> list[Dependency]:
130
+ """Parse Cargo.toml dependencies."""
131
+ deps: list[Dependency] = []
132
+ try:
133
+ import tomllib
134
+ data = tomllib.loads(content)
135
+ except Exception:
136
+ for m in re.finditer(r'(\w+)\s*=\s*\{?\s*version\s*=\s*"([^"]+)"', content):
137
+ deps.append(Dependency(name=m.group(1), version=m.group(2), source_file="Cargo.toml"))
138
+ return deps
139
+
140
+ for name, info in data.get("dependencies", {}).items():
141
+ if isinstance(info, str):
142
+ deps.append(Dependency(name=name, version=info, source_file="Cargo.toml"))
143
+ elif isinstance(info, dict):
144
+ deps.append(Dependency(name=name, version=info.get("version", "*"), source_file="Cargo.toml"))
145
+ return deps
146
+
147
+ def _parse_gomod(self, content: str) -> list[Dependency]:
148
+ """Parse go.mod dependencies."""
149
+ deps: list[Dependency] = []
150
+ for line in content.splitlines():
151
+ line = line.strip()
152
+ if line.startswith("require (") or line.startswith(")"):
153
+ continue
154
+ m = re.match(r'^([\w./-]+)\s+v?([\w.+-]+)', line)
155
+ if m and not m.group(1).startswith("go ") and m.group(1) != "require":
156
+ deps.append(Dependency(name=m.group(1), version="v" + m.group(2).lstrip("v"), source_file="go.mod"))
157
+ return deps
158
+
159
+ def _parse_file(self, content: str, path: Path) -> list[Dependency]:
160
+ """Dispatch to the appropriate parser based on filename."""
161
+ name = path.name
162
+ if name == "pyproject.toml":
163
+ return self._parse_pyproject(content)
164
+ elif name == "requirements.txt" or name.endswith("-requirements.txt"):
165
+ return self._parse_requirements(content)
166
+ elif name == "package.json":
167
+ return self._parse_package_json(content)
168
+ elif name == "Cargo.toml":
169
+ return self._parse_cargo(content)
170
+ elif name == "go.mod":
171
+ return self._parse_gomod(content)
172
+ return []
173
+
174
+ # ------------------------------------------------------------------
175
+ # Git comparison
176
+ # ------------------------------------------------------------------
177
+ def _get_head_version(self, rel_path: str) -> str | None:
178
+ """Get the committed version of a file from git HEAD."""
179
+ try:
180
+ result = subprocess.run(
181
+ ["git", "show", f"HEAD:{rel_path}"],
182
+ capture_output=True, text=True, timeout=5,
183
+ cwd=self.repo_root,
184
+ )
185
+ if result.returncode == 0:
186
+ return result.stdout
187
+ except Exception:
188
+ pass
189
+ return None
190
+
191
+ # ------------------------------------------------------------------
192
+ # Registry metadata
193
+ # ------------------------------------------------------------------
194
+ def _lookup_pypi(self, package: str) -> dict[str, Any] | None:
195
+ """Look up package metadata from PyPI."""
196
+ cache_key = f"pypi:{package.lower()}"
197
+ if cache_key in self._cache:
198
+ return self._cache[cache_key]["data"]
199
+
200
+ try:
201
+ url = f"https://pypi.org/pypi/{package}/json"
202
+ req = urllib.request.Request(url)
203
+ req.add_header("User-Agent", "deadpush/0.2.0")
204
+ resp = urllib.request.urlopen(req, timeout=REGISTRY_TIMEOUT)
205
+ data = json.loads(resp.read().decode("utf-8"))
206
+ info = data.get("info", {})
207
+ releases = data.get("releases", {})
208
+ first_release = min(releases.keys()) if releases else None
209
+ result = {
210
+ "name": info.get("name", package),
211
+ "latest_version": info.get("version", ""),
212
+ "summary": (info.get("summary", "") or "")[:120],
213
+ "first_release": first_release,
214
+ "home_page": info.get("home_page") or info.get("project_urls", {}).get("Homepage", ""),
215
+ }
216
+ self._cache[cache_key] = {"data": result, "cached_at": time.time()}
217
+ self._save_cache()
218
+ return result
219
+ except Exception:
220
+ self._cache[cache_key] = {"data": None, "cached_at": time.time()}
221
+ self._save_cache()
222
+ return None
223
+
224
+ def _lookup_npm(self, package: str) -> dict[str, Any] | None:
225
+ """Look up package metadata from npm."""
226
+ cache_key = f"npm:{package.lower()}"
227
+ if cache_key in self._cache:
228
+ return self._cache[cache_key]["data"]
229
+
230
+ try:
231
+ url = f"https://registry.npmjs.org/{package}/latest"
232
+ req = urllib.request.Request(url)
233
+ req.add_header("User-Agent", "deadpush/0.2.0")
234
+ resp = urllib.request.urlopen(req, timeout=REGISTRY_TIMEOUT)
235
+ data = json.loads(resp.read().decode("utf-8"))
236
+ result = {
237
+ "name": data.get("name", package),
238
+ "latest_version": data.get("version", ""),
239
+ "description": (data.get("description", "") or "")[:120],
240
+ "home_page": data.get("homepage", ""),
241
+ }
242
+ self._cache[cache_key] = {"data": result, "cached_at": time.time()}
243
+ self._save_cache()
244
+ return result
245
+ except Exception:
246
+ self._cache[cache_key] = {"data": None, "cached_at": time.time()}
247
+ self._save_cache()
248
+ return None
249
+
250
+ # ------------------------------------------------------------------
251
+ # Public API
252
+ # ------------------------------------------------------------------
253
+ def get_dep_files(self) -> list[Path]:
254
+ """Find dependency files in the repo."""
255
+ dep_files = []
256
+ for name in ("pyproject.toml", "requirements.txt", "package.json", "Cargo.toml", "go.mod"):
257
+ p = self.repo_root / name
258
+ if p.exists():
259
+ dep_files.append(p)
260
+ return dep_files
261
+
262
+ def get_current_deps(self) -> list[Dependency]:
263
+ """Parse all current dependency files."""
264
+ all_deps: list[Dependency] = []
265
+ for path in self.get_dep_files():
266
+ try:
267
+ content = path.read_text(encoding="utf-8", errors="ignore")
268
+ all_deps.extend(self._parse_file(content, path))
269
+ except Exception:
270
+ pass
271
+ return all_deps
272
+
273
+ def diff_with_head(self) -> DepDiff:
274
+ """Compare current dependencies with the committed versions."""
275
+ current = self.get_current_deps()
276
+ current_set = {(d.name, d.source_file) for d in current}
277
+
278
+ # Parse HEAD versions
279
+ head_deps: list[Dependency] = []
280
+ for path in self.get_dep_files():
281
+ rel = path.relative_to(self.repo_root).as_posix()
282
+ head_content = self._get_head_version(rel)
283
+ if head_content is not None:
284
+ head_deps.extend(self._parse_file(head_content, path))
285
+
286
+ head_set = {(d.name, d.source_file) for d in head_deps}
287
+ head_by_key = {(d.name, d.source_file): d for d in head_deps}
288
+ current_by_key = {(d.name, d.source_file): d for d in current}
289
+
290
+ added_keys = current_set - head_set
291
+ removed_keys = head_set - current_set
292
+ common_keys = current_set & head_set
293
+
294
+ added = [current_by_key[k] for k in added_keys]
295
+ removed = [head_by_key[k] for k in removed_keys]
296
+ changed = []
297
+ for k in common_keys:
298
+ old = head_by_key[k]
299
+ new = current_by_key[k]
300
+ if old.version != new.version:
301
+ changed.append((old, new))
302
+
303
+ return DepDiff(added=added, removed=removed, changed=changed)
304
+
305
+ def review_added(self, added: list[Dependency]) -> list[dict[str, Any]]:
306
+ """Look up registry metadata for newly added dependencies."""
307
+ reviews: list[dict[str, Any]] = []
308
+ for dep in added:
309
+ info = None
310
+ source = dep.source_file
311
+ if "pyproject" in source or "requirements" in source:
312
+ info = self._lookup_pypi(dep.name)
313
+ elif "package" in source:
314
+ info = self._lookup_npm(dep.name)
315
+
316
+ review = {
317
+ "name": dep.name,
318
+ "version": dep.version,
319
+ "source_file": dep.source_file,
320
+ "registry_info": info,
321
+ }
322
+ reviews.append(review)
323
+ return reviews