locust 2.46.6.dev10__tar.gz → 2.46.7__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.6.dev10 → locust-2.46.7}/PKG-INFO +4 -3
- {locust-2.46.6.dev10 → locust-2.46.7}/README.md +2 -1
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/_version.py +2 -2
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/clients.py +13 -24
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/contrib/fasthttp.py +30 -11
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/dispatch.py +6 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/runners.py +13 -9
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/stats.py +15 -11
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/user/task.py +2 -2
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/user/users.py +2 -2
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/user/wait_time.py +5 -3
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/util/cache.py +2 -4
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/util/date.py +2 -2
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/util/exception_handler.py +1 -1
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/web.py +3 -3
- {locust-2.46.6.dev10 → locust-2.46.7}/pyproject.toml +8 -6
- {locust-2.46.6.dev10 → locust-2.46.7}/.gitignore +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/LICENSE +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/hatch_build.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/__init__.py +13 -13
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/__main__.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/argument_parser.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/contrib/__init__.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/contrib/csv_request_logger.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/contrib/dns.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/contrib/milvus.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/contrib/mongodb.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/contrib/mqtt.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/contrib/oai.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/contrib/postgres.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/contrib/qdrant.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/contrib/socketio.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/debug.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/env.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/event.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/exception.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/html.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/input_events.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/log.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/main.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/opentelemetry.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/py.typed +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/rpc/__init__.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/rpc/protocol.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/rpc/zmqrpc.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/shape.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/user/__init__.py +2 -2
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/user/inspectuser.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/user/markov_taskset.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/user/sequential_taskset.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/util/__init__.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/util/deprecation.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/util/directory.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/util/load_locustfile.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/util/rounding.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/util/timespan.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/util/url.py +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/webui/dist/assets/favicon-dark.png +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/webui/dist/assets/favicon-light.png +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/webui/dist/assets/graphs-dark.png +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/webui/dist/assets/graphs-light.png +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/webui/dist/assets/src-pSlzKFX1.js +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/webui/dist/assets/terminal.gif +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/webui/dist/assets/testruns-dark.png +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/webui/dist/assets/testruns-light.png +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/webui/dist/auth.html +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/webui/dist/index.html +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/locust/webui/dist/report.html +0 -0
- {locust-2.46.6.dev10 → locust-2.46.7}/pytest_locust/plugin.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: locust
|
|
3
|
-
Version: 2.46.
|
|
3
|
+
Version: 2.46.7
|
|
4
4
|
Summary: Developer-friendly load testing framework
|
|
5
5
|
Project-URL: Homepage, https://locust.cloud/
|
|
6
6
|
Project-URL: Repository, https://github.com/locustio/locust
|
|
@@ -38,7 +38,7 @@ Requires-Dist: python-engineio>=4.12.2
|
|
|
38
38
|
Requires-Dist: python-socketio[client]>=5.13.0
|
|
39
39
|
Requires-Dist: pywin32; sys_platform == 'win32'
|
|
40
40
|
Requires-Dist: pyzmq>=25.0.0
|
|
41
|
-
Requires-Dist: requests>=2.
|
|
41
|
+
Requires-Dist: requests>=2.33.1
|
|
42
42
|
Requires-Dist: typing-extensions>=4.6.0; python_version < '3.12'
|
|
43
43
|
Requires-Dist: werkzeug>=2.0.0
|
|
44
44
|
Provides-Extra: dns
|
|
@@ -83,11 +83,12 @@ If you want your users to loop, perform some conditional behaviour or do some ca
|
|
|
83
83
|
```python
|
|
84
84
|
from locust import HttpUser, task, between
|
|
85
85
|
|
|
86
|
+
|
|
86
87
|
class QuickstartUser(HttpUser):
|
|
87
88
|
wait_time = between(1, 2)
|
|
88
89
|
|
|
89
90
|
def on_start(self):
|
|
90
|
-
self.client.post("/login", json={"username":"foo", "password":"bar"})
|
|
91
|
+
self.client.post("/login", json={"username": "foo", "password": "bar"})
|
|
91
92
|
|
|
92
93
|
@task
|
|
93
94
|
def hello_world(self):
|
|
@@ -24,11 +24,12 @@ If you want your users to loop, perform some conditional behaviour or do some ca
|
|
|
24
24
|
```python
|
|
25
25
|
from locust import HttpUser, task, between
|
|
26
26
|
|
|
27
|
+
|
|
27
28
|
class QuickstartUser(HttpUser):
|
|
28
29
|
wait_time = between(1, 2)
|
|
29
30
|
|
|
30
31
|
def on_start(self):
|
|
31
|
-
self.client.post("/login", json={"username":"foo", "password":"bar"})
|
|
32
|
+
self.client.post("/login", json={"username": "foo", "password": "bar"})
|
|
32
33
|
|
|
33
34
|
@task
|
|
34
35
|
def hello_world(self):
|
|
@@ -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.7'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 46, 7)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -8,10 +8,9 @@ import sys
|
|
|
8
8
|
import time
|
|
9
9
|
from contextlib import contextmanager
|
|
10
10
|
from typing import TYPE_CHECKING, cast
|
|
11
|
-
from urllib.parse import urlparse, urlunparse
|
|
11
|
+
from urllib.parse import unquote, urlparse, urlunparse
|
|
12
12
|
|
|
13
13
|
import requests
|
|
14
|
-
from packaging.version import Version
|
|
15
14
|
from requests import Response
|
|
16
15
|
from requests.adapters import HTTPAdapter
|
|
17
16
|
from requests.auth import HTTPBasicAuth
|
|
@@ -28,8 +27,6 @@ if sys.version_info >= (3, 12):
|
|
|
28
27
|
else:
|
|
29
28
|
from typing_extensions import override
|
|
30
29
|
|
|
31
|
-
requests_version = Version(requests.__version__).release
|
|
32
|
-
|
|
33
30
|
if TYPE_CHECKING:
|
|
34
31
|
from collections.abc import Callable, Generator, Iterable, Mapping, MutableMapping
|
|
35
32
|
from typing import Any, TypedDict, Unpack
|
|
@@ -45,7 +42,7 @@ if TYPE_CHECKING:
|
|
|
45
42
|
cookies: RequestsCookieJar | MutableMapping[str, str] | None
|
|
46
43
|
files: Any # simplified signature
|
|
47
44
|
auth: Any # simplified signature
|
|
48
|
-
timeout: float | tuple[float, float
|
|
45
|
+
timeout: float | tuple[float | None, float | None] | None
|
|
49
46
|
allow_redirects: bool
|
|
50
47
|
proxies: MutableMapping[str, str] | None
|
|
51
48
|
hooks: Mapping[str, Iterable[Callable[[Response], Any]] | Callable[[Response], Any]] | None
|
|
@@ -61,9 +58,8 @@ if TYPE_CHECKING:
|
|
|
61
58
|
|
|
62
59
|
absolute_http_url_regexp = re.compile(r"^https?://", re.IGNORECASE)
|
|
63
60
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
_preloaded_ssl_context.load_verify_locations(extract_zipped_paths(DEFAULT_CA_BUNDLE_PATH))
|
|
61
|
+
_preloaded_ssl_context = create_urllib3_context()
|
|
62
|
+
_preloaded_ssl_context.load_verify_locations(extract_zipped_paths(DEFAULT_CA_BUNDLE_PATH))
|
|
67
63
|
|
|
68
64
|
|
|
69
65
|
class HttpSession(requests.Session):
|
|
@@ -104,16 +100,13 @@ class HttpSession(requests.Session):
|
|
|
104
100
|
# Check for basic authentication
|
|
105
101
|
parsed_url = urlparse(self.base_url)
|
|
106
102
|
if parsed_url.username and parsed_url.password:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
netloc += ":%d" % parsed_url.port
|
|
110
|
-
|
|
111
|
-
# remove username and password from the base_url
|
|
103
|
+
# remove username and password from the base_url (keeping e.g. the brackets of an IPv6 host)
|
|
104
|
+
netloc = parsed_url.netloc.rpartition("@")[2]
|
|
112
105
|
self.base_url = urlunparse(
|
|
113
106
|
(parsed_url.scheme, netloc, parsed_url.path, parsed_url.params, parsed_url.query, parsed_url.fragment)
|
|
114
107
|
)
|
|
115
|
-
# configure requests to use basic auth
|
|
116
|
-
self.auth = HTTPBasicAuth(parsed_url.username, parsed_url.password)
|
|
108
|
+
# configure requests to use basic auth (credentials in a URL are percent-encoded)
|
|
109
|
+
self.auth = HTTPBasicAuth(unquote(parsed_url.username), unquote(parsed_url.password))
|
|
117
110
|
|
|
118
111
|
self.mount("https://", LocustHttpAdapter(pool_manager=pool_manager))
|
|
119
112
|
self.mount("http://", LocustHttpAdapter(pool_manager=pool_manager))
|
|
@@ -198,7 +191,7 @@ class HttpSession(requests.Session):
|
|
|
198
191
|
response = self._send_request_safe_mode(method, complete_url, data=data, json=json, **kwargs)
|
|
199
192
|
response_time = (time.perf_counter() - start_perf_counter) * 1000
|
|
200
193
|
|
|
201
|
-
if request_before_redirect := (response.history and response.history[0] or response).request:
|
|
194
|
+
if request_before_redirect := ((response.history and response.history[0]) or response).request:
|
|
202
195
|
complete_url = str(request_before_redirect.url)
|
|
203
196
|
if not name:
|
|
204
197
|
name = request_before_redirect.path_url
|
|
@@ -491,14 +484,10 @@ class LocustHttpAdapter(HTTPAdapter):
|
|
|
491
484
|
if self.poolmanager is None:
|
|
492
485
|
super().init_poolmanager(*args, **kwargs)
|
|
493
486
|
|
|
494
|
-
#
|
|
495
|
-
#
|
|
496
|
-
#
|
|
497
|
-
# We re-implement this change to increase the performance
|
|
487
|
+
# requests reverted https://github.com/psf/requests/pull/6667 in 2.32.5, so without
|
|
488
|
+
# re-implementing it here the root CA certificates are loaded on every request.
|
|
489
|
+
# We re-implement it to increase performance (see _preloaded_ssl_context above).
|
|
498
490
|
def cert_verify(self, conn, url, verify, cert):
|
|
499
|
-
if requests_version < (2, 32, 5):
|
|
500
|
-
return super().cert_verify(conn, url, verify, cert)
|
|
501
|
-
|
|
502
491
|
if url.lower().startswith("https") and verify:
|
|
503
492
|
conn.cert_reqs = "CERT_REQUIRED"
|
|
504
493
|
|
|
@@ -532,7 +521,7 @@ class LocustHttpAdapter(HTTPAdapter):
|
|
|
532
521
|
def build_connection_pool_key_attributes(self, request, verify, cert=None):
|
|
533
522
|
host_params, pool_kwargs = super().build_connection_pool_key_attributes(request, verify, cert)
|
|
534
523
|
|
|
535
|
-
if
|
|
524
|
+
if verify is True:
|
|
536
525
|
pool_kwargs["ssl_context"] = _preloaded_ssl_context
|
|
537
526
|
|
|
538
527
|
return host_params, pool_kwargs
|
|
@@ -12,10 +12,11 @@ import socket
|
|
|
12
12
|
import time
|
|
13
13
|
import zlib
|
|
14
14
|
from base64 import b64encode
|
|
15
|
+
from contextlib import contextmanager
|
|
15
16
|
from http.cookiejar import CookieJar
|
|
16
17
|
from ssl import SSLError
|
|
17
18
|
from typing import TYPE_CHECKING
|
|
18
|
-
from urllib.parse import urlparse, urlunparse
|
|
19
|
+
from urllib.parse import unquote, urlparse, urlunparse
|
|
19
20
|
|
|
20
21
|
import gevent
|
|
21
22
|
from charset_normalizer import detect
|
|
@@ -111,6 +112,11 @@ class FastHttpSession:
|
|
|
111
112
|
self.request_event = request_event
|
|
112
113
|
self.cookiejar = CookieJar()
|
|
113
114
|
self.user = user
|
|
115
|
+
|
|
116
|
+
# Requests can be grouped under a common name by setting request_name, or by using the rename_request context manager
|
|
117
|
+
# This is an alternative to passing in the "name" parameter to the requests function
|
|
118
|
+
self.request_name: str | None = None
|
|
119
|
+
|
|
114
120
|
if not ssl_context_factory:
|
|
115
121
|
if insecure:
|
|
116
122
|
ssl_context_factory = insecure_ssl_context_factory
|
|
@@ -128,11 +134,8 @@ class FastHttpSession:
|
|
|
128
134
|
if self.base_url:
|
|
129
135
|
parsed_url = urlparse(self.base_url)
|
|
130
136
|
if parsed_url.username and parsed_url.password:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
netloc += ":%d" % parsed_url.port
|
|
134
|
-
|
|
135
|
-
# remove username and password from the base_url
|
|
137
|
+
# remove username and password from the base_url (keeping e.g. the brackets of an IPv6 host)
|
|
138
|
+
netloc = parsed_url.netloc.rpartition("@")[2]
|
|
136
139
|
self.base_url = str(
|
|
137
140
|
urlunparse(
|
|
138
141
|
(
|
|
@@ -146,7 +149,8 @@ class FastHttpSession:
|
|
|
146
149
|
)
|
|
147
150
|
)
|
|
148
151
|
# store authentication header (we construct this by using _basic_auth_str() function from requests.auth)
|
|
149
|
-
|
|
152
|
+
# (credentials in a URL are percent-encoded)
|
|
153
|
+
self.auth_header = _construct_basic_auth_str(unquote(parsed_url.username), unquote(parsed_url.password))
|
|
150
154
|
|
|
151
155
|
def _build_url(self, path: str) -> str:
|
|
152
156
|
"""prepend url with hostname unless it's already an absolute URL"""
|
|
@@ -155,6 +159,16 @@ class FastHttpSession:
|
|
|
155
159
|
else:
|
|
156
160
|
return f"{self.base_url}{path}"
|
|
157
161
|
|
|
162
|
+
@contextmanager
|
|
163
|
+
def rename_request(self, name: str) -> Generator[None]:
|
|
164
|
+
"""Group requests using the "with" keyword"""
|
|
165
|
+
|
|
166
|
+
self.request_name = name
|
|
167
|
+
try:
|
|
168
|
+
yield
|
|
169
|
+
finally:
|
|
170
|
+
self.request_name = None
|
|
171
|
+
|
|
158
172
|
def _send_request_safe_mode(self, method: str, url: str, **kwargs) -> FastResponse:
|
|
159
173
|
"""
|
|
160
174
|
Send an HTTP request, and catch any exception that might occur due to either
|
|
@@ -223,6 +237,10 @@ class FastHttpSession:
|
|
|
223
237
|
:return: A :py:class:`FastResponse <locust.contrib.fasthttp.FastResponse>` object if catch_response is False, and
|
|
224
238
|
:py:class:`ResponseContextManager <locust.contrib.fasthttp.ResponseContextManager>` if True.
|
|
225
239
|
"""
|
|
240
|
+
# if group name has been set and no name parameter has been passed in; set the name parameter to group_name
|
|
241
|
+
if self.request_name and not name:
|
|
242
|
+
name = self.request_name
|
|
243
|
+
|
|
226
244
|
# prepend url with hostname unless it's already an absolute URL
|
|
227
245
|
built_url = self._build_url(url)
|
|
228
246
|
|
|
@@ -231,7 +249,7 @@ class FastHttpSession:
|
|
|
231
249
|
if self.user:
|
|
232
250
|
context = {**self.user.context(), **context}
|
|
233
251
|
|
|
234
|
-
headers = headers
|
|
252
|
+
headers = dict(headers) if headers else {}
|
|
235
253
|
if auth:
|
|
236
254
|
headers["Authorization"] = _construct_basic_auth_str(auth[0], auth[1])
|
|
237
255
|
elif self.auth_header:
|
|
@@ -247,8 +265,8 @@ class FastHttpSession:
|
|
|
247
265
|
headers["Accept"] = "application/json"
|
|
248
266
|
|
|
249
267
|
if not allow_redirects:
|
|
250
|
-
|
|
251
|
-
self.client.redirect_resonse_codes = frozenset()
|
|
268
|
+
old_redirect_resonse_codes = self.client.redirect_resonse_codes
|
|
269
|
+
self.client.redirect_resonse_codes = self.client.redirect_response_codes = frozenset()
|
|
252
270
|
|
|
253
271
|
start_perf_counter = time.perf_counter()
|
|
254
272
|
# send request, and catch any exceptions
|
|
@@ -264,7 +282,7 @@ class FastHttpSession:
|
|
|
264
282
|
}
|
|
265
283
|
|
|
266
284
|
if not allow_redirects:
|
|
267
|
-
self.client.redirect_resonse_codes =
|
|
285
|
+
self.client.redirect_resonse_codes = self.client.redirect_response_codes = old_redirect_resonse_codes
|
|
268
286
|
|
|
269
287
|
request_meta["response_length"] = 0 # default value, if length cannot be determined
|
|
270
288
|
|
|
@@ -605,6 +623,7 @@ class LocustUserAgent(UserAgent):
|
|
|
605
623
|
request_type = FastRequest
|
|
606
624
|
valid_response_codes = frozenset([200, 201, 202, 203, 204, 205, 206, 207, 208, 226, 301, 302, 303, 304, 307, 308])
|
|
607
625
|
redirect_resonse_codes = frozenset([301, 302, 303, 307, 308])
|
|
626
|
+
redirect_response_codes = redirect_resonse_codes # geventhttpclient 2.5+ spelling
|
|
608
627
|
|
|
609
628
|
def __init__(self, client_pool: HTTPClientPool | None = None, **kwargs):
|
|
610
629
|
super().__init__(**kwargs)
|
|
@@ -192,6 +192,12 @@ class UsersDispatcher(Iterator):
|
|
|
192
192
|
:param spawn_rate: The spawn rate
|
|
193
193
|
:param user_classes: The user classes to be used for the new dispatch
|
|
194
194
|
"""
|
|
195
|
+
if target_user_count < 0:
|
|
196
|
+
raise ValueError(f"target_user_count must be >= 0, got {target_user_count}")
|
|
197
|
+
|
|
198
|
+
if spawn_rate <= 0:
|
|
199
|
+
raise ValueError(f"spawn_rate must be > 0, got {spawn_rate}")
|
|
200
|
+
|
|
195
201
|
if user_classes is not None and self._user_classes != sorted(user_classes, key=attrgetter("__name__")):
|
|
196
202
|
self._user_classes = sorted(user_classes, key=attrgetter("__name__"))
|
|
197
203
|
self._user_generator = self._user_gen()
|
|
@@ -917,10 +917,8 @@ class MasterRunner(DistributedRunner):
|
|
|
917
917
|
self.greenlet.kill(block=True)
|
|
918
918
|
|
|
919
919
|
def check_stopped(self) -> None:
|
|
920
|
-
if (
|
|
921
|
-
|
|
922
|
-
and all(x.state == STATE_INIT for x in self.clients.all)
|
|
923
|
-
or all(x.state not in (STATE_RUNNING, STATE_SPAWNING, STATE_INIT) for x in self.clients.all)
|
|
920
|
+
if (self.state == STATE_STOPPING and all(x.state == STATE_INIT for x in self.clients.all)) or all(
|
|
921
|
+
x.state not in (STATE_RUNNING, STATE_SPAWNING, STATE_INIT) for x in self.clients.all
|
|
924
922
|
):
|
|
925
923
|
self.update_state(STATE_STOPPED)
|
|
926
924
|
|
|
@@ -938,7 +936,7 @@ class MasterRunner(DistributedRunner):
|
|
|
938
936
|
missing_clients_to_be_removed.append(client.id)
|
|
939
937
|
|
|
940
938
|
if client.heartbeat < 0 and client.state != STATE_MISSING:
|
|
941
|
-
logger.info(f"Worker {
|
|
939
|
+
logger.info(f"Worker {client.id!s} failed to send heartbeat, setting state to missing.")
|
|
942
940
|
client.state = STATE_MISSING
|
|
943
941
|
client.user_classes_count = {}
|
|
944
942
|
if self._users_dispatcher is not None:
|
|
@@ -1023,7 +1021,7 @@ class MasterRunner(DistributedRunner):
|
|
|
1023
1021
|
elif msg.data != __version__ and msg.data != -1:
|
|
1024
1022
|
if msg.data[0:4] == __version__[0:4]:
|
|
1025
1023
|
logger.debug(
|
|
1026
|
-
f"A worker ({client_id}) running a different patch version ({
|
|
1024
|
+
f"A worker ({client_id}) running a different patch version ({msg.data!r}) connected, master version is {__version__!r}"
|
|
1027
1025
|
)
|
|
1028
1026
|
else:
|
|
1029
1027
|
logger.warning(
|
|
@@ -1113,7 +1111,7 @@ class MasterRunner(DistributedRunner):
|
|
|
1113
1111
|
c.heartbeat = HEARTBEAT_LIVENESS
|
|
1114
1112
|
client_state = msg.data["state"]
|
|
1115
1113
|
if c.state == STATE_MISSING:
|
|
1116
|
-
logger.info(f"Worker {
|
|
1114
|
+
logger.info(f"Worker {c.id!s} self-healed with heartbeat, setting state to {client_state}.")
|
|
1117
1115
|
if self._users_dispatcher is not None:
|
|
1118
1116
|
self._users_dispatcher.add_worker(worker_node=c)
|
|
1119
1117
|
if not self._users_dispatcher.dispatch_in_progress and self.state == STATE_RUNNING:
|
|
@@ -1143,8 +1141,14 @@ class MasterRunner(DistributedRunner):
|
|
|
1143
1141
|
self.server.send_to_client(Message("quit", None, msg.node_id))
|
|
1144
1142
|
case "spawning_complete":
|
|
1145
1143
|
# a worker finished spawning (this happens multiple times during rampup)
|
|
1146
|
-
|
|
1147
|
-
|
|
1144
|
+
try:
|
|
1145
|
+
self.clients[msg.node_id].state = STATE_RUNNING
|
|
1146
|
+
self.clients[msg.node_id].user_classes_count = msg.data["user_classes_count"]
|
|
1147
|
+
except KeyError:
|
|
1148
|
+
logger.warning(
|
|
1149
|
+
f"Got spawning_complete message from unknown worker {msg.node_id}. Asking worker to quit."
|
|
1150
|
+
)
|
|
1151
|
+
self.server.send_to_client(Message("quit", None, msg.node_id))
|
|
1148
1152
|
case "logs":
|
|
1149
1153
|
self.environment.update_worker_logs(msg.data)
|
|
1150
1154
|
case "quit":
|
|
@@ -539,7 +539,7 @@ class StatsEntry:
|
|
|
539
539
|
# (which is what the response times cache is used for) uses an approximation of the
|
|
540
540
|
# last 10 seconds anyway, it should be fine to ignore this.
|
|
541
541
|
last_time = int(self.last_request_timestamp) if self.last_request_timestamp else None
|
|
542
|
-
if last_time and last_time > (old_last_request_timestamp and int(old_last_request_timestamp) or 0):
|
|
542
|
+
if last_time and last_time > ((old_last_request_timestamp and int(old_last_request_timestamp)) or 0):
|
|
543
543
|
self._cache_response_times(last_time)
|
|
544
544
|
|
|
545
545
|
def serialize(self) -> StatsEntryDict:
|
|
@@ -585,7 +585,7 @@ class StatsEntry:
|
|
|
585
585
|
+ str((STATS_NAME_WIDTH - STATS_TYPE_WIDTH) + 4)
|
|
586
586
|
+ "s %7d %12s |%7d %7d %7d%7d | %7.2f %11.2f"
|
|
587
587
|
) % (
|
|
588
|
-
(self.method and self.method + " " or ""),
|
|
588
|
+
((self.method and self.method + " ") or ""),
|
|
589
589
|
self.name,
|
|
590
590
|
self.num_requests,
|
|
591
591
|
"%d(%.2f%%)" % (self.num_failures, self.fail_ratio * 100),
|
|
@@ -661,12 +661,15 @@ class StatsEntry:
|
|
|
661
661
|
if not self.num_requests:
|
|
662
662
|
raise ValueError("Can't calculate percentile on url with no successful requests")
|
|
663
663
|
|
|
664
|
-
tpl = f"%-{
|
|
664
|
+
tpl = f"%-{STATS_TYPE_WIDTH!s}s %-{STATS_NAME_WIDTH!s}s %8d {' '.join(['%6d'] * len(PERCENTILES_TO_REPORT))}"
|
|
665
665
|
|
|
666
666
|
return tpl % (
|
|
667
|
-
(
|
|
668
|
-
|
|
669
|
-
|
|
667
|
+
(
|
|
668
|
+
self.method or "",
|
|
669
|
+
self.name,
|
|
670
|
+
*tuple(self.get_response_time_percentile(p) for p in PERCENTILES_TO_REPORT),
|
|
671
|
+
self.num_requests,
|
|
672
|
+
)
|
|
670
673
|
)
|
|
671
674
|
|
|
672
675
|
def _cache_response_times(self, t: int) -> None:
|
|
@@ -897,9 +900,9 @@ def get_percentile_stats_summary(stats: RequestStats) -> list[str]:
|
|
|
897
900
|
Percentile stats summary will be returned as list of string
|
|
898
901
|
"""
|
|
899
902
|
summary = ["Response time percentiles (approximated)"]
|
|
900
|
-
headers = ("Type", "Name"
|
|
903
|
+
headers = ("Type", "Name", *tuple(get_readable_percentiles(PERCENTILES_TO_REPORT)), "# reqs")
|
|
901
904
|
summary.append(
|
|
902
|
-
|
|
905
|
+
f"%-{STATS_TYPE_WIDTH!s}s %-{STATS_NAME_WIDTH!s}s %8s {' '.join(['%6s'] * len(PERCENTILES_TO_REPORT))}"
|
|
903
906
|
% headers
|
|
904
907
|
)
|
|
905
908
|
separator = (
|
|
@@ -1008,7 +1011,8 @@ class StatsCSV:
|
|
|
1008
1011
|
"Average Content Size",
|
|
1009
1012
|
"Requests/s",
|
|
1010
1013
|
"Failures/s",
|
|
1011
|
-
|
|
1014
|
+
*get_readable_percentiles(self.percentiles_to_report),
|
|
1015
|
+
]
|
|
1012
1016
|
|
|
1013
1017
|
self.failures_columns = [
|
|
1014
1018
|
"Method",
|
|
@@ -1190,8 +1194,8 @@ class StatsCSVFileWriter(StatsCSV):
|
|
|
1190
1194
|
|
|
1191
1195
|
def _stats_history_data_rows(self, csv_writer: CSVWriter, now: float) -> None:
|
|
1192
1196
|
"""
|
|
1193
|
-
Write CSV rows with the *current* stats. By default only includes the
|
|
1194
|
-
Aggregated stats entry, but if self.full_history is set to True, a row for each entry
|
|
1197
|
+
Write CSV rows with the *current* stats. By default, only includes the
|
|
1198
|
+
Aggregated stats entry, but if self.full_history is set to True, a row for each entry
|
|
1195
1199
|
will be included.
|
|
1196
1200
|
|
|
1197
1201
|
Note that this method differs from the other methods as it appends time-stamped data to the file, whereas the other methods overwrites the data.
|
|
@@ -14,7 +14,7 @@ import random
|
|
|
14
14
|
import traceback
|
|
15
15
|
from collections import deque
|
|
16
16
|
from collections.abc import Callable
|
|
17
|
-
from time import time
|
|
17
|
+
from time import perf_counter, time
|
|
18
18
|
from typing import (
|
|
19
19
|
TYPE_CHECKING,
|
|
20
20
|
Protocol,
|
|
@@ -316,7 +316,7 @@ class TaskSet(metaclass=TaskSetMeta):
|
|
|
316
316
|
self.max_wait = self.user.max_wait
|
|
317
317
|
if not self.wait_function:
|
|
318
318
|
self.wait_function = self.user.wait_function
|
|
319
|
-
self._cp_last_run: float =
|
|
319
|
+
self._cp_last_run: float = perf_counter() # used by constant_pacing wait_time
|
|
320
320
|
self._cp_last_wait_time: float = 0 # used by constant_pacing wait_time
|
|
321
321
|
|
|
322
322
|
@property
|
|
@@ -141,7 +141,7 @@ class User(metaclass=UserMeta):
|
|
|
141
141
|
self._greenlet: greenlet.Greenlet | None = None
|
|
142
142
|
self._group: Group
|
|
143
143
|
self._taskset_instance: TaskSet | None = None
|
|
144
|
-
self._cp_last_run: float = time.
|
|
144
|
+
self._cp_last_run: float = time.perf_counter() # used by constant_pacing wait_time
|
|
145
145
|
self._cp_last_wait_time: float = 0 # used by constant_pacing wait_time
|
|
146
146
|
|
|
147
147
|
def on_start(self) -> None:
|
|
@@ -275,7 +275,7 @@ class RestMixin(Generic[RestResponseT]):
|
|
|
275
275
|
* Sets ``catch_response=True`` (so always use a :ref:`with-block <catch-response>`)
|
|
276
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
277
|
"""
|
|
278
|
-
headers = headers
|
|
278
|
+
headers = dict(headers) if headers else {}
|
|
279
279
|
if not ("Content-Type" in headers or "content-type" in headers):
|
|
280
280
|
headers["Content-Type"] = "application/json"
|
|
281
281
|
if not ("Accept" in headers or "accept" in headers):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import random
|
|
2
2
|
from collections.abc import Callable
|
|
3
|
-
from time import
|
|
3
|
+
from time import perf_counter
|
|
4
4
|
from typing import TYPE_CHECKING
|
|
5
5
|
|
|
6
6
|
if TYPE_CHECKING:
|
|
@@ -52,9 +52,9 @@ def constant_pacing(wait_time: float) -> Callable[["User"], float]:
|
|
|
52
52
|
"""
|
|
53
53
|
|
|
54
54
|
def wait_time_func(self: "User") -> float:
|
|
55
|
-
run_time: float =
|
|
55
|
+
run_time: float = perf_counter() - self._cp_last_run - self._cp_last_wait_time
|
|
56
56
|
self._cp_last_wait_time = max(0, wait_time - run_time)
|
|
57
|
-
self._cp_last_run =
|
|
57
|
+
self._cp_last_run = perf_counter()
|
|
58
58
|
return self._cp_last_wait_time
|
|
59
59
|
|
|
60
60
|
return wait_time_func
|
|
@@ -83,6 +83,8 @@ def constant_throughput(task_runs_per_second: float) -> Callable[["User"], float
|
|
|
83
83
|
If a task execution exceeds the specified wait_time, the wait will be 0 before starting
|
|
84
84
|
the next task.
|
|
85
85
|
"""
|
|
86
|
+
if task_runs_per_second <= 0:
|
|
87
|
+
raise ValueError(f"constant_throughput() requires a positive rate, got: {task_runs_per_second}")
|
|
86
88
|
return constant_pacing(1 / task_runs_per_second)
|
|
87
89
|
|
|
88
90
|
|
|
@@ -24,10 +24,8 @@ def memoize(timeout, dynamic_timeout=False):
|
|
|
24
24
|
return cache["result"]
|
|
25
25
|
|
|
26
26
|
def clear_cache():
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if "result" in cache:
|
|
30
|
-
del cache["result"]
|
|
27
|
+
cache.pop("time", None)
|
|
28
|
+
cache.pop("result", None)
|
|
31
29
|
|
|
32
30
|
wrapper.clear_cache = clear_cache
|
|
33
31
|
return wrapper
|
|
@@ -10,7 +10,7 @@ def format_safe_timestamp(unix_timestamp):
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def format_duration(start_time, end_time):
|
|
13
|
-
seconds = int(end_time
|
|
13
|
+
seconds = int(end_time - start_time)
|
|
14
14
|
days = seconds // 86400
|
|
15
15
|
hours = (seconds % 86400) // 3600
|
|
16
16
|
minutes = (seconds % 3600) // 60
|
|
@@ -20,4 +20,4 @@ def format_duration(start_time, end_time):
|
|
|
20
20
|
|
|
21
21
|
parts = [f"{value} {label}{'s' if value != 1 else ''}" for value, label in time_parts if value > 0]
|
|
22
22
|
|
|
23
|
-
return " and ".join(filter(None, [", ".join(parts[:-1])
|
|
23
|
+
return " and ".join(filter(None, [", ".join(parts[:-1]), *parts[-1:]])) if parts else "0 seconds"
|
|
@@ -274,7 +274,7 @@ class WebUI:
|
|
|
274
274
|
err_msg = f"Invalid user_count value: {value!r} (must be an integer)"
|
|
275
275
|
logger.error(err_msg)
|
|
276
276
|
return jsonify({"success": False, "message": err_msg, "host": environment.host})
|
|
277
|
-
parsed_options_dict["
|
|
277
|
+
parsed_options_dict["num_users"] = user_count
|
|
278
278
|
case "spawn_rate":
|
|
279
279
|
try:
|
|
280
280
|
spawn_rate = float(value)
|
|
@@ -338,8 +338,8 @@ class WebUI:
|
|
|
338
338
|
self._swarm_greenlet = None
|
|
339
339
|
|
|
340
340
|
if environment.runner is not None:
|
|
341
|
-
if user_count is None or
|
|
342
|
-
err_msg = "Missing user_count or spawn_rate from /swarm request"
|
|
341
|
+
if user_count is None or user_count < 0 or spawn_rate is None or spawn_rate <= 0:
|
|
342
|
+
err_msg = "Missing or invalid user_count or spawn_rate from /swarm request"
|
|
343
343
|
logger.error(err_msg)
|
|
344
344
|
return jsonify({"success": False, "message": err_msg, "host": environment.host})
|
|
345
345
|
self._swarm_greenlet = gevent.spawn(environment.runner.start, user_count, spawn_rate)
|
|
@@ -35,7 +35,7 @@ classifiers = [
|
|
|
35
35
|
dependencies = [
|
|
36
36
|
"flask>=2.0.0",
|
|
37
37
|
"Werkzeug>=2.0.0",
|
|
38
|
-
"requests>=2.
|
|
38
|
+
"requests>=2.33.1",
|
|
39
39
|
"msgpack>=1.0.0",
|
|
40
40
|
"pyzmq>=25.0.0",
|
|
41
41
|
"geventhttpclient>=2.3.1",
|
|
@@ -81,9 +81,10 @@ test = [
|
|
|
81
81
|
]
|
|
82
82
|
lint = [
|
|
83
83
|
"pre-commit>=4.5.0,<5.0.0",
|
|
84
|
-
"ruff==0.
|
|
84
|
+
"ruff==0.16.8",
|
|
85
85
|
"mypy>=1.13.0,<1.15.0",
|
|
86
|
-
"types-requests==2.
|
|
86
|
+
"types-requests==2.33.0.20260906",
|
|
87
|
+
"typos>=1.50.1"
|
|
87
88
|
]
|
|
88
89
|
release = ["twine>=5.1.1,<6.0.0"]
|
|
89
90
|
docs = [
|
|
@@ -158,8 +159,8 @@ extend-exclude = [
|
|
|
158
159
|
"src/readthedocs-sphinx-search/",
|
|
159
160
|
"examples/grpc/hello_pb2*.py",
|
|
160
161
|
]
|
|
161
|
-
lint.ignore = ["E402", "E501", "E713", "E731", "E741", "UP031", "PERF203"]
|
|
162
|
-
lint.select = ["E", "F", "W", "UP", "FA102", "I001", "FURB", "PERF"]
|
|
162
|
+
lint.ignore = ["E402", "E501", "E713", "E731", "E741", "UP031", "PERF203", "RUF012", "RUF059", "RUF015"]
|
|
163
|
+
lint.select = ["E", "F", "W", "UP", "FA102", "I001", "FURB", "PERF", "RUF"]
|
|
163
164
|
lint.dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?)|resp)$"
|
|
164
165
|
|
|
165
166
|
[tool.ruff.lint.per-file-ignores]
|
|
@@ -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
|
|
@@ -36,28 +36,28 @@ from .user.wait_time import between, constant, constant_pacing, constant_through
|
|
|
36
36
|
events = Events()
|
|
37
37
|
|
|
38
38
|
__all__ = (
|
|
39
|
-
"
|
|
39
|
+
"FastHttpUser",
|
|
40
|
+
"HttpLocust",
|
|
41
|
+
"HttpUser",
|
|
42
|
+
"LoadTestShape",
|
|
43
|
+
"Locust",
|
|
40
44
|
"MarkovTaskSet",
|
|
41
|
-
"
|
|
42
|
-
"transitions",
|
|
43
|
-
"wait_time",
|
|
44
|
-
"task",
|
|
45
|
-
"tag",
|
|
45
|
+
"SequentialTaskSet",
|
|
46
46
|
"TaskSet",
|
|
47
|
-
"HttpUser",
|
|
48
|
-
"FastHttpUser",
|
|
49
47
|
"User",
|
|
48
|
+
"__version__",
|
|
50
49
|
"between",
|
|
51
50
|
"constant",
|
|
52
51
|
"constant_pacing",
|
|
53
52
|
"constant_throughput",
|
|
54
|
-
"poisson",
|
|
55
53
|
"events",
|
|
56
|
-
"
|
|
54
|
+
"poisson",
|
|
57
55
|
"run_single_user",
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
56
|
+
"tag",
|
|
57
|
+
"task",
|
|
58
|
+
"transition",
|
|
59
|
+
"transitions",
|
|
60
|
+
"wait_time",
|
|
61
61
|
)
|
|
62
62
|
|
|
63
63
|
# Used for raising a DeprecationWarning if old Locust/HttpLocust is used
|
|
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
|