locust 2.30.1.dev17__py3-none-any.whl → 2.30.1.dev25__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
locust/_version.py CHANGED
@@ -14,7 +14,7 @@ __version_tuple__: VERSION_TUPLE
14
14
  version_tuple: VERSION_TUPLE
15
15
 
16
16
 
17
- __version__ = "2.30.1.dev17"
17
+ __version__ = "2.30.1.dev25"
18
18
  version = __version__
19
- __version_tuple__ = (2, 30, 1, "dev17")
19
+ __version_tuple__ = (2, 30, 1, "dev25")
20
20
  version_tuple = __version_tuple__
locust/argument_parser.py CHANGED
@@ -496,6 +496,13 @@ def setup_parser_arguments(parser):
496
496
  help="Enable select boxes in the web interface to choose from all available User classes and Shape classes",
497
497
  env_var="LOCUST_USERCLASS_PICKER",
498
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
+ )
499
506
  web_ui_group.add_argument(
500
507
  "--legacy-ui",
501
508
  default=False,
locust/dispatch.py CHANGED
@@ -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]]:
locust/env.py CHANGED
@@ -171,6 +171,7 @@ class Environment:
171
171
  stats_csv_writer: StatsCSV | None = None,
172
172
  delayed_start=False,
173
173
  userclass_picker_is_active=False,
174
+ build_path: str | None = None,
174
175
  ) -> WebUI:
175
176
  """
176
177
  Creates a :class:`WebUI <locust.web.WebUI>` instance for this Environment and start running the web server
@@ -197,6 +198,7 @@ class Environment:
197
198
  stats_csv_writer=stats_csv_writer,
198
199
  delayed_start=delayed_start,
199
200
  userclass_picker_is_active=userclass_picker_is_active,
201
+ build_path=build_path,
200
202
  )
201
203
  return self.web_ui
202
204
 
locust/html.py CHANGED
@@ -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
  )
locust/main.py CHANGED
@@ -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
 
@@ -482,6 +486,7 @@ See https://github.com/locustio/locust/wiki/Installation#increasing-maximum-numb
482
486
  stats_csv_writer=stats_csv_writer,
483
487
  delayed_start=True,
484
488
  userclass_picker_is_active=options.class_picker,
489
+ build_path=options.build_path,
485
490
  )
486
491
  else:
487
492
  web_ui = None
locust/web.py CHANGED
@@ -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")
@@ -158,7 +157,13 @@ class WebUI:
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,