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.
- {locust-2.29.2.dev72 → locust-2.30.1}/PKG-INFO +1 -1
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/_version.py +2 -2
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/argument_parser.py +34 -33
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/dispatch.py +27 -48
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/env.py +5 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/html.py +6 -18
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/main.py +16 -1
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/runners.py +30 -16
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/stats.py +3 -0
- locust-2.30.1/locust/util/directory.py +12 -0
- locust-2.30.1/locust/util/url.py +15 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/web.py +16 -8
- {locust-2.29.2.dev72 → locust-2.30.1}/pyproject.toml +1 -1
- {locust-2.29.2.dev72 → locust-2.30.1}/LICENSE +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/README.md +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/__init__.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/__main__.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/clients.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/contrib/__init__.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/contrib/fasthttp.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/debug.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/event.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/exception.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/input_events.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/log.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/py.typed +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/rpc/__init__.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/rpc/protocol.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/rpc/zmqrpc.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/shape.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/user/__init__.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/user/inspectuser.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/user/sequential_taskset.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/user/task.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/user/users.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/user/wait_time.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/util/__init__.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/util/cache.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/util/date.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/util/deprecation.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/util/exception_handler.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/util/load_locustfile.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/util/rounding.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/locust/util/timespan.py +0 -0
- {locust-2.29.2.dev72 → locust-2.30.1}/pre_build.py +0 -0
@@ -14,7 +14,7 @@ __version_tuple__: VERSION_TUPLE
|
|
14
14
|
version_tuple: VERSION_TUPLE
|
15
15
|
|
16
16
|
|
17
|
-
__version__ = "2.
|
17
|
+
__version__ = "2.30.1"
|
18
18
|
version = __version__
|
19
|
-
__version_tuple__ = (2,
|
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
|
-
|
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",
|
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
|
-
|
275
|
-
|
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
|
-
|
281
|
-
|
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
|
-
|
343
|
-
|
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
|
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
|
-
|
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:
|
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
|
-
|
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
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
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,
|
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
|
-
|
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
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
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
|
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
|
-
|
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
|
23
|
-
env =
|
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
|
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.
|
1029
|
-
|
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
|
-
|
1032
|
-
|
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
|
-
|
1035
|
-
|
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={"
|
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
|
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 =
|
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 {
|
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
|
-
|
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
|
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.
|
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
|
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
|