bumpmap 0.1.0__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.
- bumpmap-0.1.0/PKG-INFO +43 -0
- bumpmap-0.1.0/README.md +29 -0
- bumpmap-0.1.0/pyproject.toml +27 -0
- bumpmap-0.1.0/setup.cfg +4 -0
- bumpmap-0.1.0/src/bumpmap/__init__.py +3 -0
- bumpmap-0.1.0/src/bumpmap/__main__.py +5 -0
- bumpmap-0.1.0/src/bumpmap/cli.py +92 -0
- bumpmap-0.1.0/src/bumpmap/deps.py +113 -0
- bumpmap-0.1.0/src/bumpmap/pypi.py +40 -0
- bumpmap-0.1.0/src/bumpmap.egg-info/PKG-INFO +43 -0
- bumpmap-0.1.0/src/bumpmap.egg-info/SOURCES.txt +13 -0
- bumpmap-0.1.0/src/bumpmap.egg-info/dependency_links.txt +1 -0
- bumpmap-0.1.0/src/bumpmap.egg-info/entry_points.txt +2 -0
- bumpmap-0.1.0/src/bumpmap.egg-info/top_level.txt +1 -0
- bumpmap-0.1.0/tests/test_bumpmap.py +119 -0
bumpmap-0.1.0/PKG-INFO
ADDED
|
@@ -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`.
|
bumpmap-0.1.0/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# bumpmap
|
|
2
|
+
|
|
3
|
+
Dependency upgrade plans with risk classes, changelog links — and the honest positioning: Renovate and Dependabot open the PR; this is about landing it.
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
$ bumpmap plan requirements.txt
|
|
7
|
+
== requirements.txt
|
|
8
|
+
[MAJOR ] pydantic 1.10.13 -> 2.7.4
|
|
9
|
+
major jump 1.10.13 -> 2.7.4: read the changelog's breaking changes and plan code changes before bumping
|
|
10
|
+
[MINOR ] requests 2.31.0 -> 2.32.3
|
|
11
|
+
changelog: https://requests.readthedocs.io/en/latest/community/updates/
|
|
12
|
+
|
|
13
|
+
2 dependencies: major 1, minor 1
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
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.
|
|
17
|
+
|
|
18
|
+
## Commands
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
bumpmap plan <pyproject.toml | requirements.txt> [--json] [--from-cache cache.json]
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Parses `pyproject.toml` (project.dependencies + optional-dependencies) and `requirements*.txt` (comments, `-e`, git/url lines skipped). Python 3.11+, stdlib only.
|
|
25
|
+
|
|
26
|
+
Install: `pip install bumpmap` — or from this repo:
|
|
27
|
+
`pip install "git+https://github.com/juzorei/bumpmap#subdirectory=tools/bumpmap"`
|
|
28
|
+
|
|
29
|
+
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,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bumpmap"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Dependency upgrade plans with risk classes: pins in, majors flagged, changelogs attached — stdlib only"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "juzorei" }]
|
|
13
|
+
keywords = ["dependencies", "upgrade", "pypi", "renovate", "agent-skills"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Topic :: Software Development :: Build Tools",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/juzorei/bumpmap"
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
bumpmap = "bumpmap.cli:main"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
bumpmap-0.1.0/setup.cfg
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())
|
|
@@ -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")
|
|
@@ -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,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/bumpmap/__init__.py
|
|
4
|
+
src/bumpmap/__main__.py
|
|
5
|
+
src/bumpmap/cli.py
|
|
6
|
+
src/bumpmap/deps.py
|
|
7
|
+
src/bumpmap/pypi.py
|
|
8
|
+
src/bumpmap.egg-info/PKG-INFO
|
|
9
|
+
src/bumpmap.egg-info/SOURCES.txt
|
|
10
|
+
src/bumpmap.egg-info/dependency_links.txt
|
|
11
|
+
src/bumpmap.egg-info/entry_points.txt
|
|
12
|
+
src/bumpmap.egg-info/top_level.txt
|
|
13
|
+
tests/test_bumpmap.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bumpmap
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from bumpmap.cli import main
|
|
6
|
+
from bumpmap.deps import parse_dependencies
|
|
7
|
+
from bumpmap.pypi import enrich
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def reqs(tmp_path):
|
|
12
|
+
p = tmp_path / "requirements.txt"
|
|
13
|
+
p.write_text(
|
|
14
|
+
"# app deps\n"
|
|
15
|
+
"requests==2.31.0\n"
|
|
16
|
+
"pydantic==1.10.13\n"
|
|
17
|
+
"flask>=2.0,<3\n"
|
|
18
|
+
"orjson==3.9.15 # fast json\n"
|
|
19
|
+
"some-unpinned\n"
|
|
20
|
+
"-e ./local\n"
|
|
21
|
+
"git+https://github.com/x/y.git\n"
|
|
22
|
+
"\n",
|
|
23
|
+
encoding="utf-8")
|
|
24
|
+
return str(p)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def pyproject(tmp_path):
|
|
29
|
+
p = tmp_path / "pyproject.toml"
|
|
30
|
+
p.write_text(
|
|
31
|
+
'[project]\nname = "app"\nversion = "1.0"\n'
|
|
32
|
+
'dependencies = ["requests==2.31.0", "pydantic==1.10.13"]\n',
|
|
33
|
+
encoding="utf-8")
|
|
34
|
+
return str(p)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fake_fetcher_factory(latest_by_name, urls=None):
|
|
38
|
+
urls = urls or {}
|
|
39
|
+
|
|
40
|
+
def fetch(name):
|
|
41
|
+
if name == "some-unpinned":
|
|
42
|
+
return {"info": {"version": "9.9.9", "project_urls": {}}}
|
|
43
|
+
if name not in latest_by_name:
|
|
44
|
+
return None
|
|
45
|
+
return {"info": {"version": latest_by_name[name],
|
|
46
|
+
"project_urls": urls.get(name, {})}}
|
|
47
|
+
|
|
48
|
+
return fetch
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_parse_requirements(reqs):
|
|
52
|
+
deps = parse_dependencies(reqs)
|
|
53
|
+
names = {d.name for d in deps}
|
|
54
|
+
assert names == {"requests", "pydantic", "flask", "orjson", "some-unpinned"}
|
|
55
|
+
requests = next(d for d in deps if d.name == "requests")
|
|
56
|
+
assert requests.operator == "==" and requests.version == "2.31.0"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_parse_pyproject(pyproject):
|
|
60
|
+
deps = parse_dependencies(pyproject)
|
|
61
|
+
assert {d.name for d in deps} == {"requests", "pydantic"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_classify_risks(reqs):
|
|
65
|
+
deps = parse_dependencies(reqs)
|
|
66
|
+
entries = enrich([type("E", (), {"dep": d, "latest": None, "risk": "unknown", "note": "", "changelog": None})() for d in deps]
|
|
67
|
+
if False else
|
|
68
|
+
[__import__("bumpmap.deps", fromlist=["PlanEntry"]).PlanEntry(dep=d) for d in deps],
|
|
69
|
+
fetcher=fake_fetcher_factory({
|
|
70
|
+
"requests": "2.32.3", # same major -> minor
|
|
71
|
+
"pydantic": "2.7.4", # 1 -> 2: major
|
|
72
|
+
"flask": "3.0.3", # spec >=2.0,<3 base "2.0" -> major-ish
|
|
73
|
+
"orjson": "3.9.15", # latest already
|
|
74
|
+
}))
|
|
75
|
+
by = {e.dep.name: e for e in entries}
|
|
76
|
+
assert by["requests"].risk == "minor"
|
|
77
|
+
assert by["pydantic"].risk == "major"
|
|
78
|
+
assert "breaking" in by["pydantic"].note
|
|
79
|
+
assert by["orjson"].risk == "latest"
|
|
80
|
+
assert by["some-unpinned"].risk == "unpinned"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_changelog_url_from_project_urls(reqs):
|
|
84
|
+
deps = [d for d in parse_dependencies(reqs) if d.name == "requests"]
|
|
85
|
+
entries = enrich(
|
|
86
|
+
[__import__("bumpmap.deps", fromlist=["PlanEntry"]).PlanEntry(dep=d) for d in deps],
|
|
87
|
+
fetcher=fake_fetcher_factory({"requests": "2.32.3"},
|
|
88
|
+
{"requests": {"Changelog": "https://requests.readthedocs.io/changelog"}}))
|
|
89
|
+
assert entries[0].changelog == "https://requests.readthedocs.io/changelog"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_unknown_package_notes_lookup_failure(reqs):
|
|
93
|
+
deps = [d for d in parse_dependencies(reqs) if d.name == "requests"]
|
|
94
|
+
from bumpmap.deps import PlanEntry
|
|
95
|
+
entries = enrich([PlanEntry(dep=d) for d in deps], fetcher=lambda n: None)
|
|
96
|
+
assert entries[0].latest is None and "failed" in entries[0].note
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_cli_plan_exit_one_on_major(reqs, tmp_path, capsys):
|
|
100
|
+
cache = tmp_path / "pypi.json"
|
|
101
|
+
cache.write_text(json.dumps({
|
|
102
|
+
"requests": {"info": {"version": "2.32.3", "project_urls": {}}},
|
|
103
|
+
"pydantic": {"info": {"version": "2.7.4", "project_urls": {}}},
|
|
104
|
+
"flask": {"info": {"version": "3.0.3", "project_urls": {}}},
|
|
105
|
+
"orjson": {"info": {"version": "3.9.15", "project_urls": {}}},
|
|
106
|
+
"some-unpinned": {"info": {"version": "9.9.9", "project_urls": {}}},
|
|
107
|
+
}), encoding="utf-8")
|
|
108
|
+
assert main(["plan", reqs, "--from-cache", str(cache), "--json"]) == 1
|
|
109
|
+
rows = json.loads(capsys.readouterr().out)
|
|
110
|
+
by = {r["name"]: r for r in rows}
|
|
111
|
+
assert by["pydantic"]["risk"] == "major"
|
|
112
|
+
assert by["requests"]["risk"] == "minor"
|
|
113
|
+
assert by["some-unpinned"]["risk"] == "unpinned"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_cli_no_deps(tmp_path):
|
|
117
|
+
p = tmp_path / "empty.txt"
|
|
118
|
+
p.write_text("# nothing here\n", encoding="utf-8")
|
|
119
|
+
assert main(["plan", str(p)]) == 2
|