chaindiff 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.
chaindiff/evaluate.py ADDED
@@ -0,0 +1,229 @@
1
+ """Decide whether a client upgrade is safe."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import datetime, timedelta
7
+
8
+ from chaindiff.models import (
9
+ CURRENT,
10
+ DOWNGRADE,
11
+ PRERELEASE,
12
+ REVIEW,
13
+ SAFE,
14
+ UNSAFE,
15
+ Advisory,
16
+ CheckResult,
17
+ Client,
18
+ Release,
19
+ )
20
+ from chaindiff.versions import Version
21
+
22
+ STALE_AFTER = timedelta(days=5)
23
+ _SECURITY = re.compile(r"(?i)\b(security|cve-\d+|vulnerability)\b")
24
+ _SEVERITY_RANK = {"none": 0, "note": 1, "deprecated": 2, "breaking": 3}
25
+
26
+
27
+ def latest_stable(releases: list[Release]) -> Release | None:
28
+ stable = [item for item in releases if not item.prerelease]
29
+ if not stable:
30
+ return None
31
+ return max(stable, key=lambda item: (item.version, item.published_at))
32
+
33
+
34
+ def newer_prerelease(releases: list[Release], stable: Release | None) -> Release | None:
35
+ if stable is None:
36
+ return None
37
+ newer = [item for item in releases if item.prerelease and item.version > stable.version]
38
+ if not newer:
39
+ return None
40
+ return max(newer, key=lambda item: (item.version, item.published_at))
41
+
42
+
43
+ def advisories_for(advisories: list[Advisory], version: Version) -> list[Advisory]:
44
+ return [item for item in advisories if item.version == version]
45
+
46
+
47
+ def _worst(advisories: list[Advisory]) -> Advisory | None:
48
+ if not advisories:
49
+ return None
50
+ return max(advisories, key=lambda item: _SEVERITY_RANK[item.severity])
51
+
52
+
53
+ def _backup_steps() -> list[str]:
54
+ return [
55
+ "Read the release notes for every release in this range.",
56
+ "Back up the data directory, or snapshot the disk, before changing the binary.",
57
+ "Keep the current binary so you can roll back.",
58
+ "Upgrade this client only. Confirm it is healthy before changing the client paired with it.",
59
+ "If the release notes require an order for a network upgrade, follow that order.",
60
+ "If the node fails to start or stops syncing, restore the previous binary.",
61
+ ]
62
+
63
+
64
+ def _plan_steps(verdict: str, hops: list[Release], advisories: list[Advisory]) -> list[str]:
65
+ if verdict == CURRENT:
66
+ return ["No upgrade to plan."]
67
+ if verdict == DOWNGRADE:
68
+ return ["Stay on the newer version you already run."]
69
+ steps: list[str] = []
70
+ for hop in sorted(hops, key=lambda item: item.version):
71
+ for advisory in advisories_for(advisories, hop.version):
72
+ if advisory.severity in ("breaking", "deprecated") or (
73
+ advisory.severity == "note" and advisory.action
74
+ ):
75
+ steps.append(f"{hop.tag}: {advisory.action} ({advisory.source})")
76
+ steps.extend(_backup_steps())
77
+ return steps
78
+
79
+
80
+ def _choose_target(releases: list[Release], target: Version) -> Release | None:
81
+ matches = [item for item in releases if item.version == target]
82
+ if not matches:
83
+ return None
84
+ if target.pre is None:
85
+ stable = [item for item in matches if not item.prerelease]
86
+ if stable:
87
+ matches = stable
88
+ return max(matches, key=lambda item: item.published_at)
89
+
90
+
91
+ def _major_crossings(client: Client, current: Version, hops: list[Release]) -> list[Release]:
92
+ """First release in range that enters each new semver major."""
93
+ if client.versioning != "semver":
94
+ return []
95
+ crossings: list[Release] = []
96
+ seen: set[int] = set()
97
+ for hop in sorted(hops, key=lambda item: item.version):
98
+ major = hop.version.numbers[0]
99
+ if major <= current.numbers[0] or major in seen:
100
+ continue
101
+ seen.add(major)
102
+ crossings.append(hop)
103
+ return crossings
104
+
105
+
106
+ def evaluate(
107
+ *,
108
+ client: Client,
109
+ releases: list[Release],
110
+ advisories: list[Advisory],
111
+ current: Version,
112
+ target: Version,
113
+ target_is_latest: bool,
114
+ fetched_at: datetime,
115
+ now: datetime,
116
+ ) -> CheckResult:
117
+ warnings: list[str] = []
118
+ reasons: list[str] = []
119
+ stale = now - fetched_at > STALE_AFTER
120
+ chosen = _choose_target(releases, target)
121
+ target_tag = chosen.tag if chosen else None
122
+
123
+ if not any(item.version == current and not item.prerelease for item in releases):
124
+ warnings.append(
125
+ "The current version is not a known stable tag. Comparison uses the version number only."
126
+ )
127
+ if chosen is None:
128
+ warnings.append("The target version is not in the catalog. The hop list may be incomplete.")
129
+
130
+ def finish(verdict: str, hops: list[Release]) -> CheckResult:
131
+ if stale and not (target_is_latest and verdict == REVIEW):
132
+ warnings.append("The release catalog is more than 5 days old.")
133
+ return CheckResult(
134
+ client=client,
135
+ current=current,
136
+ target=target,
137
+ target_tag=target_tag,
138
+ verdict=verdict,
139
+ reasons=reasons,
140
+ warnings=warnings,
141
+ releases=hops,
142
+ steps=_plan_steps(verdict, hops, advisories),
143
+ fetched_at=fetched_at,
144
+ stale=stale,
145
+ )
146
+
147
+ if current == target:
148
+ reasons.append("The target is the version you already run.")
149
+ if stale and target_is_latest:
150
+ reasons.append(
151
+ "The release catalog is more than 5 days old, so a newer stable release may exist."
152
+ )
153
+ result = finish(REVIEW, [])
154
+ result.steps = ["Refresh the release catalog, then run this check again."]
155
+ return result
156
+ return finish(CURRENT, [])
157
+
158
+ if target < current:
159
+ reasons.append("The target is older than the version you already run.")
160
+ return finish(DOWNGRADE, [])
161
+
162
+ include_prereleases = target.pre is not None or (chosen is not None and chosen.prerelease)
163
+ hops = [
164
+ item
165
+ for item in releases
166
+ if current < item.version <= target and (include_prereleases or not item.prerelease)
167
+ ]
168
+ hops.sort(key=lambda item: item.version, reverse=True)
169
+
170
+ breaking: list[tuple[Release, Advisory]] = []
171
+ deprecated: list[tuple[Release, Advisory]] = []
172
+ unreviewed: list[Release] = []
173
+ for hop in hops:
174
+ matched = advisories_for(advisories, hop.version)
175
+ if not matched:
176
+ unreviewed.append(hop)
177
+ continue
178
+ worst = _worst(matched)
179
+ if worst is not None and worst.severity == "breaking":
180
+ breaking.append((hop, worst))
181
+ elif worst is not None and worst.severity == "deprecated":
182
+ deprecated.append((hop, worst))
183
+
184
+ unreviewed_versions = {hop.version for hop in unreviewed}
185
+ unreviewed_majors = [
186
+ hop
187
+ for hop in _major_crossings(client, current, hops)
188
+ if hop.version in unreviewed_versions
189
+ ]
190
+
191
+ for hop, advisory in sorted(breaking, key=lambda item: item[0].version):
192
+ reasons.append(f"{hop.tag} is breaking: {advisory.summary}")
193
+ for hop in unreviewed_majors:
194
+ reasons.append(f"{hop.tag} raises the major version and has no review. Treat it as breaking.")
195
+ if len(unreviewed) == 1 and len(hops) == 1:
196
+ reasons.append("The release in this range has no advisory yet.")
197
+ elif len(unreviewed) == 1:
198
+ reasons.append(f"1 of {len(hops)} releases in this range has no advisory yet.")
199
+ elif unreviewed and len(unreviewed) == len(hops):
200
+ reasons.append(f"None of the {len(hops)} releases in this range has an advisory yet.")
201
+ elif unreviewed:
202
+ reasons.append(f"{len(unreviewed)} of {len(hops)} releases in this range have no advisory yet.")
203
+ for hop, advisory in sorted(deprecated, key=lambda item: item[0].version):
204
+ reasons.append(f"{hop.tag} deprecates something you may still be using: {advisory.summary}")
205
+ if not hops:
206
+ reasons.append("No catalog releases sit strictly between these versions.")
207
+
208
+ for hop in hops:
209
+ if _SECURITY.search(hop.name):
210
+ warnings.append(f"{hop.tag}: the release title mentions a security fix.")
211
+
212
+ target_is_pre = target.pre is not None or (chosen is not None and chosen.prerelease)
213
+ if target_is_pre:
214
+ reasons.append("The target is a prerelease. Do not run it on a mainnet node.")
215
+ if breaking or unreviewed_majors:
216
+ verdict = UNSAFE
217
+ elif target_is_pre:
218
+ verdict = PRERELEASE
219
+ elif not hops or unreviewed or deprecated or (stale and target_is_latest):
220
+ verdict = REVIEW
221
+ if stale and target_is_latest:
222
+ reasons.append(
223
+ "The release catalog is more than 5 days old, so this latest-stable result may be behind."
224
+ )
225
+ else:
226
+ verdict = SAFE
227
+ reasons.append("Every release in this range has been reviewed, and none is marked breaking.")
228
+
229
+ return finish(verdict, hops)
chaindiff/models.py ADDED
@@ -0,0 +1,69 @@
1
+ """Shared records for clients, releases, and advisories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+
8
+ from chaindiff.versions import Version
9
+
10
+ CURRENT = "current"
11
+ SAFE = "safe"
12
+ REVIEW = "review"
13
+ UNSAFE = "unsafe"
14
+ DOWNGRADE = "downgrade"
15
+ PRERELEASE = "prerelease"
16
+
17
+ VERDICT_LABELS = {
18
+ CURRENT: "ALREADY CURRENT",
19
+ SAFE: "SAFE",
20
+ REVIEW: "REVIEW REQUIRED",
21
+ UNSAFE: "NOT SAFE",
22
+ DOWNGRADE: "DOWNGRADE",
23
+ PRERELEASE: "PRERELEASE",
24
+ }
25
+
26
+ SEVERITIES = ("none", "note", "deprecated", "breaking")
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Client:
31
+ id: str
32
+ name: str
33
+ role: str
34
+ github: str
35
+ versioning: str
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Release:
40
+ tag: str
41
+ name: str
42
+ published_at: str
43
+ prerelease: bool
44
+ url: str
45
+ version: Version
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Advisory:
50
+ version: Version
51
+ severity: str
52
+ summary: str
53
+ action: str
54
+ source: str
55
+
56
+
57
+ @dataclass
58
+ class CheckResult:
59
+ client: Client
60
+ current: Version
61
+ target: Version
62
+ target_tag: str | None
63
+ verdict: str
64
+ reasons: list[str]
65
+ warnings: list[str]
66
+ releases: list[Release]
67
+ steps: list[str]
68
+ fetched_at: datetime
69
+ stale: bool
chaindiff/refresh.py ADDED
@@ -0,0 +1,155 @@
1
+ """Fetch client releases from GitHub."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import urllib.error
9
+ import urllib.request
10
+ from collections.abc import Callable
11
+ from datetime import datetime, timezone
12
+ from typing import Any
13
+
14
+ from chaindiff.catalog import load_clients, save_releases
15
+ from chaindiff.models import Client, Release
16
+ from chaindiff.versions import parse_version, tag_is_prerelease
17
+
18
+ API_ROOT = "https://api.github.com"
19
+ _NEXT_LINK = re.compile(r'<([^>]+)>;\s*rel="next"')
20
+ Opener = Callable[[str, str | None], tuple[Any, str | None]]
21
+
22
+
23
+ class GitHubError(Exception):
24
+ pass
25
+
26
+
27
+ def _default_opener(url: str, token: str | None) -> tuple[Any, str | None]:
28
+ headers = {
29
+ "Accept": "application/vnd.github+json",
30
+ "User-Agent": "chaindiff/0.1",
31
+ "X-GitHub-Api-Version": "2022-11-28",
32
+ }
33
+ if token:
34
+ headers["Authorization"] = f"Bearer {token}"
35
+ request = urllib.request.Request(url, headers=headers)
36
+ try:
37
+ with urllib.request.urlopen(request, timeout=30) as response:
38
+ payload = json.load(response)
39
+ return payload, response.headers.get("Link")
40
+ except urllib.error.HTTPError as exc:
41
+ detail = exc.read().decode("utf-8", errors="replace")
42
+ message = ""
43
+ try:
44
+ message = json.loads(detail).get("message", "")
45
+ except json.JSONDecodeError:
46
+ message = detail.strip()
47
+ if exc.code == 403 and "rate limit" in message.lower():
48
+ raise GitHubError(
49
+ "GitHub rate limit reached. Set GH_TOKEN or GITHUB_TOKEN and run refresh again."
50
+ ) from exc
51
+ raise GitHubError(f"GitHub returned {exc.code} for {url}: {message}") from exc
52
+ except urllib.error.URLError as exc:
53
+ raise GitHubError(f"Could not reach GitHub: {exc.reason}") from exc
54
+
55
+
56
+ def _next_url(link_header: str | None) -> str | None:
57
+ if not link_header:
58
+ return None
59
+ matched = _NEXT_LINK.search(link_header)
60
+ if not matched:
61
+ return None
62
+ return matched.group(1)
63
+
64
+
65
+ def releases_from_payload(items: list[dict[str, Any]]) -> tuple[list[Release], int]:
66
+ releases: list[Release] = []
67
+ ignored = 0
68
+ seen: set[str] = set()
69
+ for item in items:
70
+ if item.get("draft"):
71
+ ignored += 1
72
+ continue
73
+ tag = str(item.get("tag_name") or "")
74
+ if not tag or tag in seen:
75
+ ignored += 1
76
+ continue
77
+ version = parse_version(tag)
78
+ if version is None:
79
+ ignored += 1
80
+ continue
81
+ seen.add(tag)
82
+ published = str(item.get("published_at") or "")
83
+ prerelease = bool(item.get("prerelease")) or tag_is_prerelease(tag)
84
+ releases.append(
85
+ Release(
86
+ tag=tag,
87
+ name=str(item.get("name") or tag),
88
+ published_at=published,
89
+ prerelease=prerelease,
90
+ url=str(item.get("html_url") or ""),
91
+ version=version,
92
+ )
93
+ )
94
+ return releases, ignored
95
+
96
+
97
+ def fetch_releases(
98
+ client: Client,
99
+ token: str | None = None,
100
+ opener: Opener | None = None,
101
+ ) -> tuple[list[Release], int]:
102
+ fetch = opener or _default_opener
103
+ url = f"{API_ROOT}/repos/{client.github}/releases?per_page=100"
104
+ pages: list[dict[str, Any]] = []
105
+ for _ in range(20):
106
+ payload, link = fetch(url, token)
107
+ if isinstance(payload, dict):
108
+ message = payload.get("message", "unexpected GitHub response")
109
+ raise GitHubError(f"{client.github}: {message}")
110
+ if not isinstance(payload, list):
111
+ raise GitHubError(f"{client.github}: unexpected GitHub response")
112
+ pages.extend(payload)
113
+ nxt = _next_url(link)
114
+ if nxt is None:
115
+ break
116
+ url = nxt
117
+ else:
118
+ raise GitHubError(f"{client.github}: too many release pages")
119
+ return releases_from_payload(pages)
120
+
121
+
122
+ def github_token() -> str | None:
123
+ return os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") or None
124
+
125
+
126
+ def refresh_catalog(client_ids: list[str] | None = None) -> int:
127
+ clients = load_clients()
128
+ if client_ids:
129
+ wanted = {item.strip().lower() for item in client_ids}
130
+ unknown = wanted - {client.id for client in clients}
131
+ if unknown:
132
+ names = ", ".join(sorted(unknown))
133
+ print(f"Unknown client: {names}")
134
+ return 3
135
+ clients = [client for client in clients if client.id in wanted]
136
+
137
+ token = github_token()
138
+ failures: list[str] = []
139
+ fetched_at = datetime.now(timezone.utc)
140
+ for client in clients:
141
+ try:
142
+ releases, ignored = fetch_releases(client, token=token)
143
+ except GitHubError as exc:
144
+ failures.append(f"{client.id}: {exc}")
145
+ print(f"{client.id}: failed — {exc}")
146
+ continue
147
+ save_releases(client, releases, ignored, fetched_at)
148
+ stable = sum(1 for item in releases if not item.prerelease)
149
+ print(
150
+ f"{client.id}: {stable} stable releases, "
151
+ f"{len(releases) - stable} prereleases, {ignored} ignored tags"
152
+ )
153
+ if failures:
154
+ return 1
155
+ return 0
chaindiff/report.py ADDED
@@ -0,0 +1,166 @@
1
+ """Text and JSON reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta, timezone
6
+
7
+ from chaindiff.catalog import format_time
8
+ from chaindiff.evaluate import latest_stable, newer_prerelease
9
+ from chaindiff.models import VERDICT_LABELS, CheckResult, Client, Release
10
+
11
+
12
+ def _day(value: str) -> str:
13
+ return value[:10] if len(value) >= 10 else "-"
14
+
15
+
16
+ def _shown_releases(releases: list[Release]) -> tuple[list[Release | None], int]:
17
+ if len(releases) <= 12:
18
+ return list(releases), 0
19
+ hidden = releases[8:-3]
20
+ shown: list[Release | None] = list(releases[:8]) + [None] + list(releases[-3:])
21
+ return shown, len(hidden)
22
+
23
+
24
+ def format_check(result: CheckResult, *, plan_only: bool = False) -> str:
25
+ lines = [
26
+ f"{result.client.name} {result.client.role}",
27
+ result.client.github,
28
+ f"Catalog: {format_time(result.fetched_at)}",
29
+ "",
30
+ f"{result.current.text} → {result.target.text}"
31
+ + (f" ({result.target_tag})" if result.target_tag else ""),
32
+ "",
33
+ f"Verdict: {VERDICT_LABELS[result.verdict]}",
34
+ "",
35
+ ]
36
+ if result.reasons:
37
+ lines.append("Why")
38
+ lines.extend(f" {reason}" for reason in result.reasons)
39
+ lines.append("")
40
+ if result.warnings:
41
+ lines.append("Warnings")
42
+ lines.extend(f" {warning}" for warning in result.warnings)
43
+ lines.append("")
44
+ if not plan_only and result.releases:
45
+ lines.append("Releases")
46
+ shown, hidden = _shown_releases(result.releases)
47
+ for item in shown:
48
+ if item is None:
49
+ lines.append(f" ({hidden} older releases omitted)")
50
+ continue
51
+ lines.append(f" {item.tag:<16} {_day(item.published_at)} {item.url}")
52
+ oldest = min(result.releases, key=lambda item: item.version)
53
+ if len(result.releases) > 1:
54
+ lines.append(
55
+ f" First release after {result.current.text}: {oldest.tag} on {_day(oldest.published_at)}"
56
+ )
57
+ lines.append("")
58
+ if result.steps:
59
+ lines.append("Before any upgrade")
60
+ for index, step in enumerate(result.steps, start=1):
61
+ lines.append(f" {index}. {step}")
62
+ lines.append("")
63
+ return "\n".join(lines).rstrip() + "\n"
64
+
65
+
66
+ def check_json(result: CheckResult) -> dict:
67
+ return {
68
+ "client": result.client.id,
69
+ "name": result.client.name,
70
+ "role": result.client.role,
71
+ "github": result.client.github,
72
+ "from": result.current.text,
73
+ "to": result.target.text,
74
+ "target_tag": result.target_tag,
75
+ "verdict": result.verdict,
76
+ "reasons": result.reasons,
77
+ "warnings": result.warnings,
78
+ "releases": [
79
+ {
80
+ "tag": item.tag,
81
+ "name": item.name,
82
+ "published_at": item.published_at,
83
+ "prerelease": item.prerelease,
84
+ "url": item.url,
85
+ }
86
+ for item in result.releases
87
+ ],
88
+ "steps": result.steps,
89
+ "catalog_fetched_at": format_time(result.fetched_at),
90
+ "catalog_stale": result.stale,
91
+ }
92
+
93
+
94
+ def format_versions(
95
+ rows: list[tuple[Client, datetime | None, list[Release] | None]],
96
+ now: datetime | None = None,
97
+ ) -> str:
98
+ moment = now or datetime.now(timezone.utc)
99
+ lines = [f"{'Client':<12} {'Role':<10} {'Latest':<18} {'Published':<12} Notes"]
100
+ stale = False
101
+ for client, fetched_at, releases in rows:
102
+ if releases is None or fetched_at is None:
103
+ lines.append(f"{client.id:<12} {client.role:<10} {'-':<18} {'-':<12} no catalog")
104
+ continue
105
+ is_stale = moment - fetched_at > timedelta(days=5)
106
+ stale = stale or is_stale
107
+ stable = latest_stable(releases)
108
+ ahead = newer_prerelease(releases, stable)
109
+ if stable is None:
110
+ latest = "-"
111
+ published = "-"
112
+ else:
113
+ latest = stable.tag
114
+ published = _day(stable.published_at)
115
+ notes = f"prerelease {ahead.tag} is newer" if ahead else ""
116
+ if is_stale:
117
+ notes = (notes + "; " if notes else "") + "catalog is stale"
118
+ lines.append(f"{client.id:<12} {client.role:<10} {latest:<18} {published:<12} {notes}".rstrip())
119
+ if stale:
120
+ lines.insert(0, "Release catalog is more than 5 days old. Run: chaindiff refresh")
121
+ lines.insert(1, "")
122
+ return "\n".join(lines) + "\n"
123
+
124
+
125
+ def versions_json(
126
+ rows: list[tuple[Client, datetime | None, list[Release] | None]],
127
+ now: datetime | None = None,
128
+ ) -> dict:
129
+ moment = now or datetime.now(timezone.utc)
130
+ clients = []
131
+ for client, fetched_at, releases in rows:
132
+ if releases is None or fetched_at is None:
133
+ clients.append(
134
+ {
135
+ "id": client.id,
136
+ "name": client.name,
137
+ "role": client.role,
138
+ "github": client.github,
139
+ "catalog": None,
140
+ }
141
+ )
142
+ continue
143
+ stable = latest_stable(releases)
144
+ ahead = newer_prerelease(releases, stable)
145
+ clients.append(
146
+ {
147
+ "id": client.id,
148
+ "name": client.name,
149
+ "role": client.role,
150
+ "github": client.github,
151
+ "fetched_at": format_time(fetched_at),
152
+ "stale": moment - fetched_at > timedelta(days=5),
153
+ "latest_stable": None
154
+ if stable is None
155
+ else {
156
+ "tag": stable.tag,
157
+ "version": stable.version.text,
158
+ "published_at": stable.published_at,
159
+ "url": stable.url,
160
+ },
161
+ "prerelease_ahead": None
162
+ if ahead is None
163
+ else {"tag": ahead.tag, "published_at": ahead.published_at, "url": ahead.url},
164
+ }
165
+ )
166
+ return {"clients": clients}