testinghq 1.0.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.
- testinghq/__init__.py +4 -0
- testinghq/barrage/__init__.py +18 -0
- testinghq/barrage/executor.py +134 -0
- testinghq/barrage/fire.py +549 -0
- testinghq/barrage/payloads.py +49 -0
- testinghq/barrage/report.py +650 -0
- testinghq/barrage/runner.py +588 -0
- testinghq/blast/__init__.py +1 -0
- testinghq/blast/attachments.py +235 -0
- testinghq/blast/catalog.py +418 -0
- testinghq/blast/corpus.py +204 -0
- testinghq/blast/corrupt.py +678 -0
- testinghq/blast/generate.py +313 -0
- testinghq/blast/payload.py +222 -0
- testinghq/blast/serialize.py +124 -0
- testinghq/cli/__init__.py +32 -0
- testinghq/cli/__main__.py +15 -0
- testinghq/cli/barrage.py +293 -0
- testinghq/cli/blast.py +516 -0
- testinghq/cli/common.py +275 -0
- testinghq/cli/main.py +98 -0
- testinghq/cli/pipeline.py +715 -0
- testinghq/cli/sigcheck.py +115 -0
- testinghq/cli/utility.py +145 -0
- testinghq/compare/__init__.py +32 -0
- testinghq/compare/load.py +356 -0
- testinghq/compare/runs.py +503 -0
- testinghq/config_doc.py +392 -0
- testinghq/core/__init__.py +1 -0
- testinghq/core/config.py +354 -0
- testinghq/core/config_schema.py +682 -0
- testinghq/core/exit_codes.py +90 -0
- testinghq/core/formats.py +500 -0
- testinghq/core/guardrails.py +336 -0
- testinghq/core/ratelimit.py +136 -0
- testinghq/core/report.py +533 -0
- testinghq/core/targets.py +109 -0
- testinghq/core/transport.py +388 -0
- testinghq/pipeline/__init__.py +166 -0
- testinghq/pipeline/adapters.py +1607 -0
- testinghq/pipeline/common.py +903 -0
- testinghq/pipeline/expectations.py +949 -0
- testinghq/pipeline/fixtures/steady_intents.json +264 -0
- testinghq/pipeline/ledger.py +844 -0
- testinghq/pipeline/loop.py +1106 -0
- testinghq/pipeline/messages.py +298 -0
- testinghq/pipeline/readback.py +523 -0
- testinghq/pipeline/redeliver.py +1228 -0
- testinghq/pipeline/sigcheck.py +999 -0
- testinghq/pipeline/steady.py +1257 -0
- testinghq/pipeline/toolconfig.py +86 -0
- testinghq/pipeline/verify.py +1063 -0
- testinghq/report_html/__init__.py +521 -0
- testinghq/report_html/render.js +178 -0
- testinghq/report_html/template.html +129 -0
- testinghq/reporting.py +1088 -0
- testinghq-1.0.0.dist-info/METADATA +266 -0
- testinghq-1.0.0.dist-info/RECORD +62 -0
- testinghq-1.0.0.dist-info/WHEEL +5 -0
- testinghq-1.0.0.dist-info/entry_points.txt +2 -0
- testinghq-1.0.0.dist-info/licenses/LICENSE +21 -0
- testinghq-1.0.0.dist-info/top_level.txt +1 -0
testinghq/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Barrage: a load generator for TestingHQ.
|
|
2
|
+
|
|
3
|
+
Barrage fires provider-shaped payloads at an endpoint the operator controls,
|
|
4
|
+
at a high but controlled rate, and reports throughput, latency distribution,
|
|
5
|
+
and error behaviour under sustained load. It is a load tester against your
|
|
6
|
+
own infrastructure.
|
|
7
|
+
|
|
8
|
+
Barrage is explicitly NOT an email sender, NOT a flooding tool, and NOT for
|
|
9
|
+
endpoints you do not own. The rate ceiling, the configured-target-only rule,
|
|
10
|
+
and the dry-run default are what keep it a load tester rather than a
|
|
11
|
+
weapon. They are non-negotiable: nothing in this package weakens them.
|
|
12
|
+
|
|
13
|
+
Where Blast (testinghq/blast) proves the parser is correct under messy
|
|
14
|
+
input, Barrage proves the pipeline holds under load. Same suite, same
|
|
15
|
+
installable, same core: Barrage reuses testinghq.core.ratelimit,
|
|
16
|
+
testinghq.core.guardrails, and testinghq.core.transport rather than
|
|
17
|
+
reimplementing any of them.
|
|
18
|
+
"""
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Dispatch executors for Barrage.
|
|
2
|
+
|
|
3
|
+
Two strategies behind one small interface, so `runner.py` stays the only thing
|
|
4
|
+
that knows about pacing:
|
|
5
|
+
|
|
6
|
+
- `SerialExecutor` runs each task inline on the calling thread. It is the
|
|
7
|
+
correct implementation for `concurrency == 1` and what hermetic tests with a
|
|
8
|
+
fake clock get, because one worker has nothing to overlap.
|
|
9
|
+
- `PoolExecutor` is a fixed set of daemon worker threads fed from a queue.
|
|
10
|
+
|
|
11
|
+
Threads rather than asyncio because the transport is a blocking `http.client`
|
|
12
|
+
call and the project takes no runtime dependencies. One thread per in-flight
|
|
13
|
+
request is why `concurrency` is capped (see `runner.check_rate_ceiling`).
|
|
14
|
+
|
|
15
|
+
Interface: `submit(fn) -> Future`, `shutdown(cancel_pending=...)`, and
|
|
16
|
+
`max_in_flight`. The runner waits on the futures itself, with a deadline, so
|
|
17
|
+
an executor never decides how long a run lasts.
|
|
18
|
+
|
|
19
|
+
The pool's threads are daemon threads on purpose. A request the run has
|
|
20
|
+
abandoned at its deadline may still be blocked in a socket read, bounded only
|
|
21
|
+
by the transport timeout; a non-daemon thread would hold the interpreter open
|
|
22
|
+
until it returned.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import queue
|
|
27
|
+
import threading
|
|
28
|
+
from concurrent.futures import Future
|
|
29
|
+
from typing import Callable, Set
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Completed(Future):
|
|
33
|
+
"""A Future that is already resolved, for inline execution."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, value=None, exception=None):
|
|
36
|
+
super().__init__()
|
|
37
|
+
if exception is not None:
|
|
38
|
+
self.set_exception(exception)
|
|
39
|
+
else:
|
|
40
|
+
self.set_result(value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SerialExecutor:
|
|
44
|
+
"""Runs each task inline, immediately, on the calling thread."""
|
|
45
|
+
|
|
46
|
+
max_in_flight = 1
|
|
47
|
+
|
|
48
|
+
def submit(self, fn: Callable[[], object]) -> Future:
|
|
49
|
+
# Exception, not BaseException: a KeyboardInterrupt must stop the run
|
|
50
|
+
# where it happened instead of being parked on a Future.
|
|
51
|
+
try:
|
|
52
|
+
value = fn()
|
|
53
|
+
except Exception as exc: # noqa: BLE001 - surfaced by the runner
|
|
54
|
+
return _Completed(exception=exc)
|
|
55
|
+
return _Completed(value=value)
|
|
56
|
+
|
|
57
|
+
def shutdown(self, cancel_pending: bool = True) -> None:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PoolExecutor:
|
|
62
|
+
"""A fixed pool of daemon worker threads.
|
|
63
|
+
|
|
64
|
+
`max_workers` is the number of tasks running at once. Work submitted while
|
|
65
|
+
every worker is busy waits in the queue; the task itself decides what to do
|
|
66
|
+
about having waited (Barrage drops a request that waited too long).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, max_workers: int, thread_name_prefix: str = "barrage") -> None:
|
|
70
|
+
if max_workers < 1:
|
|
71
|
+
raise ValueError(f"max_workers must be >= 1, got {max_workers!r}")
|
|
72
|
+
self.max_in_flight = max_workers
|
|
73
|
+
self._queue: "queue.SimpleQueue" = queue.SimpleQueue()
|
|
74
|
+
self._pending: Set[Future] = set()
|
|
75
|
+
self._lock = threading.Lock()
|
|
76
|
+
self._closed = False
|
|
77
|
+
self._threads = [
|
|
78
|
+
threading.Thread(
|
|
79
|
+
target=self._work, name=f"{thread_name_prefix}-{i}", daemon=True
|
|
80
|
+
)
|
|
81
|
+
for i in range(max_workers)
|
|
82
|
+
]
|
|
83
|
+
for thread in self._threads:
|
|
84
|
+
thread.start()
|
|
85
|
+
|
|
86
|
+
def _work(self) -> None:
|
|
87
|
+
while True:
|
|
88
|
+
item = self._queue.get()
|
|
89
|
+
if item is None:
|
|
90
|
+
return
|
|
91
|
+
future, fn = item
|
|
92
|
+
with self._lock:
|
|
93
|
+
self._pending.discard(future)
|
|
94
|
+
if not future.set_running_or_notify_cancel():
|
|
95
|
+
continue
|
|
96
|
+
try:
|
|
97
|
+
result = fn()
|
|
98
|
+
except BaseException as exc: # noqa: BLE001 - delivered via the Future
|
|
99
|
+
future.set_exception(exc)
|
|
100
|
+
else:
|
|
101
|
+
future.set_result(result)
|
|
102
|
+
|
|
103
|
+
def submit(self, fn: Callable[[], object]) -> Future:
|
|
104
|
+
future: Future = Future()
|
|
105
|
+
with self._lock:
|
|
106
|
+
if self._closed:
|
|
107
|
+
raise RuntimeError("cannot submit to an executor that was shut down")
|
|
108
|
+
self._pending.add(future)
|
|
109
|
+
self._queue.put((future, fn))
|
|
110
|
+
return future
|
|
111
|
+
|
|
112
|
+
def shutdown(self, cancel_pending: bool = True) -> None:
|
|
113
|
+
"""Stop accepting work and let the workers exit once idle.
|
|
114
|
+
|
|
115
|
+
With `cancel_pending`, work still in the queue is cancelled and never
|
|
116
|
+
runs. A task already running finishes on its own thread; it is not
|
|
117
|
+
waited for, because a request on the wire cannot be recalled and the
|
|
118
|
+
run has already decided what to call it.
|
|
119
|
+
"""
|
|
120
|
+
with self._lock:
|
|
121
|
+
self._closed = True
|
|
122
|
+
pending, self._pending = self._pending, set()
|
|
123
|
+
if cancel_pending:
|
|
124
|
+
for future in pending:
|
|
125
|
+
future.cancel()
|
|
126
|
+
for _ in self._threads:
|
|
127
|
+
self._queue.put(None)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def make_executor(concurrency: int) -> "SerialExecutor | PoolExecutor":
|
|
131
|
+
"""Serial for one worker, a pool otherwise."""
|
|
132
|
+
if concurrency == 1:
|
|
133
|
+
return SerialExecutor()
|
|
134
|
+
return PoolExecutor(concurrency)
|