locust 2.29.2.dev72__tar.gz → 2.30.1__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 (45) hide show
  1. {locust-2.29.2.dev72 → locust-2.30.1}/PKG-INFO +1 -1
  2. {locust-2.29.2.dev72 → locust-2.30.1}/locust/_version.py +2 -2
  3. {locust-2.29.2.dev72 → locust-2.30.1}/locust/argument_parser.py +34 -33
  4. {locust-2.29.2.dev72 → locust-2.30.1}/locust/dispatch.py +27 -48
  5. {locust-2.29.2.dev72 → locust-2.30.1}/locust/env.py +5 -0
  6. {locust-2.29.2.dev72 → locust-2.30.1}/locust/html.py +6 -18
  7. {locust-2.29.2.dev72 → locust-2.30.1}/locust/main.py +16 -1
  8. {locust-2.29.2.dev72 → locust-2.30.1}/locust/runners.py +30 -16
  9. {locust-2.29.2.dev72 → locust-2.30.1}/locust/stats.py +3 -0
  10. locust-2.30.1/locust/util/directory.py +12 -0
  11. locust-2.30.1/locust/util/url.py +15 -0
  12. {locust-2.29.2.dev72 → locust-2.30.1}/locust/web.py +16 -8
  13. {locust-2.29.2.dev72 → locust-2.30.1}/pyproject.toml +1 -1
  14. {locust-2.29.2.dev72 → locust-2.30.1}/LICENSE +0 -0
  15. {locust-2.29.2.dev72 → locust-2.30.1}/README.md +0 -0
  16. {locust-2.29.2.dev72 → locust-2.30.1}/locust/__init__.py +0 -0
  17. {locust-2.29.2.dev72 → locust-2.30.1}/locust/__main__.py +0 -0
  18. {locust-2.29.2.dev72 → locust-2.30.1}/locust/clients.py +0 -0
  19. {locust-2.29.2.dev72 → locust-2.30.1}/locust/contrib/__init__.py +0 -0
  20. {locust-2.29.2.dev72 → locust-2.30.1}/locust/contrib/fasthttp.py +0 -0
  21. {locust-2.29.2.dev72 → locust-2.30.1}/locust/debug.py +0 -0
  22. {locust-2.29.2.dev72 → locust-2.30.1}/locust/event.py +0 -0
  23. {locust-2.29.2.dev72 → locust-2.30.1}/locust/exception.py +0 -0
  24. {locust-2.29.2.dev72 → locust-2.30.1}/locust/input_events.py +0 -0
  25. {locust-2.29.2.dev72 → locust-2.30.1}/locust/log.py +0 -0
  26. {locust-2.29.2.dev72 → locust-2.30.1}/locust/py.typed +0 -0
  27. {locust-2.29.2.dev72 → locust-2.30.1}/locust/rpc/__init__.py +0 -0
  28. {locust-2.29.2.dev72 → locust-2.30.1}/locust/rpc/protocol.py +0 -0
  29. {locust-2.29.2.dev72 → locust-2.30.1}/locust/rpc/zmqrpc.py +0 -0
  30. {locust-2.29.2.dev72 → locust-2.30.1}/locust/shape.py +0 -0
  31. {locust-2.29.2.dev72 → locust-2.30.1}/locust/user/__init__.py +0 -0
  32. {locust-2.29.2.dev72 → locust-2.30.1}/locust/user/inspectuser.py +0 -0
  33. {locust-2.29.2.dev72 → locust-2.30.1}/locust/user/sequential_taskset.py +0 -0
  34. {locust-2.29.2.dev72 → locust-2.30.1}/locust/user/task.py +0 -0
  35. {locust-2.29.2.dev72 → locust-2.30.1}/locust/user/users.py +0 -0
  36. {locust-2.29.2.dev72 → locust-2.30.1}/locust/user/wait_time.py +0 -0
  37. {locust-2.29.2.dev72 → locust-2.30.1}/locust/util/__init__.py +0 -0
  38. {locust-2.29.2.dev72 → locust-2.30.1}/locust/util/cache.py +0 -0
  39. {locust-2.29.2.dev72 → locust-2.30.1}/locust/util/date.py +0 -0
  40. {locust-2.29.2.dev72 → locust-2.30.1}/locust/util/deprecation.py +0 -0
  41. {locust-2.29.2.dev72 → locust-2.30.1}/locust/util/exception_handler.py +0 -0
  42. {locust-2.29.2.dev72 → locust-2.30.1}/locust/util/load_locustfile.py +0 -0
  43. {locust-2.29.2.dev72 → locust-2.30.1}/locust/util/rounding.py +0 -0
  44. {locust-2.29.2.dev72 → locust-2.30.1}/locust/util/timespan.py +0 -0
  45. {locust-2.29.2.dev72 → locust-2.30.1}/pre_build.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: locust
3
- Version: 2.29.2.dev72
3
+ Version: 2.30.1
4
4
  Summary: Developer-friendly load testing framework
5
5
  Home-page: https://locust.io/
6
6
  License: MIT
@@ -14,7 +14,7 @@ __version_tuple__: VERSION_TUPLE
14
14
  version_tuple: VERSION_TUPLE
15
15
 
16
16
 
17
- __version__ = "2.29.2.dev72"
17
+ __version__ = "2.30.1"
18
18
  version = __version__
19
- __version_tuple__ = (2, 29, 2, "dev72")
19
+ __version_tuple__ = (2, 30, 1)
20
20
  version_tuple = __version_tuple__
@@ -15,7 +15,6 @@ import tempfile
15
15
  import textwrap
16
16
  from collections import OrderedDict
17
17
  from typing import Any, NamedTuple
18
- from urllib.parse import urlparse
19
18
  from uuid import uuid4
20
19
 
21
20
  if sys.version_info >= (3, 11):
@@ -27,6 +26,9 @@ import configargparse
27
26
  import gevent
28
27
  import requests
29
28
 
29
+ from .util.directory import get_abspaths_in
30
+ from .util.url import is_url
31
+
30
32
  version = locust.__version__
31
33
 
32
34
 
@@ -125,14 +127,7 @@ def _parse_locustfile_path(path: str) -> list[str]:
125
127
  parsed_paths.append(download_locustfile_from_url(path))
126
128
  elif os.path.isdir(path):
127
129
  # Find all .py files in directory tree
128
- for root, _dirs, fs in os.walk(path):
129
- parsed_paths.extend(
130
- [
131
- os.path.abspath(os.path.join(root, f))
132
- for f in fs
133
- if os.path.isfile(os.path.join(root, f)) and f.endswith(".py") and not f.startswith("_")
134
- ]
135
- )
130
+ parsed_paths.extend(get_abspaths_in(path, extension=".py"))
136
131
  if not parsed_paths:
137
132
  sys.stderr.write(f"Could not find any locustfiles in directory '{path}'")
138
133
  sys.exit(1)
@@ -148,20 +143,6 @@ def _parse_locustfile_path(path: str) -> list[str]:
148
143
  return parsed_paths
149
144
 
150
145
 
151
- def is_url(url: str) -> bool:
152
- """
153
- Check if path is an url
154
- """
155
- try:
156
- result = urlparse(url)
157
- if result.scheme == "https" or result.scheme == "http":
158
- return True
159
- else:
160
- return False
161
- except ValueError:
162
- return False
163
-
164
-
165
146
  def download_locustfile_from_url(url: str) -> str:
166
147
  """
167
148
  Attempt to download and save locustfile from url.
@@ -238,7 +219,7 @@ def download_locustfile_from_master(master_host: str, master_port: int) -> str:
238
219
 
239
220
  def ask_for_locustfile():
240
221
  while not got_reply:
241
- tempclient.send(Message("locustfile", None, client_id))
222
+ tempclient.send(Message("locustfile", {"version": version}, client_id))
242
223
  gevent.sleep(1)
243
224
 
244
225
  def log_warning():
@@ -271,14 +252,26 @@ def download_locustfile_from_master(master_host: str, master_port: int) -> str:
271
252
  sys.stderr.write(f"Got error from master: {msg.data['error']}\n")
272
253
  sys.exit(1)
273
254
 
274
- filename = msg.data["filename"]
275
- with open(os.path.join(tempfile.gettempdir(), filename), "w", encoding="utf-8") as locustfile:
276
- locustfile.write(msg.data["contents"])
255
+ tempclient.close()
256
+ return msg.data.get("locustfiles", [])
277
257
 
278
- atexit.register(exit_handler, locustfile.name)
279
258
 
280
- tempclient.close()
281
- return locustfile.name
259
+ def parse_locustfiles_from_master(locustfile_sources) -> list[str]:
260
+ locustfiles = []
261
+
262
+ for source in locustfile_sources:
263
+ if "contents" in source:
264
+ filename = source["filename"]
265
+ file_contents = source["contents"]
266
+
267
+ with open(os.path.join(tempfile.gettempdir(), filename), "w", encoding="utf-8") as locustfile:
268
+ locustfile.write(file_contents)
269
+
270
+ locustfiles.append(locustfile.name)
271
+ else:
272
+ locustfiles.append(source)
273
+
274
+ return locustfiles
282
275
 
283
276
 
284
277
  def parse_locustfile_option(args=None) -> list[str]:
@@ -339,10 +332,11 @@ def parse_locustfile_option(args=None) -> list[str]:
339
332
  )
340
333
  sys.exit(1)
341
334
  # having this in argument_parser module is a bit weird, but it needs to be done early
342
- filename = download_locustfile_from_master(options.master_host, options.master_port)
343
- return [filename]
335
+ locustfile_sources = download_locustfile_from_master(options.master_host, options.master_port)
336
+ locustfile_list = parse_locustfiles_from_master(locustfile_sources)
337
+ else:
338
+ locustfile_list = [f.strip() for f in options.locustfile.split(",")]
344
339
 
345
- locustfile_list = [f.strip() for f in options.locustfile.split(",")]
346
340
  parsed_paths = parse_locustfile_paths(locustfile_list)
347
341
 
348
342
  if not parsed_paths:
@@ -502,6 +496,13 @@ def setup_parser_arguments(parser):
502
496
  help="Enable select boxes in the web interface to choose from all available User classes and Shape classes",
503
497
  env_var="LOCUST_USERCLASS_PICKER",
504
498
  )
499
+ web_ui_group.add_argument(
500
+ "--build-path",
501
+ type=str,
502
+ default="",
503
+ help=configargparse.SUPPRESS,
504
+ env_var="LOCUST_BUILD_PATH",
505
+ )
505
506
  web_ui_group.add_argument(
506
507
  "--legacy-ui",
507
508
  default=False,
@@ -5,7 +5,7 @@ import itertools
5
5
  import math
6
6
  import time
7
7
  from collections import defaultdict
8
- from collections.abc import Generator, Iterator
8
+ from collections.abc import Iterator
9
9
  from heapq import heapify, heapreplace
10
10
  from math import log2
11
11
  from operator import attrgetter
@@ -17,41 +17,31 @@ if TYPE_CHECKING:
17
17
  from locust import User
18
18
  from locust.runners import WorkerNode
19
19
 
20
+ from collections.abc import Generator, Iterable
21
+ from typing import TypeVar
20
22
 
21
- # To profile line-by-line, uncomment the code below (i.e. `import line_profiler ...`) and
22
- # place `@profile` on the functions/methods you wish to profile. Then, in the unit test you are
23
- # running, use `from locust.dispatch import profile; profile.print_stats()` at the end of the unit test.
24
- # Placing it in a `finally` block is recommended.
25
- # import line_profiler
26
- #
27
- # profile = line_profiler.LineProfiler()
23
+ T = TypeVar("T")
28
24
 
29
25
 
30
- def _kl_generator(users: list[tuple[type[User], float]]) -> Iterator[str | None]:
26
+ def _kl_generator(users: Iterable[tuple[T, float]]) -> Iterator[T | None]:
31
27
  """Generator based on Kullback-Leibler divergence
32
28
 
33
29
  For example, given users A, B with weights 5 and 1 respectively,
34
30
  this algorithm will yield AAABAAAAABAA.
35
31
  """
36
- if not users:
32
+ heap = [(x * log2(x / (x + 1.0)), x + 1.0, x, name) for name, x in users]
33
+ if not heap:
37
34
  while True:
38
35
  yield None
39
36
 
40
- names = [u[0].__name__ for u in users]
41
- weights = [u[1] for u in users]
42
- generated = weights.copy()
43
-
44
- heap = [(x * log2(x / (x + 1.0)), i) for i, x in enumerate(generated)]
45
37
  heapify(heap)
46
-
47
38
  while True:
48
- i = heap[0][1] # choose element which choosing minimizes divergence the most
49
- yield names[i]
50
- generated[i] += 1.0
51
- x = generated[i]
52
- kl_diff = weights[i] * log2(x / (x + 1.0))
39
+ _, x, weight, name = heap[0]
40
+ # (divergence diff, number of generated elements + initial weight, initial weight, name) = heap[0]
41
+ yield name
42
+ kl_diff = weight * log2(x / (x + 1.0))
53
43
  # calculate how much choosing element i for (x + 1)th time decreases divergence
54
- heapreplace(heap, (kl_diff, i))
44
+ heapreplace(heap, (kl_diff, x + 1.0, weight, name))
55
45
 
56
46
 
57
47
  class UsersDispatcher(Iterator):
@@ -378,35 +368,24 @@ class UsersDispatcher(Iterator):
378
368
  return users_on_workers, user_gen, worker_gen, active_users
379
369
 
380
370
  def _user_gen(self) -> Iterator[str | None]:
381
- fixed_users = {u.__name__: u for u in self._user_classes if u.fixed_count}
382
-
383
- fixed_users_gen = _kl_generator([(u, u.fixed_count) for u in fixed_users.values()])
384
- weighted_users_gen = _kl_generator([(u, u.weight) for u in self._user_classes if not u.fixed_count])
371
+ weighted_users_gen = _kl_generator((u.__name__, u.weight) for u in self._user_classes if not u.fixed_count)
385
372
 
386
- # Spawn users
387
373
  while True:
388
- if self._try_dispatch_fixed:
374
+ if self._try_dispatch_fixed: # Fixed_count users are spawned before weight users.
375
+ # Some peoples treat this implementation detail as a feature.
389
376
  self._try_dispatch_fixed = False
390
- current_fixed_users_count = {u: self._get_user_current_count(u) for u in fixed_users}
391
- spawned_classes: set[str] = set()
392
- while len(spawned_classes) != len(fixed_users):
393
- user_name: str | None = next(fixed_users_gen)
394
- if not user_name:
395
- break
396
-
397
- if current_fixed_users_count[user_name] < fixed_users[user_name].fixed_count:
398
- current_fixed_users_count[user_name] += 1
399
- yield user_name
400
-
401
- # 'self._try_dispatch_fixed' was changed outhere, we have to recalculate current count
402
- if self._try_dispatch_fixed:
403
- current_fixed_users_count = {u: self._get_user_current_count(u) for u in fixed_users}
404
- spawned_classes.clear()
405
- self._try_dispatch_fixed = False
406
- else:
407
- spawned_classes.add(user_name)
408
-
409
- yield next(weighted_users_gen)
377
+ fixed_users_missing = [
378
+ (u.__name__, miss)
379
+ for u in self._user_classes
380
+ if u.fixed_count and (miss := u.fixed_count - self._get_user_current_count(u.__name__)) > 0
381
+ ]
382
+ total_miss = sum(miss for _, miss in fixed_users_missing)
383
+ fixed_users_gen = _kl_generator(fixed_users_missing) # type: ignore[arg-type]
384
+ # https://mypy.readthedocs.io/en/stable/common_issues.html#variance
385
+ for _ in range(total_miss):
386
+ yield next(fixed_users_gen)
387
+ else:
388
+ yield next(weighted_users_gen)
410
389
 
411
390
  @staticmethod
412
391
  def _fast_users_on_workers_copy(users_on_workers: dict[str, dict[str, int]]) -> dict[str, dict[str, int]]:
@@ -33,6 +33,7 @@ class Environment:
33
33
  stop_timeout: float | None = None,
34
34
  catch_exceptions=True,
35
35
  parsed_options: Namespace | None = None,
36
+ parsed_locustfiles: list[str] | None = None,
36
37
  available_user_classes: dict[str, User] | None = None,
37
38
  available_shape_classes: dict[str, LoadTestShape] | None = None,
38
39
  available_user_tasks: dict[str, list[TaskSet | Callable]] | None = None,
@@ -91,6 +92,8 @@ class Environment:
91
92
  """
92
93
  self.parsed_options = parsed_options
93
94
  """Reference to the parsed command line options (used to pre-populate fields in Web UI). When using Locust as a library, this should either be `None` or an object created by `argument_parser.parse_args()`"""
95
+ self.parsed_locustfiles = parsed_locustfiles
96
+ """A list of all locustfiles for the test"""
94
97
  self.available_user_classes = available_user_classes
95
98
  """List of the available User Classes to pick from in the UserClass Picker"""
96
99
  self.available_shape_classes = available_shape_classes
@@ -168,6 +171,7 @@ class Environment:
168
171
  stats_csv_writer: StatsCSV | None = None,
169
172
  delayed_start=False,
170
173
  userclass_picker_is_active=False,
174
+ build_path: str | None = None,
171
175
  ) -> WebUI:
172
176
  """
173
177
  Creates a :class:`WebUI <locust.web.WebUI>` instance for this Environment and start running the web server
@@ -194,6 +198,7 @@ class Environment:
194
198
  stats_csv_writer=stats_csv_writer,
195
199
  delayed_start=delayed_start,
196
200
  userclass_picker_is_active=userclass_picker_is_active,
201
+ build_path=build_path,
197
202
  )
198
203
  return self.web_ui
199
204
 
@@ -5,7 +5,8 @@ from html import escape
5
5
  from itertools import chain
6
6
  from json import dumps
7
7
 
8
- from jinja2 import Environment, FileSystemLoader
8
+ from jinja2 import Environment as JinjaEnvironment
9
+ from jinja2 import FileSystemLoader
9
10
 
10
11
  from . import stats as stats_module
11
12
  from .runners import STATE_STOPPED, STATE_STOPPING, MasterRunner
@@ -14,13 +15,11 @@ from .user.inspectuser import get_ratio
14
15
  from .util.date import format_utc_timestamp
15
16
 
16
17
  PERCENTILES_FOR_HTML_REPORT = [0.50, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99, 1.0]
17
- ROOT_PATH = os.path.dirname(os.path.abspath(__file__))
18
- BUILD_PATH = os.path.join(ROOT_PATH, "webui", "dist")
19
- STATIC_PATH = os.path.join(BUILD_PATH, "assets")
18
+ DEFAULT_BUILD_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "webui", "dist")
20
19
 
21
20
 
22
- def render_template(file, **kwargs):
23
- env = Environment(loader=FileSystemLoader(BUILD_PATH), extensions=["jinja2.ext.do"])
21
+ def render_template_from(file, build_path=DEFAULT_BUILD_PATH, **kwargs):
22
+ env = JinjaEnvironment(loader=FileSystemLoader(build_path))
24
23
  template = env.get_template(file)
25
24
  return template.render(**kwargs)
26
25
 
@@ -56,16 +55,6 @@ def get_html_report(
56
55
  update_stats_history(environment.runner)
57
56
  history = stats.history
58
57
 
59
- static_js = []
60
- js_files = [os.path.basename(filepath) for filepath in glob.glob(os.path.join(STATIC_PATH, "*.js"))]
61
-
62
- for js_file in js_files:
63
- path = os.path.join(STATIC_PATH, js_file)
64
- static_js.append("// " + js_file + "\n")
65
- with open(path, encoding="utf8") as f:
66
- static_js.append(f.read())
67
- static_js.extend(["", ""])
68
-
69
58
  is_distributed = isinstance(environment.runner, MasterRunner)
70
59
  user_spawned = (
71
60
  environment.runner.reported_user_classes_count if is_distributed else environment.runner.user_classes_count
@@ -79,7 +68,7 @@ def get_html_report(
79
68
  "total": get_ratio(environment.user_classes, user_spawned, True),
80
69
  }
81
70
 
82
- return render_template(
71
+ return render_template_from(
83
72
  "report.html",
84
73
  template_args={
85
74
  "is_report": True,
@@ -107,5 +96,4 @@ def get_html_report(
107
96
  "percentiles_to_chart": stats_module.PERCENTILES_TO_CHART,
108
97
  },
109
98
  theme=theme,
110
- static_js="\n".join(static_js),
111
99
  )
@@ -36,11 +36,15 @@ from .user.inspectuser import print_task_ratio, print_task_ratio_json
36
36
  from .util.load_locustfile import load_locustfile
37
37
  from .util.timespan import parse_timespan
38
38
 
39
+ # import external plugins if installed to allow for registering custom arguments etc
39
40
  try:
40
- # import locust_plugins if it is installed, to allow it to register custom arguments etc
41
41
  import locust_plugins # pyright: ignore[reportMissingImports]
42
42
  except ModuleNotFoundError:
43
43
  pass
44
+ try:
45
+ import locust_cloud # pyright: ignore[reportMissingImports]
46
+ except ModuleNotFoundError:
47
+ pass
44
48
 
45
49
  version = locust.__version__
46
50
 
@@ -59,6 +63,7 @@ def create_environment(
59
63
  events=None,
60
64
  shape_class=None,
61
65
  locustfile=None,
66
+ parsed_locustfiles=None,
62
67
  available_user_classes=None,
63
68
  available_shape_classes=None,
64
69
  available_user_tasks=None,
@@ -74,6 +79,7 @@ def create_environment(
74
79
  host=options.host,
75
80
  reset_stats=options.reset_stats,
76
81
  parsed_options=options,
82
+ parsed_locustfiles=parsed_locustfiles,
77
83
  available_user_classes=available_user_classes,
78
84
  available_shape_classes=available_shape_classes,
79
85
  available_user_tasks=available_user_tasks,
@@ -322,6 +328,13 @@ def main():
322
328
  # list() call is needed to consume the dict_view object in Python 3
323
329
  user_classes = list(user_classes.values())
324
330
 
331
+ if not shape_class and options.num_users:
332
+ fixed_count_total = sum([user_class.fixed_count for user_class in user_classes])
333
+ if fixed_count_total > options.num_users:
334
+ logger.info(
335
+ f"Total fixed_count of User classes ({fixed_count_total}) is greater than the specified number of users ({options.num_users}), so not all will be spawned."
336
+ )
337
+
325
338
  if os.name != "nt" and not options.master:
326
339
  try:
327
340
  import resource
@@ -349,6 +362,7 @@ See https://github.com/locustio/locust/wiki/Installation#increasing-maximum-numb
349
362
  events=locust.events,
350
363
  shape_class=shape_class,
351
364
  locustfile=locustfile_path,
365
+ parsed_locustfiles=locustfiles,
352
366
  available_user_classes=available_user_classes,
353
367
  available_shape_classes=available_shape_classes,
354
368
  available_user_tasks=available_user_tasks,
@@ -479,6 +493,7 @@ See https://github.com/locustio/locust/wiki/Installation#increasing-maximum-numb
479
493
  stats_csv_writer=stats_csv_writer,
480
494
  delayed_start=True,
481
495
  userclass_picker_is_active=options.class_picker,
496
+ build_path=options.build_path,
482
497
  )
483
498
  else:
484
499
  web_ui = None
@@ -32,6 +32,8 @@ from .exception import RPCError, RPCReceiveError, RPCSendError
32
32
  from .log import get_logs, greenlet_exception_logger
33
33
  from .rpc import Message, rpc
34
34
  from .stats import RequestStats, StatsError, setup_distributed_stats_event_listeners
35
+ from .util.directory import get_abspaths_in
36
+ from .util.url import is_url
35
37
 
36
38
  if TYPE_CHECKING:
37
39
  from . import User
@@ -1024,33 +1026,45 @@ class MasterRunner(DistributedRunner):
1024
1026
  # if abs(time() - msg.data["time"]) > 5.0:
1025
1027
  # warnings.warn("The worker node's clock seem to be out of sync. For the statistics to be correct the different locust servers need to have synchronized clocks.")
1026
1028
  elif msg.type == "locustfile":
1029
+ if msg.data["version"][0:4] == __version__[0:4]:
1030
+ logger.debug(
1031
+ f"A worker ({msg.node_id}) running a different patch version ({msg.data['version']}) connected, master version is {__version__}"
1032
+ )
1033
+
1027
1034
  logging.debug("Worker requested locust file")
1028
- assert self.environment.parsed_options
1029
- filename = self.environment.parsed_options.locustfile
1035
+ assert self.environment.parsed_locustfiles
1036
+ locustfile_options = self.environment.parsed_locustfiles
1037
+ locustfile_list = [f.strip() for f in locustfile_options if not os.path.isdir(f)]
1038
+
1039
+ for locustfile_option in locustfile_options:
1040
+ if os.path.isdir(locustfile_option):
1041
+ locustfile_list.extend(get_abspaths_in(locustfile_option, extension=".py"))
1042
+
1030
1043
  try:
1031
- with open(filename) as f:
1032
- file_contents = f.read()
1044
+ locustfiles: list[str | dict[str, str]] = []
1045
+
1046
+ for filename in locustfile_list:
1047
+ if is_url(filename):
1048
+ locustfiles.append(filename)
1049
+ else:
1050
+ with open(filename) as f:
1051
+ filename = os.path.basename(filename)
1052
+ file_contents = f.read()
1053
+
1054
+ locustfiles.append({"filename": filename, "contents": file_contents})
1033
1055
  except Exception as e:
1034
- logger.error(
1035
- f"--locustfile must be a full path to a single locustfile for file distribution to work {e}"
1036
- )
1056
+ error_message = "locustfile must be a full path to a single locustfile, a comma-separated list of .py files, or a URL for file distribution to work"
1057
+ logger.error(f"{error_message} {e}")
1037
1058
  self.send_message(
1038
1059
  "locustfile",
1039
1060
  client_id=client_id,
1040
- data={
1041
- "error": f"locustfile must be a full path to a single locustfile for file distribution to work (was '{filename}')"
1042
- },
1061
+ data={"error": f"{error_message} (was '{filename}')"},
1043
1062
  )
1044
1063
  else:
1045
- if getattr(self, "_old_file_contents", file_contents) != file_contents:
1046
- logger.warning(
1047
- "Locustfile contents changed on disk after first worker requested locustfile, sending new content. If you make any major changes (like changing User class names) you need to restart master."
1048
- )
1049
- self._old_file_contents = file_contents
1050
1064
  self.send_message(
1051
1065
  "locustfile",
1052
1066
  client_id=client_id,
1053
- data={"filename": os.path.basename(filename), "contents": file_contents},
1067
+ data={"locustfiles": locustfiles},
1054
1068
  )
1055
1069
  continue
1056
1070
  elif msg.type == "client_stopped":
@@ -679,7 +679,10 @@ class StatsEntry:
679
679
  "max_response_time": proper_round(self.max_response_time),
680
680
  "current_rps": self.current_rps,
681
681
  "current_fail_per_sec": self.current_fail_per_sec,
682
+ "avg_response_time": self.avg_response_time,
682
683
  "median_response_time": self.median_response_time,
684
+ "total_rps": self.total_rps,
685
+ "total_fail_per_sec": self.total_fail_per_sec,
683
686
  **response_time_percentiles,
684
687
  "avg_content_length": self.avg_content_length,
685
688
  }
@@ -0,0 +1,12 @@
1
+ import os
2
+
3
+
4
+ def get_abspaths_in(path, extension=None):
5
+ return [
6
+ os.path.abspath(os.path.join(root, f))
7
+ for root, _dirs, fs in os.walk(path)
8
+ for f in fs
9
+ if os.path.isfile(os.path.join(root, f))
10
+ and (f.endswith(extension) or extension is None)
11
+ and not f.startswith("_")
12
+ ]
@@ -0,0 +1,15 @@
1
+ from urllib.parse import urlparse
2
+
3
+
4
+ def is_url(url: str) -> bool:
5
+ """
6
+ Check if path is an url
7
+ """
8
+ try:
9
+ result = urlparse(url)
10
+ if result.scheme == "https" or result.scheme == "http":
11
+ return True
12
+ else:
13
+ return False
14
+ except ValueError:
15
+ return False
@@ -33,12 +33,13 @@ from gevent import pywsgi
33
33
  from . import __version__ as version
34
34
  from . import argument_parser
35
35
  from . import stats as stats_module
36
- from .html import BUILD_PATH, ROOT_PATH, STATIC_PATH, get_html_report
36
+ from .html import DEFAULT_BUILD_PATH, get_html_report, render_template_from
37
37
  from .log import get_logs, greenlet_exception_logger
38
38
  from .runners import STATE_MISSING, STATE_RUNNING, MasterRunner
39
39
  from .stats import StatsCSV, StatsCSVFileWriter, StatsErrorDict, sort_stats
40
40
  from .user.inspectuser import get_ratio
41
41
  from .util.cache import memoize
42
+ from .util.date import format_utc_timestamp
42
43
  from .util.timespan import parse_timespan
43
44
 
44
45
  if TYPE_CHECKING:
@@ -96,6 +97,7 @@ class WebUI:
96
97
  stats_csv_writer: StatsCSV | None = None,
97
98
  delayed_start=False,
98
99
  userclass_picker_is_active=False,
100
+ build_path: str | None = None,
99
101
  ):
100
102
  """
101
103
  Create WebUI instance and start running the web server in a separate greenlet (self.greenlet)
@@ -124,14 +126,11 @@ class WebUI:
124
126
  self.app = app
125
127
  app.jinja_env.add_extension("jinja2.ext.do")
126
128
  app.debug = True
127
- app.root_path = ROOT_PATH
128
- self.webui_build_path = BUILD_PATH
129
129
  self.greenlet: gevent.Greenlet | None = None
130
130
  self._swarm_greenlet: gevent.Greenlet | None = None
131
131
  self.template_args = {}
132
132
  self.auth_args = {}
133
- self.app.template_folder = BUILD_PATH
134
- self.app.static_folder = STATIC_PATH
133
+ self.app.template_folder = build_path or DEFAULT_BUILD_PATH
135
134
  self.app.static_url_path = "/assets/"
136
135
  # ensures static js files work on Windows
137
136
  mimetypes.add_type("application/javascript", ".js")
@@ -152,13 +151,19 @@ class WebUI:
152
151
  error_code = getattr(error, "code", 500)
153
152
  logger.log(
154
153
  logging.INFO if error_code <= 404 else logging.ERROR,
155
- f"UI got request for {request.path}, but it resulted in a {error.code}: {error.name}",
154
+ f"UI got request for {request.path}, but it resulted in a {error_code}: {error.name}",
156
155
  )
157
156
  return make_response(error_message, error_code)
158
157
 
159
158
  @app.route("/assets/<path:path>")
160
159
  def send_assets(path):
161
- return send_from_directory(os.path.join(self.webui_build_path, "assets"), path)
160
+ directory = (
161
+ os.path.join(self.app.template_folder, "assets")
162
+ if os.path.exists(os.path.join(app.template_folder, "assets", path))
163
+ else os.path.join(DEFAULT_BUILD_PATH, "assets")
164
+ )
165
+
166
+ return send_from_directory(directory, path)
162
167
 
163
168
  @app.route("/")
164
169
  @self.auth_required_if_enabled
@@ -499,7 +504,7 @@ class WebUI:
499
504
  if not self.web_login:
500
505
  return redirect(url_for("index"))
501
506
 
502
- return render_template(
507
+ return render_template_from(
503
508
  "auth.html",
504
509
  auth_args=self.auth_args,
505
510
  )
@@ -613,6 +618,8 @@ class WebUI:
613
618
  else None
614
619
  )
615
620
 
621
+ start_time = format_utc_timestamp(stats.start_time)
622
+
616
623
  self.template_args = {
617
624
  "locustfile": self.environment.locustfile,
618
625
  "state": self.environment.runner.state,
@@ -630,6 +637,7 @@ class WebUI:
630
637
  and not (self.userclass_picker_is_active or self.environment.shape_class.use_common_options)
631
638
  ),
632
639
  "stats_history_enabled": options and options.stats_history_enabled,
640
+ "start_time": start_time,
633
641
  "tasks": dumps({}),
634
642
  "extra_options": extra_options,
635
643
  "run_time": options and options.run_time,
@@ -5,7 +5,7 @@ build-backend = "poetry_dynamic_versioning.backend"
5
5
  [tool.poetry]
6
6
  name = "locust"
7
7
  description = "Developer-friendly load testing framework"
8
- version = "2.29.2.dev72"
8
+ version = "2.30.1"
9
9
  license = "MIT"
10
10
  readme = "README.md"
11
11
  authors = ["Jonatan Heyman", "Lars Holmberg"]
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