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 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`.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Dependency upgrade plans with risk classes: pyproject/requirements in, plan out."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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,2 @@
1
+ [console_scripts]
2
+ bumpmap = bumpmap.cli:main
@@ -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