strobengine 0.1.0__cp311-abi3-win_amd64.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.
- strobengine/__init__.py +19 -0
- strobengine/_strobengine.pyd +0 -0
- strobengine/_strobengine.pyi +52 -0
- strobengine/cli.py +263 -0
- strobengine/engine.py +122 -0
- strobengine/py.typed +0 -0
- strobengine/reporter.py +123 -0
- strobengine-0.1.0.dist-info/METADATA +199 -0
- strobengine-0.1.0.dist-info/RECORD +13 -0
- strobengine-0.1.0.dist-info/WHEEL +4 -0
- strobengine-0.1.0.dist-info/entry_points.txt +2 -0
- strobengine-0.1.0.dist-info/licenses/LICENSE +201 -0
- strobengine-0.1.0.dist-info/sboms/strobengine.cyclonedx.json +7155 -0
strobengine/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from strobengine._strobengine import (
|
|
2
|
+
LoadProfile,
|
|
3
|
+
TestConfig,
|
|
4
|
+
TestSummary,
|
|
5
|
+
init_logging,
|
|
6
|
+
run_load_profiles,
|
|
7
|
+
run_load_test,
|
|
8
|
+
)
|
|
9
|
+
from strobengine.engine import StrobEngine
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"LoadProfile",
|
|
13
|
+
"StrobEngine",
|
|
14
|
+
"TestConfig",
|
|
15
|
+
"TestSummary",
|
|
16
|
+
"init_logging",
|
|
17
|
+
"run_load_profiles",
|
|
18
|
+
"run_load_test",
|
|
19
|
+
]
|
|
Binary file
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
class LoadProfile:
|
|
2
|
+
@staticmethod
|
|
3
|
+
def constant(concurrency: int = 10, duration_secs: int = 10) -> LoadProfile: ...
|
|
4
|
+
@staticmethod
|
|
5
|
+
def ramp(
|
|
6
|
+
start_concurrency: int,
|
|
7
|
+
target_concurrency: int,
|
|
8
|
+
ramp_secs: int,
|
|
9
|
+
hold_secs: int,
|
|
10
|
+
) -> LoadProfile: ...
|
|
11
|
+
@staticmethod
|
|
12
|
+
def spike(
|
|
13
|
+
baseline_concurrency: int,
|
|
14
|
+
peak_concurrency: int,
|
|
15
|
+
pre_spike_secs: int,
|
|
16
|
+
spike_secs: int,
|
|
17
|
+
post_spike_secs: int,
|
|
18
|
+
) -> LoadProfile: ...
|
|
19
|
+
def total_duration(self) -> int: ...
|
|
20
|
+
def max_concurrency(self) -> int: ...
|
|
21
|
+
def target_concurrency(self, elapsed: float) -> int: ...
|
|
22
|
+
|
|
23
|
+
class TestConfig:
|
|
24
|
+
url: str
|
|
25
|
+
concurrency: int
|
|
26
|
+
duration_secs: int
|
|
27
|
+
timeout_secs: int
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
url: str,
|
|
31
|
+
concurrency: int = 10,
|
|
32
|
+
duration_secs: int = 10,
|
|
33
|
+
timeout_secs: int = 10,
|
|
34
|
+
) -> None: ...
|
|
35
|
+
|
|
36
|
+
class TestSummary:
|
|
37
|
+
@property
|
|
38
|
+
def total_requests(self) -> int: ...
|
|
39
|
+
@property
|
|
40
|
+
def total_errors(self) -> int: ...
|
|
41
|
+
@property
|
|
42
|
+
def average_latency_ms(self) -> float: ...
|
|
43
|
+
@property
|
|
44
|
+
def p95_latency_ms(self) -> float: ...
|
|
45
|
+
@property
|
|
46
|
+
def p99_latency_ms(self) -> float: ...
|
|
47
|
+
|
|
48
|
+
def init_logging(level: str, log_file: str | None = None) -> None: ...
|
|
49
|
+
def run_load_test(config: TestConfig) -> TestSummary: ...
|
|
50
|
+
def run_load_profiles(
|
|
51
|
+
url: str, timeout_secs: int, profile: LoadProfile
|
|
52
|
+
) -> TestSummary: ...
|
strobengine/cli.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from strobengine._strobengine import init_logging
|
|
10
|
+
from strobengine.engine import StrobEngine
|
|
11
|
+
from strobengine.reporter import print_summary
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_version() -> str:
|
|
15
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
return version("strobengine")
|
|
19
|
+
except PackageNotFoundError:
|
|
20
|
+
return "0.0.0-dev"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _version_callback(value: bool) -> None:
|
|
24
|
+
if value:
|
|
25
|
+
typer.echo(f"strobengine {_get_version()}")
|
|
26
|
+
raise typer.Exit()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _resolve_log_level(verbose_count: int, quiet: bool) -> str:
|
|
30
|
+
if quiet:
|
|
31
|
+
return "off"
|
|
32
|
+
return {0: "warn", 1: "info", 2: "debug"}.get(verbose_count, "trace")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _configure_logging(level: str, log_file: str | None = None) -> None:
|
|
36
|
+
import logging
|
|
37
|
+
|
|
38
|
+
python_level = {
|
|
39
|
+
"off": logging.CRITICAL + 1,
|
|
40
|
+
"warn": logging.WARNING,
|
|
41
|
+
"info": logging.INFO,
|
|
42
|
+
"debug": logging.DEBUG,
|
|
43
|
+
"trace": logging.DEBUG,
|
|
44
|
+
}.get(level, logging.WARNING)
|
|
45
|
+
|
|
46
|
+
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
|
|
47
|
+
if log_file:
|
|
48
|
+
handlers.append(logging.FileHandler(log_file))
|
|
49
|
+
|
|
50
|
+
logging.basicConfig(
|
|
51
|
+
level=python_level,
|
|
52
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
53
|
+
handlers=handlers,
|
|
54
|
+
force=True,
|
|
55
|
+
)
|
|
56
|
+
init_logging(level, log_file)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
app = typer.Typer(
|
|
60
|
+
name="strobengine",
|
|
61
|
+
help="High-performance load testing engine powered by Rust.",
|
|
62
|
+
no_args_is_help=True,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
KNOWN_SUBCOMMANDS = {"load", "stress", "spike"}
|
|
66
|
+
HELP_FLAGS = {"-h", "--help"}
|
|
67
|
+
VERSION_FLAGS = {"-V", "--version"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.callback()
|
|
71
|
+
def _global_options(
|
|
72
|
+
verbose: Annotated[
|
|
73
|
+
int,
|
|
74
|
+
typer.Option(
|
|
75
|
+
"-v",
|
|
76
|
+
"--verbose",
|
|
77
|
+
count=True,
|
|
78
|
+
help="Increase verbosity (-v INFO, -vv DEBUG, -vvv TRACE)",
|
|
79
|
+
),
|
|
80
|
+
] = 0,
|
|
81
|
+
quiet: Annotated[
|
|
82
|
+
bool, typer.Option("-q", "--quiet", help="Suppress all output")
|
|
83
|
+
] = False,
|
|
84
|
+
log_file: Annotated[
|
|
85
|
+
str | None, typer.Option("--log-file", help="Write logs to file")
|
|
86
|
+
] = None,
|
|
87
|
+
version: Annotated[
|
|
88
|
+
bool,
|
|
89
|
+
typer.Option(
|
|
90
|
+
"-V",
|
|
91
|
+
"--version",
|
|
92
|
+
help="Show version and exit",
|
|
93
|
+
is_eager=True,
|
|
94
|
+
callback=_version_callback,
|
|
95
|
+
),
|
|
96
|
+
] = False,
|
|
97
|
+
) -> None:
|
|
98
|
+
level = _resolve_log_level(verbose, quiet)
|
|
99
|
+
_configure_logging(level, log_file)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _output_results(summary, url: str, duration_secs: int, json_output: bool) -> None:
|
|
103
|
+
if json_output:
|
|
104
|
+
print(
|
|
105
|
+
json.dumps(
|
|
106
|
+
{
|
|
107
|
+
"url": url,
|
|
108
|
+
"total_requests": summary.total_requests,
|
|
109
|
+
"total_errors": summary.total_errors,
|
|
110
|
+
"average_latency_ms": summary.average_latency_ms,
|
|
111
|
+
"p95_latency_ms": summary.p95_latency_ms,
|
|
112
|
+
"p99_latency_ms": summary.p99_latency_ms,
|
|
113
|
+
},
|
|
114
|
+
indent=2,
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
print_summary(summary, url=url, duration_secs=duration_secs)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command()
|
|
122
|
+
def load(
|
|
123
|
+
url: Annotated[str, typer.Argument(help="Target HTTP/HTTPS URL")],
|
|
124
|
+
concurrency: Annotated[
|
|
125
|
+
int,
|
|
126
|
+
typer.Option("-c", "--concurrency", min=1, help="Number of concurrent workers"),
|
|
127
|
+
] = 10,
|
|
128
|
+
duration: Annotated[
|
|
129
|
+
int,
|
|
130
|
+
typer.Option("-d", "--duration", min=1, help="Test duration in seconds"),
|
|
131
|
+
] = 10,
|
|
132
|
+
timeout: Annotated[
|
|
133
|
+
int,
|
|
134
|
+
typer.Option("-t", "--timeout", min=1, help="Request timeout in seconds"),
|
|
135
|
+
] = 10,
|
|
136
|
+
json_output: Annotated[
|
|
137
|
+
bool, typer.Option("--json", help="Output raw JSON results")
|
|
138
|
+
] = False,
|
|
139
|
+
) -> None:
|
|
140
|
+
engine = StrobEngine.load_test(
|
|
141
|
+
url=url, concurrency=concurrency, duration=duration, timeout=timeout
|
|
142
|
+
)
|
|
143
|
+
summary = engine.run()
|
|
144
|
+
_output_results(summary, url, duration, json_output)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.command()
|
|
148
|
+
def stress(
|
|
149
|
+
url: Annotated[str, typer.Argument(help="Target HTTP/HTTPS URL")],
|
|
150
|
+
start: Annotated[
|
|
151
|
+
int,
|
|
152
|
+
typer.Option("--from", help="Starting concurrency", min=1),
|
|
153
|
+
] = 10,
|
|
154
|
+
target: Annotated[
|
|
155
|
+
int,
|
|
156
|
+
typer.Option("--to", help="Target concurrency", min=1),
|
|
157
|
+
] = 200,
|
|
158
|
+
ramp: Annotated[
|
|
159
|
+
int,
|
|
160
|
+
typer.Option("--ramp", help="Ramp duration in seconds", min=1),
|
|
161
|
+
] = 60,
|
|
162
|
+
hold: Annotated[
|
|
163
|
+
int,
|
|
164
|
+
typer.Option("--hold", help="Hold duration in seconds", min=0),
|
|
165
|
+
] = 30,
|
|
166
|
+
timeout: Annotated[
|
|
167
|
+
int,
|
|
168
|
+
typer.Option("-t", "--timeout", help="Request timeout in seconds", min=1),
|
|
169
|
+
] = 10,
|
|
170
|
+
json_output: Annotated[
|
|
171
|
+
bool, typer.Option("--json", help="Output raw JSON results")
|
|
172
|
+
] = False,
|
|
173
|
+
) -> None:
|
|
174
|
+
engine = StrobEngine.stress_test(
|
|
175
|
+
url=url,
|
|
176
|
+
start_concurrency=start,
|
|
177
|
+
max_concurrency=target,
|
|
178
|
+
ramp_duration=ramp,
|
|
179
|
+
hold_duration=hold,
|
|
180
|
+
timeout=timeout,
|
|
181
|
+
)
|
|
182
|
+
summary = engine.run()
|
|
183
|
+
_output_results(summary, url, ramp + hold, json_output)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@app.command()
|
|
187
|
+
def spike(
|
|
188
|
+
url: Annotated[str, typer.Argument(help="Target HTTP/HTTPS URL")],
|
|
189
|
+
baseline: Annotated[
|
|
190
|
+
int,
|
|
191
|
+
typer.Option("--baseline", help="Baseline concurrency", min=1),
|
|
192
|
+
] = 5,
|
|
193
|
+
peak: Annotated[
|
|
194
|
+
int,
|
|
195
|
+
typer.Option("--peak", help="Peak concurrency", min=1),
|
|
196
|
+
] = 500,
|
|
197
|
+
pre_spike: Annotated[
|
|
198
|
+
int,
|
|
199
|
+
typer.Option("--pre-spike", help="Pre-spike duration in seconds", min=0),
|
|
200
|
+
] = 5,
|
|
201
|
+
spike_duration: Annotated[
|
|
202
|
+
int,
|
|
203
|
+
typer.Option("--spike-duration", help="Spike duration in seconds", min=1),
|
|
204
|
+
] = 10,
|
|
205
|
+
post_spike: Annotated[
|
|
206
|
+
int,
|
|
207
|
+
typer.Option("--post-spike", help="Post-spike duration in seconds", min=0),
|
|
208
|
+
] = 5,
|
|
209
|
+
timeout: Annotated[
|
|
210
|
+
int,
|
|
211
|
+
typer.Option("-t", "--timeout", help="Request timeout in seconds", min=1),
|
|
212
|
+
] = 10,
|
|
213
|
+
json_output: Annotated[
|
|
214
|
+
bool, typer.Option("--json", help="Output raw JSON results")
|
|
215
|
+
] = False,
|
|
216
|
+
) -> None:
|
|
217
|
+
engine = StrobEngine.spike_test(
|
|
218
|
+
url=url,
|
|
219
|
+
baseline=baseline,
|
|
220
|
+
peak_concurrency=peak,
|
|
221
|
+
pre_spike_duration=pre_spike,
|
|
222
|
+
spike_duration=spike_duration,
|
|
223
|
+
post_spike_duration=post_spike,
|
|
224
|
+
timeout=timeout,
|
|
225
|
+
)
|
|
226
|
+
summary = engine.run()
|
|
227
|
+
_output_results(summary, url, pre_spike + spike_duration + post_spike, json_output)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _first_positional(argv: list[str]) -> str | None:
|
|
231
|
+
skip_next = False
|
|
232
|
+
for arg in argv:
|
|
233
|
+
if skip_next:
|
|
234
|
+
skip_next = False
|
|
235
|
+
continue
|
|
236
|
+
if arg.startswith("-"):
|
|
237
|
+
if arg in ("--log-file",):
|
|
238
|
+
skip_next = True
|
|
239
|
+
continue
|
|
240
|
+
return arg
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def main(argv: list[str] | None = None) -> None:
|
|
245
|
+
if argv is None:
|
|
246
|
+
argv = sys.argv[1:]
|
|
247
|
+
|
|
248
|
+
if argv and set(argv) & (HELP_FLAGS | VERSION_FLAGS):
|
|
249
|
+
app(args=argv)
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
first = _first_positional(argv)
|
|
253
|
+
if first is not None and first not in KNOWN_SUBCOMMANDS:
|
|
254
|
+
argv = ["load", *argv]
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
app(args=argv)
|
|
258
|
+
except SystemExit as e:
|
|
259
|
+
raise e
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
if __name__ == "__main__":
|
|
263
|
+
main()
|
strobengine/engine.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from strobengine._strobengine import (
|
|
4
|
+
LoadProfile,
|
|
5
|
+
TestConfig,
|
|
6
|
+
TestSummary,
|
|
7
|
+
run_load_profiles,
|
|
8
|
+
run_load_test,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StrobEngine:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
url: str,
|
|
16
|
+
concurrency: int = 10,
|
|
17
|
+
duration: int = 10,
|
|
18
|
+
timeout: int = 10,
|
|
19
|
+
profile: LoadProfile | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
self._url = url
|
|
22
|
+
self._timeout = timeout
|
|
23
|
+
|
|
24
|
+
if profile is None:
|
|
25
|
+
if concurrency <= 0:
|
|
26
|
+
raise ValueError("Concurrency must be greater than 0")
|
|
27
|
+
if duration <= 0:
|
|
28
|
+
raise ValueError("Duration must be greater than 0")
|
|
29
|
+
if timeout <= 0:
|
|
30
|
+
raise ValueError("Timeout must be greater than 0")
|
|
31
|
+
|
|
32
|
+
self.config = TestConfig(
|
|
33
|
+
url=url,
|
|
34
|
+
concurrency=concurrency,
|
|
35
|
+
duration_secs=duration,
|
|
36
|
+
timeout_secs=timeout,
|
|
37
|
+
)
|
|
38
|
+
self._profile = None
|
|
39
|
+
else:
|
|
40
|
+
if timeout <= 0:
|
|
41
|
+
raise ValueError("Timeout must be greater than 0")
|
|
42
|
+
|
|
43
|
+
self.config = None
|
|
44
|
+
self._profile = profile
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def load_test(
|
|
48
|
+
cls,
|
|
49
|
+
url: str,
|
|
50
|
+
concurrency: int = 10,
|
|
51
|
+
duration: int = 10,
|
|
52
|
+
timeout: int = 10,
|
|
53
|
+
) -> "StrobEngine":
|
|
54
|
+
return cls(url=url, concurrency=concurrency, duration=duration, timeout=timeout)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def stress_test(
|
|
58
|
+
cls,
|
|
59
|
+
url: str,
|
|
60
|
+
start_concurrency: int = 10,
|
|
61
|
+
max_concurrency: int = 200,
|
|
62
|
+
ramp_duration: int = 60,
|
|
63
|
+
hold_duration: int = 30,
|
|
64
|
+
timeout: int = 10,
|
|
65
|
+
) -> "StrobEngine":
|
|
66
|
+
if start_concurrency <= 0:
|
|
67
|
+
raise ValueError("start_concurrency must be greater than 0")
|
|
68
|
+
if max_concurrency <= 0:
|
|
69
|
+
raise ValueError("max_concurrency must be greater than 0")
|
|
70
|
+
if start_concurrency > max_concurrency:
|
|
71
|
+
raise ValueError("start_concurrency must be <= max_concurrency")
|
|
72
|
+
if ramp_duration < 0:
|
|
73
|
+
raise ValueError("ramp_duration must be >= 0")
|
|
74
|
+
if hold_duration < 0:
|
|
75
|
+
raise ValueError("hold_duration must be >= 0")
|
|
76
|
+
|
|
77
|
+
profile = LoadProfile.ramp(
|
|
78
|
+
start_concurrency=start_concurrency,
|
|
79
|
+
target_concurrency=max_concurrency,
|
|
80
|
+
ramp_secs=ramp_duration,
|
|
81
|
+
hold_secs=hold_duration,
|
|
82
|
+
)
|
|
83
|
+
return cls(url=url, timeout=timeout, profile=profile)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def spike_test(
|
|
87
|
+
cls,
|
|
88
|
+
url: str,
|
|
89
|
+
baseline: int = 5,
|
|
90
|
+
peak_concurrency: int = 500,
|
|
91
|
+
pre_spike_duration: int = 5,
|
|
92
|
+
spike_duration: int = 10,
|
|
93
|
+
post_spike_duration: int = 5,
|
|
94
|
+
timeout: int = 10,
|
|
95
|
+
) -> "StrobEngine":
|
|
96
|
+
if baseline <= 0:
|
|
97
|
+
raise ValueError("baseline must be greater than 0")
|
|
98
|
+
if peak_concurrency <= 0:
|
|
99
|
+
raise ValueError("peak_concurrency must be greater than 0")
|
|
100
|
+
if pre_spike_duration < 0:
|
|
101
|
+
raise ValueError("pre_spike_duration must be >= 0")
|
|
102
|
+
if spike_duration < 0:
|
|
103
|
+
raise ValueError("spike_duration must be >= 0")
|
|
104
|
+
if post_spike_duration < 0:
|
|
105
|
+
raise ValueError("post_spike_duration must be >= 0")
|
|
106
|
+
|
|
107
|
+
profile = LoadProfile.spike(
|
|
108
|
+
baseline_concurrency=baseline,
|
|
109
|
+
peak_concurrency=peak_concurrency,
|
|
110
|
+
pre_spike_secs=pre_spike_duration,
|
|
111
|
+
spike_secs=spike_duration,
|
|
112
|
+
post_spike_secs=post_spike_duration,
|
|
113
|
+
)
|
|
114
|
+
return cls(url=url, timeout=timeout, profile=profile)
|
|
115
|
+
|
|
116
|
+
def run(self) -> TestSummary:
|
|
117
|
+
if self._profile is not None:
|
|
118
|
+
return run_load_profiles(self._url, self._timeout, self._profile)
|
|
119
|
+
return run_load_test(self.config)
|
|
120
|
+
|
|
121
|
+
async def run_async(self) -> TestSummary:
|
|
122
|
+
return await asyncio.to_thread(self.run)
|
strobengine/py.typed
ADDED
|
File without changes
|
strobengine/reporter.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from strobengine._strobengine import TestSummary
|
|
7
|
+
|
|
8
|
+
_HAS_RICH = False
|
|
9
|
+
try:
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
_HAS_RICH = True
|
|
14
|
+
except ImportError:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def print_summary(
|
|
19
|
+
summary: TestSummary,
|
|
20
|
+
url: str,
|
|
21
|
+
duration_secs: int | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
if _HAS_RICH:
|
|
24
|
+
_print_rich(summary, url, duration_secs)
|
|
25
|
+
else:
|
|
26
|
+
_print_plain(summary, url, duration_secs)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _format_number(n: int) -> str:
|
|
30
|
+
return f"{n:,}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _error_rate(total: int, errors: int) -> str:
|
|
34
|
+
if total == 0:
|
|
35
|
+
return "0.00%"
|
|
36
|
+
return f"{errors / total * 100:.2f}%"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _print_rich(
|
|
40
|
+
summary: TestSummary,
|
|
41
|
+
url: str,
|
|
42
|
+
duration_secs: int | None,
|
|
43
|
+
) -> None:
|
|
44
|
+
console = Console()
|
|
45
|
+
|
|
46
|
+
table = Table(title="Load Test Results", show_lines=True, padding=(0, 1))
|
|
47
|
+
table.add_column("Metric", style="bold cyan", no_wrap=True)
|
|
48
|
+
table.add_column("Value", justify="right")
|
|
49
|
+
|
|
50
|
+
table.add_row("Target URL", url)
|
|
51
|
+
table.add_row("Total Requests", _format_number(summary.total_requests))
|
|
52
|
+
|
|
53
|
+
if summary.total_errors > 0:
|
|
54
|
+
rate = _error_rate(summary.total_requests, summary.total_errors)
|
|
55
|
+
table.add_row(
|
|
56
|
+
"Errors",
|
|
57
|
+
f"[bold red]{_format_number(summary.total_errors)} ({rate})[/]",
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
table.add_row(
|
|
61
|
+
"Errors",
|
|
62
|
+
f"[green]{_format_number(summary.total_errors)} (0.00%)[/]",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if duration_secs is not None and duration_secs > 0:
|
|
66
|
+
rps = summary.total_requests / duration_secs
|
|
67
|
+
table.add_row("Requests/sec", f"{rps:.1f}")
|
|
68
|
+
|
|
69
|
+
table.add_row("Avg Latency", f"{summary.average_latency_ms:.2f} ms")
|
|
70
|
+
table.add_row("P95 Latency", f"{summary.p95_latency_ms:.2f} ms")
|
|
71
|
+
table.add_row("P99 Latency", f"{summary.p99_latency_ms:.2f} ms")
|
|
72
|
+
|
|
73
|
+
console.print()
|
|
74
|
+
console.print(table)
|
|
75
|
+
console.print()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _print_plain(
|
|
79
|
+
summary: TestSummary,
|
|
80
|
+
url: str,
|
|
81
|
+
duration_secs: int | None,
|
|
82
|
+
) -> None:
|
|
83
|
+
use_color = (
|
|
84
|
+
not os.environ.get("NO_COLOR")
|
|
85
|
+
and hasattr(sys.stdout, "isatty")
|
|
86
|
+
and sys.stdout.isatty()
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
RED = "\033[91m" if use_color else ""
|
|
90
|
+
GREEN = "\033[92m" if use_color else ""
|
|
91
|
+
BOLD = "\033[1m" if use_color else ""
|
|
92
|
+
RESET = "\033[0m" if use_color else ""
|
|
93
|
+
|
|
94
|
+
width = 44
|
|
95
|
+
sep = "=" * width
|
|
96
|
+
|
|
97
|
+
lines = [
|
|
98
|
+
f"{BOLD}{'Load Test Results':^{width}}{RESET}",
|
|
99
|
+
sep,
|
|
100
|
+
f" Target URL: {url}",
|
|
101
|
+
f" Total Requests: {_format_number(summary.total_requests)}",
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
if summary.total_errors > 0:
|
|
105
|
+
rate = _error_rate(summary.total_requests, summary.total_errors)
|
|
106
|
+
lines.append(
|
|
107
|
+
f" Errors: {RED}{_format_number(summary.total_errors)} ({rate}){RESET}"
|
|
108
|
+
)
|
|
109
|
+
else:
|
|
110
|
+
lines.append(
|
|
111
|
+
f" Errors: {GREEN}{_format_number(summary.total_errors)} (0.00%){RESET}"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if duration_secs is not None and duration_secs > 0:
|
|
115
|
+
rps = summary.total_requests / duration_secs
|
|
116
|
+
lines.append(f" Requests/sec: {rps:.1f}")
|
|
117
|
+
|
|
118
|
+
lines.append(f" Avg Latency: {summary.average_latency_ms:.2f} ms")
|
|
119
|
+
lines.append(f" P95 Latency: {summary.p95_latency_ms:.2f} ms")
|
|
120
|
+
lines.append(f" P99 Latency: {summary.p99_latency_ms:.2f} ms")
|
|
121
|
+
lines.append(sep)
|
|
122
|
+
|
|
123
|
+
print("\n".join(lines))
|