depwake 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- depwake-0.3.1/LICENSE +21 -0
- depwake-0.3.1/PKG-INFO +91 -0
- depwake-0.3.1/README.md +73 -0
- depwake-0.3.1/pyproject.toml +29 -0
- depwake-0.3.1/setup.cfg +4 -0
- depwake-0.3.1/src/depwake/__init__.py +3 -0
- depwake-0.3.1/src/depwake/__main__.py +13 -0
- depwake-0.3.1/src/depwake/apply.py +124 -0
- depwake-0.3.1/src/depwake/cache.py +44 -0
- depwake-0.3.1/src/depwake/cli.py +228 -0
- depwake-0.3.1/src/depwake/color.py +42 -0
- depwake-0.3.1/src/depwake/config.py +58 -0
- depwake-0.3.1/src/depwake/manifests.py +226 -0
- depwake-0.3.1/src/depwake/plan.py +137 -0
- depwake-0.3.1/src/depwake/registry.py +157 -0
- depwake-0.3.1/src/depwake/report.py +88 -0
- depwake-0.3.1/src/depwake/semver.py +56 -0
- depwake-0.3.1/src/depwake.egg-info/PKG-INFO +91 -0
- depwake-0.3.1/src/depwake.egg-info/SOURCES.txt +21 -0
- depwake-0.3.1/src/depwake.egg-info/dependency_links.txt +1 -0
- depwake-0.3.1/src/depwake.egg-info/entry_points.txt +2 -0
- depwake-0.3.1/src/depwake.egg-info/top_level.txt +1 -0
- depwake-0.3.1/tests/test_depwake.py +289 -0
depwake-0.3.1/LICENSE
ADDED
|
@@ -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.
|
depwake-0.3.1/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://github.com/YOUR-USER/depwake/actions/workflows/ci.yml)
|
|
22
|
+
[](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).
|
depwake-0.3.1/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Depwake ⏰
|
|
2
|
+
|
|
3
|
+
[](https://github.com/YOUR-USER/depwake/actions/workflows/ci.yml)
|
|
4
|
+
[](https://scorecard.dev/viewer/?uri=github.com/YOUR-USER/depwake)
|
|
5
|
+
|
|
6
|
+
**Wake your sleeping dependencies.**
|
|
7
|
+
|
|
8
|
+
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.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install depwake
|
|
12
|
+
depwake plan ./my-project # risk-grouped plan, Markdown/JSON too
|
|
13
|
+
depwake apply ./my-project --dry-run # preview safe bumps (patch only)
|
|
14
|
+
depwake apply ./my-project # bump patches, .bak backups, majors never touched
|
|
15
|
+
depwake verify ./my-project --strict # facts vs assumptions, no network
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
> ⭐ If dependency hell has ever eaten your weekend, star this — it helps other devs find it.
|
|
19
|
+
|
|
20
|
+
Advisories included: every plan queries OSV and sorts 🔒 rows first —
|
|
21
|
+
urllib3 1.26 alone carries 20 advisories ([measured](benchmarks/BENCH.md)).
|
|
22
|
+
|
|
23
|
+
## Before / after
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
$ depwake plan ./my-project
|
|
27
|
+
depwake plan: 4 dep(s) — 1 major, 3 minor, 0 patch, 0 unknown
|
|
28
|
+
MAJOR npm express 4.17.0 -> 5.2.1 [floor]
|
|
29
|
+
MINOR npm lodash 4.17.20 -> 4.18.1 [floor]
|
|
30
|
+
MINOR pypi requests 2.28.0 -> 2.34.2
|
|
31
|
+
MINOR pypi six 1.16.0 -> 1.17.0
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
$ depwake plan ./my-project --format markdown > UPGRADE.md # paste into a PR
|
|
36
|
+
$ depwake plan ./my-project --fail-on major # CI gate: exit 1 while majors pend
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Why not Dependabot / Renovate?
|
|
40
|
+
|
|
41
|
+
| | Depwake | Bots |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| Unit of work | one grouped plan, you decide the order | N noisy PRs, no prioritization |
|
|
44
|
+
| Risk language | patch today / minor this week / major deliberately | version numbers, you interpret |
|
|
45
|
+
| Honesty | range floors labeled `[floor]`; offline → `unknown`, never guessed | — |
|
|
46
|
+
| Scope | works without installs, lockfiles optional but rewarded | needs full install + config |
|
|
47
|
+
| Agent-native | `SKILL.md` your coding agent can run | docs page, if you're lucky |
|
|
48
|
+
|
|
49
|
+
Dependabot is a fine notifier. Depwake is the Sunday-morning plan *and* the safe pair of hands.
|
|
50
|
+
|
|
51
|
+
## Supported
|
|
52
|
+
|
|
53
|
+
`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.
|
|
54
|
+
|
|
55
|
+
## Contributing — built for drive-by PRs
|
|
56
|
+
|
|
57
|
+
- 📦 New manifest format = one parser + fixtures (`good first issue`)
|
|
58
|
+
- 🔌 New ecosystem (crates.io, RubyGems…) = one fetcher + tests
|
|
59
|
+
- 🌍 Translations welcome (`README.<lang>.md`)
|
|
60
|
+
- Rules: stdlib only, every feature ships with a test, never auto-bump a major
|
|
61
|
+
|
|
62
|
+
## Roadmap
|
|
63
|
+
|
|
64
|
+
- [x] v0.1 — plan / apply, 3 manifest formats, Markdown + JSON + CI gate
|
|
65
|
+
- [x] v0.2 — OSV advisories, cache, `--offline`, `verify`, includes/markers/optionals, Action + CI + demo, real-repo validation
|
|
66
|
+
- [x] v0.3 — colors, progress, config file, `-o`, severity filter, JSON counts, apply hints
|
|
67
|
+
- [ ] Advisory reachability (is the vulnerable function even imported?)
|
|
68
|
+
- [ ] `depwake PR` — open the grouped upgrade as draft PRs per risk tier (needs GitHub)
|
|
69
|
+
- [ ] Lockfile generation for lockless projects (`--write-lock`)
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "depwake"
|
|
7
|
+
version = "0.3.1"
|
|
8
|
+
description = "Wake your sleeping dependencies — risk-grouped upgrade plans, safe auto-bumps, no noise"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["dependencies", "upgrades", "npm", "pypi", "maintenance"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
depwake = "depwake.cli:main"
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/YOUR-USER/depwake"
|
|
26
|
+
Issues = "https://github.com/YOUR-USER/depwake/issues"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
depwake-0.3.1/setup.cfg
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())
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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())
|