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.
@@ -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
@@ -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))