testoperations 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {testoperations-0.2.0/src/testoperations.egg-info → testoperations-0.3.0}/PKG-INFO +1 -1
- {testoperations-0.2.0 → testoperations-0.3.0}/pyproject.toml +1 -1
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/network_endpoint.py +1 -1
- testoperations-0.3.0/src/testoperations/throughput.py +188 -0
- {testoperations-0.2.0 → testoperations-0.3.0/src/testoperations.egg-info}/PKG-INFO +1 -1
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations.egg-info/SOURCES.txt +2 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_network_endpoint.py +0 -1
- {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_sip_phone.py +1 -1
- testoperations-0.3.0/tests/test_throughput.py +177 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/LICENSE +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/NOTICE +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/README.md +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/setup.cfg +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/__init__.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/device_management.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/dhcp_client.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/firewall.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/homing.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/http_server.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/iperf_client.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/iperf_generator.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/netem_controller.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/pcap_capture.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/py.typed +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/sdwan.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/segmentation.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/sip_phone.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/tr069_server.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations/wifi_client.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations.egg-info/dependency_links.txt +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations.egg-info/requires.txt +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations.egg-info/top_level.txt +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_device_management.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_dhcp_client.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_firewall.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_homing.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_http_server.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_iperf_client.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_iperf_generator.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_netem_controller.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_pcap_capture.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_sdwan.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_segmentation.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_tr069_server.py +0 -0
- {testoperations-0.2.0 → testoperations-0.3.0}/tests/test_wifi_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: testoperations
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Framework-agnostic, assertion-free composition functions over testprotocols capabilities.
|
|
5
5
|
Author-email: Alottabits <rjvisser@alottabits.com>
|
|
6
6
|
Maintainer-email: Alottabits <rjvisser@alottabits.com>
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Concurrent iperf3 throughput measurement over typed iperf capabilities.
|
|
2
|
+
|
|
3
|
+
Framing owned here (the reason this is a ``testoperations`` function and not a
|
|
4
|
+
step one-liner): start every receiver FIRST (one iperf3 server instance per
|
|
5
|
+
flow, each on its own port), snapshot each receiver log's completed-session
|
|
6
|
+
count, launch all senders together so the flows genuinely overlap, then poll
|
|
7
|
+
each receiver log until a NEW completed session appears and report its
|
|
8
|
+
measured rate. The RECEIVE side is read deliberately: the receiver sits behind
|
|
9
|
+
the device under test, so its rate is what actually crossed that device
|
|
10
|
+
(send-side rates include bytes a shaper may still be queueing or dropping).
|
|
11
|
+
|
|
12
|
+
Assertion-free and stdlib-only: returns facts (per-flow Mbit/s) and raises only
|
|
13
|
+
on operational failures (duplicate ports, a receiver that never produces a
|
|
14
|
+
completed session). Pass/fail thresholds belong to the caller.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import time
|
|
21
|
+
from collections.abc import Callable, Sequence
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from testprotocols.iperf_client import IperfClient
|
|
26
|
+
from testprotocols.iperf_server import IperfServer
|
|
27
|
+
|
|
28
|
+
# A finished iperf3 session is flushed to the --logfile when the sender
|
|
29
|
+
# disconnects; allow a grace window after the nominal duration for that flush
|
|
30
|
+
# (and for clock skew between controller and endpoints).
|
|
31
|
+
DEFAULT_RESULT_TIMEOUT_S = 30.0
|
|
32
|
+
_POLL_INTERVAL_S = 1.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ThroughputFlow:
|
|
37
|
+
"""One measured flow: *sender* pushes TCP to *dest_host*:*port*, *receiver* listens.
|
|
38
|
+
|
|
39
|
+
``port`` must be unique across the flows of one measurement call — each
|
|
40
|
+
flow gets its own iperf3 server instance (and per-port logfile) on the
|
|
41
|
+
receiving device.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
sender: IperfClient
|
|
45
|
+
receiver: IperfServer
|
|
46
|
+
dest_host: str
|
|
47
|
+
port: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class FlowThroughput:
|
|
52
|
+
"""The measured receive-side rate of one flow."""
|
|
53
|
+
|
|
54
|
+
port: int
|
|
55
|
+
mbps: float
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def iter_json_docs(text: str) -> list[Any]:
|
|
59
|
+
"""Parse the top-level JSON documents concatenated in *text*, in order.
|
|
60
|
+
|
|
61
|
+
iperf3 appends one pretty-printed JSON document per session to its
|
|
62
|
+
``--logfile``; a restarted server appends to the same per-port file, so a
|
|
63
|
+
log may hold several documents (and a trailing, still-open one while a
|
|
64
|
+
session is running). Documents are extracted with a string-aware brace
|
|
65
|
+
scanner; only complete, parseable documents are returned.
|
|
66
|
+
"""
|
|
67
|
+
docs: list[Any] = []
|
|
68
|
+
depth = 0
|
|
69
|
+
start = -1
|
|
70
|
+
in_string = False
|
|
71
|
+
escaped = False
|
|
72
|
+
for i, ch in enumerate(text):
|
|
73
|
+
if in_string:
|
|
74
|
+
if escaped:
|
|
75
|
+
escaped = False
|
|
76
|
+
elif ch == "\\":
|
|
77
|
+
escaped = True
|
|
78
|
+
elif ch == '"':
|
|
79
|
+
in_string = False
|
|
80
|
+
continue
|
|
81
|
+
if ch == '"':
|
|
82
|
+
in_string = True
|
|
83
|
+
elif ch == "{":
|
|
84
|
+
if depth == 0:
|
|
85
|
+
start = i
|
|
86
|
+
depth += 1
|
|
87
|
+
elif ch == "}" and depth > 0:
|
|
88
|
+
depth -= 1
|
|
89
|
+
if depth == 0 and start >= 0:
|
|
90
|
+
try:
|
|
91
|
+
docs.append(json.loads(text[start : i + 1]))
|
|
92
|
+
except ValueError:
|
|
93
|
+
pass
|
|
94
|
+
start = -1
|
|
95
|
+
return docs
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def count_sessions(log_text: str) -> int:
|
|
99
|
+
"""The number of completed iperf3 session documents in *log_text*."""
|
|
100
|
+
return len(iter_json_docs(log_text))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def last_session_mbps(log_text: str) -> float | None:
|
|
104
|
+
"""The receive-side rate (Mbit/s) of the LAST completed session, or ``None``.
|
|
105
|
+
|
|
106
|
+
Prefers ``end.sum_received.bits_per_second`` (TCP receive-side goodput);
|
|
107
|
+
falls back to ``end.sum.bits_per_second`` (UDP sessions). Returns ``None``
|
|
108
|
+
when no completed session carries either summary.
|
|
109
|
+
"""
|
|
110
|
+
docs = iter_json_docs(log_text)
|
|
111
|
+
if not docs:
|
|
112
|
+
return None
|
|
113
|
+
end = docs[-1].get("end", {}) if isinstance(docs[-1], dict) else {}
|
|
114
|
+
for key in ("sum_received", "sum"):
|
|
115
|
+
bps = end.get(key, {}).get("bits_per_second")
|
|
116
|
+
if bps is not None:
|
|
117
|
+
return float(bps) / 1e6
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def measure_concurrent_throughput(
|
|
122
|
+
flows: Sequence[ThroughputFlow],
|
|
123
|
+
*,
|
|
124
|
+
duration_s: int = 10,
|
|
125
|
+
result_timeout_s: float = DEFAULT_RESULT_TIMEOUT_S,
|
|
126
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
127
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
128
|
+
) -> list[FlowThroughput]:
|
|
129
|
+
"""Run all *flows* concurrently for *duration_s*; return per-flow receive rates.
|
|
130
|
+
|
|
131
|
+
Receivers are started before any sender (no connection race), every sender
|
|
132
|
+
is launched before the measurement wait (the flows overlap for the whole
|
|
133
|
+
duration), and both sides are stopped in a ``finally`` so no iperf process
|
|
134
|
+
outlives the call — also on failure. Results are ordered like *flows*.
|
|
135
|
+
|
|
136
|
+
Raises ``ValueError`` on duplicate ports (two flows would share a server
|
|
137
|
+
instance and logfile) and ``RuntimeError`` when a receiver produces no new
|
|
138
|
+
completed session within *duration_s* + *result_timeout_s*.
|
|
139
|
+
"""
|
|
140
|
+
ports = [f.port for f in flows]
|
|
141
|
+
if len(set(ports)) != len(ports):
|
|
142
|
+
raise ValueError(f"flow ports must be unique per measurement, got {ports}")
|
|
143
|
+
|
|
144
|
+
started: list[tuple[ThroughputFlow, int, str, int]] = []
|
|
145
|
+
sender_pids: list[tuple[IperfClient, int]] = []
|
|
146
|
+
try:
|
|
147
|
+
for flow in flows:
|
|
148
|
+
receiver_pid, receiver_log = flow.receiver.start_traffic_receiver(flow.port)
|
|
149
|
+
prior_sessions = count_sessions(flow.receiver.get_iperf_logs(receiver_log))
|
|
150
|
+
started.append((flow, receiver_pid, receiver_log, prior_sessions))
|
|
151
|
+
|
|
152
|
+
for flow, _, _, _ in started:
|
|
153
|
+
sender_pid, _ = flow.sender.start_traffic_sender(
|
|
154
|
+
flow.dest_host, flow.port, time=duration_s
|
|
155
|
+
)
|
|
156
|
+
sender_pids.append((flow.sender, sender_pid))
|
|
157
|
+
|
|
158
|
+
sleep(float(duration_s))
|
|
159
|
+
|
|
160
|
+
results: list[FlowThroughput] = []
|
|
161
|
+
deadline = monotonic() + result_timeout_s
|
|
162
|
+
for flow, _, receiver_log, prior_sessions in started:
|
|
163
|
+
while True:
|
|
164
|
+
log_text = flow.receiver.get_iperf_logs(receiver_log)
|
|
165
|
+
if count_sessions(log_text) > prior_sessions:
|
|
166
|
+
mbps = last_session_mbps(log_text)
|
|
167
|
+
if mbps is not None:
|
|
168
|
+
results.append(FlowThroughput(port=flow.port, mbps=mbps))
|
|
169
|
+
break
|
|
170
|
+
if monotonic() >= deadline:
|
|
171
|
+
raise RuntimeError(
|
|
172
|
+
f"iperf receiver on port {flow.port} produced no completed "
|
|
173
|
+
f"session within {result_timeout_s}s after the "
|
|
174
|
+
f"{duration_s}s measurement window"
|
|
175
|
+
)
|
|
176
|
+
sleep(_POLL_INTERVAL_S)
|
|
177
|
+
return results
|
|
178
|
+
finally:
|
|
179
|
+
for sender, sender_pid in sender_pids:
|
|
180
|
+
try:
|
|
181
|
+
sender.stop_traffic(sender_pid)
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
184
|
+
for flow, receiver_pid, _, _ in started:
|
|
185
|
+
try:
|
|
186
|
+
flow.receiver.stop_traffic(receiver_pid)
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: testoperations
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Framework-agnostic, assertion-free composition functions over testprotocols capabilities.
|
|
5
5
|
Author-email: Alottabits <rjvisser@alottabits.com>
|
|
6
6
|
Maintainer-email: Alottabits <rjvisser@alottabits.com>
|
|
@@ -17,6 +17,7 @@ src/testoperations/py.typed
|
|
|
17
17
|
src/testoperations/sdwan.py
|
|
18
18
|
src/testoperations/segmentation.py
|
|
19
19
|
src/testoperations/sip_phone.py
|
|
20
|
+
src/testoperations/throughput.py
|
|
20
21
|
src/testoperations/tr069_server.py
|
|
21
22
|
src/testoperations/wifi_client.py
|
|
22
23
|
src/testoperations.egg-info/PKG-INFO
|
|
@@ -37,5 +38,6 @@ tests/test_pcap_capture.py
|
|
|
37
38
|
tests/test_sdwan.py
|
|
38
39
|
tests/test_segmentation.py
|
|
39
40
|
tests/test_sip_phone.py
|
|
41
|
+
tests/test_throughput.py
|
|
40
42
|
tests/test_tr069_server.py
|
|
41
43
|
tests/test_wifi_client.py
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Tests for testoperations.throughput (concurrent iperf3 measurement framing)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from unittest.mock import MagicMock
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from testoperations.throughput import (
|
|
10
|
+
ThroughputFlow,
|
|
11
|
+
count_sessions,
|
|
12
|
+
iter_json_docs,
|
|
13
|
+
last_session_mbps,
|
|
14
|
+
measure_concurrent_throughput,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _session_doc(rx_bps: float | None = None, sum_bps: float | None = None) -> str:
|
|
19
|
+
end: dict[str, dict[str, float]] = {}
|
|
20
|
+
if rx_bps is not None:
|
|
21
|
+
end["sum_received"] = {"bits_per_second": rx_bps}
|
|
22
|
+
if sum_bps is not None:
|
|
23
|
+
end["sum"] = {"bits_per_second": sum_bps}
|
|
24
|
+
return json.dumps({"start": {"test_start": {}}, "end": end}, indent=2)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# --- JSON log parsing ---------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestLogParsing:
|
|
31
|
+
def test_iter_json_docs_multiple_documents(self) -> None:
|
|
32
|
+
text = _session_doc(rx_bps=1e6) + "\n" + _session_doc(rx_bps=2e6)
|
|
33
|
+
assert len(iter_json_docs(text)) == 2
|
|
34
|
+
|
|
35
|
+
def test_iter_json_docs_ignores_trailing_incomplete_document(self) -> None:
|
|
36
|
+
text = _session_doc(rx_bps=1e6) + '\n{"start": {"test_start"'
|
|
37
|
+
assert len(iter_json_docs(text)) == 1
|
|
38
|
+
|
|
39
|
+
def test_iter_json_docs_braces_inside_strings_do_not_confuse_scanner(self) -> None:
|
|
40
|
+
text = json.dumps({"note": "brace } in { string", "end": {}})
|
|
41
|
+
docs = iter_json_docs(text)
|
|
42
|
+
assert len(docs) == 1
|
|
43
|
+
assert docs[0]["note"] == "brace } in { string"
|
|
44
|
+
|
|
45
|
+
def test_count_sessions_empty_log(self) -> None:
|
|
46
|
+
assert count_sessions("") == 0
|
|
47
|
+
|
|
48
|
+
def test_last_session_mbps_prefers_receive_side_of_last_doc(self) -> None:
|
|
49
|
+
text = _session_doc(rx_bps=100e6) + "\n" + _session_doc(rx_bps=47.5e6)
|
|
50
|
+
assert last_session_mbps(text) == pytest.approx(47.5)
|
|
51
|
+
|
|
52
|
+
def test_last_session_mbps_falls_back_to_sum(self) -> None:
|
|
53
|
+
assert last_session_mbps(_session_doc(sum_bps=9e6)) == pytest.approx(9.0)
|
|
54
|
+
|
|
55
|
+
def test_last_session_mbps_none_when_no_summary(self) -> None:
|
|
56
|
+
assert last_session_mbps("") is None
|
|
57
|
+
assert last_session_mbps(json.dumps({"end": {}})) is None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --- measure_concurrent_throughput ---------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _flow(
|
|
64
|
+
port: int, mbps: float, *, stale_docs: int = 0
|
|
65
|
+
) -> tuple[ThroughputFlow, MagicMock, MagicMock]:
|
|
66
|
+
"""A flow over mocked capabilities; returns (flow, sender_mock, receiver_mock).
|
|
67
|
+
|
|
68
|
+
The fake ``get_iperf_logs`` returns only the *stale* documents until the
|
|
69
|
+
sender has been started, then appends the fresh session — mirroring the
|
|
70
|
+
real sequencing (session completes only after the measurement window).
|
|
71
|
+
"""
|
|
72
|
+
stale = "\n".join(_session_doc(rx_bps=1e6) for _ in range(stale_docs))
|
|
73
|
+
fresh = stale + "\n" + _session_doc(rx_bps=mbps * 1e6)
|
|
74
|
+
|
|
75
|
+
sender = MagicMock()
|
|
76
|
+
sender.start_traffic_sender.return_value = (4000 + port, f"/tmp/cl_{port}.log")
|
|
77
|
+
|
|
78
|
+
receiver = MagicMock()
|
|
79
|
+
receiver.start_traffic_receiver.return_value = (5000 + port, f"/tmp/rx_{port}.log")
|
|
80
|
+
receiver.get_iperf_logs.side_effect = lambda _log: (
|
|
81
|
+
fresh if sender.start_traffic_sender.called else stale
|
|
82
|
+
)
|
|
83
|
+
flow = ThroughputFlow(sender=sender, receiver=receiver, dest_host="192.168.32.3", port=port)
|
|
84
|
+
return flow, sender, receiver
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TestMeasureConcurrentThroughput:
|
|
88
|
+
def test_single_flow_returns_receive_rate(self) -> None:
|
|
89
|
+
flow, _, _ = _flow(5301, mbps=47.5)
|
|
90
|
+
results = measure_concurrent_throughput([flow], duration_s=10, sleep=lambda _s: None)
|
|
91
|
+
assert [r.port for r in results] == [5301]
|
|
92
|
+
assert results[0].mbps == pytest.approx(47.5)
|
|
93
|
+
|
|
94
|
+
def test_receiver_started_before_sender_with_expected_args(self) -> None:
|
|
95
|
+
flow, sender, receiver = _flow(5301, mbps=10.0)
|
|
96
|
+
order: list[str] = []
|
|
97
|
+
|
|
98
|
+
def _rx(port: int) -> tuple[int, str]:
|
|
99
|
+
order.append(f"rx:{port}")
|
|
100
|
+
return (5301, "/tmp/rx.log")
|
|
101
|
+
|
|
102
|
+
def _tx(host: str, port: int, time: int) -> tuple[int, str]:
|
|
103
|
+
order.append(f"tx:{host}:{port}:t={time}")
|
|
104
|
+
return (4001, "/tmp/tx.log")
|
|
105
|
+
|
|
106
|
+
receiver.start_traffic_receiver.side_effect = _rx
|
|
107
|
+
sender.start_traffic_sender.side_effect = _tx
|
|
108
|
+
measure_concurrent_throughput([flow], duration_s=7, sleep=lambda _s: None)
|
|
109
|
+
assert order == ["rx:5301", "tx:192.168.32.3:5301:t=7"]
|
|
110
|
+
|
|
111
|
+
def test_stale_sessions_in_log_are_not_read_as_results(self) -> None:
|
|
112
|
+
# Two stale docs at 1 Mbps sit in the per-port log from an earlier run;
|
|
113
|
+
# the fresh session measures 42 Mbps and must be the one reported.
|
|
114
|
+
flow, _, _ = _flow(5301, mbps=42.0, stale_docs=2)
|
|
115
|
+
results = measure_concurrent_throughput([flow], duration_s=10, sleep=lambda _s: None)
|
|
116
|
+
assert results[0].mbps == pytest.approx(42.0)
|
|
117
|
+
|
|
118
|
+
def test_concurrent_flows_all_senders_started_before_wait_and_ordered_results(self) -> None:
|
|
119
|
+
flow_a, sender_a, _ = _flow(5301, mbps=9.5)
|
|
120
|
+
flow_b, sender_b, _ = _flow(5302, mbps=11.0)
|
|
121
|
+
sleeps: list[float] = []
|
|
122
|
+
results = measure_concurrent_throughput(
|
|
123
|
+
[flow_a, flow_b], duration_s=10, sleep=sleeps.append
|
|
124
|
+
)
|
|
125
|
+
assert [r.port for r in results] == [5301, 5302]
|
|
126
|
+
assert [r.mbps for r in results] == [pytest.approx(9.5), pytest.approx(11.0)]
|
|
127
|
+
# Both senders launched; the measurement wait happened exactly once.
|
|
128
|
+
sender_a.start_traffic_sender.assert_called_once()
|
|
129
|
+
sender_b.start_traffic_sender.assert_called_once()
|
|
130
|
+
assert sleeps.count(10.0) == 1
|
|
131
|
+
|
|
132
|
+
def test_duplicate_ports_rejected(self) -> None:
|
|
133
|
+
flow_a, _, _ = _flow(5301, mbps=1.0)
|
|
134
|
+
flow_b, _, _ = _flow(5301, mbps=2.0)
|
|
135
|
+
with pytest.raises(ValueError, match="unique"):
|
|
136
|
+
measure_concurrent_throughput([flow_a, flow_b], sleep=lambda _s: None)
|
|
137
|
+
|
|
138
|
+
def test_timeout_when_no_new_session_appears(self) -> None:
|
|
139
|
+
flow, _, receiver = _flow(5301, mbps=42.0)
|
|
140
|
+
receiver.get_iperf_logs.side_effect = lambda _log: "" # never completes
|
|
141
|
+
clock = iter(float(t) for t in range(0, 1000, 5))
|
|
142
|
+
with pytest.raises(RuntimeError, match="port 5301"):
|
|
143
|
+
measure_concurrent_throughput(
|
|
144
|
+
[flow],
|
|
145
|
+
duration_s=10,
|
|
146
|
+
result_timeout_s=20.0,
|
|
147
|
+
sleep=lambda _s: None,
|
|
148
|
+
monotonic=lambda: next(clock),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def test_both_sides_stopped_on_success(self) -> None:
|
|
152
|
+
flow, sender, receiver = _flow(5301, mbps=5.0)
|
|
153
|
+
measure_concurrent_throughput([flow], duration_s=10, sleep=lambda _s: None)
|
|
154
|
+
sender.stop_traffic.assert_called_once_with(4000 + 5301)
|
|
155
|
+
receiver.stop_traffic.assert_called_once_with(5000 + 5301)
|
|
156
|
+
|
|
157
|
+
def test_cleanup_runs_even_when_measurement_times_out(self) -> None:
|
|
158
|
+
flow, sender, receiver = _flow(5301, mbps=5.0)
|
|
159
|
+
receiver.get_iperf_logs.side_effect = lambda _log: ""
|
|
160
|
+
clock = iter(float(t) for t in range(0, 1000, 5))
|
|
161
|
+
with pytest.raises(RuntimeError):
|
|
162
|
+
measure_concurrent_throughput(
|
|
163
|
+
[flow],
|
|
164
|
+
duration_s=10,
|
|
165
|
+
result_timeout_s=15.0,
|
|
166
|
+
sleep=lambda _s: None,
|
|
167
|
+
monotonic=lambda: next(clock),
|
|
168
|
+
)
|
|
169
|
+
sender.stop_traffic.assert_called_once()
|
|
170
|
+
receiver.stop_traffic.assert_called_once()
|
|
171
|
+
|
|
172
|
+
def test_cleanup_swallows_stop_errors(self) -> None:
|
|
173
|
+
flow, sender, receiver = _flow(5301, mbps=5.0)
|
|
174
|
+
sender.stop_traffic.side_effect = RuntimeError("already gone")
|
|
175
|
+
receiver.stop_traffic.side_effect = RuntimeError("already gone")
|
|
176
|
+
results = measure_concurrent_throughput([flow], duration_s=10, sleep=lambda _s: None)
|
|
177
|
+
assert results[0].mbps == pytest.approx(5.0)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{testoperations-0.2.0 → testoperations-0.3.0}/src/testoperations.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|