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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Decide whether an Ethereum node client upgrade is safe."""
2
+
3
+ __version__ = "0.1.0"
chaindiff/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from chaindiff.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
chaindiff/catalog.py ADDED
@@ -0,0 +1,179 @@
1
+ """Load and store the client registry, release catalog, and advisories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ from chaindiff.models import SEVERITIES, Advisory, Client, Release
11
+ from chaindiff.versions import parse_version
12
+
13
+ SCHEMA_VERSION = 1
14
+
15
+
16
+ def data_dir() -> Path:
17
+ override = os.environ.get("CHAINDIFF_DATA")
18
+ if override:
19
+ return Path(override)
20
+ return Path(__file__).resolve().parent / "data"
21
+
22
+
23
+ def format_time(moment: datetime) -> str:
24
+ return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
25
+
26
+
27
+ def parse_time(value: str) -> datetime:
28
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
29
+
30
+
31
+ def _read_json(path: Path) -> dict:
32
+ try:
33
+ payload = json.loads(path.read_text())
34
+ except json.JSONDecodeError as exc:
35
+ raise ValueError(f"{path} is not valid JSON") from exc
36
+ if not isinstance(payload, dict):
37
+ raise ValueError(f"{path} must contain a JSON object")
38
+ if payload.get("schema_version") != SCHEMA_VERSION:
39
+ raise ValueError(f"{path} has an unsupported schema_version")
40
+ return payload
41
+
42
+
43
+ def load_clients() -> list[Client]:
44
+ path = data_dir() / "clients.json"
45
+ payload = _read_json(path)
46
+ clients: list[Client] = []
47
+ seen: set[str] = set()
48
+ for raw in payload.get("clients", []):
49
+ client_id = str(raw.get("id", "")).strip()
50
+ role = raw.get("role")
51
+ versioning = raw.get("versioning")
52
+ github = str(raw.get("github", "")).strip()
53
+ name = str(raw.get("name", "")).strip()
54
+ if not client_id or not name or not github:
55
+ raise ValueError(f"{path} has a client missing id, name, or github")
56
+ if role not in ("execution", "consensus"):
57
+ raise ValueError(f"{client_id} has an unknown role")
58
+ if versioning not in ("semver", "calver"):
59
+ raise ValueError(f"{client_id} has an unknown versioning scheme")
60
+ if client_id in seen:
61
+ raise ValueError(f"{client_id} is listed twice")
62
+ seen.add(client_id)
63
+ clients.append(
64
+ Client(
65
+ id=client_id,
66
+ name=name,
67
+ role=role,
68
+ github=github,
69
+ versioning=versioning,
70
+ )
71
+ )
72
+ if not clients:
73
+ raise ValueError(f"{path} does not list any clients")
74
+ return clients
75
+
76
+
77
+ def client_by_id(clients: list[Client], client_id: str) -> Client | None:
78
+ key = client_id.strip().lower()
79
+ for client in clients:
80
+ if client.id == key:
81
+ return client
82
+ return None
83
+
84
+
85
+ def releases_path(client_id: str) -> Path:
86
+ return data_dir() / "releases" / f"{client_id}.json"
87
+
88
+
89
+ def load_releases(client_id: str) -> tuple[datetime, list[Release]] | None:
90
+ path = releases_path(client_id)
91
+ if not path.exists():
92
+ return None
93
+ payload = _read_json(path)
94
+ fetched_at = parse_time(str(payload["fetched_at"]))
95
+ releases: list[Release] = []
96
+ for raw in payload.get("releases", []):
97
+ tag = str(raw.get("tag", ""))
98
+ version = parse_version(tag)
99
+ if version is None:
100
+ continue
101
+ releases.append(
102
+ Release(
103
+ tag=tag,
104
+ name=str(raw.get("name") or tag),
105
+ published_at=str(raw.get("published_at") or ""),
106
+ prerelease=bool(raw.get("prerelease")),
107
+ url=str(raw.get("url") or ""),
108
+ version=version,
109
+ )
110
+ )
111
+ return fetched_at, releases
112
+
113
+
114
+ def save_releases(
115
+ client: Client,
116
+ releases: list[Release],
117
+ ignored_tag_count: int,
118
+ fetched_at: datetime,
119
+ ) -> None:
120
+ path = releases_path(client.id)
121
+ path.parent.mkdir(parents=True, exist_ok=True)
122
+ ordered = sorted(releases, key=lambda item: (item.version, item.published_at, item.tag), reverse=True)
123
+ payload = {
124
+ "schema_version": SCHEMA_VERSION,
125
+ "client": client.id,
126
+ "github": client.github,
127
+ "fetched_at": format_time(fetched_at),
128
+ "ignored_tag_count": ignored_tag_count,
129
+ "releases": [
130
+ {
131
+ "tag": item.tag,
132
+ "name": item.name,
133
+ "published_at": item.published_at,
134
+ "prerelease": item.prerelease,
135
+ "url": item.url,
136
+ }
137
+ for item in ordered
138
+ ],
139
+ }
140
+ text = json.dumps(payload, indent=2) + "\n"
141
+ temporary = path.with_suffix(".json.tmp")
142
+ temporary.write_text(text)
143
+ temporary.replace(path)
144
+
145
+
146
+ def load_advisories(client_id: str) -> list[Advisory]:
147
+ path = data_dir() / "advisories" / f"{client_id}.json"
148
+ if not path.exists():
149
+ return []
150
+ payload = _read_json(path)
151
+ advisories: list[Advisory] = []
152
+ for index, raw in enumerate(payload.get("advisories", []), start=1):
153
+ version_text = str(raw.get("version", "")).strip()
154
+ version = parse_version(version_text)
155
+ severity = str(raw.get("severity", "")).strip()
156
+ summary = str(raw.get("summary", "")).strip()
157
+ action = str(raw.get("action", "")).strip()
158
+ source = str(raw.get("source", "")).strip()
159
+ where = f"{path} advisory {index} ({version_text or 'missing version'})"
160
+ if version is None:
161
+ raise ValueError(f"{where} has a version ChainDiff cannot parse")
162
+ if severity not in SEVERITIES:
163
+ raise ValueError(f"{where} has an unknown severity")
164
+ if not summary:
165
+ raise ValueError(f"{where} needs a summary")
166
+ if severity in ("breaking", "deprecated") and not action:
167
+ raise ValueError(f"{where} needs an action")
168
+ if not source.startswith(("https://", "http://")):
169
+ raise ValueError(f"{where} needs an http(s) source URL")
170
+ advisories.append(
171
+ Advisory(
172
+ version=version,
173
+ severity=severity,
174
+ summary=summary,
175
+ action=action,
176
+ source=source,
177
+ )
178
+ )
179
+ return advisories
chaindiff/cli.py ADDED
@@ -0,0 +1,156 @@
1
+ """Command line for ChainDiff."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from datetime import datetime, timezone
9
+
10
+ from chaindiff import __version__
11
+ from chaindiff.catalog import client_by_id, load_advisories, load_clients, load_releases
12
+ from chaindiff.evaluate import evaluate, latest_stable
13
+ from chaindiff.models import CURRENT, DOWNGRADE, PRERELEASE, REVIEW, SAFE, UNSAFE
14
+ from chaindiff.refresh import refresh_catalog
15
+ from chaindiff.report import check_json, format_check, format_versions, versions_json
16
+ from chaindiff.versions import explain_unparsed, parse_version
17
+
18
+ _EXIT = {
19
+ CURRENT: 0,
20
+ SAFE: 0,
21
+ REVIEW: 1,
22
+ UNSAFE: 2,
23
+ DOWNGRADE: 2,
24
+ PRERELEASE: 2,
25
+ }
26
+
27
+
28
+ def _parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(
30
+ prog="chaindiff",
31
+ description="Decide whether an Ethereum node client upgrade is safe.",
32
+ )
33
+ parser.add_argument("--version", action="version", version=f"chaindiff {__version__}")
34
+ commands = parser.add_subparsers(dest="command", required=True)
35
+
36
+ versions = commands.add_parser("versions", help="Show the latest stable release of each client")
37
+ versions.add_argument("--client", action="append", dest="clients", help="Limit to one client id")
38
+ versions.add_argument("--json", action="store_true")
39
+
40
+ check = commands.add_parser("check", help="Compare an installed version with a target release")
41
+ plan = commands.add_parser("plan", help="Checklist for the same comparison as check")
42
+ for command in (check, plan):
43
+ command.add_argument("--client", required=True, help="Client id, for example geth")
44
+ command.add_argument("--from", dest="current", required=True, help="Installed version")
45
+ command.add_argument("--to", dest="target", default="latest", help="Target version, or latest")
46
+ command.add_argument("--json", action="store_true")
47
+
48
+ refresh = commands.add_parser("refresh", help="Download the release catalog from GitHub")
49
+ refresh.add_argument("--client", action="append", dest="clients", help="Refresh one client id")
50
+ return parser
51
+
52
+
53
+ def _selected_clients(raw_ids: list[str] | None):
54
+ try:
55
+ clients = load_clients()
56
+ except ValueError as exc:
57
+ print(str(exc), file=sys.stderr)
58
+ return None
59
+ if not raw_ids:
60
+ return clients
61
+ selected = []
62
+ for raw in raw_ids:
63
+ client = client_by_id(clients, raw)
64
+ if client is None:
65
+ known = ", ".join(item.id for item in clients)
66
+ print(f"Unknown client '{raw}'. Known clients: {known}", file=sys.stderr)
67
+ return None
68
+ selected.append(client)
69
+ return selected
70
+
71
+
72
+ def _versions(args: argparse.Namespace) -> int:
73
+ clients = _selected_clients(args.clients)
74
+ if clients is None:
75
+ return 3
76
+ rows = []
77
+ missing = False
78
+ for client in clients:
79
+ try:
80
+ loaded = load_releases(client.id)
81
+ except ValueError as exc:
82
+ print(str(exc), file=sys.stderr)
83
+ return 3
84
+ if loaded is None:
85
+ missing = True
86
+ rows.append((client, None, None))
87
+ continue
88
+ fetched_at, releases = loaded
89
+ rows.append((client, fetched_at, releases))
90
+ now = datetime.now(timezone.utc)
91
+ if args.json:
92
+ print(json.dumps(versions_json(rows, now=now), indent=2))
93
+ else:
94
+ print(format_versions(rows, now=now), end="")
95
+ if missing:
96
+ print("Run `chaindiff refresh` to fill a missing catalog.", file=sys.stderr)
97
+ return 0
98
+
99
+
100
+ def _check(args: argparse.Namespace, *, plan_only: bool) -> int:
101
+ clients = _selected_clients([args.client])
102
+ if clients is None:
103
+ return 3
104
+ client = clients[0]
105
+ current = parse_version(args.current)
106
+ if current is None:
107
+ print(explain_unparsed(args.current), file=sys.stderr)
108
+ return 3
109
+ try:
110
+ loaded = load_releases(client.id)
111
+ advisories = load_advisories(client.id)
112
+ except ValueError as exc:
113
+ print(str(exc), file=sys.stderr)
114
+ return 3
115
+ if loaded is None:
116
+ print(f"No release catalog for {client.id}. Run: chaindiff refresh", file=sys.stderr)
117
+ return 3
118
+ fetched_at, releases = loaded
119
+ target_is_latest = args.target.lower() in ("latest", "stable")
120
+ if target_is_latest:
121
+ stable = latest_stable(releases)
122
+ if stable is None:
123
+ print(f"{client.id} has no stable release in the catalog.", file=sys.stderr)
124
+ return 3
125
+ target = stable.version
126
+ else:
127
+ target = parse_version(args.target)
128
+ if target is None:
129
+ print(explain_unparsed(args.target), file=sys.stderr)
130
+ return 3
131
+ result = evaluate(
132
+ client=client,
133
+ releases=releases,
134
+ advisories=advisories,
135
+ current=current,
136
+ target=target,
137
+ target_is_latest=target_is_latest,
138
+ fetched_at=fetched_at,
139
+ now=datetime.now(timezone.utc),
140
+ )
141
+ if args.json:
142
+ print(json.dumps(check_json(result), indent=2))
143
+ else:
144
+ print(format_check(result, plan_only=plan_only), end="")
145
+ return _EXIT[result.verdict]
146
+
147
+
148
+ def main(argv: list[str] | None = None) -> int:
149
+ args = _parser().parse_args(argv)
150
+ if args.command == "versions":
151
+ return _versions(args)
152
+ if args.command == "refresh":
153
+ return refresh_catalog(args.clients)
154
+ if args.command == "plan":
155
+ return _check(args, plan_only=True)
156
+ return _check(args, plan_only=False)
@@ -0,0 +1,68 @@
1
+ {
2
+ "schema_version": 1,
3
+ "clients": [
4
+ {
5
+ "id": "geth",
6
+ "name": "Geth",
7
+ "role": "execution",
8
+ "github": "ethereum/go-ethereum",
9
+ "versioning": "semver"
10
+ },
11
+ {
12
+ "id": "nethermind",
13
+ "name": "Nethermind",
14
+ "role": "execution",
15
+ "github": "NethermindEth/nethermind",
16
+ "versioning": "semver"
17
+ },
18
+ {
19
+ "id": "erigon",
20
+ "name": "Erigon",
21
+ "role": "execution",
22
+ "github": "erigontech/erigon",
23
+ "versioning": "semver"
24
+ },
25
+ {
26
+ "id": "besu",
27
+ "name": "Besu",
28
+ "role": "execution",
29
+ "github": "besu-eth/besu",
30
+ "versioning": "calver"
31
+ },
32
+ {
33
+ "id": "reth",
34
+ "name": "Reth",
35
+ "role": "execution",
36
+ "github": "paradigmxyz/reth",
37
+ "versioning": "semver"
38
+ },
39
+ {
40
+ "id": "lighthouse",
41
+ "name": "Lighthouse",
42
+ "role": "consensus",
43
+ "github": "sigp/lighthouse",
44
+ "versioning": "semver"
45
+ },
46
+ {
47
+ "id": "prysm",
48
+ "name": "Prysm",
49
+ "role": "consensus",
50
+ "github": "OffchainLabs/prysm",
51
+ "versioning": "semver"
52
+ },
53
+ {
54
+ "id": "teku",
55
+ "name": "Teku",
56
+ "role": "consensus",
57
+ "github": "Consensys-Incorporated/teku",
58
+ "versioning": "calver"
59
+ },
60
+ {
61
+ "id": "nimbus",
62
+ "name": "Nimbus",
63
+ "role": "consensus",
64
+ "github": "status-im/nimbus-eth2",
65
+ "versioning": "calver"
66
+ }
67
+ ]
68
+ }