locust 2.46.5.dev8__tar.gz → 2.46.6__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.
- {locust-2.46.5.dev8 → locust-2.46.6}/PKG-INFO +1 -1
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/__init__.py +2 -1
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/_version.py +2 -2
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/clients.py +4 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/contrib/fasthttp.py +5 -69
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/runners.py +8 -2
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/stats.py +4 -15
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/user/users.py +79 -3
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/user/wait_time.py +27 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/web.py +9 -2
- {locust-2.46.5.dev8 → locust-2.46.6}/pyproject.toml +3 -1
- {locust-2.46.5.dev8 → locust-2.46.6}/.gitignore +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/LICENSE +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/README.md +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/hatch_build.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/__main__.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/argument_parser.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/contrib/__init__.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/contrib/csv_request_logger.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/contrib/dns.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/contrib/milvus.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/contrib/mongodb.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/contrib/mqtt.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/contrib/oai.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/contrib/postgres.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/contrib/qdrant.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/contrib/socketio.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/debug.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/dispatch.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/env.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/event.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/exception.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/html.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/input_events.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/log.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/main.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/opentelemetry.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/py.typed +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/rpc/__init__.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/rpc/protocol.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/rpc/zmqrpc.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/shape.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/user/__init__.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/user/inspectuser.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/user/markov_taskset.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/user/sequential_taskset.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/user/task.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/util/__init__.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/util/cache.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/util/date.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/util/deprecation.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/util/directory.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/util/exception_handler.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/util/load_locustfile.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/util/rounding.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/util/timespan.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/util/url.py +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/webui/dist/assets/favicon-dark.png +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/webui/dist/assets/favicon-light.png +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/webui/dist/assets/graphs-dark.png +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/webui/dist/assets/graphs-light.png +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/webui/dist/assets/src-pSlzKFX1.js +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/webui/dist/assets/terminal.gif +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/webui/dist/assets/testruns-dark.png +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/webui/dist/assets/testruns-light.png +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/webui/dist/auth.html +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/webui/dist/index.html +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/locust/webui/dist/report.html +0 -0
- {locust-2.46.5.dev8 → locust-2.46.6}/pytest_locust/plugin.py +0 -0
|
@@ -31,7 +31,7 @@ from .user.markov_taskset import MarkovTaskSet, transition, transitions
|
|
|
31
31
|
from .user.sequential_taskset import SequentialTaskSet
|
|
32
32
|
from .user.task import TaskSet, tag, task
|
|
33
33
|
from .user.users import HttpUser, User
|
|
34
|
-
from .user.wait_time import between, constant, constant_pacing, constant_throughput
|
|
34
|
+
from .user.wait_time import between, constant, constant_pacing, constant_throughput, poisson
|
|
35
35
|
|
|
36
36
|
events = Events()
|
|
37
37
|
|
|
@@ -51,6 +51,7 @@ __all__ = (
|
|
|
51
51
|
"constant",
|
|
52
52
|
"constant_pacing",
|
|
53
53
|
"constant_throughput",
|
|
54
|
+
"poisson",
|
|
54
55
|
"events",
|
|
55
56
|
"LoadTestShape",
|
|
56
57
|
"run_single_user",
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '2.46.
|
|
22
|
-
__version_tuple__ = version_tuple = (2, 46,
|
|
21
|
+
__version__ = version = '2.46.6'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 46, 6)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -478,6 +478,10 @@ class ResponseContextManager(Response):
|
|
|
478
478
|
self._manual_result = exc
|
|
479
479
|
|
|
480
480
|
|
|
481
|
+
class RestResponseContextManager(ResponseContextManager):
|
|
482
|
+
js: dict # This is technically an Optional, but I dont want to force everyone to check it
|
|
483
|
+
|
|
484
|
+
|
|
481
485
|
class LocustHttpAdapter(HTTPAdapter):
|
|
482
486
|
def __init__(self, pool_manager: PoolManager | None, *args, **kwargs):
|
|
483
487
|
self.poolmanager = pool_manager
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from locust.exception import CatchResponseError, LocustError, ResponseError, StopTest
|
|
4
4
|
from locust.user import User
|
|
5
|
+
from locust.user.users import RestMixin
|
|
5
6
|
from locust.util.deprecation import DeprecatedFastHttpLocustClass as FastHttpLocust # noqa: F401
|
|
6
7
|
|
|
7
8
|
import json
|
|
@@ -9,14 +10,11 @@ import json as unshadowed_json # some methods take a named parameter called jso
|
|
|
9
10
|
import re
|
|
10
11
|
import socket
|
|
11
12
|
import time
|
|
12
|
-
import traceback
|
|
13
13
|
import zlib
|
|
14
14
|
from base64 import b64encode
|
|
15
|
-
from contextlib import contextmanager
|
|
16
15
|
from http.cookiejar import CookieJar
|
|
17
|
-
from json.decoder import JSONDecodeError
|
|
18
16
|
from ssl import SSLError
|
|
19
|
-
from typing import TYPE_CHECKING
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
20
18
|
from urllib.parse import urlparse, urlunparse
|
|
21
19
|
|
|
22
20
|
import gevent
|
|
@@ -249,7 +247,7 @@ class FastHttpSession:
|
|
|
249
247
|
headers["Accept"] = "application/json"
|
|
250
248
|
|
|
251
249
|
if not allow_redirects:
|
|
252
|
-
|
|
250
|
+
old_redirect_resonse_codes = self.client.redirect_resonse_codes
|
|
253
251
|
self.client.redirect_resonse_codes = frozenset()
|
|
254
252
|
|
|
255
253
|
start_perf_counter = time.perf_counter()
|
|
@@ -266,7 +264,7 @@ class FastHttpSession:
|
|
|
266
264
|
}
|
|
267
265
|
|
|
268
266
|
if not allow_redirects:
|
|
269
|
-
self.client.redirect_resonse_codes =
|
|
267
|
+
self.client.redirect_resonse_codes = old_redirect_resonse_codes
|
|
270
268
|
|
|
271
269
|
request_meta["response_length"] = 0 # default value, if length cannot be determined
|
|
272
270
|
|
|
@@ -355,7 +353,7 @@ class FastHttpSession:
|
|
|
355
353
|
return self.request("PUT", url, data=data, **kwargs)
|
|
356
354
|
|
|
357
355
|
|
|
358
|
-
class FastHttpUser(User):
|
|
356
|
+
class FastHttpUser(User, RestMixin["RestResponseContextManager"]):
|
|
359
357
|
"""
|
|
360
358
|
FastHttpUser provides the same API as HttpUser, but uses geventhttpclient instead of python-requests
|
|
361
359
|
as its underlying client. It uses considerably less CPU on the load generator, and should work
|
|
@@ -413,8 +411,6 @@ class FastHttpUser(User):
|
|
|
413
411
|
abstract = True
|
|
414
412
|
"""Dont register this as a User class that can be run by itself"""
|
|
415
413
|
|
|
416
|
-
_callstack_regex = re.compile(r' File "(\/.[^"]*)", line (\d*),(.*)')
|
|
417
|
-
|
|
418
414
|
def __init__(self, environment) -> None:
|
|
419
415
|
super().__init__(environment)
|
|
420
416
|
if self.host is None:
|
|
@@ -443,66 +439,6 @@ class FastHttpUser(User):
|
|
|
443
439
|
The client support cookies, and therefore keeps the session between HTTP requests.
|
|
444
440
|
"""
|
|
445
441
|
|
|
446
|
-
@contextmanager
|
|
447
|
-
def rest(self, method, url, headers: dict | None = None, **kwargs) -> Generator[RestResponseContextManager]:
|
|
448
|
-
"""
|
|
449
|
-
A wrapper for self.client.request that:
|
|
450
|
-
|
|
451
|
-
* Parses the JSON response to a dict called ``js`` in the response object. Marks the request as failed if the response was not valid JSON.
|
|
452
|
-
* Defaults ``Content-Type`` and ``Accept`` headers to ``application/json``
|
|
453
|
-
* Sets ``catch_response=True`` (so always use a :ref:`with-block <catch-response>`)
|
|
454
|
-
* Catches any unhandled exceptions thrown inside your with-block, marking the sample as failed (instead of exiting the task immediately without even firing the request event)
|
|
455
|
-
"""
|
|
456
|
-
headers = headers or {}
|
|
457
|
-
if not ("Content-Type" in headers or "content-type" in headers):
|
|
458
|
-
headers["Content-Type"] = "application/json"
|
|
459
|
-
if not ("Accept" in headers or "accept" in headers):
|
|
460
|
-
headers["Accept"] = "application/json"
|
|
461
|
-
with self.client.request(method, url, catch_response=True, headers=headers, **kwargs) as r:
|
|
462
|
-
resp = cast(RestResponseContextManager, r)
|
|
463
|
-
resp.js = None # type: ignore
|
|
464
|
-
if resp.content is None:
|
|
465
|
-
resp.failure(str(resp.error))
|
|
466
|
-
elif resp.text:
|
|
467
|
-
try:
|
|
468
|
-
resp.js = resp.json()
|
|
469
|
-
except JSONDecodeError as e:
|
|
470
|
-
resp.failure(
|
|
471
|
-
f"Could not parse response as JSON. {resp.text[:250]}, response code {resp.status_code}, error {e}"
|
|
472
|
-
)
|
|
473
|
-
try:
|
|
474
|
-
yield resp
|
|
475
|
-
except AssertionError as e:
|
|
476
|
-
if e.args:
|
|
477
|
-
if e.args[0].endswith(","):
|
|
478
|
-
short_resp = resp.text[:200] if resp.text else resp.text
|
|
479
|
-
resp.failure(f"{e.args[0][:-1]}, response was {short_resp}")
|
|
480
|
-
else:
|
|
481
|
-
resp.failure(e.args[0])
|
|
482
|
-
else:
|
|
483
|
-
resp.failure("Assertion failed")
|
|
484
|
-
|
|
485
|
-
except Exception as e:
|
|
486
|
-
error_lines = []
|
|
487
|
-
for l in traceback.format_exc().split("\n"):
|
|
488
|
-
if m := self._callstack_regex.match(l):
|
|
489
|
-
filename = re.sub(r"/((home|Users)/\w*)/", "~/", m.group(1))
|
|
490
|
-
error_lines.append(filename + ":" + m.group(2) + m.group(3))
|
|
491
|
-
short_resp = resp.text[:200] if resp.text else resp.text
|
|
492
|
-
resp.failure(f"{e.__class__.__name__}: {e} at {', '.join(error_lines)}. Response was {short_resp}")
|
|
493
|
-
|
|
494
|
-
@contextmanager
|
|
495
|
-
def rest_(self, method, url, name=None, **kwargs) -> Generator[RestResponseContextManager]:
|
|
496
|
-
"""
|
|
497
|
-
Some REST api:s use a timestamp as part of their query string (mainly to break through caches).
|
|
498
|
-
This is a convenience method for that, appending a _=<timestamp> parameter automatically
|
|
499
|
-
"""
|
|
500
|
-
separator = "&" if "?" in url else "?"
|
|
501
|
-
if name is None:
|
|
502
|
-
name = url + separator + "_=..."
|
|
503
|
-
with self.rest(method, f"{url}{separator}_={int(time.time() * 1000)}", name=name, **kwargs) as resp:
|
|
504
|
-
yield resp
|
|
505
|
-
|
|
506
442
|
|
|
507
443
|
class FastRequest(CompatRequest):
|
|
508
444
|
payload: str | None = None
|
|
@@ -1143,8 +1143,14 @@ class MasterRunner(DistributedRunner):
|
|
|
1143
1143
|
self.server.send_to_client(Message("quit", None, msg.node_id))
|
|
1144
1144
|
case "spawning_complete":
|
|
1145
1145
|
# a worker finished spawning (this happens multiple times during rampup)
|
|
1146
|
-
|
|
1147
|
-
|
|
1146
|
+
try:
|
|
1147
|
+
self.clients[msg.node_id].state = STATE_RUNNING
|
|
1148
|
+
self.clients[msg.node_id].user_classes_count = msg.data["user_classes_count"]
|
|
1149
|
+
except KeyError:
|
|
1150
|
+
logger.warning(
|
|
1151
|
+
f"Got spawning_complete message from unknown worker {msg.node_id}. Asking worker to quit."
|
|
1152
|
+
)
|
|
1153
|
+
self.server.send_to_client(Message("quit", None, msg.node_id))
|
|
1148
1154
|
case "logs":
|
|
1149
1155
|
self.environment.update_worker_logs(msg.data)
|
|
1150
1156
|
case "quit":
|
|
@@ -435,7 +435,10 @@ class StatsEntry:
|
|
|
435
435
|
def median_response_time(self) -> int:
|
|
436
436
|
if not self.response_times:
|
|
437
437
|
return 0
|
|
438
|
-
median =
|
|
438
|
+
median = (
|
|
439
|
+
calculate_response_time_percentile(self.response_times, self.num_requests - self.num_none_requests, 0.5)
|
|
440
|
+
or 0
|
|
441
|
+
)
|
|
439
442
|
|
|
440
443
|
# Since we only use two digits of precision when calculating the median response time
|
|
441
444
|
# while still using the exact values for min and max response times, the following checks
|
|
@@ -812,20 +815,6 @@ def avg(values: list[float | int]) -> float:
|
|
|
812
815
|
return sum(values, 0.0) / max(len(values), 1)
|
|
813
816
|
|
|
814
817
|
|
|
815
|
-
def median_from_dict(total: int, count: dict[int, int]) -> int:
|
|
816
|
-
"""
|
|
817
|
-
total is the number of requests made
|
|
818
|
-
count is a dict {response_time: count}
|
|
819
|
-
"""
|
|
820
|
-
pos = (total - 1) / 2
|
|
821
|
-
for k in sorted(count.keys()):
|
|
822
|
-
if pos < count[k]:
|
|
823
|
-
return k
|
|
824
|
-
pos -= count[k]
|
|
825
|
-
|
|
826
|
-
return k
|
|
827
|
-
|
|
828
|
-
|
|
829
818
|
def setup_distributed_stats_event_listeners(events: Events, stats: RequestStats) -> None:
|
|
830
819
|
def on_report_to_master(client_id: str, data: dict[str, Any]) -> None:
|
|
831
820
|
data["stats"] = stats.serialize_stats()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from locust.clients import HttpSession
|
|
3
|
+
from locust.clients import HttpSession, RestResponseContextManager
|
|
4
4
|
from locust.exception import CatchResponseError, StopTest, StopUser
|
|
5
5
|
from locust.user.task import (
|
|
6
6
|
LOCUST_STATE_RUNNING,
|
|
@@ -14,11 +14,14 @@ from locust.user.wait_time import constant
|
|
|
14
14
|
from locust.util import deprecation
|
|
15
15
|
|
|
16
16
|
import logging
|
|
17
|
+
import re
|
|
17
18
|
import sys
|
|
18
19
|
import time
|
|
19
20
|
import traceback
|
|
20
21
|
from collections.abc import Callable
|
|
21
|
-
from
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from json.decoder import JSONDecodeError
|
|
24
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar, final
|
|
22
25
|
|
|
23
26
|
from gevent import GreenletExit, greenlet
|
|
24
27
|
from gevent.pool import Group
|
|
@@ -32,6 +35,8 @@ else:
|
|
|
32
35
|
from typing_extensions import override
|
|
33
36
|
|
|
34
37
|
if TYPE_CHECKING:
|
|
38
|
+
from collections.abc import Generator
|
|
39
|
+
|
|
35
40
|
import pytest
|
|
36
41
|
|
|
37
42
|
logger = logging.getLogger(__name__)
|
|
@@ -250,7 +255,78 @@ class User(metaclass=UserMeta):
|
|
|
250
255
|
return ".".join(filter(lambda x: x != "<locals>", (cls.__module__ + "." + cls.__qualname__).split(".")))
|
|
251
256
|
|
|
252
257
|
|
|
253
|
-
|
|
258
|
+
RestResponseT = TypeVar("RestResponseT")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class RestMixin(Generic[RestResponseT]):
|
|
262
|
+
"""
|
|
263
|
+
Provides the ``rest`` and ``rest_`` methods for User classes that have an HTTP ``client`` (HttpUser and FastHttpUser)
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
_callstack_regex = re.compile(r' File "(\/.[^"]*)", line (\d*),(.*)')
|
|
267
|
+
|
|
268
|
+
@contextmanager
|
|
269
|
+
def rest(self, method, url, headers: dict | None = None, **kwargs) -> Generator[RestResponseT]:
|
|
270
|
+
"""
|
|
271
|
+
A wrapper for self.client.request that:
|
|
272
|
+
|
|
273
|
+
* Parses the JSON response to a dict called ``js`` in the response object. Marks the request as failed if the response was not valid JSON.
|
|
274
|
+
* Defaults ``Content-Type`` and ``Accept`` headers to ``application/json``
|
|
275
|
+
* Sets ``catch_response=True`` (so always use a :ref:`with-block <catch-response>`)
|
|
276
|
+
* Catches any unhandled exceptions thrown inside your with-block, marking the sample as failed (instead of exiting the task immediately without even firing the request event)
|
|
277
|
+
"""
|
|
278
|
+
headers = headers or {}
|
|
279
|
+
if not ("Content-Type" in headers or "content-type" in headers):
|
|
280
|
+
headers["Content-Type"] = "application/json"
|
|
281
|
+
if not ("Accept" in headers or "accept" in headers):
|
|
282
|
+
headers["Accept"] = "application/json"
|
|
283
|
+
with self.client.request(method, url, catch_response=True, headers=headers, **kwargs) as r: # type: ignore[attr-defined]
|
|
284
|
+
resp: Any = r
|
|
285
|
+
resp.js = None
|
|
286
|
+
if resp.content is None:
|
|
287
|
+
resp.failure(str(resp.error))
|
|
288
|
+
elif resp.text:
|
|
289
|
+
try:
|
|
290
|
+
resp.js = resp.json()
|
|
291
|
+
except JSONDecodeError as e:
|
|
292
|
+
resp.failure(
|
|
293
|
+
f"Could not parse response as JSON. {resp.text[:250]}, response code {resp.status_code}, error {e}"
|
|
294
|
+
)
|
|
295
|
+
try:
|
|
296
|
+
yield resp
|
|
297
|
+
except AssertionError as e:
|
|
298
|
+
if e.args:
|
|
299
|
+
if e.args[0].endswith(","):
|
|
300
|
+
short_resp = resp.text[:200] if resp.text else resp.text
|
|
301
|
+
resp.failure(f"{e.args[0][:-1]}, response was {short_resp}")
|
|
302
|
+
else:
|
|
303
|
+
resp.failure(e.args[0])
|
|
304
|
+
else:
|
|
305
|
+
resp.failure("Assertion failed")
|
|
306
|
+
|
|
307
|
+
except Exception as e:
|
|
308
|
+
error_lines = []
|
|
309
|
+
for l in traceback.format_exc().split("\n"):
|
|
310
|
+
if m := self._callstack_regex.match(l):
|
|
311
|
+
filename = re.sub(r"/((home|Users)/\w*)/", "~/", m.group(1))
|
|
312
|
+
error_lines.append(filename + ":" + m.group(2) + m.group(3))
|
|
313
|
+
short_resp = resp.text[:200] if resp.text else resp.text
|
|
314
|
+
resp.failure(f"{e.__class__.__name__}: {e} at {', '.join(error_lines)}. Response was {short_resp}")
|
|
315
|
+
|
|
316
|
+
@contextmanager
|
|
317
|
+
def rest_(self, method, url, name=None, **kwargs) -> Generator[RestResponseT]:
|
|
318
|
+
"""
|
|
319
|
+
Some REST api:s use a timestamp as part of their query string (mainly to break through caches).
|
|
320
|
+
This is a convenience method for that, appending a _=<timestamp> parameter automatically
|
|
321
|
+
"""
|
|
322
|
+
separator = "&" if "?" in url else "?"
|
|
323
|
+
if name is None:
|
|
324
|
+
name = url + separator + "_=..."
|
|
325
|
+
with self.rest(method, f"{url}{separator}_={int(time.time() * 1000)}", name=name, **kwargs) as resp:
|
|
326
|
+
yield resp
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class HttpUser(User, RestMixin[RestResponseContextManager]):
|
|
254
330
|
"""
|
|
255
331
|
Represents an HTTP "user" which is to be spawned and attack the system that is to be load tested.
|
|
256
332
|
|
|
@@ -84,3 +84,30 @@ def constant_throughput(task_runs_per_second: float) -> Callable[["User"], float
|
|
|
84
84
|
the next task.
|
|
85
85
|
"""
|
|
86
86
|
return constant_pacing(1 / task_runs_per_second)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def poisson(rate: float) -> Callable[["User"], float]:
|
|
90
|
+
"""
|
|
91
|
+
Returns a function that will return a random wait time sampled from an exponential
|
|
92
|
+
distribution with mean ``1/rate`` seconds. Tasks will therefore arrive according to a
|
|
93
|
+
Poisson process with mean rate ``rate`` tasks per second, which is a good approximation
|
|
94
|
+
of how real users arrive.
|
|
95
|
+
|
|
96
|
+
The average number of task runs per second will be ``rate``, but unlike
|
|
97
|
+
:py:func:`constant_throughput <locust.wait_time.constant_throughput>` the individual
|
|
98
|
+
intervals vary randomly, so bursts and quiet periods occur naturally.
|
|
99
|
+
|
|
100
|
+
Example::
|
|
101
|
+
|
|
102
|
+
class MyUser(User):
|
|
103
|
+
wait_time = poisson(2) # on average, each user runs 2 tasks per second
|
|
104
|
+
@task
|
|
105
|
+
def my_task(self):
|
|
106
|
+
...
|
|
107
|
+
|
|
108
|
+
If you have multiple requests in a task your RPS will of course be higher than the
|
|
109
|
+
specified throughput.
|
|
110
|
+
"""
|
|
111
|
+
if rate <= 0:
|
|
112
|
+
raise ValueError(f"poisson() requires a positive rate, got: {rate}")
|
|
113
|
+
return lambda instance: random.expovariate(rate)
|
|
@@ -29,6 +29,7 @@ from flask import (
|
|
|
29
29
|
from flask_cors import CORS
|
|
30
30
|
from flask_login import LoginManager, login_required
|
|
31
31
|
from gevent import pywsgi
|
|
32
|
+
from jinja2 import TemplateNotFound
|
|
32
33
|
|
|
33
34
|
from . import __version__ as version
|
|
34
35
|
from . import argument_parser, stats
|
|
@@ -206,8 +207,14 @@ class WebUI:
|
|
|
206
207
|
if not environment.runner:
|
|
207
208
|
return make_response("Error: Locust Environment does not have any runner", 500)
|
|
208
209
|
self.update_template_args()
|
|
209
|
-
|
|
210
|
-
|
|
210
|
+
try:
|
|
211
|
+
return render_template("index.html", template_args=self.template_args)
|
|
212
|
+
except TemplateNotFound:
|
|
213
|
+
return make_response(
|
|
214
|
+
"Web UI assets are missing. If running Locust from source, "
|
|
215
|
+
"please refer to docs/developing-locust.rst for instructions on building the Web UI.",
|
|
216
|
+
500,
|
|
217
|
+
)
|
|
211
218
|
|
|
212
219
|
@app_blueprint.route("/swarm", methods=["POST"])
|
|
213
220
|
@self.auth_required_if_enabled
|
|
@@ -84,6 +84,7 @@ lint = [
|
|
|
84
84
|
"ruff==0.15.12",
|
|
85
85
|
"mypy>=1.13.0,<1.15.0",
|
|
86
86
|
"types-requests==2.32.4.20250809",
|
|
87
|
+
"typos>=1.50.1"
|
|
87
88
|
]
|
|
88
89
|
release = ["twine>=5.1.1,<6.0.0"]
|
|
89
90
|
docs = [
|
|
@@ -273,6 +274,7 @@ python = ["3.12"]
|
|
|
273
274
|
all = [""]
|
|
274
275
|
types = ["mypy locust/"]
|
|
275
276
|
format = ["ruff check .", "ruff format --check"]
|
|
277
|
+
spelling = ["typos ."]
|
|
276
278
|
|
|
277
279
|
|
|
278
280
|
## Hatch docs building environment
|
|
@@ -294,7 +296,7 @@ filterwarnings = [
|
|
|
294
296
|
# but that isn't always possible
|
|
295
297
|
"ignore::gevent.monkey.MonkeyPatchWarning",
|
|
296
298
|
# "This process is multi-threaded, use of fork() may lead to deadlocks in the child." started happening in Python 3.12
|
|
297
|
-
# It could potentially be a real issue, but I
|
|
299
|
+
# It could potentially be a real issue, but I don't think
|
|
298
300
|
"ignore::DeprecationWarning:gevent",
|
|
299
301
|
|
|
300
302
|
]
|
|
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
|
|
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
|
|
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
|