ipyrf 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.
ipyrf/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """ipyrf package: a tiny iperf3-like tool with JSON output."""
2
+
3
+ __all__ = [
4
+ "main",
5
+ ]
ipyrf/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
ipyrf/cli.py ADDED
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+ import argparse
5
+ import sys
6
+
7
+ from .logger import Logger
8
+ from .utils import parse_bandwidth, parse_ip, tcp_congestion_control_info
9
+ from . import tcp, udp
10
+ from .interactive import InteractiveController
11
+ from .controllers import StaticPacingController
12
+
13
+
14
+ def main():
15
+ p = argparse.ArgumentParser(description="Minimal iperf3-like tool (JSON output)")
16
+
17
+ common = argparse.ArgumentParser(add_help=False)
18
+ common.add_argument("--port", type=int, default=5201, help="Port number")
19
+ common.add_argument(
20
+ "--logfile", help="Write log messages to a file instead of stdout"
21
+ )
22
+ common.add_argument(
23
+ "--json_log",
24
+ help="Write the log messages as JSON.",
25
+ action="store_true",
26
+ default=False,
27
+ )
28
+ common.add_argument("--interval", type=float, default=1.0, help="Stats interval")
29
+
30
+ subp = p.add_subparsers(dest="protocol", required=True)
31
+
32
+ tcp_parser = subp.add_parser("tcp", help="TCP mode")
33
+ tcp_sub = tcp_parser.add_subparsers(dest="role", required=True)
34
+
35
+ congestion_control = tcp_congestion_control_info()
36
+ common_tcp = argparse.ArgumentParser(add_help=False)
37
+ common_tcp.add_argument(
38
+ "--congestion-control",
39
+ choices=congestion_control.get("allowed", []),
40
+ default=None,
41
+ help=(
42
+ argparse.SUPPRESS
43
+ if congestion_control == {}
44
+ else (
45
+ "TCP: set congestion control algorithm "
46
+ f"(default: system default '{congestion_control.get('current')}')"
47
+ )
48
+ ),
49
+ )
50
+
51
+ tcp_srv = tcp_sub.add_parser(
52
+ "server", parents=[common, common_tcp], help="Run a TCP server"
53
+ )
54
+ tcp_srv.add_argument(
55
+ "address", metavar="ADDRESS", type=parse_ip, help="Listen address"
56
+ )
57
+
58
+ tcp_cli = tcp_sub.add_parser(
59
+ "client", parents=[common, common_tcp], help="Run a TCP client"
60
+ )
61
+ tcp_cli.add_argument(
62
+ "--bandwidth", type=parse_bandwidth, help="Target bandwidth, e.g., 50M"
63
+ )
64
+ tcp_cli.add_argument("address", metavar="ADDRESS", help="Server address to connect")
65
+ tcp_cli.add_argument(
66
+ "--set-mss", dest="set_mss", type=int, help="TCP: set TCP_MAXSEG (approx MSS)"
67
+ )
68
+
69
+ # Time and interactive mode are mutually exclusive
70
+ tcp_time_group = tcp_cli.add_mutually_exclusive_group()
71
+ tcp_time_group.add_argument(
72
+ "--time", type=int, default=10, help="Test duration in seconds"
73
+ )
74
+ tcp_time_group.add_argument(
75
+ "--interactive", action="store_true", help="Run client in interactive mode"
76
+ )
77
+
78
+ udp_parser = subp.add_parser("udp", help="UDP mode")
79
+ udp_sub = udp_parser.add_subparsers(dest="role", required=True)
80
+
81
+ udp_srv = udp_sub.add_parser("server", parents=[common], help="Run a UDP server")
82
+ udp_srv.add_argument(
83
+ "address", metavar="ADDRESS", type=parse_ip, help="Listen address"
84
+ )
85
+
86
+ udp_cli = udp_sub.add_parser("client", parents=[common], help="Run a UDP client")
87
+ udp_cli.add_argument("address", metavar="ADDRESS", help="Server address to connect")
88
+ udp_cli.add_argument(
89
+ "--bandwidth", type=parse_bandwidth, help="Target bandwidth, e.g., 50M"
90
+ )
91
+ udp_cli.add_argument(
92
+ "-l", dest="length", type=int, default=1200, help="UDP payload length"
93
+ )
94
+
95
+ # Time and interactive mode are mutually exclusive
96
+ udp_time_group = udp_cli.add_mutually_exclusive_group()
97
+ udp_time_group.add_argument(
98
+ "--time", type=int, default=10, help="Test duration in seconds"
99
+ )
100
+ udp_time_group.add_argument(
101
+ "--interactive", action="store_true", help="Run client in interactive mode"
102
+ )
103
+
104
+ args = p.parse_args()
105
+
106
+ if args.role not in ("server", "client"):
107
+ raise ValueError(f"Invalid role: {args.role}. Must be 'server' or 'client'.")
108
+
109
+ log = Logger(args.json_log, args.protocol, args.role, args.logfile)
110
+
111
+ controller = None
112
+ if args.protocol == "udp":
113
+ if args.role == "server":
114
+ udp.server(log, args.address, args.port, args.interval)
115
+ else:
116
+ bw = (
117
+ args.bandwidth or parse_bandwidth("50M")
118
+ if args.interactive
119
+ else (args.bandwidth or 1e9)
120
+ )
121
+ if args.interactive:
122
+ controller = InteractiveController(bw, args.length, args.interval)
123
+ else:
124
+ controller = StaticPacingController(
125
+ bw, max(args.length, udp.UDP_HDR.size), args.time, args.interval
126
+ )
127
+ udp.client(
128
+ log,
129
+ args.address,
130
+ args.port,
131
+ args.length,
132
+ controller,
133
+ )
134
+
135
+ else:
136
+ if args.role == "server":
137
+ tcp.server(
138
+ log, args.address, args.port, args.interval, args.congestion_control
139
+ )
140
+ else:
141
+ if args.interactive:
142
+ quantum = args.set_mss if args.set_mss else 1200
143
+ # If no bandwidth provided, controller will act as unlimited until adjusted
144
+ controller = InteractiveController(
145
+ args.bandwidth, quantum, args.interval
146
+ )
147
+ else:
148
+ controller = StaticPacingController(
149
+ args.bandwidth,
150
+ args.set_mss if args.set_mss else 1200,
151
+ args.time,
152
+ args.interval,
153
+ )
154
+ tcp.client(
155
+ log,
156
+ args.address,
157
+ args.port,
158
+ args.congestion_control,
159
+ args.set_mss,
160
+ controller,
161
+ )
162
+
163
+ if controller is not None:
164
+ controller.stop()
ipyrf/controllers.py ADDED
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+ import time
3
+ from typing import Dict, Optional
4
+
5
+ from .token_bucket import TokenBucket
6
+
7
+
8
+ class BasePacingController:
9
+ def __init__(self, interval_seconds: float = 1.0):
10
+ self.interval_seconds = interval_seconds
11
+
12
+ def is_pacing(self) -> bool:
13
+ return False
14
+
15
+ def maybe_sleep(self, n_bytes: int):
16
+ return
17
+
18
+ def get_update_fields(self) -> Dict[str, float]:
19
+ return {}
20
+
21
+ def should_stop(self) -> bool:
22
+ return False
23
+
24
+ def stop_reason(self) -> str:
25
+ return "unknown"
26
+
27
+ def start(self):
28
+ pass
29
+
30
+ def stop(self):
31
+ pass
32
+
33
+
34
+ class StaticPacingController(BasePacingController):
35
+ def __init__(
36
+ self,
37
+ bandwidth_bps: Optional[float],
38
+ quantum_bytes: int,
39
+ duration_seconds: float,
40
+ interval_seconds: float,
41
+ ):
42
+ super().__init__(interval_seconds=interval_seconds)
43
+ self.bandwidth_bps = bandwidth_bps
44
+ self.duration_seconds = duration_seconds
45
+ self.start_time = None
46
+ self.tb: Optional[TokenBucket] = None
47
+ if bandwidth_bps is not None:
48
+ self.tb = TokenBucket(bandwidth_bps, quantum_bytes)
49
+
50
+ def is_pacing(self) -> bool:
51
+ return self.tb is not None
52
+
53
+ def maybe_sleep(self, n_bytes: int):
54
+ if self.tb is None:
55
+ return
56
+ while True:
57
+ sleep_time = self.tb.take(n_bytes)
58
+ if sleep_time <= 0:
59
+ break
60
+ time.sleep(sleep_time)
61
+
62
+ def get_update_fields(self) -> Dict[str, float]:
63
+ if self.bandwidth_bps is None:
64
+ return {}
65
+ return {"target_bandwidth_bps": float(self.bandwidth_bps)}
66
+
67
+ def start(self):
68
+ """Call this when the test starts to begin duration tracking."""
69
+ self.start_time = time.time()
70
+
71
+ def should_stop(self) -> bool:
72
+ """Check if the test should stop based on duration."""
73
+ assert self.start_time is not None
74
+ return (time.time() - self.start_time) >= self.duration_seconds
75
+
76
+ def stop_reason(self) -> str:
77
+ return "duration"
ipyrf/interactive.py ADDED
@@ -0,0 +1,250 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ import time
6
+ import threading
7
+ import termios
8
+ import fcntl
9
+ import select
10
+
11
+ from .utils import human_bps
12
+ from .token_bucket import TokenBucket
13
+ from .controllers import BasePacingController
14
+
15
+
16
+ class DynamicTokenBucket(TokenBucket):
17
+ """Token bucket that allows updating the target rate at runtime."""
18
+
19
+ def set_rate_bps(self, new_rate_bps: float, quantum_bytes: int):
20
+ # Convert bits/sec -> bytes/sec as used internally by TokenBucket
21
+ self.rate_bps = max(1e-6, new_rate_bps / 8.0)
22
+ self.capacity = max(quantum_bytes * 2, int(self.rate_bps))
23
+ self.tokens = min(self.tokens, self.capacity)
24
+
25
+
26
+ class KeyReader:
27
+ """Non-blocking key reader that recognizes arrow keys and a few chars.
28
+
29
+ Reads from /dev/tty (the controlling terminal), so stdout can be redirected.
30
+ Prevents terminal echo so arrow keys won't shift the cursor (no stray spaces).
31
+ """
32
+
33
+ def __init__(self):
34
+ self.tty_path = "/dev/tty"
35
+ self.fd = None
36
+ self.orig_attrs = None
37
+ self.orig_flags = None
38
+
39
+ def __enter__(self):
40
+ try:
41
+ self.fd = os.open(self.tty_path, os.O_RDONLY | os.O_NONBLOCK)
42
+ except OSError:
43
+ self.fd = sys.stdin.fileno()
44
+
45
+ self.orig_attrs = termios.tcgetattr(self.fd)
46
+ attrs = termios.tcgetattr(self.fd)
47
+ lflag = attrs[3]
48
+ attrs[3] = lflag & ~(termios.ECHO | termios.ICANON | termios.IEXTEN)
49
+ cc = list(attrs[6])
50
+ cc[termios.VMIN] = 0
51
+ cc[termios.VTIME] = 0
52
+ attrs[6] = cc
53
+ termios.tcsetattr(self.fd, termios.TCSADRAIN, attrs)
54
+
55
+ self.orig_flags = fcntl.fcntl(self.fd, fcntl.F_GETFL)
56
+ fcntl.fcntl(self.fd, fcntl.F_SETFL, self.orig_flags | os.O_NONBLOCK)
57
+ return self
58
+
59
+ def __exit__(self, exc_type, exc, tb):
60
+ try:
61
+ if self.orig_attrs is not None:
62
+ termios.tcsetattr(self.fd, termios.TCSADRAIN, self.orig_attrs)
63
+ if self.orig_flags is not None:
64
+ fcntl.fcntl(self.fd, fcntl.F_SETFL, self.orig_flags)
65
+ if self.fd not in (None, sys.stdin.fileno()):
66
+ os.close(self.fd)
67
+ except Exception:
68
+ pass
69
+
70
+ def read_keys(self, stop_event: threading.Event, on_key):
71
+ buf = bytearray()
72
+ allowed_chars = {"q", "Q", "0", "u", "U"}
73
+ while not stop_event.is_set():
74
+ try:
75
+ rlist, _, _ = select.select([self.fd], [], [], 0.05)
76
+ except Exception:
77
+ rlist = []
78
+ if not rlist:
79
+ continue
80
+ try:
81
+ chunk = os.read(self.fd, 16)
82
+ except BlockingIOError:
83
+ continue
84
+ except Exception:
85
+ break
86
+ if not chunk:
87
+ continue
88
+ buf.extend(chunk)
89
+ while True:
90
+ if not buf:
91
+ break
92
+ c = buf[0]
93
+ if c == 0x1B: # ESC
94
+ if len(buf) >= 3 and buf[1] == 0x5B:
95
+ code = buf[2]
96
+ if code in (0x41, 0x42, 0x43, 0x44): # A,B,C,D
97
+ del buf[:3]
98
+ mapping = {
99
+ 0x41: "UP",
100
+ 0x42: "DOWN",
101
+ 0x43: "RIGHT",
102
+ 0x44: "LEFT",
103
+ }
104
+ on_key(mapping[code])
105
+ continue
106
+ if len(buf) < 3:
107
+ break
108
+ del buf[0]
109
+ continue
110
+ else:
111
+ ch = chr(c)
112
+ del buf[0]
113
+ if ch in allowed_chars:
114
+ on_key(ch)
115
+ continue
116
+
117
+
118
+ class InteractiveController(BasePacingController):
119
+ def __init__(
120
+ self, initial_bps: float | None, quantum_bytes: int, interval: float = 1.0
121
+ ):
122
+ super().__init__(interval_seconds=interval)
123
+ self.lock = threading.Lock()
124
+ self.stop_event = threading.Event()
125
+ self.quantum = quantum_bytes
126
+ self.pacing = initial_bps is not None
127
+ self.target_bps = (
128
+ float(initial_bps) if initial_bps is not None else float("inf")
129
+ )
130
+ self.tb: DynamicTokenBucket | None = (
131
+ DynamicTokenBucket(self.target_bps, self.quantum) if self.pacing else None
132
+ )
133
+ self.keyloop_thread = start_interactive_keys(self, initial_bps)
134
+
135
+ def is_pacing(self) -> bool:
136
+ with self.lock:
137
+ return self.pacing and self.target_bps != float("inf")
138
+
139
+ def maybe_sleep(self, n_bytes: int):
140
+ tb = None
141
+ with self.lock:
142
+ tb = self.tb
143
+ if tb is None:
144
+ return
145
+ while True:
146
+ sleep_time = tb.take(n_bytes)
147
+ if sleep_time <= 0:
148
+ break
149
+ time.sleep(sleep_time)
150
+
151
+ def get_update_fields(self):
152
+ with self.lock:
153
+ if self.pacing and self.target_bps != float("inf"):
154
+ return {"target_bandwidth_bps": self.target_bps}
155
+ return {}
156
+
157
+ def should_stop(self) -> bool:
158
+ return self.stop_event.is_set()
159
+
160
+ def stop_reason(self) -> str:
161
+ return "user-stop"
162
+
163
+ def reset(self, initial_bps: float | None):
164
+ with self.lock:
165
+ if initial_bps is None:
166
+ self.target_bps = float("inf")
167
+ self.pacing = False
168
+ self.tb = None
169
+ else:
170
+ self.target_bps = float(initial_bps)
171
+ self.pacing = True
172
+ if self.tb is None:
173
+ self.tb = DynamicTokenBucket(self.target_bps, self.quantum)
174
+ else:
175
+ self.tb.set_rate_bps(self.target_bps, self.quantum)
176
+
177
+ def unlimited(self):
178
+ with self.lock:
179
+ self.pacing = False
180
+ self.target_bps = float("inf")
181
+ self.tb = None
182
+
183
+ def bump(self, delta_bps: float = 0.0, scale: float = 1.0):
184
+ with self.lock:
185
+ if not self.pacing or self.target_bps == float("inf"):
186
+ self.pacing = True
187
+ if not (self.target_bps != float("inf")):
188
+ self.target_bps = 50e6
189
+ if self.tb is None:
190
+ self.tb = DynamicTokenBucket(self.target_bps, self.quantum)
191
+ if scale != 1.0:
192
+ self.target_bps = max(1e3, self.target_bps * scale)
193
+ else:
194
+ self.target_bps = max(1e3, self.target_bps + delta_bps)
195
+ self.tb.set_rate_bps(self.target_bps, self.quantum)
196
+ return self.target_bps
197
+
198
+ def request_stop(self):
199
+ self.stop_event.set()
200
+
201
+ def stop(self):
202
+ if self.keyloop_thread is not None:
203
+ self.keyloop_thread.join()
204
+ self.keyloop_thread = None
205
+
206
+
207
+ def start_interactive_keys(
208
+ controller: InteractiveController, initial_bps: float | None
209
+ ):
210
+ def on_key(k: str):
211
+ if k == "RIGHT":
212
+ new = controller.bump(delta_bps=1e6)
213
+ print(f"[interactive] target = {human_bps(new)}", file=sys.stderr)
214
+ elif k == "LEFT":
215
+ new = controller.bump(delta_bps=-1e6)
216
+ print(f"[interactive] target = {human_bps(new)}", file=sys.stderr)
217
+ elif k == "UP":
218
+ new = controller.bump(scale=1.10)
219
+ print(f"[interactive] target = {human_bps(new)}", file=sys.stderr)
220
+ elif k == "DOWN":
221
+ new = controller.bump(scale=0.90)
222
+ print(f"[interactive] target = {human_bps(new)}", file=sys.stderr)
223
+ elif k == "0":
224
+ controller.reset(initial_bps)
225
+ print(
226
+ f"[interactive] reset -> {human_bps(initial_bps if initial_bps is not None else float('inf'))}",
227
+ file=sys.stderr,
228
+ )
229
+ elif k in ("u", "U"):
230
+ controller.unlimited()
231
+ print("[interactive] pacing: unlimited", file=sys.stderr)
232
+ elif k in ("q", "Q"):
233
+ controller.request_stop()
234
+
235
+ print(
236
+ "[interactive] Controls: ← -1 Mbps, -> +1 Mbps, ↓ -10%, ↑ +10%, 0 reset, u unlimited, q quit, Ctrl+C exit",
237
+ file=sys.stderr,
238
+ )
239
+ print(
240
+ f"[interactive] starting at {human_bps(initial_bps if initial_bps is not None else float('inf'))}",
241
+ file=sys.stderr,
242
+ )
243
+
244
+ def _keyloop():
245
+ with KeyReader() as kr:
246
+ kr.read_keys(controller.stop_event, on_key)
247
+
248
+ keyloop_thread = threading.Thread(target=_keyloop, daemon=True)
249
+ keyloop_thread.start()
250
+ return keyloop_thread
ipyrf/logger.py ADDED
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import sys
4
+
5
+ from .utils import human_readable_bytes
6
+
7
+
8
+ log_types = ["start", "test", "update", "summary"]
9
+ log_directions = ["tx", "rx"]
10
+ log_modes = ["tcp", "udp"]
11
+
12
+
13
+ class Logger:
14
+ def __init__(
15
+ self, json_log: bool, mode: str, role: str, logfile: str | None = None
16
+ ):
17
+ self.json_log = json_log
18
+ assert mode in log_modes
19
+ self.mode = mode
20
+ self.direction = "tx" if role == "client" else "rx"
21
+ assert self.direction in log_directions
22
+ self.test_start_time = None
23
+ self.logfile = logfile
24
+
25
+ def start(self, ip: str, port: int):
26
+ self._log(
27
+ log_type="start",
28
+ mode=self.mode,
29
+ direction=self.direction,
30
+ address=f"{ip}:{port}",
31
+ )
32
+
33
+ def test(self, peer_ip: str, peer_port: int, start_ts: float):
34
+ self._log(
35
+ log_type="test",
36
+ peer=f"{peer_ip}:{peer_port}",
37
+ ts=start_ts,
38
+ )
39
+ self.test_start_time = start_ts
40
+
41
+ def update(self, start_ts: float, end_ts: float, bytes: int, **obj):
42
+ if self.test_start_time is None:
43
+ raise RuntimeError("test() must be called before update()")
44
+ end_ts -= self.test_start_time
45
+ start_ts -= self.test_start_time
46
+ delta_t = end_ts - start_ts
47
+
48
+ bps = (bytes * 8.0) / delta_t if delta_t > 0 else 0.0
49
+ self._log(
50
+ log_type="update",
51
+ start=start_ts,
52
+ end=end_ts,
53
+ bytes=bytes,
54
+ bits_per_second=bps,
55
+ **obj,
56
+ )
57
+
58
+ def summary(self, **obj):
59
+ self._log(log_type="summary", **obj)
60
+
61
+ def write(self, message: str):
62
+ if self.logfile:
63
+ with open(self.logfile, "a", buffering=1) as f:
64
+ f.write(message + "\n")
65
+ else:
66
+ print(message)
67
+ sys.stdout.flush()
68
+
69
+ def _log(self, log_type, **obj):
70
+ if self.json_log:
71
+ obj["type"] = log_type
72
+ obj["mode"] = self.mode
73
+ obj["direction"] = self.direction
74
+ self.write(json.dumps(obj, separators=(",", ":")) + "\n")
75
+ return
76
+ assert log_type in log_types
77
+ if log_type == "start":
78
+ self.write(
79
+ f"▶ {self.mode.upper()} {self.direction.upper()} — {obj['address']}"
80
+ )
81
+ elif log_type == "test":
82
+ self.write(f"▶ TEST peer={obj['peer']} ts={obj['ts']}")
83
+ elif log_type == "update":
84
+ message = (
85
+ f"⏱ {obj['start']:.2f}-{obj['end']:.2f} sec"
86
+ f" | {human_readable_bytes(obj['bytes'])}"
87
+ )
88
+ if "target_bandwidth_bps" in obj:
89
+ message += f" | {obj['bits_per_second'] / 1e6:.2f}/{obj['target_bandwidth_bps'] / 1e6:.2f} Mbps"
90
+ else:
91
+ message += f" | {obj['bits_per_second'] / 1e6:.2f} Mbps"
92
+ if "lost_packets" in obj and "packets" in obj:
93
+ message += f" | {obj['lost_packets']}/{obj['packets']} lost ({obj['lost_percent']:.1f}%)"
94
+ elif "packets" in obj:
95
+ message += f" | {obj['packets']} pkts"
96
+ self.write(message)
97
+ elif log_type == "summary":
98
+ self.write(
99
+ "\n━ SUMMARY ━\n"
100
+ f" {self.mode.upper()} {self.direction.upper()}\n"
101
+ f" {obj.get('sender', '')} → {obj.get('receiver', '')}\n"
102
+ f" duration : {obj['seconds']:.2f} sec\n"
103
+ f" data : {human_readable_bytes(obj['bytes'])}\n"
104
+ f" rate : {obj['bits_per_second'] / 1e6:.2f} Mbps\n"
105
+ f" reason : {obj.get('stop_reason', '')}\n"
106
+ )