rpcbench 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.
rpcbench/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """RPCBench: vendor-neutral RPC quality comparison CLI."""
2
+
3
+ __version__ = "0.1.0"
rpcbench/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from rpcbench.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
rpcbench/cli.py ADDED
@@ -0,0 +1,196 @@
1
+ """rpcbench CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from rpcbench import __version__
10
+ from rpcbench.config import ConfigError, load_targets
11
+ from rpcbench.methods import MethodError, resolve_method
12
+ from rpcbench.report import format_json, format_run
13
+ from rpcbench.run import run_endpoints
14
+ from rpcbench.safety import SafetyError, check_budget, kill_switch_reason
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ parser = argparse.ArgumentParser(
19
+ prog="rpcbench",
20
+ description="Measure RPC quality and compare providers.",
21
+ )
22
+ parser.add_argument("--version", action="version", version=f"rpcbench {__version__}")
23
+ sub = parser.add_subparsers(dest="command")
24
+ _add_run_parser(
25
+ sub,
26
+ "run",
27
+ "Measure JSON-RPC round-trip latency and print a comparison report",
28
+ )
29
+ _add_run_parser(
30
+ sub,
31
+ "compare",
32
+ "Same as run: print a ranked CLI report for configured endpoints",
33
+ )
34
+ return parser
35
+
36
+
37
+ def _add_run_parser(sub, name: str, help_text: str) -> None:
38
+ run = sub.add_parser(name, help=help_text)
39
+ run.add_argument(
40
+ "--endpoints",
41
+ required=True,
42
+ metavar="FILE|URL",
43
+ help="YAML/JSON file, or a single http(s) URL (localhost is allowed)",
44
+ )
45
+ run.add_argument(
46
+ "--method",
47
+ default=None,
48
+ help="JSON-RPC method (default: eth_blockNumber). Do not combine with --preset.",
49
+ )
50
+ run.add_argument(
51
+ "--preset",
52
+ default=None,
53
+ metavar="NAME",
54
+ help="Read-only method pack: head, chainId, or balance",
55
+ )
56
+ run.add_argument(
57
+ "--params",
58
+ default=None,
59
+ metavar="JSON",
60
+ help='JSON array of params, e.g. \'["0x0","latest"]\'',
61
+ )
62
+ run.add_argument(
63
+ "--allow-writes",
64
+ action="store_true",
65
+ help="Allow write methods (eth_send*, personal_*, …). Default is read-only.",
66
+ )
67
+ run.add_argument(
68
+ "--samples",
69
+ type=int,
70
+ default=10,
71
+ help="Timed samples per endpoint after warmup (default: 10)",
72
+ )
73
+ run.add_argument(
74
+ "--warmup",
75
+ type=int,
76
+ default=1,
77
+ help="Warmup requests excluded from stats (default: 1)",
78
+ )
79
+ run.add_argument(
80
+ "--timeout",
81
+ type=float,
82
+ default=10.0,
83
+ help="Per-request timeout in seconds (default: 10)",
84
+ )
85
+ run.add_argument(
86
+ "--budget",
87
+ type=int,
88
+ default=128,
89
+ help="Max HTTP requests for the whole run, including warmup (default: 128)",
90
+ )
91
+ run.add_argument(
92
+ "--max-duration",
93
+ type=float,
94
+ default=600.0,
95
+ metavar="SEC",
96
+ help="Stop the run after this many seconds and still print a report (default: 600; 0 = no limit)",
97
+ )
98
+ run.add_argument(
99
+ "--concurrency",
100
+ type=int,
101
+ default=1,
102
+ help="Max in-flight requests (only 1 is supported)",
103
+ )
104
+ run.add_argument(
105
+ "--verbose",
106
+ "-v",
107
+ action="store_true",
108
+ help="Print per-sample latency and error-class detail",
109
+ )
110
+ run.add_argument(
111
+ "--json",
112
+ action="store_true",
113
+ help="Print a JSON report to stdout instead of the CLI table",
114
+ )
115
+ run.add_argument(
116
+ "-o",
117
+ "--output",
118
+ metavar="FILE",
119
+ help="Write the JSON report to FILE (CLI table still prints unless --json)",
120
+ )
121
+
122
+
123
+ def main(argv: list[str] | None = None) -> int:
124
+ parser = build_parser()
125
+ args = parser.parse_args(argv)
126
+ if args.command is None:
127
+ parser.print_help()
128
+ return 2
129
+ if args.command in {"run", "compare"}:
130
+ return _cmd_run(args)
131
+ parser.print_help()
132
+ return 2
133
+
134
+
135
+ def _cmd_run(args: argparse.Namespace) -> int:
136
+ stopped = kill_switch_reason()
137
+ if stopped:
138
+ print(f"rpcbench: disabled ({stopped})", file=sys.stderr)
139
+ return 2
140
+ try:
141
+ check_budget(args.budget)
142
+ config = load_targets(args.endpoints)
143
+ method, params = resolve_method(
144
+ method=args.method,
145
+ preset=args.preset,
146
+ params_json=args.params,
147
+ allow_writes=args.allow_writes,
148
+ )
149
+ except (ConfigError, MethodError, SafetyError) as exc:
150
+ print(f"rpcbench: {exc}", file=sys.stderr)
151
+ return 2
152
+ if (
153
+ args.timeout <= 0
154
+ or args.budget < 1
155
+ or args.samples < 1
156
+ or args.warmup < 0
157
+ or args.max_duration < 0
158
+ ):
159
+ print(
160
+ "rpcbench: --timeout must be > 0, --samples >= 1, "
161
+ "--warmup >= 0, --budget >= 1, --max-duration >= 0",
162
+ file=sys.stderr,
163
+ )
164
+ return 2
165
+ if args.concurrency != 1:
166
+ print("rpcbench: only --concurrency 1 is supported", file=sys.stderr)
167
+ return 2
168
+ result = run_endpoints(
169
+ config,
170
+ method=method,
171
+ params=params,
172
+ samples=args.samples,
173
+ warmup=args.warmup,
174
+ timeout=args.timeout,
175
+ budget=args.budget,
176
+ max_duration=args.max_duration,
177
+ )
178
+ payload = format_json(result) if (args.json or args.output) else None
179
+ if args.output:
180
+ path = Path(args.output)
181
+ try:
182
+ path.write_text(payload or "", encoding="utf-8")
183
+ except OSError as exc:
184
+ print(f"rpcbench: cannot write {path}: {exc}", file=sys.stderr)
185
+ return 2
186
+ if args.json:
187
+ sys.stdout.write(payload or "")
188
+ else:
189
+ sys.stdout.write(format_run(result, verbose=args.verbose))
190
+ if any(outcome.stats.n_ok for outcome in result.outcomes):
191
+ return 0
192
+ return 1
193
+
194
+
195
+ if __name__ == "__main__":
196
+ raise SystemExit(main())
rpcbench/config.py ADDED
@@ -0,0 +1,122 @@
1
+ """Load named RPC endpoints from YAML or JSON. Localhost is allowed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from urllib.parse import urlsplit
9
+
10
+ import yaml
11
+
12
+ from rpcbench.urls import display_url, url_fingerprint
13
+
14
+
15
+ class ConfigError(ValueError):
16
+ pass
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Endpoint:
21
+ name: str
22
+ url: str
23
+ headers: tuple[tuple[str, str], ...] = ()
24
+
25
+ @property
26
+ def display_url(self) -> str:
27
+ return display_url(self.url)
28
+
29
+ @property
30
+ def url_id(self) -> str:
31
+ return url_fingerprint(self.url)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class BenchConfig:
36
+ endpoints: tuple[Endpoint, ...]
37
+
38
+
39
+ def load_targets(spec: str | Path) -> BenchConfig:
40
+ text = str(spec).strip()
41
+ if text.startswith("http://") or text.startswith("https://"):
42
+ host = urlsplit(text).hostname or "cli"
43
+ return parse_endpoints(
44
+ {"endpoints": [{"name": host, "url": text}]},
45
+ source="cli",
46
+ )
47
+ return load_endpoints(text)
48
+
49
+
50
+ def load_endpoints(path: str | Path) -> BenchConfig:
51
+ raw_path = Path(path)
52
+ if not raw_path.is_file():
53
+ raise ConfigError(f"endpoints file not found: {raw_path}")
54
+ text = raw_path.read_text(encoding="utf-8")
55
+ suffix = raw_path.suffix.lower()
56
+ try:
57
+ if suffix == ".json":
58
+ data = json.loads(text)
59
+ else:
60
+ data = yaml.safe_load(text)
61
+ except (json.JSONDecodeError, yaml.YAMLError) as exc:
62
+ raise ConfigError(f"invalid endpoints file {raw_path}: {exc}") from exc
63
+ return parse_endpoints(data, source=str(raw_path))
64
+
65
+
66
+ def parse_endpoints(data: object, *, source: str = "config") -> BenchConfig:
67
+ if not isinstance(data, dict):
68
+ raise ConfigError(f"{source}: expected a mapping with an 'endpoints' list")
69
+ items = data.get("endpoints")
70
+ if not isinstance(items, list) or not items:
71
+ raise ConfigError(f"{source}: 'endpoints' must be a non-empty list")
72
+ seen: set[str] = set()
73
+ endpoints: list[Endpoint] = []
74
+ for i, item in enumerate(items):
75
+ if not isinstance(item, dict):
76
+ raise ConfigError(f"{source}: endpoints[{i}] must be a mapping")
77
+ name = item.get("name")
78
+ url = item.get("url")
79
+ if not isinstance(name, str) or not name.strip():
80
+ raise ConfigError(f"{source}: endpoints[{i}].name is required")
81
+ if not isinstance(url, str) or not url.strip():
82
+ raise ConfigError(f"{source}: endpoints[{i}].url is required")
83
+ name = name.strip()
84
+ url = url.strip()
85
+ if name in seen:
86
+ raise ConfigError(f"{source}: duplicate endpoint name {name!r}")
87
+ scheme = url.split(":", 1)[0].lower()
88
+ if scheme not in {"http", "https"}:
89
+ raise ConfigError(
90
+ f"{source}: endpoints[{i}] ({name}) URL must be http or https"
91
+ )
92
+ headers = _parse_headers(item, source=source, index=i, name=name)
93
+ seen.add(name)
94
+ endpoints.append(Endpoint(name=name, url=url, headers=headers))
95
+ return BenchConfig(endpoints=tuple(endpoints))
96
+
97
+
98
+ def _parse_headers(
99
+ item: dict, *, source: str, index: int, name: str
100
+ ) -> tuple[tuple[str, str], ...]:
101
+ headers: list[tuple[str, str]] = []
102
+ raw = item.get("headers")
103
+ if raw is not None:
104
+ if not isinstance(raw, dict):
105
+ raise ConfigError(
106
+ f"{source}: endpoints[{index}] ({name}) headers must be a mapping"
107
+ )
108
+ for key, value in raw.items():
109
+ if not isinstance(key, str) or not isinstance(value, str):
110
+ raise ConfigError(
111
+ f"{source}: endpoints[{index}] ({name}) header names and values "
112
+ "must be strings"
113
+ )
114
+ headers.append((key.strip(), value))
115
+ bearer = item.get("bearer")
116
+ if bearer is not None:
117
+ if not isinstance(bearer, str) or not bearer.strip():
118
+ raise ConfigError(
119
+ f"{source}: endpoints[{index}] ({name}) bearer must be a string"
120
+ )
121
+ headers.append(("Authorization", f"Bearer {bearer.strip()}"))
122
+ return tuple(headers)
rpcbench/methods.py ADDED
@@ -0,0 +1,76 @@
1
+ """Read-only JSON-RPC methods used for latency probes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
9
+
10
+ # Presets: chain head, identity, and a cheap account read.
11
+ PRESETS: dict[str, tuple[str, list[Any]]] = {
12
+ "head": ("eth_blockNumber", []),
13
+ "chainId": ("eth_chainId", []),
14
+ "balance": ("eth_getBalance", [ZERO_ADDRESS, "latest"]),
15
+ }
16
+
17
+ _WRITE_PREFIXES = (
18
+ "eth_send",
19
+ "eth_sign",
20
+ "personal_",
21
+ "miner_",
22
+ "admin_",
23
+ "wallet_",
24
+ )
25
+
26
+
27
+ class MethodError(ValueError):
28
+ pass
29
+
30
+
31
+ def resolve_method(
32
+ *,
33
+ method: str | None,
34
+ preset: str | None,
35
+ params_json: str | None,
36
+ allow_writes: bool = False,
37
+ ) -> tuple[str, list[Any]]:
38
+ if preset and method:
39
+ raise MethodError("use either --preset or --method, not both")
40
+ if preset:
41
+ key = preset.strip().lower()
42
+ matched = next((name for name in PRESETS if name.lower() == key), None)
43
+ if matched is None:
44
+ known = ", ".join(sorted(PRESETS))
45
+ raise MethodError(f"unknown preset {preset!r} (try {known})")
46
+ name, params = PRESETS[matched]
47
+ if params_json:
48
+ params = parse_params(params_json)
49
+ if not allow_writes:
50
+ _reject_writes(name)
51
+ return name, params
52
+ name = (method or "eth_blockNumber").strip()
53
+ if not name:
54
+ raise MethodError("method is required")
55
+ if not allow_writes:
56
+ _reject_writes(name)
57
+ params = parse_params(params_json) if params_json else []
58
+ return name, params
59
+
60
+
61
+ def parse_params(raw: str) -> list[Any]:
62
+ try:
63
+ data = json.loads(raw)
64
+ except json.JSONDecodeError as exc:
65
+ raise MethodError(f"invalid --params JSON: {exc}") from exc
66
+ if not isinstance(data, list):
67
+ raise MethodError("--params must be a JSON array")
68
+ return data
69
+
70
+
71
+ def _reject_writes(method: str) -> None:
72
+ lower = method.lower()
73
+ if any(lower.startswith(p) for p in _WRITE_PREFIXES):
74
+ raise MethodError(
75
+ f"{method} is a write method; pass --allow-writes to run it anyway"
76
+ )