bumpmap 0.1.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.
- bumpmap/__init__.py +3 -0
- bumpmap/__main__.py +5 -0
- bumpmap/cli.py +92 -0
- bumpmap/deps.py +113 -0
- bumpmap/pypi.py +40 -0
- bumpmap-0.1.0.dist-info/METADATA +43 -0
- bumpmap-0.1.0.dist-info/RECORD +10 -0
- bumpmap-0.1.0.dist-info/WHEEL +5 -0
- bumpmap-0.1.0.dist-info/entry_points.txt +2 -0
- bumpmap-0.1.0.dist-info/top_level.txt +1 -0
bumpmap/__init__.py
ADDED
bumpmap/__main__.py
ADDED
bumpmap/cli.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""bumpmap CLI: dependency upgrade plan with risk classes and changelog links."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .deps import parse_dependencies
|
|
12
|
+
from .pypi import enrich
|
|
13
|
+
|
|
14
|
+
RISK_ORDER = {"major": 0, "unpinned": 1, "unknown": 2, "minor": 3, "latest": 4, "ok": 5}
|
|
15
|
+
RISK_EXIT = {"major": 1}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cmd_plan(args) -> int:
|
|
19
|
+
deps = parse_dependencies(args.file)
|
|
20
|
+
if not deps:
|
|
21
|
+
print(f"bumpmap: no dependency pins found in {args.file}", file=sys.stderr)
|
|
22
|
+
return 2
|
|
23
|
+
from .deps import PlanEntry
|
|
24
|
+
from .pypi import fetch_pypi
|
|
25
|
+
|
|
26
|
+
fetcher = fetch_pypi
|
|
27
|
+
if args.from_cache:
|
|
28
|
+
cache = json.loads(Path(args.from_cache).read_text(encoding="utf-8"))
|
|
29
|
+
fetcher = lambda name: cache.get(name) # noqa: E731
|
|
30
|
+
entries = [PlanEntry(dep=d) for d in deps]
|
|
31
|
+
enrich(entries, fetcher=fetcher, delay=args.delay)
|
|
32
|
+
order = RISK_ORDER
|
|
33
|
+
entries.sort(key=lambda e: (order.get(e.risk, 9), e.dep.name.lower()))
|
|
34
|
+
majors = sum(1 for e in entries if e.risk == "major")
|
|
35
|
+
if args.json:
|
|
36
|
+
print(json.dumps([{
|
|
37
|
+
"name": e.dep.name, "current": e.dep.version or None, "latest": e.latest,
|
|
38
|
+
"risk": e.risk, "note": e.note, "changelog": e.changelog,
|
|
39
|
+
"source": e.dep.source,
|
|
40
|
+
} for e in entries], indent=2))
|
|
41
|
+
else:
|
|
42
|
+
current_file = None
|
|
43
|
+
for e in entries:
|
|
44
|
+
if e.dep.source != current_file:
|
|
45
|
+
current_file = e.dep.source
|
|
46
|
+
print(f"== {current_file}")
|
|
47
|
+
latest = e.latest or "?"
|
|
48
|
+
line = f" [{e.risk.upper():8}] {e.dep.name:<24} {e.dep.version or '(unpinned)':<12} -> {latest}"
|
|
49
|
+
print(line)
|
|
50
|
+
if e.changelog:
|
|
51
|
+
print(f" changelog: {e.changelog}")
|
|
52
|
+
if e.note:
|
|
53
|
+
print(f" {e.note}")
|
|
54
|
+
print(f"\n{len(entries)} dependencies: "
|
|
55
|
+
+ ", ".join(f"{r} {n}" for r, n in
|
|
56
|
+
sorted(((r, sum(1 for e in entries if e.risk == r)) for r in
|
|
57
|
+
{e.risk for e in entries}), key=lambda kv: order.get(kv[0], 9))))
|
|
58
|
+
if majors:
|
|
59
|
+
print("landing majors: read the changelog, bump one at a time, run the tests, "
|
|
60
|
+
"commit; see the dependency-upgrade skill")
|
|
61
|
+
return 1 if majors else 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
65
|
+
parser = argparse.ArgumentParser(prog="bumpmap", description=__doc__)
|
|
66
|
+
parser.add_argument("--version", action="version", version=f"bumpmap {__version__}")
|
|
67
|
+
p = parser.add_subparsers(dest="command", required=True)
|
|
68
|
+
plan = p.add_parser("plan", help="risk-classified upgrade plan for a dependency file")
|
|
69
|
+
plan.add_argument("file", help="pyproject.toml or requirements*.txt")
|
|
70
|
+
plan.add_argument("--json", action="store_true")
|
|
71
|
+
plan.add_argument("--from-cache", help="JSON file {name: {info: {version, project_urls}}}"
|
|
72
|
+
" instead of live PyPI lookups (offline mode)")
|
|
73
|
+
plan.add_argument("--delay", type=float, default=0.0,
|
|
74
|
+
help="pause between PyPI lookups (be polite on big files)")
|
|
75
|
+
plan.set_defaults(func=cmd_plan)
|
|
76
|
+
return parser
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main(argv: list[str] | None = None) -> int:
|
|
80
|
+
args = build_parser().parse_args(argv)
|
|
81
|
+
try:
|
|
82
|
+
return args.func(args)
|
|
83
|
+
except OSError as exc:
|
|
84
|
+
print(f"bumpmap: error: {exc}", file=sys.stderr)
|
|
85
|
+
return 2
|
|
86
|
+
except ValueError as exc:
|
|
87
|
+
print(f"bumpmap: error: {exc}", file=sys.stderr)
|
|
88
|
+
return 2
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
sys.exit(main())
|
bumpmap/deps.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Parse dependency pins from pyproject.toml / requirements files, classify upgrade risk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
OP_VERSION = re.compile(r"^\s*([A-Za-z0-9_.-]+)(?:\[([^\]]*)\])?\s*([=<>!~^]+)\s*([0-9][0-9A-Za-z.*-]*)")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Dependency:
|
|
15
|
+
name: str
|
|
16
|
+
source: str # file it came from
|
|
17
|
+
operator: str # ==, >=, ~=, ...
|
|
18
|
+
version: str # pinned base version
|
|
19
|
+
extras: str = ""
|
|
20
|
+
specifier: str = "" # the raw spec, when multi-clause
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def major(self) -> int | None:
|
|
24
|
+
m = re.match(r"(\d+)", self.version)
|
|
25
|
+
return int(m.group(1)) if m else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class PlanEntry:
|
|
30
|
+
dep: Dependency
|
|
31
|
+
latest: str | None = None
|
|
32
|
+
risk: str = "unknown" # ok | minor | major | unpinned | latest | unknown
|
|
33
|
+
note: str = ""
|
|
34
|
+
changelog: str | None = None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def kind(self) -> str:
|
|
38
|
+
return "major" if self.risk == "major" else self.risk
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_dependencies(path: str | Path) -> list[Dependency]:
|
|
42
|
+
p = Path(path)
|
|
43
|
+
deps: list[Dependency] = []
|
|
44
|
+
if p.suffix == ".toml" or p.name == "pyproject.toml":
|
|
45
|
+
data = tomllib.loads(p.read_text(encoding="utf-8", errors="replace"))
|
|
46
|
+
project = data.get("project", {})
|
|
47
|
+
raw = list(project.get("dependencies", []))
|
|
48
|
+
for group in (project.get("optional-dependencies") or {}).values():
|
|
49
|
+
raw += list(group)
|
|
50
|
+
for tool_dep in (data.get("tool", {}).get("uv", {}) or {}).get("sources", {}):
|
|
51
|
+
pass
|
|
52
|
+
source = p.name
|
|
53
|
+
else:
|
|
54
|
+
raw = []
|
|
55
|
+
source = p.name
|
|
56
|
+
for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
57
|
+
line = line.strip()
|
|
58
|
+
if line and not line.startswith(("#", "-", "git+", "http")):
|
|
59
|
+
raw.append(line)
|
|
60
|
+
for spec in raw:
|
|
61
|
+
spec = spec.split("#", 1)[0].strip().rstrip(",")
|
|
62
|
+
if not spec or " @ " in spec or "://" in spec:
|
|
63
|
+
continue
|
|
64
|
+
m = OP_VERSION.match(spec)
|
|
65
|
+
if not m:
|
|
66
|
+
deps.append(Dependency(name=spec.strip(), source=source, operator="",
|
|
67
|
+
version="", specifier=spec))
|
|
68
|
+
continue
|
|
69
|
+
name, extras, op, version = m.groups()
|
|
70
|
+
version = version.rstrip(".*")
|
|
71
|
+
deps.append(Dependency(name=name, source=source, operator=op, version=version,
|
|
72
|
+
extras=extras or "", specifier=spec))
|
|
73
|
+
return deps
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _first_int(version: str) -> int | None:
|
|
77
|
+
m = re.match(r"(\d+)", version)
|
|
78
|
+
return int(m.group(1)) if m else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def classify(dep: Dependency, latest: str | None) -> PlanEntry:
|
|
82
|
+
entry = PlanEntry(dep=dep, latest=latest)
|
|
83
|
+
if not dep.version:
|
|
84
|
+
entry.risk = "unpinned"
|
|
85
|
+
entry.note = "not pinned — pin it first; an unpinned dep can't be planned safely"
|
|
86
|
+
return entry
|
|
87
|
+
if latest is None:
|
|
88
|
+
entry.note = "couldn't fetch the latest version"
|
|
89
|
+
return entry
|
|
90
|
+
if latest == dep.version:
|
|
91
|
+
entry.risk = "latest"
|
|
92
|
+
return entry
|
|
93
|
+
cur_major, new_major = dep.major, _first_int(latest)
|
|
94
|
+
if cur_major is None or new_major is None:
|
|
95
|
+
entry.risk = "unknown"
|
|
96
|
+
return entry
|
|
97
|
+
if new_major > cur_major:
|
|
98
|
+
entry.risk = "major"
|
|
99
|
+
entry.note = (f"major jump {dep.version} -> {latest}: read the changelog's breaking "
|
|
100
|
+
f"changes and plan code changes before bumping")
|
|
101
|
+
elif latest != dep.version:
|
|
102
|
+
entry.risk = "minor"
|
|
103
|
+
entry.note = "minor/patch within the same major: usually safe, tests still decide"
|
|
104
|
+
return entry
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def changelog_url(dep: Dependency, project_urls: dict[str, str] | None) -> str | None:
|
|
108
|
+
urls = project_urls or {}
|
|
109
|
+
for key in ("Changelog", "Changes", "What's New", "Release Notes", "History"):
|
|
110
|
+
for k, v in urls.items():
|
|
111
|
+
if k.lower() == key.lower():
|
|
112
|
+
return v
|
|
113
|
+
return urls.get("Homepage") or urls.get("Source")
|
bumpmap/pypi.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""PyPI JSON API client (injectable fetcher so tests run offline)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
|
|
9
|
+
from .deps import PlanEntry, changelog_url, classify
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def fetch_pypi(name: str, timeout: int = 15) -> dict | None:
|
|
13
|
+
url = f"https://pypi.org/pypi/{name}/json"
|
|
14
|
+
try:
|
|
15
|
+
with urllib.request.urlopen(url, timeout=timeout) as response:
|
|
16
|
+
return json.load(response)
|
|
17
|
+
except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError):
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def enrich(entries: list[PlanEntry], fetcher=fetch_pypi, delay: float = 0.0,
|
|
22
|
+
progress=None) -> list[PlanEntry]:
|
|
23
|
+
"""Fill latest version / changelog / risk for each entry via the PyPI API."""
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
for i, entry in enumerate(entries):
|
|
27
|
+
if progress:
|
|
28
|
+
progress(i, entry)
|
|
29
|
+
data = fetcher(entry.dep.name)
|
|
30
|
+
if data is None:
|
|
31
|
+
entry.note = entry.note or "PyPI lookup failed"
|
|
32
|
+
continue
|
|
33
|
+
info = data.get("info", {})
|
|
34
|
+
entry.latest = info.get("version")
|
|
35
|
+
entry.changelog = changelog_url(entry.dep, info.get("project_urls") or {})
|
|
36
|
+
updated = classify(entry.dep, entry.latest)
|
|
37
|
+
entry.risk, entry.note = updated.risk, updated.note
|
|
38
|
+
if delay and i + 1 < len(entries):
|
|
39
|
+
time.sleep(delay)
|
|
40
|
+
return entries
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bumpmap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Dependency upgrade plans with risk classes: pins in, majors flagged, changelogs attached — stdlib only
|
|
5
|
+
Author: juzorei
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/juzorei/bumpmap
|
|
8
|
+
Keywords: dependencies,upgrade,pypi,renovate,agent-skills
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# bumpmap
|
|
16
|
+
|
|
17
|
+
Dependency upgrade plans with risk classes, changelog links — and the honest positioning: Renovate and Dependabot open the PR; this is about landing it.
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
$ bumpmap plan requirements.txt
|
|
21
|
+
== requirements.txt
|
|
22
|
+
[MAJOR ] pydantic 1.10.13 -> 2.7.4
|
|
23
|
+
major jump 1.10.13 -> 2.7.4: read the changelog's breaking changes and plan code changes before bumping
|
|
24
|
+
[MINOR ] requests 2.31.0 -> 2.32.3
|
|
25
|
+
changelog: https://requests.readthedocs.io/en/latest/community/updates/
|
|
26
|
+
|
|
27
|
+
2 dependencies: major 1, minor 1
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Risk classes: `latest`, `minor` (same major — usually safe), `major` (the real work), `unpinned` (pin first). Exit 1 on any major — CI-gateable. `--from-cache pypi.json` for offline runs; `--json` for agents. Changelog links are pulled from each package's PyPI metadata.
|
|
31
|
+
|
|
32
|
+
## Commands
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
bumpmap plan <pyproject.toml | requirements.txt> [--json] [--from-cache cache.json]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Parses `pyproject.toml` (project.dependencies + optional-dependencies) and `requirements*.txt` (comments, `-e`, git/url lines skipped). Python 3.11+, stdlib only.
|
|
39
|
+
|
|
40
|
+
Install: `pip install bumpmap` — or from this repo:
|
|
41
|
+
`pip install "git+https://github.com/juzorei/bumpmap#subdirectory=tools/bumpmap"`
|
|
42
|
+
|
|
43
|
+
The companion agent skill lives in [skills/dependency-upgrade](../skills/dependency-upgrade): the loop that lands a breaking major — changelog → blast radius → bump → tests → fix → commit, one dependency per commit. Skills: `npx skills add juzorei/bumpmap`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
bumpmap/__init__.py,sha256=1GkaQsL2T0oDPy-wUQFBrc8tFEbtQKtUqIjvi3zHdi0,110
|
|
2
|
+
bumpmap/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
+
bumpmap/cli.py,sha256=QsCrQcLKtdZ1016ar4VoGHTLCRXe2VmjSy8CNlSzLjw,3619
|
|
4
|
+
bumpmap/deps.py,sha256=-Lx6VcJotzYPQv5A7H8GzSdkdEduGmJx-HCOCDSLbhw,4051
|
|
5
|
+
bumpmap/pypi.py,sha256=Q_xwU8-sL7R5lyI9cCxCvzZoVvcDOpFdof01hUNPk8U,1373
|
|
6
|
+
bumpmap-0.1.0.dist-info/METADATA,sha256=KoiMJINWRSdrEPVWNwfDHNenxSw8wCXJ9tu4eJVuu_g,2050
|
|
7
|
+
bumpmap-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
bumpmap-0.1.0.dist-info/entry_points.txt,sha256=jy09GdiBYT7XOvCx3m1n-7yiXUEXh7Bvd1n2P3opGYY,45
|
|
9
|
+
bumpmap-0.1.0.dist-info/top_level.txt,sha256=Ss2BBRu3Zz40iAkZAAEcib8b8zuTN0f15Mj8KATjmi4,8
|
|
10
|
+
bumpmap-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bumpmap
|