depwake 0.3.1__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.
depwake/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Depwake: wake your sleeping dependencies. Stdlib only."""
2
+
3
+ __version__ = "0.3.1"
depwake/__main__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Entry point. Works as `python -m depwake` AND as `python path/to/__main__.py`."""
2
+
3
+ try:
4
+ from .cli import main
5
+ except ImportError: # pragma: no cover - path-invoked fallback
6
+ import os
7
+ import sys
8
+
9
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
10
+ from depwake.cli import main
11
+
12
+ if __name__ == "__main__":
13
+ raise SystemExit(main())
depwake/apply.py ADDED
@@ -0,0 +1,124 @@
1
+ """Safe auto-bumps: patch (default) and optionally minor. Never major.
2
+
3
+ Rules, in order:
4
+ 1. Only items whose risk is included AND whose current version is exact
5
+ (lockfile or == pin). Range floors are assumptions — listed, not touched.
6
+ 2. package.json: keep the range prefix, replace the version triple
7
+ (^4.17.0 -> ^4.17.21). requirements/pyproject: only == pins.
8
+ 3. Numbered .bak backups, never clobbered. --dry-run changes nothing.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ from .plan import Plan
19
+
20
+ TRIPLE = re.compile(r"\d+\.\d+\.\d+[^,\s|]*")
21
+
22
+
23
+ @dataclass
24
+ class ApplyResult:
25
+ changed: list[str] = field(default_factory=list) # "file: name old -> new"
26
+ skipped: list[str] = field(default_factory=list) # "name: reason"
27
+ backups: list[str] = field(default_factory=list)
28
+
29
+
30
+ def _backup(path: Path) -> Path:
31
+ dest = path.with_suffix(path.suffix + ".bak")
32
+ i = 1
33
+ while dest.exists():
34
+ dest = Path(f"{path}.bak.{i}")
35
+ i += 1
36
+ dest.write_bytes(path.read_bytes())
37
+ return dest
38
+
39
+
40
+ def _bump_npm_range(rng: str, latest: str) -> str | None:
41
+ if not TRIPLE.search(rng):
42
+ return None
43
+ return TRIPLE.sub(latest, rng, count=1)
44
+
45
+
46
+ def apply_plan(root: str | Path, plan: Plan, include: set[str] | None = None,
47
+ dry_run: bool = False) -> ApplyResult:
48
+ include = include or {"patch"}
49
+ root = Path(root)
50
+ res = ApplyResult()
51
+ eligible = [i for i in plan.items
52
+ if i.risk in include
53
+ and i.latest
54
+ and i.dep.current_source in ("lock", "pin")]
55
+ for item in plan.items:
56
+ if item not in eligible:
57
+ if item.risk in ("major",):
58
+ n = len(item.advisories)
59
+ extra = f" — ⚠ {n} open advisor{'y' if n == 1 else 'ies'}, plan it soon" if n else ""
60
+ res.skipped.append(f"{item.dep.name}: major — upgrade deliberately, never auto-bumped{extra}")
61
+ elif item.risk == "unknown":
62
+ res.skipped.append(f"{item.dep.name}: unknown version — resolve first ({item.note})")
63
+ elif item.risk in include:
64
+ res.skipped.append(f"{item.dep.name}: current is a range floor, not exact — add a lockfile")
65
+ continue
66
+ by_file: dict[Path, list] = {}
67
+ for item in eligible:
68
+ if item.dep.ecosystem == "npm":
69
+ by_file.setdefault(root / "package.json", []).append(item)
70
+ else:
71
+ req = root / "requirements.txt"
72
+ proj = root / "pyproject.toml"
73
+ by_file.setdefault(req if req.exists() else proj, []).append(item)
74
+ for path, items in by_file.items():
75
+ if not path.exists():
76
+ for item in items:
77
+ res.skipped.append(f"{item.dep.name}: {path.name} not found")
78
+ continue
79
+ if not dry_run:
80
+ res.backups.append(str(_backup(path)))
81
+ text = path.read_text(encoding="utf-8")
82
+ for item in items:
83
+ d = item.dep
84
+ assert item.latest
85
+ if path.name == "package.json":
86
+ data = json.loads(text)
87
+ done = False
88
+ for section in ("dependencies", "devDependencies",
89
+ "peerDependencies", "optionalDependencies"):
90
+ if d.name in (data.get(section) or {}):
91
+ new = _bump_npm_range(data[section][d.name], item.latest)
92
+ if new and new != data[section][d.name]:
93
+ data[section][d.name] = new
94
+ res.changed.append(f"{path.name}: {d.name} {d.current} -> {item.latest}")
95
+ done = True
96
+ text = json.dumps(data, indent=2) + "\n"
97
+ if not done:
98
+ res.skipped.append(f"{d.name}: range {d.wanted!r} has no version triple to bump")
99
+ else:
100
+ new_text, n = re.subn(
101
+ rf"(?m)^(\s*{re.escape(d.name)}\s*==\s*){re.escape(d.current or '')}\s*$",
102
+ rf"\g<1>{item.latest}", text, count=1)
103
+ if n:
104
+ text = new_text
105
+ res.changed.append(f"{path.name}: {d.name} {d.current} -> {item.latest}")
106
+ else:
107
+ res.skipped.append(f"{d.name}: pin line not found or not ==")
108
+ if not dry_run:
109
+ path.write_text(text, encoding="utf-8")
110
+ return res
111
+
112
+
113
+ def format_apply(res: ApplyResult, dry_run: bool = False) -> str:
114
+ head = "depwake apply (dry run — nothing written):" if dry_run else "depwake apply:"
115
+ lines = [head]
116
+ for c in res.changed:
117
+ lines.append(f" BUMP {c}")
118
+ for s in res.skipped:
119
+ lines.append(f" SKIP {s}")
120
+ for b in res.backups:
121
+ lines.append(f" backup: {b}")
122
+ if not res.changed:
123
+ lines.append(" nothing to bump.")
124
+ return "\n".join(lines)
depwake/cache.py ADDED
@@ -0,0 +1,44 @@
1
+ """Tiny file cache for registry lookups. Stdlib only.
2
+
3
+ Same TTL for version + advisory data: staleness here costs a delayed
4
+ upgrade notice, never a wrong one (versions only move forward; advisories
5
+ only accumulate — both refresh within the hour).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import time
13
+ from pathlib import Path
14
+
15
+ TTL = 3600
16
+
17
+
18
+ def cache_dir() -> Path:
19
+ import os
20
+ base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
21
+ d = Path(base) / "depwake"
22
+ d.mkdir(parents=True, exist_ok=True)
23
+ return d
24
+
25
+
26
+ def _path(key: str) -> Path:
27
+ return cache_dir() / (hashlib.sha256(key.encode()).hexdigest() + ".json")
28
+
29
+
30
+ def get(key: str, ttl: float = TTL) -> object | None:
31
+ try:
32
+ p = _path(key)
33
+ if not p.exists() or time.time() - p.stat().st_mtime > ttl:
34
+ return None
35
+ return json.loads(p.read_text(encoding="utf-8"))
36
+ except (OSError, ValueError):
37
+ return None
38
+
39
+
40
+ def put(key: str, value: object) -> None:
41
+ try:
42
+ _path(key).write_text(json.dumps(value), encoding="utf-8")
43
+ except OSError:
44
+ pass # cache is best-effort; a full disk must never break a plan
depwake/cli.py ADDED
@@ -0,0 +1,228 @@
1
+ """`depwake` CLI. Stdlib only. Exit codes: 0 clean, 1 findings, 2 usage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ import time
8
+
9
+ from . import __version__
10
+ from .apply import apply_plan, format_apply
11
+ from .cache import get as cache_get
12
+ from .cache import put as cache_put
13
+ from .color import enabled as color_enabled
14
+ from .config import load as load_config
15
+ from .manifests import discover
16
+ from .plan import build
17
+ from .registry import Latest, fetch, fetch_advisories
18
+ from .report import format_json, format_markdown, format_text
19
+
20
+ RISK_ORDER = {"major": 0, "minor": 1, "patch": 2}
21
+
22
+
23
+ def _version_fetcher(args: argparse.Namespace):
24
+ def go(eco: str, name: str) -> Latest:
25
+ if args.offline:
26
+ return Latest(None, "", "offline mode (--offline)")
27
+ key = f"latest:{eco}:{name}"
28
+ if not args.no_cache:
29
+ hit = cache_get(key)
30
+ if isinstance(hit, dict) and "version" in hit:
31
+ return Latest(hit.get("version"), hit.get("url", ""), hit.get("error", ""))
32
+ got = fetch(eco, name, args.timeout)
33
+ if not args.no_cache:
34
+ cache_put(key, {"version": got.version, "url": got.url, "error": got.error})
35
+ return got
36
+ return go
37
+
38
+
39
+ def _advisory_fetcher(args: argparse.Namespace):
40
+ def go(deps: list[tuple[str, str, str]]):
41
+ if args.offline or args.no_advisories:
42
+ return {}
43
+ out: dict = {}
44
+ missing = []
45
+ for eco, name, ver in deps:
46
+ key = f"osv:{eco}:{name}:{ver}"
47
+ hit = None if args.no_cache else cache_get(key)
48
+ if isinstance(hit, list):
49
+ from .registry import Advisory
50
+ out[(eco, name)] = [Advisory(**a) for a in hit]
51
+ else:
52
+ missing.append((eco, name, ver))
53
+ if missing:
54
+ fresh = fetch_advisories(missing, args.timeout)
55
+ by_key = {(e, n): v for e, n, v in missing}
56
+ for k, advs in fresh.items():
57
+ out[k] = advs
58
+ if not args.no_cache and k in by_key:
59
+ cache_put(f"osv:{k[0]}:{k[1]}:{by_key[k]}",
60
+ [a.to_dict() for a in advs])
61
+ return out
62
+ return go
63
+
64
+
65
+ def _shared_flags(p: argparse.ArgumentParser) -> None:
66
+ p.add_argument("--timeout", type=float, default=10.0, help="Registry timeout (s).")
67
+ p.add_argument("--no-advisories", action="store_true", help="Skip OSV advisory lookup.")
68
+ p.add_argument("--no-cache", action="store_true", help="Bypass the registry cache.")
69
+ p.add_argument("--offline", action="store_true",
70
+ help="No network: versions/advisories become unknown with reasons.")
71
+ p.add_argument("--color", choices=["auto", "always", "never"], default="auto")
72
+ p.add_argument("--quiet", action="store_true", help="Only the report on stdout.")
73
+
74
+
75
+ def _resolve_config(args: argparse.Namespace) -> tuple[list, list, list, str]:
76
+ """Apply config file: ignore list + fail_on default.
77
+
78
+ Returns (deps, notes, ignored_names, fail_on). Ignored deps vanish from
79
+ counts but always leave a note — silent filtering would be lying by omission.
80
+ """
81
+ cfg = load_config(args.root)
82
+ deps, notes = discover(args.root)
83
+ ignored = sorted({d.name for d in deps if d.name.lower() in set(cfg.get("ignore", []))})
84
+ if ignored:
85
+ deps = [d for d in deps if d.name not in ignored]
86
+ notes.append(f"ignored {len(ignored)} dep(s) per config: {', '.join(ignored)}")
87
+ fail_on = args.fail_on if getattr(args, "fail_on", None) else cfg.get("fail_on", "never")
88
+ return (deps, notes, ignored, fail_on)
89
+
90
+
91
+ def _say(args: argparse.Namespace, msg: str) -> None:
92
+ if not args.quiet:
93
+ print(f"depwake: {msg}", file=sys.stderr)
94
+
95
+
96
+ def cmd_plan(args: argparse.Namespace) -> int:
97
+ try:
98
+ deps, notes, _, fail_on = _resolve_config(args)
99
+ except ValueError as exc:
100
+ print(f"depwake: error: {exc}", file=sys.stderr)
101
+ return 2
102
+ color = color_enabled(args.color)
103
+ _say(args, f"checking {len(deps)} dep(s)…")
104
+ t0 = time.time()
105
+ plan = build(deps, fetcher=_version_fetcher(args), timeout=args.timeout,
106
+ advisory_fetcher=_advisory_fetcher(args),
107
+ min_severity=args.min_severity)
108
+ plan.notes.extend(notes)
109
+ _say(args, f"done in {time.time() - t0:.1f}s")
110
+ if args.format == "json":
111
+ rendered = format_json(plan)
112
+ elif args.format == "markdown":
113
+ rendered = format_markdown(plan, args.root)
114
+ else:
115
+ rendered = format_text(plan, color=color)
116
+ if args.output:
117
+ from pathlib import Path
118
+ Path(args.output).write_text(rendered if rendered.endswith("\n") else rendered + "\n",
119
+ encoding="utf-8")
120
+ print(f"wrote {args.output}")
121
+ return 0 if fail_on == "never" else _gate(plan, fail_on)
122
+ if args.format == "markdown":
123
+ print(rendered, end="")
124
+ else:
125
+ print(rendered)
126
+ if fail_on == "never":
127
+ return 0
128
+ return _gate(plan, fail_on)
129
+
130
+
131
+ def _gate(plan, fail_on: str) -> int:
132
+ threshold = RISK_ORDER[fail_on]
133
+ bad = [i for i in plan.items if i.risk in RISK_ORDER and RISK_ORDER[i.risk] <= threshold]
134
+ return 1 if bad else 0
135
+
136
+
137
+ def cmd_apply(args: argparse.Namespace) -> int:
138
+ try:
139
+ deps, notes, _, _ = _resolve_config(args)
140
+ except ValueError as exc:
141
+ print(f"depwake: error: {exc}", file=sys.stderr)
142
+ return 2
143
+ _say(args, f"checking {len(deps)} dep(s)…")
144
+ plan = build(deps, fetcher=_version_fetcher(args),
145
+ timeout=args.timeout, advisory_fetcher=_advisory_fetcher(args))
146
+ include = {"patch"} | ({"minor"} if args.include_minor else set())
147
+ res = apply_plan(args.root, plan, include=include, dry_run=args.dry_run)
148
+ print(format_apply(res, dry_run=args.dry_run))
149
+ if res.changed and not args.dry_run:
150
+ npm = any(c.startswith("package.json") for c in res.changed)
151
+ pypi = any(not c.startswith("package.json") for c in res.changed)
152
+ hints = []
153
+ if npm:
154
+ hints.append("npm install (refresh the lockfile)")
155
+ if pypi:
156
+ hints.append("pip install -r requirements.txt (or reinstall your project)")
157
+ hints.append("re-run depwake plan to confirm")
158
+ print(" next: " + " → ".join(hints))
159
+ return 0
160
+
161
+
162
+ def cmd_verify(args: argparse.Namespace) -> int:
163
+ """How much of this project is exactly known? Facts (lock/pin) vs
164
+ assumptions (range floors) vs blind spots (unknown). No network."""
165
+ try:
166
+ deps, notes = discover(args.root)
167
+ except ValueError as exc:
168
+ print(f"depwake: error: {exc}", file=sys.stderr)
169
+ return 2
170
+ exact = [d for d in deps if d.current_source in ("lock", "pin")]
171
+ floors = [d for d in deps if d.current_source == "floor"]
172
+ unknown = [d for d in deps if d.current_source == "unknown"]
173
+ lines = [f"depwake verify: {len(exact)}/{len(deps)} exact, "
174
+ f"{len(floors)} range-floor assumptions, {len(unknown)} unknown"]
175
+ for d in floors:
176
+ lines.append(f" FLOOR {d.ecosystem:4} {d.name} {d.wanted} (add a lockfile)")
177
+ for d in unknown:
178
+ lines.append(f" UNKNOWN {d.ecosystem:4} {d.name} {d.wanted}")
179
+ for note in notes:
180
+ lines.append(f" note: {note}")
181
+ print("\n".join(lines))
182
+ if args.strict and (floors or unknown):
183
+ return 1
184
+ return 0
185
+
186
+
187
+ def build_parser() -> argparse.ArgumentParser:
188
+ p = argparse.ArgumentParser(
189
+ prog="depwake",
190
+ description="Wake your sleeping dependencies — risk-grouped plans, safe auto-bumps.",
191
+ )
192
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
193
+ sub = p.add_subparsers(dest="command", required=True)
194
+
195
+ pl = sub.add_parser("plan", help="Risk-grouped upgrade plan (patch/minor/major/unknown).")
196
+ pl.add_argument("root", nargs="?", default=".", help="Project directory.")
197
+ pl.add_argument("--format", choices=["text", "markdown", "json"], default="text")
198
+ _shared_flags(pl)
199
+ pl.add_argument("--fail-on", choices=["major", "minor", "patch", "never"], default=None,
200
+ help="Exit 1 if upgrades at/above this risk are pending "
201
+ "(default: config fail_on, else never).")
202
+ pl.add_argument("-o", "--output", default=None, help="Write report to file instead of stdout.")
203
+ pl.add_argument("--min-severity", choices=["Critical", "High", "Medium", "Low"], default=None,
204
+ help="Only attach advisories at/above this severity (Unscored always shown).")
205
+ pl.set_defaults(func=cmd_plan)
206
+
207
+ ap = sub.add_parser("apply", help="Auto-bump patch (and optionally minor). Never major.")
208
+ ap.add_argument("root", nargs="?", default=".")
209
+ _shared_flags(ap)
210
+ ap.add_argument("--include-minor", action="store_true", help="Also bump minor upgrades.")
211
+ ap.add_argument("--dry-run", action="store_true", help="Show what would change.")
212
+ ap.set_defaults(func=cmd_apply)
213
+
214
+ vf = sub.add_parser("verify", help="Facts vs assumptions: lock/pin coverage, no network.")
215
+ vf.add_argument("root", nargs="?", default=".")
216
+ vf.add_argument("--strict", action="store_true",
217
+ help="Exit 1 unless every dep is exactly known.")
218
+ vf.set_defaults(func=cmd_verify)
219
+ return p
220
+
221
+
222
+ def main(argv: list[str] | None = None) -> int:
223
+ args = build_parser().parse_args(argv)
224
+ return int(args.func(args))
225
+
226
+
227
+ if __name__ == "__main__":
228
+ raise SystemExit(main())
depwake/color.py ADDED
@@ -0,0 +1,42 @@
1
+ """Terminal colors without dependencies. Respects NO_COLOR and pipes.
2
+
3
+ Rule: color only when the user sees it (TTY) unless forced. CI logs and
4
+ redirects stay clean — a green test suite must never print escapes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+
12
+ CODES = {
13
+ "red": "\033[31m",
14
+ "yellow": "\033[33m",
15
+ "green": "\033[32m",
16
+ "dim": "\033[2m",
17
+ "bold": "\033[1m",
18
+ "reset": "\033[0m",
19
+ }
20
+
21
+ RISK_COLORS = {"major": "red", "minor": "yellow", "patch": "green",
22
+ "same": "green", "unknown": "dim"}
23
+
24
+
25
+ def enabled(mode: str = "auto") -> bool:
26
+ """mode: auto | always | never."""
27
+ if mode == "always":
28
+ return True
29
+ if mode == "never":
30
+ return False
31
+ if os.environ.get("NO_COLOR") is not None:
32
+ return False
33
+ try:
34
+ return sys.stdout.isatty()
35
+ except (AttributeError, ValueError):
36
+ return False
37
+
38
+
39
+ def paint(text: str, color: str, on: bool) -> str:
40
+ if not on or color not in CODES:
41
+ return text
42
+ return f"{CODES[color]}{text}{CODES['reset']}"
depwake/config.py ADDED
@@ -0,0 +1,58 @@
1
+ """Project config: depwake.toml > package.json \"depwake\" key > pyproject [tool.depwake].
2
+
3
+ Keys:
4
+ ignore = ["pkg", ...] deps to exclude from every plan (with a note)
5
+ fail_on = "major" default gate when --fail-on isn't passed
6
+
7
+ Explicit CLI flags always win over config. Unknown keys are ignored, not
8
+ errors — configs must never break a plan.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import tomllib
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+
19
+ def load(root: str | Path) -> dict[str, Any]:
20
+ root = Path(root)
21
+ cfg: dict[str, Any] = {}
22
+ toml = root / "depwake.toml"
23
+ if toml.exists():
24
+ try:
25
+ cfg.update(tomllib.loads(toml.read_bytes().decode("utf-8", errors="replace")))
26
+ except (ValueError, OSError):
27
+ pass
28
+ return _clean(cfg)
29
+ pkg = root / "package.json"
30
+ if pkg.exists():
31
+ try:
32
+ data = json.loads(pkg.read_text(encoding="utf-8"))
33
+ if isinstance(data.get("depwake"), dict):
34
+ cfg.update(data["depwake"])
35
+ return _clean(cfg)
36
+ except (ValueError, OSError):
37
+ pass
38
+ proj = root / "pyproject.toml"
39
+ if proj.exists():
40
+ try:
41
+ data = tomllib.loads(proj.read_bytes().decode("utf-8", errors="replace"))
42
+ tool = (data.get("tool") or {}).get("depwake")
43
+ if isinstance(tool, dict):
44
+ cfg.update(tool)
45
+ except (ValueError, OSError):
46
+ pass
47
+ return _clean(cfg)
48
+
49
+
50
+ def _clean(cfg: dict[str, Any]) -> dict[str, Any]:
51
+ out: dict[str, Any] = {}
52
+ ignore = cfg.get("ignore", [])
53
+ if isinstance(ignore, list):
54
+ out["ignore"] = sorted({str(x).lower() for x in ignore if str(x).strip()})
55
+ fail_on = cfg.get("fail_on")
56
+ if fail_on in ("major", "minor", "patch", "never"):
57
+ out["fail_on"] = fail_on
58
+ return out
depwake/manifests.py ADDED
@@ -0,0 +1,226 @@
1
+ """Manifest parsing: package.json (+ lock), requirements.txt, pyproject.toml.
2
+
3
+ Every dep becomes a Dep(name, ecosystem, wanted, current, current_source):
4
+ - wanted: the range/pin written in the manifest.
5
+ - current: the actually-installed version (lockfile) or the range floor
6
+ when no lock exists — always labeled via current_source so plans never
7
+ present guesses as facts.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ import tomllib
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+
19
+ @dataclass
20
+ class Dep:
21
+ name: str
22
+ ecosystem: str # "npm" | "pypi"
23
+ wanted: str
24
+ current: str | None # None when unresolvable
25
+ current_source: str # "lock" | "pin" | "floor" | "unknown"
26
+
27
+
28
+ REQ_LINE = re.compile(r"^\s*([A-Za-z0-9_.\-]+(?:\[[A-Za-z0-9_,\-]+\])?)\s*(.*)$")
29
+ REQ_PIN = re.compile(r"==\s*([^;\s]+)")
30
+ REQ_FLOOR = re.compile(r"(?:>=|~=|>|===)\s*([^;\s,]+)")
31
+
32
+
33
+ def _normalize_floor(v: str | None) -> str | None:
34
+ """Pad partial floors ("2" -> 2.0.0) so >=2,<4 yields a usable floor.
35
+
36
+ Still labeled `floor` downstream — padding is arithmetic, not knowledge.
37
+ """
38
+ if not v:
39
+ return None
40
+ v = v.strip().rstrip(".*")
41
+ parts = v.split(".")
42
+ if not all(p.isdigit() for p in parts) or len(parts) > 3:
43
+ return None
44
+ return ".".join(parts + ["0"] * (3 - len(parts)))
45
+
46
+
47
+ def _floor_npm_range(rng: str) -> str | None:
48
+ """Best-effort floor of an npm range: ^4.17.0 -> 4.17.0, * -> None."""
49
+ rng = rng.strip()
50
+ if not rng or rng in ("*", "latest", "x"):
51
+ return None
52
+ m = re.search(r"(\d+\.\d+\.\d+[^,\s|]*)", rng)
53
+ return m.group(1) if m else None
54
+
55
+
56
+ def parse_package_json(root: Path) -> list[Dep]:
57
+ manifest = json.loads((root / "package.json").read_text(encoding="utf-8"))
58
+ wanted: dict[str, str] = {}
59
+ for section in ("dependencies", "devDependencies",
60
+ "peerDependencies", "optionalDependencies"):
61
+ wanted.update(manifest.get(section, {}) or {})
62
+ locked = _read_npm_lock(root)
63
+ deps = []
64
+ for name, rng in sorted(wanted.items()):
65
+ if name in locked:
66
+ deps.append(Dep(name, "npm", rng, locked[name], "lock"))
67
+ continue
68
+ floor = _floor_npm_range(rng)
69
+ deps.append(Dep(name, "npm", rng, floor,
70
+ "floor" if floor else "unknown"))
71
+ return deps
72
+
73
+
74
+ def _read_npm_lock(root: Path) -> dict[str, str]:
75
+ lock = root / "package-lock.json"
76
+ if not lock.exists():
77
+ return {}
78
+ try:
79
+ data = json.loads(lock.read_text(encoding="utf-8"))
80
+ except json.JSONDecodeError:
81
+ return {}
82
+ out: dict[str, str] = {}
83
+ packages = data.get("packages")
84
+ if isinstance(packages, dict): # lockfileVersion 2/3
85
+ for path, meta in packages.items():
86
+ if not path or not path.startswith("node_modules/"):
87
+ continue
88
+ name = path[len("node_modules/"):]
89
+ if "/" not in name and meta.get("version"):
90
+ out[name] = meta["version"]
91
+ else: # lockfileVersion 1
92
+ for name, meta in (data.get("dependencies") or {}).items():
93
+ if isinstance(meta, dict) and meta.get("version"):
94
+ out[name] = meta["version"]
95
+ # shrinkwrap twin
96
+ shrink = root / "npm-shrinkwrap.json"
97
+ if shrink.exists() and not out:
98
+ try:
99
+ data = json.loads(shrink.read_text(encoding="utf-8"))
100
+ for name, meta in (data.get("dependencies") or {}).items():
101
+ if isinstance(meta, dict) and meta.get("version"):
102
+ out[name] = meta["version"]
103
+ except json.JSONDecodeError:
104
+ pass
105
+ return out
106
+
107
+
108
+ def parse_requirements(path: Path, _seen: set[Path] | None = None) -> list[Dep]:
109
+ path = Path(path)
110
+ _seen = _seen or set()
111
+ if path in _seen:
112
+ return [] # include cycle — report once, never loop
113
+ _seen.add(path)
114
+ deps: list[Dep] = []
115
+ try:
116
+ raw = path.read_text(encoding="utf-8", errors="replace")
117
+ except OSError:
118
+ return []
119
+ # join continuations before parsing
120
+ lines: list[str] = []
121
+ buf = ""
122
+ for line in raw.splitlines():
123
+ if line.rstrip().endswith("\\"):
124
+ buf += line.rstrip()[:-1]
125
+ continue
126
+ lines.append(buf + line)
127
+ buf = ""
128
+ if buf:
129
+ lines.append(buf)
130
+ for line in lines:
131
+ line = line.strip()
132
+ if not line or line.startswith("#"):
133
+ continue
134
+ if line.startswith(("-r ", "--requirement ", "-r\t", "--requirement=")):
135
+ inc = line.split(None, 1)[1] if " " in line or "\t" in line \
136
+ else line.split("=", 1)[1]
137
+ deps.extend(parse_requirements(path.parent / inc.strip(), _seen))
138
+ continue
139
+ if line.startswith("-"):
140
+ continue # options (-c constraints, --index-url, ...) are out of scope
141
+ m = REQ_LINE.match(line)
142
+ if not m:
143
+ continue
144
+ name, spec = m.group(1), m.group(2).strip()
145
+ # strip environment markers for version parsing ("pkg==1.0; python_version>'3'")
146
+ spec = spec.split(";")[0].strip()
147
+ pin = REQ_PIN.search(spec)
148
+ if pin:
149
+ deps.append(Dep(name, "pypi", spec or "any", pin.group(1), "pin"))
150
+ continue
151
+ mfloor = REQ_FLOOR.search(spec)
152
+ floor = _normalize_floor(mfloor.group(1)) if mfloor else None
153
+ deps.append(Dep(name, "pypi", spec or "any", floor,
154
+ "floor" if floor else "unknown"))
155
+ return sorted(deps, key=lambda d: d.name.lower())
156
+
157
+
158
+ def parse_pyproject(path: Path) -> tuple[list[Dep], list[str]]:
159
+ """Returns (deps, notes). Optional-dependency groups are reported in
160
+ notes, not deps: counting uninstalled extras as stale would be noise."""
161
+ try:
162
+ data = tomllib.loads(path.read_bytes().decode("utf-8", errors="replace"))
163
+ except (ValueError, OSError):
164
+ return [], []
165
+ notes: list[str] = []
166
+ optional = data.get("project", {}).get("optional-dependencies", {}) or {}
167
+ if optional:
168
+ n = sum(len(v) for v in optional.values())
169
+ notes.append(f"{n} optional deps ignored (extras: {', '.join(sorted(optional))})")
170
+ reqs = data.get("project", {}).get("dependencies", []) or []
171
+ deps = []
172
+ for req in reqs:
173
+ m = REQ_LINE.match(str(req).strip())
174
+ if not m:
175
+ continue
176
+ name, spec = m.group(1), m.group(2).strip()
177
+ spec = spec.split(";")[0].strip()
178
+ pin = REQ_PIN.search(spec)
179
+ if pin:
180
+ deps.append(Dep(name, "pypi", spec, pin.group(1), "pin"))
181
+ continue
182
+ mfloor = REQ_FLOOR.search(spec)
183
+ floor = _normalize_floor(mfloor.group(1)) if mfloor else None
184
+ deps.append(Dep(name, "pypi", spec or "any", floor,
185
+ "floor" if floor else "unknown"))
186
+ return sorted(deps, key=lambda d: d.name.lower()), notes
187
+
188
+
189
+ def discover(root: Path) -> tuple[list[Dep], list[str]]:
190
+ """Collect deps from every supported manifest under root.
191
+
192
+ Returns (deps, notes). Later manifests win on name collisions so the
193
+ most specific pin (e.g. requirements.txt over pyproject) is reported.
194
+ """
195
+ root = Path(root)
196
+ if not root.exists():
197
+ raise ValueError(f"{root}: no such file or directory")
198
+ found: dict[tuple[str, str], Dep] = {}
199
+ notes: list[str] = []
200
+ if (root / "package.json").exists():
201
+ for d in parse_package_json(root):
202
+ found[("npm", d.name)] = d
203
+ else:
204
+ notes.append("no package.json")
205
+ reqs = root / "requirements.txt"
206
+ if reqs.exists():
207
+ for d in parse_requirements(reqs):
208
+ found[("pypi", d.name)] = d
209
+ else:
210
+ notes.append("no requirements.txt")
211
+ proj = root / "pyproject.toml"
212
+ if proj.exists():
213
+ pdeps, pnotes = parse_pyproject(proj)
214
+ notes.extend(pnotes)
215
+ for d in pdeps:
216
+ found.setdefault(("pypi", d.name), d)
217
+ else:
218
+ notes.append("no pyproject.toml")
219
+ manifests_seen = ((root / "package.json").exists() or reqs.exists()
220
+ or proj.exists())
221
+ if not manifests_seen:
222
+ raise ValueError(f"{root}: no supported manifest found "
223
+ "(package.json, requirements.txt, pyproject.toml)")
224
+ if not found:
225
+ notes.append("manifests present but declare zero dependencies")
226
+ return sorted(found.values(), key=lambda d: (d.ecosystem, d.name.lower())), notes
depwake/plan.py ADDED
@@ -0,0 +1,137 @@
1
+ """Upgrade planning: deps + latest versions -> risk-grouped plan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+
8
+ from .manifests import Dep
9
+ from .registry import Advisory, Latest, fetch, fetch_advisories
10
+ from .semver import RISK_BLURB, classify, parse
11
+
12
+ ORDER = {"major": 0, "minor": 1, "patch": 2, "same": 3, "unknown": 4}
13
+ SEV_ORDER = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3, "Unscored": 4}
14
+
15
+
16
+ @dataclass
17
+ class Item:
18
+ dep: Dep
19
+ latest: str | None
20
+ url: str
21
+ risk: str # major | minor | patch | same | unknown
22
+ note: str = "" # why this row needs attention (or why we can't tell)
23
+ advisories: list[Advisory] = field(default_factory=list)
24
+
25
+ def worst_severity(self) -> str:
26
+ if not self.advisories:
27
+ return ""
28
+ return min((a.severity for a in self.advisories),
29
+ key=lambda s: SEV_ORDER.get(s, 9))
30
+
31
+ def to_dict(self) -> dict:
32
+ return {"name": self.dep.name, "ecosystem": self.dep.ecosystem,
33
+ "wanted": self.dep.wanted, "current": self.dep.current,
34
+ "current_source": self.dep.current_source,
35
+ "latest": self.latest, "url": self.url,
36
+ "risk": self.risk, "note": self.note,
37
+ "advisories": [a.to_dict() for a in self.advisories]}
38
+
39
+
40
+ @dataclass
41
+ class Plan:
42
+ items: list[Item]
43
+ notes: list[str] = field(default_factory=list)
44
+
45
+ def to_dict(self) -> dict:
46
+ counts = self.counts()
47
+ return {"items": [i.to_dict() for i in self.items], "notes": self.notes,
48
+ "counts": counts, "advisory_count": self.advisory_count()}
49
+
50
+ def grouped(self) -> dict[str, list[Item]]:
51
+ groups: dict[str, list[Item]] = {k: [] for k in ORDER}
52
+ for item in sorted(self.items,
53
+ key=lambda i: (ORDER[i.risk], not i.advisories,
54
+ i.dep.ecosystem, i.dep.name.lower())):
55
+ groups[item.risk].append(item)
56
+ return groups
57
+
58
+ def counts(self) -> dict[str, int]:
59
+ out = {k: 0 for k in ORDER}
60
+ for item in self.items:
61
+ out[item.risk] += 1
62
+ return out
63
+
64
+ def advisory_count(self) -> int:
65
+ return sum(len(i.advisories) for i in self.items)
66
+
67
+
68
+ def _security_note(item: Item) -> str:
69
+ n = len(item.advisories)
70
+ worst = item.worst_severity()
71
+ top = sorted(item.advisories, key=lambda a: SEV_ORDER.get(a.severity, 9))[0]
72
+ fixed = f"fixed in {top.fixed_in}" if top.fixed_in else "no fix published yet"
73
+ return (f"🔒 {n} advisor{'y' if n == 1 else 'ies'} (worst {worst}): "
74
+ f"{top.id} — {fixed}")
75
+
76
+
77
+ def build(deps: list[Dep],
78
+ fetcher: Callable[[str, str], Latest] = fetch,
79
+ timeout: float = 10.0,
80
+ advisory_fetcher: Callable[[list[tuple[str, str, str]]],
81
+ dict[tuple[str, str], list[Advisory]]]
82
+ | None = fetch_advisories,
83
+ with_advisories: bool = True,
84
+ min_severity: str | None = None) -> Plan:
85
+ items: list[Item] = []
86
+ for dep in deps:
87
+ if dep.current is None:
88
+ items.append(Item(dep, None, "", "unknown",
89
+ "no lockfile pin and no parseable range floor — "
90
+ "add a lockfile for exact tracking"))
91
+ continue
92
+ current = parse(dep.current)
93
+ if current is None:
94
+ items.append(Item(dep, None, "", "unknown",
95
+ f"unparseable current version {dep.current!r}"))
96
+ continue
97
+ try:
98
+ got = fetcher(dep.ecosystem, dep.name)
99
+ except Exception as exc: # a fetcher must never kill a plan
100
+ got = Latest(None, "", f"lookup failed: {exc}")
101
+ if not got.version:
102
+ items.append(Item(dep, None, got.url, "unknown", got.error or "lookup failed"))
103
+ continue
104
+ latest = parse(got.version)
105
+ if latest is None:
106
+ items.append(Item(dep, got.version, got.url, "unknown",
107
+ f"unparseable latest version {got.version!r}"))
108
+ continue
109
+ risk = classify(current, latest)
110
+ note = ""
111
+ if risk == "same" and dep.current_source == "floor":
112
+ note = "range floor looks current, but without a lockfile this is an assumption"
113
+ elif risk != "same" and dep.current_source == "floor":
114
+ note = "computed from range floor (no lockfile) — verify against installed tree"
115
+ items.append(Item(dep, str(latest), got.url, risk, note or RISK_BLURB[risk]))
116
+ plan = Plan(items)
117
+ if with_advisories and advisory_fetcher is not None:
118
+ try:
119
+ adv = advisory_fetcher([(i.dep.ecosystem, i.dep.name, i.dep.current or "")
120
+ for i in items if i.dep.current])
121
+ except Exception:
122
+ adv = {}
123
+ # Unscored advisories always survive the filter: unknown != safe.
124
+ threshold = SEV_ORDER.get(min_severity or "Unscored", 4)
125
+ hidden = 0
126
+ for item in items:
127
+ found = adv.get((item.dep.ecosystem, item.dep.name), [])
128
+ kept = [a for a in found
129
+ if a.severity == "Unscored" or SEV_ORDER.get(a.severity, 9) <= threshold]
130
+ hidden += len(found) - len(kept)
131
+ if kept:
132
+ item.advisories = kept
133
+ item.note = f"{_security_note(item)}. {item.note}".strip()
134
+ if hidden:
135
+ plan.notes.append(f"{hidden} advisor{'y' if hidden == 1 else 'ies'} "
136
+ f"below --min-severity {min_severity} hidden")
137
+ return plan
depwake/registry.py ADDED
@@ -0,0 +1,157 @@
1
+ """Latest-version lookup over plain HTTPS. Stdlib only.
2
+
3
+ Injectable fetch() keeps everything deterministic in tests and lets future
4
+ versions plug caches/mirrors without touching callers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import urllib.request
11
+ from dataclasses import dataclass
12
+
13
+ UA = "depwake/0.2.0"
14
+ OSV_ECO = {"npm": "npm", "pypi": "PyPI"}
15
+
16
+
17
+ @dataclass
18
+ class Latest:
19
+ version: str | None
20
+ url: str # registry/homepage link for the "why" column
21
+ error: str = "" # non-empty when lookup failed (offline, 404, ...)
22
+
23
+
24
+ def _get(url: str, timeout: float) -> tuple[int, bytes]:
25
+ req = urllib.request.Request(url, headers={"User-Agent": "depwake/0.1.0",
26
+ "Accept": "application/json"})
27
+ try:
28
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
29
+ return resp.status, resp.read()
30
+ except Exception as exc: # network, TLS, 404, timeout — all just "unknown"
31
+ return 0, str(exc).encode()
32
+
33
+
34
+ def fetch_npm(name: str, timeout: float = 10.0) -> Latest:
35
+ status, body = _get(f"https://registry.npmjs.org/{name}/latest", timeout)
36
+ if status != 200:
37
+ return Latest(None, f"https://www.npmjs.com/package/{name}",
38
+ f"npm registry: HTTP {status or 'unreachable'}")
39
+ try:
40
+ version = json.loads(body).get("version")
41
+ except json.JSONDecodeError:
42
+ version = None
43
+ if not version:
44
+ return Latest(None, f"https://www.npmjs.com/package/{name}", "no version field")
45
+ return Latest(version, f"https://www.npmjs.com/package/{name}")
46
+
47
+
48
+ def fetch_pypi(name: str, timeout: float = 10.0) -> Latest:
49
+ base = name.split("[")[0]
50
+ status, body = _get(f"https://pypi.org/pypi/{base}/json", timeout)
51
+ if status != 200:
52
+ return Latest(None, f"https://pypi.org/project/{base}/",
53
+ f"PyPI: HTTP {status or 'unreachable'}")
54
+ try:
55
+ info = json.loads(body).get("info", {})
56
+ except json.JSONDecodeError:
57
+ return Latest(None, f"https://pypi.org/project/{base}/", "bad JSON")
58
+ version = info.get("version")
59
+ url = info.get("home_page") or info.get("project_url") or f"https://pypi.org/project/{base}/"
60
+ if not version:
61
+ return Latest(None, url, "no version field")
62
+ return Latest(version, url)
63
+
64
+
65
+ def fetch(ecosystem: str, name: str, timeout: float = 10.0) -> Latest:
66
+ if ecosystem == "npm":
67
+ return fetch_npm(name, timeout)
68
+ return fetch_pypi(name, timeout)
69
+
70
+
71
+ @dataclass
72
+ class Advisory:
73
+ id: str
74
+ summary: str
75
+ severity: str # Critical | High | Medium | Low | Unscored
76
+ score: float | None
77
+ fixed_in: str | None # None when no fixed version published
78
+ url: str
79
+
80
+ def to_dict(self) -> dict:
81
+ return {"id": self.id, "summary": self.summary, "severity": self.severity,
82
+ "score": self.score, "fixed_in": self.fixed_in, "url": self.url}
83
+
84
+
85
+ def _label(score: float | None) -> str:
86
+ if score is None:
87
+ return "Unscored"
88
+ if score >= 9.0:
89
+ return "Critical"
90
+ if score >= 7.0:
91
+ return "High"
92
+ if score >= 4.0:
93
+ return "Medium"
94
+ if score > 0:
95
+ return "Low"
96
+ return "Unscored"
97
+
98
+
99
+ def _fixed_in(vuln: dict) -> str | None:
100
+ """Latest 'fixed' event across affected ranges, or None."""
101
+ fixed: list[str] = []
102
+ for aff in vuln.get("affected", []):
103
+ for rng in aff.get("ranges", []):
104
+ for ev in rng.get("events", []):
105
+ if "fixed" in ev:
106
+ fixed.append(str(ev["fixed"]))
107
+ return sorted(set(fixed))[-1] if fixed else None
108
+
109
+
110
+ def _advisory(vuln: dict) -> Advisory:
111
+ scores = [s.get("score") for s in (vuln.get("severity") or [])
112
+ if isinstance(s.get("score"), (int, float))]
113
+ score = max(scores) if scores else None
114
+ summary = (vuln.get("summary") or "")[:160]
115
+ vid = vuln.get("id", "unknown")
116
+ return Advisory(vid, summary, _label(score), score,
117
+ _fixed_in(vuln), f"https://osv.dev/vulnerability/{vid}")
118
+
119
+
120
+ def fetch_advisories(deps: list[tuple[str, str, str]],
121
+ timeout: float = 10.0) -> dict[tuple[str, str], list[Advisory]]:
122
+ """Map (ecosystem, name) -> advisories affecting that exact version.
123
+
124
+ One batch call to find who is affected, then one full-record call per
125
+ affected dep. Any failure degrades to {} — advisories inform, never gate.
126
+ """
127
+ queries = [{"package": {"name": n.split("[")[0],
128
+ "ecosystem": OSV_ECO.get(e, e)}, "version": v}
129
+ for e, n, v in deps]
130
+ if not queries:
131
+ return {}
132
+ payload = json.dumps({"queries": queries}).encode()
133
+ req = urllib.request.Request("https://api.osv.dev/v1/querybatch", data=payload,
134
+ headers={"Content-Type": "application/json",
135
+ "User-Agent": UA})
136
+ try:
137
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
138
+ batch = json.loads(resp.read()).get("results", [])
139
+ except Exception:
140
+ return {}
141
+ out: dict[tuple[str, str], list[Advisory]] = {}
142
+ for (eco, name, ver), res in zip([(e, n, v) for e, n, v in deps], batch):
143
+ if not res.get("vulns"):
144
+ continue
145
+ single = json.dumps({"package": {"name": name.split("[")[0],
146
+ "ecosystem": OSV_ECO.get(eco, eco)},
147
+ "version": ver}).encode()
148
+ req = urllib.request.Request("https://api.osv.dev/v1/query", data=single,
149
+ headers={"Content-Type": "application/json",
150
+ "User-Agent": UA})
151
+ try:
152
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
153
+ full = json.loads(resp.read()).get("vulns", [])
154
+ out[(eco, name)] = [_advisory(v) for v in full]
155
+ except Exception:
156
+ out[(eco, name)] = []
157
+ return out
depwake/report.py ADDED
@@ -0,0 +1,88 @@
1
+ """Plan rendering: text (human), markdown (PRs/files), json (machines)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from .color import RISK_COLORS, paint
8
+ from .plan import Plan
9
+
10
+ TITLES = {
11
+ "major": "## 🔴 Major — read the changelog, upgrade deliberately",
12
+ "minor": "## 🟡 Minor — backwards compatible, apply this week",
13
+ "patch": "## 🟢 Patch — safe fixes, apply today",
14
+ "same": "## ✅ Up to date",
15
+ "unknown": "## ⚪ Unknown — needs a human look",
16
+ }
17
+
18
+
19
+ def format_text(plan: Plan, color: bool = False) -> str:
20
+ counts = plan.counts()
21
+ total = len(plan.items)
22
+ adv = plan.advisory_count()
23
+ head = (f"depwake plan: {total} dep(s) — "
24
+ f"{counts['major']} major, {counts['minor']} minor, "
25
+ f"{counts['patch']} patch, {counts['unknown']} unknown")
26
+ if adv:
27
+ head += f", 🔒 {adv} advisor{'y' if adv == 1 else 'ies'}"
28
+ lines = [head]
29
+ for risk in ("major", "minor", "patch", "unknown"):
30
+ for item in plan.grouped()[risk]:
31
+ d = item.dep
32
+ arrow = f"{d.current} -> {item.latest}" if item.latest else f"{d.current} -> ?"
33
+ flag = "" if d.current_source in ("lock", "pin") else f" [{d.current_source}]"
34
+ lock = " 🔒" if item.advisories else ""
35
+ label = paint(risk.upper().ljust(7), RISK_COLORS[risk], color)
36
+ lines.append(f" {label} {d.ecosystem:4} {d.name} {arrow}{flag}{lock}")
37
+ if item.note:
38
+ lines.append(f" {item.note}")
39
+ for note in plan.notes:
40
+ lines.append(f" note: {note}")
41
+ return "\n".join(lines)
42
+
43
+
44
+ def format_markdown(plan: Plan, root: str = ".") -> str:
45
+ counts = plan.counts()
46
+ adv = plan.advisory_count()
47
+ head = (f"{len(plan.items)} dependencies: "
48
+ f"**{counts['major']}** major, **{counts['minor']}** minor, "
49
+ f"**{counts['patch']}** patch, {counts['unknown']} unknown.")
50
+ if adv:
51
+ head += f" 🔒 **{adv}** advisor{'y' if adv == 1 else 'ies'}."
52
+ lines = [f"# Depwake plan — `{root}`", "", head, ""]
53
+ for risk in ("major", "minor", "patch", "unknown", "same"):
54
+ items = plan.grouped()[risk]
55
+ if not items:
56
+ continue
57
+ lines.append(TITLES[risk])
58
+ lines.append("")
59
+ lines.append("| Package | Current | Latest | Notes |")
60
+ lines.append("|---|---|---|---|")
61
+ for item in items:
62
+ d = item.dep
63
+ cur = f"{d.current or '?'} ({d.current_source})"
64
+ latest = f"[{item.latest}]({item.url})" if item.latest and item.url else (item.latest or "?")
65
+ note = item.note.replace("|", "\\|")
66
+ lines.append(f"| {d.name} ({d.ecosystem}) | {cur} | {latest} | {note} |")
67
+ lines.append("")
68
+ lines.append("_Generated by depwake — patch today, minor this week, major deliberately._")
69
+ sec = [i for i in plan.items if i.advisories]
70
+ if sec:
71
+ lines.append("")
72
+ lines.append("## 🔒 Security notes")
73
+ lines.append("")
74
+ for item in sorted(sec, key=lambda i: (i.dep.ecosystem, i.dep.name.lower())):
75
+ d = item.dep
76
+ lines.append(f"### {d.name} ({d.ecosystem} {d.current})")
77
+ lines.append("")
78
+ for a in item.advisories:
79
+ fixed = f"fixed in {a.fixed_in}" if a.fixed_in else "no fix published"
80
+ lines.append(f"- [{a.id}]({a.url}) — {a.severity} — {fixed}")
81
+ if a.summary:
82
+ lines.append(f" {a.summary}")
83
+ lines.append("")
84
+ return "\n".join(lines).rstrip() + "\n"
85
+
86
+
87
+ def format_json(plan: Plan) -> str:
88
+ return json.dumps(plan.to_dict(), indent=2)
depwake/semver.py ADDED
@@ -0,0 +1,56 @@
1
+ """Semver parsing + risk classification. No network, no opinions beyond semver."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ VER = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$")
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Version:
13
+ major: int
14
+ minor: int
15
+ patch: int
16
+
17
+ def __str__(self) -> str:
18
+ return f"{self.major}.{self.minor}.{self.patch}"
19
+
20
+
21
+ def parse(v: str | None) -> Version | None:
22
+ """Parse X.Y.Z, tolerating leading v and partial real-world versions
23
+ ("3.19" -> 3.19.0, "2" -> 2.0.0). Non-numeric (latest, *) -> None."""
24
+ if not v:
25
+ return None
26
+ v = v.strip()
27
+ m = VER.match(v)
28
+ if m:
29
+ return Version(int(m.group(1)), int(m.group(2)), int(m.group(3)))
30
+ m = re.match(r"^v?(\d+)(?:\.(\d+))?$", v)
31
+ if m:
32
+ return Version(int(m.group(1)), int(m.group(2) or 0), 0)
33
+ return None
34
+
35
+
36
+ def classify(current: Version, latest: Version) -> str:
37
+ """patch | minor | major | same. Pre-1.0 minors count as major:
38
+ in 0.x, semver says anything may break."""
39
+ if (current.major, current.minor, current.patch) >= \
40
+ (latest.major, latest.minor, latest.patch):
41
+ return "same"
42
+ if current.major != latest.major:
43
+ return "major"
44
+ if current.major == 0 and current.minor != latest.minor:
45
+ return "major"
46
+ if current.minor != latest.minor:
47
+ return "minor"
48
+ return "patch"
49
+
50
+
51
+ RISK_BLURB = {
52
+ "patch": "Safe fixes only — apply today.",
53
+ "minor": "Features + fixes, backwards compatible — apply this week.",
54
+ "major": "May break — read the changelog, upgrade deliberately.",
55
+ "same": "Up to date.",
56
+ }
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: depwake
3
+ Version: 0.3.1
4
+ Summary: Wake your sleeping dependencies — risk-grouped upgrade plans, safe auto-bumps, no noise
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/YOUR-USER/depwake
7
+ Project-URL: Issues, https://github.com/YOUR-USER/depwake/issues
8
+ Keywords: dependencies,upgrades,npm,pypi,maintenance
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # Depwake ⏰
20
+
21
+ [![CI](https://github.com/YOUR-USER/depwake/actions/workflows/ci.yml/badge.svg)](https://github.com/YOUR-USER/depwake/actions/workflows/ci.yml)
22
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/YOUR-USER/depwake/badge)](https://scorecard.dev/viewer/?uri=github.com/YOUR-USER/depwake)
23
+
24
+ **Wake your sleeping dependencies.**
25
+
26
+ Your side project has 47 outdated packages, 3 security advisories, and a `package.json` from 2023. Dependabot spams you with 40 context-free PRs. Depwake does the opposite: one risk-grouped plan — patch today, minor this week, major deliberately — and safe auto-bumps for the boring parts.
27
+
28
+ ```bash
29
+ pip install depwake
30
+ depwake plan ./my-project # risk-grouped plan, Markdown/JSON too
31
+ depwake apply ./my-project --dry-run # preview safe bumps (patch only)
32
+ depwake apply ./my-project # bump patches, .bak backups, majors never touched
33
+ depwake verify ./my-project --strict # facts vs assumptions, no network
34
+ ```
35
+
36
+ > ⭐ If dependency hell has ever eaten your weekend, star this — it helps other devs find it.
37
+
38
+ Advisories included: every plan queries OSV and sorts 🔒 rows first —
39
+ urllib3 1.26 alone carries 20 advisories ([measured](benchmarks/BENCH.md)).
40
+
41
+ ## Before / after
42
+
43
+ ```bash
44
+ $ depwake plan ./my-project
45
+ depwake plan: 4 dep(s) — 1 major, 3 minor, 0 patch, 0 unknown
46
+ MAJOR npm express 4.17.0 -> 5.2.1 [floor]
47
+ MINOR npm lodash 4.17.20 -> 4.18.1 [floor]
48
+ MINOR pypi requests 2.28.0 -> 2.34.2
49
+ MINOR pypi six 1.16.0 -> 1.17.0
50
+ ```
51
+
52
+ ```bash
53
+ $ depwake plan ./my-project --format markdown > UPGRADE.md # paste into a PR
54
+ $ depwake plan ./my-project --fail-on major # CI gate: exit 1 while majors pend
55
+ ```
56
+
57
+ ## Why not Dependabot / Renovate?
58
+
59
+ | | Depwake | Bots |
60
+ |---|---|---|
61
+ | Unit of work | one grouped plan, you decide the order | N noisy PRs, no prioritization |
62
+ | Risk language | patch today / minor this week / major deliberately | version numbers, you interpret |
63
+ | Honesty | range floors labeled `[floor]`; offline → `unknown`, never guessed | — |
64
+ | Scope | works without installs, lockfiles optional but rewarded | needs full install + config |
65
+ | Agent-native | `SKILL.md` your coding agent can run | docs page, if you're lucky |
66
+
67
+ Dependabot is a fine notifier. Depwake is the Sunday-morning plan *and* the safe pair of hands.
68
+
69
+ ## Supported
70
+
71
+ `package.json` (+ `package-lock.json` / `npm-shrinkwrap.json`), `requirements.txt`, `pyproject.toml`. Pre-1.0 minors count as major (in 0.x, anything may break). Needs network for latest versions; offline degrades to `unknown`, never to guesses.
72
+
73
+ ## Contributing — built for drive-by PRs
74
+
75
+ - 📦 New manifest format = one parser + fixtures (`good first issue`)
76
+ - 🔌 New ecosystem (crates.io, RubyGems…) = one fetcher + tests
77
+ - 🌍 Translations welcome (`README.<lang>.md`)
78
+ - Rules: stdlib only, every feature ships with a test, never auto-bump a major
79
+
80
+ ## Roadmap
81
+
82
+ - [x] v0.1 — plan / apply, 3 manifest formats, Markdown + JSON + CI gate
83
+ - [x] v0.2 — OSV advisories, cache, `--offline`, `verify`, includes/markers/optionals, Action + CI + demo, real-repo validation
84
+ - [x] v0.3 — colors, progress, config file, `-o`, severity filter, JSON counts, apply hints
85
+ - [ ] Advisory reachability (is the vulnerable function even imported?)
86
+ - [ ] `depwake PR` — open the grouped upgrade as draft PRs per risk tier (needs GitHub)
87
+ - [ ] Lockfile generation for lockless projects (`--write-lock`)
88
+
89
+ ## License
90
+
91
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,18 @@
1
+ depwake/__init__.py,sha256=fsa2SI8kg2AjBRaIkD3pLSneVQJIQhuZHapqBkRI0Js,84
2
+ depwake/__main__.py,sha256=Jqwn9-pywCldHtT_k-Ho7oCMUtH93JyWytpPzoG1qRk,385
3
+ depwake/apply.py,sha256=600Z12rvPmaFuVmdn7C9Ppdv50CLbnphU_TL0Ay4Sfc,4949
4
+ depwake/cache.py,sha256=KvBCQaD5yjJZKqyJpDLSUA_AMbPFp_yqsAJxVQlyk-U,1181
5
+ depwake/cli.py,sha256=CIBBCR06MkW6caIOZl-FiSOPEQaVJDej8L3fcVivPjk,9490
6
+ depwake/color.py,sha256=ZLfFfNjO7ZR2GTezAk3fcuf7kUCv2m1QZQb5s6Hw6WM,1057
7
+ depwake/config.py,sha256=-WgzMYHdRoR-fYnaPUmqi7zSCAdQxSkEHXWn-FC9hBk,1865
8
+ depwake/manifests.py,sha256=3PQlpEZ7rpaI6iWgWjKlYSJPT_kH4KuJt9w1vDlIwso,8428
9
+ depwake/plan.py,sha256=MIBFOOjo5dkp7FskKGaqYje8kxhm2gVxSS7mXlWm0ig,5693
10
+ depwake/registry.py,sha256=ieCO9kav6rPdYkfl2Do33Z2JohYQCZoIYExkCnLoNQ4,5961
11
+ depwake/report.py,sha256=dgBhqZzYrDKgB2stUQ3e81cpme6HBBuRjgkQ07Am6iY,3678
12
+ depwake/semver.py,sha256=gZlfoGXAx849uzg3U_QnF2eNtNJIAQFRzTUn5_Izx2k,1654
13
+ depwake-0.3.1.dist-info/licenses/LICENSE,sha256=6Dxufnxzt_eCMwLa09imvNFFdK-zI7Bf6G5fbhrI5vk,1077
14
+ depwake-0.3.1.dist-info/METADATA,sha256=OqqGVWC4erJRDMg9H8snYP4c86gqjki1EKsfBrM7g_g,4235
15
+ depwake-0.3.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
+ depwake-0.3.1.dist-info/entry_points.txt,sha256=bBvlpkaf3Uz7hKb2Ui5NiUpCspO9NBdoVnjMQdTClzo,45
17
+ depwake-0.3.1.dist-info/top_level.txt,sha256=J6sx11FCDxO6OPXxOG4XuEhR63ULd94bevBY_7FmJec,8
18
+ depwake-0.3.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ depwake = depwake.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Depwake Contributors
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 @@
1
+ depwake