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 +3 -0
- chaindiff/__main__.py +4 -0
- chaindiff/catalog.py +179 -0
- chaindiff/cli.py +156 -0
- chaindiff/data/clients.json +68 -0
- chaindiff/data/releases/besu.json +1227 -0
- chaindiff/data/releases/erigon.json +1045 -0
- chaindiff/data/releases/geth.json +1647 -0
- chaindiff/data/releases/lighthouse.json +898 -0
- chaindiff/data/releases/nethermind.json +2319 -0
- chaindiff/data/releases/nimbus.json +793 -0
- chaindiff/data/releases/prysm.json +1248 -0
- chaindiff/data/releases/reth.json +709 -0
- chaindiff/data/releases/teku.json +926 -0
- chaindiff/evaluate.py +229 -0
- chaindiff/models.py +69 -0
- chaindiff/refresh.py +155 -0
- chaindiff/report.py +166 -0
- chaindiff/versions.py +131 -0
- chaindiff-0.1.0.dist-info/METADATA +146 -0
- chaindiff-0.1.0.dist-info/RECORD +25 -0
- chaindiff-0.1.0.dist-info/WHEEL +5 -0
- chaindiff-0.1.0.dist-info/entry_points.txt +2 -0
- chaindiff-0.1.0.dist-info/licenses/LICENSE +21 -0
- chaindiff-0.1.0.dist-info/top_level.txt +1 -0
chaindiff/__init__.py
ADDED
chaindiff/__main__.py
ADDED
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
|
+
}
|