locust 2.46.6.dev12__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.
Files changed (69) hide show
  1. {locust-2.46.6.dev12 → locust-2.46.7}/PKG-INFO +4 -3
  2. {locust-2.46.6.dev12 → locust-2.46.7}/README.md +2 -1
  3. {locust-2.46.6.dev12 → locust-2.46.7}/locust/_version.py +2 -2
  4. {locust-2.46.6.dev12 → locust-2.46.7}/locust/clients.py +13 -24
  5. {locust-2.46.6.dev12 → locust-2.46.7}/locust/contrib/fasthttp.py +29 -10
  6. {locust-2.46.6.dev12 → locust-2.46.7}/locust/dispatch.py +6 -0
  7. {locust-2.46.6.dev12 → locust-2.46.7}/locust/runners.py +5 -7
  8. {locust-2.46.6.dev12 → locust-2.46.7}/locust/stats.py +15 -11
  9. {locust-2.46.6.dev12 → locust-2.46.7}/locust/user/task.py +2 -2
  10. {locust-2.46.6.dev12 → locust-2.46.7}/locust/user/users.py +2 -2
  11. {locust-2.46.6.dev12 → locust-2.46.7}/locust/user/wait_time.py +5 -3
  12. {locust-2.46.6.dev12 → locust-2.46.7}/locust/util/cache.py +2 -4
  13. {locust-2.46.6.dev12 → locust-2.46.7}/locust/util/date.py +2 -2
  14. {locust-2.46.6.dev12 → locust-2.46.7}/locust/util/exception_handler.py +1 -1
  15. {locust-2.46.6.dev12 → locust-2.46.7}/locust/web.py +3 -3
  16. {locust-2.46.6.dev12 → locust-2.46.7}/pyproject.toml +5 -5
  17. {locust-2.46.6.dev12 → locust-2.46.7}/.gitignore +0 -0
  18. {locust-2.46.6.dev12 → locust-2.46.7}/LICENSE +0 -0
  19. {locust-2.46.6.dev12 → locust-2.46.7}/hatch_build.py +0 -0
  20. {locust-2.46.6.dev12 → locust-2.46.7}/locust/__init__.py +13 -13
  21. {locust-2.46.6.dev12 → locust-2.46.7}/locust/__main__.py +0 -0
  22. {locust-2.46.6.dev12 → locust-2.46.7}/locust/argument_parser.py +0 -0
  23. {locust-2.46.6.dev12 → locust-2.46.7}/locust/contrib/__init__.py +0 -0
  24. {locust-2.46.6.dev12 → locust-2.46.7}/locust/contrib/csv_request_logger.py +0 -0
  25. {locust-2.46.6.dev12 → locust-2.46.7}/locust/contrib/dns.py +0 -0
  26. {locust-2.46.6.dev12 → locust-2.46.7}/locust/contrib/milvus.py +0 -0
  27. {locust-2.46.6.dev12 → locust-2.46.7}/locust/contrib/mongodb.py +0 -0
  28. {locust-2.46.6.dev12 → locust-2.46.7}/locust/contrib/mqtt.py +0 -0
  29. {locust-2.46.6.dev12 → locust-2.46.7}/locust/contrib/oai.py +0 -0
  30. {locust-2.46.6.dev12 → locust-2.46.7}/locust/contrib/postgres.py +0 -0
  31. {locust-2.46.6.dev12 → locust-2.46.7}/locust/contrib/qdrant.py +0 -0
  32. {locust-2.46.6.dev12 → locust-2.46.7}/locust/contrib/socketio.py +0 -0
  33. {locust-2.46.6.dev12 → locust-2.46.7}/locust/debug.py +0 -0
  34. {locust-2.46.6.dev12 → locust-2.46.7}/locust/env.py +0 -0
  35. {locust-2.46.6.dev12 → locust-2.46.7}/locust/event.py +0 -0
  36. {locust-2.46.6.dev12 → locust-2.46.7}/locust/exception.py +0 -0
  37. {locust-2.46.6.dev12 → locust-2.46.7}/locust/html.py +0 -0
  38. {locust-2.46.6.dev12 → locust-2.46.7}/locust/input_events.py +0 -0
  39. {locust-2.46.6.dev12 → locust-2.46.7}/locust/log.py +0 -0
  40. {locust-2.46.6.dev12 → locust-2.46.7}/locust/main.py +0 -0
  41. {locust-2.46.6.dev12 → locust-2.46.7}/locust/opentelemetry.py +0 -0
  42. {locust-2.46.6.dev12 → locust-2.46.7}/locust/py.typed +0 -0
  43. {locust-2.46.6.dev12 → locust-2.46.7}/locust/rpc/__init__.py +0 -0
  44. {locust-2.46.6.dev12 → locust-2.46.7}/locust/rpc/protocol.py +0 -0
  45. {locust-2.46.6.dev12 → locust-2.46.7}/locust/rpc/zmqrpc.py +0 -0
  46. {locust-2.46.6.dev12 → locust-2.46.7}/locust/shape.py +0 -0
  47. {locust-2.46.6.dev12 → locust-2.46.7}/locust/user/__init__.py +2 -2
  48. {locust-2.46.6.dev12 → locust-2.46.7}/locust/user/inspectuser.py +0 -0
  49. {locust-2.46.6.dev12 → locust-2.46.7}/locust/user/markov_taskset.py +0 -0
  50. {locust-2.46.6.dev12 → locust-2.46.7}/locust/user/sequential_taskset.py +0 -0
  51. {locust-2.46.6.dev12 → locust-2.46.7}/locust/util/__init__.py +0 -0
  52. {locust-2.46.6.dev12 → locust-2.46.7}/locust/util/deprecation.py +0 -0
  53. {locust-2.46.6.dev12 → locust-2.46.7}/locust/util/directory.py +0 -0
  54. {locust-2.46.6.dev12 → locust-2.46.7}/locust/util/load_locustfile.py +0 -0
  55. {locust-2.46.6.dev12 → locust-2.46.7}/locust/util/rounding.py +0 -0
  56. {locust-2.46.6.dev12 → locust-2.46.7}/locust/util/timespan.py +0 -0
  57. {locust-2.46.6.dev12 → locust-2.46.7}/locust/util/url.py +0 -0
  58. {locust-2.46.6.dev12 → locust-2.46.7}/locust/webui/dist/assets/favicon-dark.png +0 -0
  59. {locust-2.46.6.dev12 → locust-2.46.7}/locust/webui/dist/assets/favicon-light.png +0 -0
  60. {locust-2.46.6.dev12 → locust-2.46.7}/locust/webui/dist/assets/graphs-dark.png +0 -0
  61. {locust-2.46.6.dev12 → locust-2.46.7}/locust/webui/dist/assets/graphs-light.png +0 -0
  62. {locust-2.46.6.dev12 → locust-2.46.7}/locust/webui/dist/assets/src-pSlzKFX1.js +0 -0
  63. {locust-2.46.6.dev12 → locust-2.46.7}/locust/webui/dist/assets/terminal.gif +0 -0
  64. {locust-2.46.6.dev12 → locust-2.46.7}/locust/webui/dist/assets/testruns-dark.png +0 -0
  65. {locust-2.46.6.dev12 → locust-2.46.7}/locust/webui/dist/assets/testruns-light.png +0 -0
  66. {locust-2.46.6.dev12 → locust-2.46.7}/locust/webui/dist/auth.html +0 -0
  67. {locust-2.46.6.dev12 → locust-2.46.7}/locust/webui/dist/index.html +0 -0
  68. {locust-2.46.6.dev12 → locust-2.46.7}/locust/webui/dist/report.html +0 -0
  69. {locust-2.46.6.dev12 → 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.6.dev12
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.32.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.6.dev12'
22
- __version_tuple__ = version_tuple = (2, 46, 6, 'dev12')
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] | tuple[float, None] | None
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
- if requests_version >= (2, 32, 5):
65
- _preloaded_ssl_context = create_urllib3_context()
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
- netloc = parsed_url.hostname
108
- if parsed_url.port:
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
- # In python requests version 2.32.5 they reverted
495
- # https://github.com/psf/requests/pull/6667
496
- # Without this change the root CA certificates are loaded on every request
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 requests_version >= (2, 32, 5) and verify is True:
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
- netloc = parsed_url.hostname or ""
132
- if parsed_url.port:
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
- self.auth_header = _construct_basic_auth_str(parsed_url.username, parsed_url.password)
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 or {}
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:
@@ -248,7 +266,7 @@ class FastHttpSession:
248
266
 
249
267
  if not allow_redirects:
250
268
  old_redirect_resonse_codes = self.client.redirect_resonse_codes
251
- self.client.redirect_resonse_codes = frozenset()
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 = old_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
- self.state == STATE_STOPPING
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 {str(client.id)} failed to send heartbeat, setting state to missing.")
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 ({repr(msg.data)}) connected, master version is {repr(__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 {str(c.id)} self-healed with heartbeat, setting state to {client_state}.")
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:
@@ -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"%-{str(STATS_TYPE_WIDTH)}s %-{str(STATS_NAME_WIDTH)}s %8d {' '.join(['%6d'] * len(PERCENTILES_TO_REPORT))}"
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
- (self.method or "", self.name)
668
- + tuple(self.get_response_time_percentile(p) for p in PERCENTILES_TO_REPORT)
669
- + (self.num_requests,)
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") + tuple(get_readable_percentiles(PERCENTILES_TO_REPORT)) + ("# reqs",)
903
+ headers = ("Type", "Name", *tuple(get_readable_percentiles(PERCENTILES_TO_REPORT)), "# reqs")
901
904
  summary.append(
902
- (f"%-{str(STATS_TYPE_WIDTH)}s %-{str(STATS_NAME_WIDTH)}s %8s {' '.join(['%6s'] * len(PERCENTILES_TO_REPORT))}")
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
- ] + get_readable_percentiles(self.percentiles_to_report)
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 will
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 = time() # used by constant_pacing wait_time
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.time() # used by constant_pacing wait_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 or {}
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 time
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 = time() - self._cp_last_run - self._cp_last_wait_time
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 = time()
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
- if "time" in cache:
28
- del cache["time"]
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) - int(start_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])] + parts[-1:])) if parts else "0 seconds"
23
+ return " and ".join(filter(None, [", ".join(parts[:-1]), *parts[-1:]])) if parts else "0 seconds"
@@ -8,7 +8,7 @@ def retry(delays=(1, 3, 5), exception=Exception):
8
8
  def decorator(function):
9
9
  def wrapper(*args, **kwargs):
10
10
  cnt = 0
11
- for delay in delays + (None,):
11
+ for delay in (*delays, None):
12
12
  try:
13
13
  return function(*args, **kwargs)
14
14
  except exception as e:
@@ -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["users"] = user_count
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 not spawn_rate:
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.32.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,9 @@ test = [
81
81
  ]
82
82
  lint = [
83
83
  "pre-commit>=4.5.0,<5.0.0",
84
- "ruff==0.15.12",
84
+ "ruff==0.16.8",
85
85
  "mypy>=1.13.0,<1.15.0",
86
- "types-requests==2.32.4.20250809",
86
+ "types-requests==2.33.0.20260906",
87
87
  "typos>=1.50.1"
88
88
  ]
89
89
  release = ["twine>=5.1.1,<6.0.0"]
@@ -159,8 +159,8 @@ extend-exclude = [
159
159
  "src/readthedocs-sphinx-search/",
160
160
  "examples/grpc/hello_pb2*.py",
161
161
  ]
162
- lint.ignore = ["E402", "E501", "E713", "E731", "E741", "UP031", "PERF203"]
163
- 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"]
164
164
  lint.dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?)|resp)$"
165
165
 
166
166
  [tool.ruff.lint.per-file-ignores]
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
- "SequentialTaskSet",
39
+ "FastHttpUser",
40
+ "HttpLocust",
41
+ "HttpUser",
42
+ "LoadTestShape",
43
+ "Locust",
40
44
  "MarkovTaskSet",
41
- "transition",
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
- "LoadTestShape",
54
+ "poisson",
57
55
  "run_single_user",
58
- "HttpLocust",
59
- "Locust",
60
- "__version__",
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
@@ -1,9 +1,9 @@
1
1
  __all__ = (
2
2
  "HttpUser",
3
- "tag",
4
- "task",
5
3
  "TaskSet",
6
4
  "User",
5
+ "tag",
6
+ "task",
7
7
  )
8
8
  from .task import TaskSet, tag, task
9
9
  from .users import HttpUser, User