locust 2.35.1.dev5__tar.gz → 2.35.1.dev20__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.35.1.dev5 → locust-2.35.1.dev20}/PKG-INFO +1 -1
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/_version.py +2 -2
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/html.py +12 -13
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/main.py +73 -81
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/stats.py +43 -4
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/util/load_locustfile.py +4 -4
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/web.py +23 -21
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/.gitignore +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/LICENSE +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/README.md +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/hatch_build.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/__init__.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/__main__.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/argument_parser.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/clients.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/contrib/__init__.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/contrib/fasthttp.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/contrib/mongodb.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/contrib/oai.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/contrib/postgres.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/debug.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/dispatch.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/env.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/event.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/exception.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/input_events.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/log.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/py.typed +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/rpc/__init__.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/rpc/protocol.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/rpc/zmqrpc.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/runners.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/shape.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/user/__init__.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/user/inspectuser.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/user/sequential_taskset.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/user/task.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/user/users.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/user/wait_time.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/util/__init__.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/util/cache.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/util/date.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/util/deprecation.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/util/directory.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/util/exception_handler.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/util/rounding.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/util/timespan.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/util/url.py +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/webui/dist/assets/favicon-dark.png +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/webui/dist/assets/favicon-light.png +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/webui/dist/assets/graphs-dark.png +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/webui/dist/assets/graphs-light.png +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/webui/dist/assets/index-DQLt1q6M.js +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/webui/dist/assets/testruns-dark.png +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/webui/dist/assets/testruns-light.png +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/webui/dist/auth.html +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/webui/dist/index.html +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/locust/webui/dist/report.html +0 -0
- {locust-2.35.1.dev5 → locust-2.35.1.dev20}/pyproject.toml +0 -0
@@ -17,5 +17,5 @@ __version__: str
|
|
17
17
|
__version_tuple__: VERSION_TUPLE
|
18
18
|
version_tuple: VERSION_TUPLE
|
19
19
|
|
20
|
-
__version__ = version = '2.35.1.
|
21
|
-
__version_tuple__ = version_tuple = (2, 35, 1, '
|
20
|
+
__version__ = version = '2.35.1.dev20'
|
21
|
+
__version_tuple__ = version_tuple = (2, 35, 1, 'dev20')
|
@@ -5,9 +5,8 @@ from itertools import chain
|
|
5
5
|
from jinja2 import Environment as JinjaEnvironment
|
6
6
|
from jinja2 import FileSystemLoader
|
7
7
|
|
8
|
-
from . import stats
|
8
|
+
from . import stats
|
9
9
|
from .runners import STATE_STOPPED, STATE_STOPPING, MasterRunner
|
10
|
-
from .stats import sort_stats, update_stats_history
|
11
10
|
from .user.inspectuser import get_ratio
|
12
11
|
from .util.date import format_duration, format_utc_timestamp
|
13
12
|
|
@@ -37,14 +36,14 @@ def get_html_report(
|
|
37
36
|
show_download_link=True,
|
38
37
|
theme="",
|
39
38
|
):
|
40
|
-
|
39
|
+
request_stats = environment.runner.stats
|
41
40
|
|
42
|
-
start_time = format_utc_timestamp(
|
41
|
+
start_time = format_utc_timestamp(request_stats.start_time)
|
43
42
|
|
44
|
-
if end_ts :=
|
43
|
+
if end_ts := request_stats.last_request_timestamp:
|
45
44
|
end_time = format_utc_timestamp(end_ts)
|
46
45
|
else:
|
47
|
-
end_ts =
|
46
|
+
end_ts = request_stats.start_time
|
48
47
|
end_time = start_time
|
49
48
|
|
50
49
|
host = None
|
@@ -55,15 +54,15 @@ def get_html_report(
|
|
55
54
|
if len(all_hosts) == 1:
|
56
55
|
host = list(all_hosts)[0]
|
57
56
|
|
58
|
-
requests_statistics = list(chain(sort_stats(
|
59
|
-
failures_statistics = sort_stats(
|
57
|
+
requests_statistics = list(chain(stats.sort_stats(request_stats.entries), [request_stats.total]))
|
58
|
+
failures_statistics = stats.sort_stats(request_stats.errors)
|
60
59
|
exceptions_statistics = [
|
61
60
|
{**exc, "nodes": ", ".join(exc["nodes"])} for exc in environment.runner.exceptions.values()
|
62
61
|
]
|
63
62
|
|
64
|
-
if
|
65
|
-
update_stats_history(environment.runner, end_time)
|
66
|
-
history =
|
63
|
+
if request_stats.history and request_stats.history[-1]["time"] < end_time:
|
64
|
+
stats.update_stats_history(environment.runner, end_time)
|
65
|
+
history = request_stats.history
|
67
66
|
|
68
67
|
is_distributed = isinstance(environment.runner, MasterRunner)
|
69
68
|
user_spawned = (
|
@@ -98,13 +97,13 @@ def get_html_report(
|
|
98
97
|
],
|
99
98
|
"start_time": start_time,
|
100
99
|
"end_time": end_time,
|
101
|
-
"duration": format_duration(
|
100
|
+
"duration": format_duration(request_stats.start_time, end_ts),
|
102
101
|
"host": escape(str(host)),
|
103
102
|
"history": history,
|
104
103
|
"show_download_link": show_download_link,
|
105
104
|
"locustfile": escape(str(environment.locustfile)),
|
106
105
|
"tasks": task_data,
|
107
|
-
"percentiles_to_chart":
|
106
|
+
"percentiles_to_chart": stats.PERCENTILES_TO_CHART,
|
108
107
|
"profile": escape(str(environment.profile)) if environment.profile else None,
|
109
108
|
},
|
110
109
|
theme=theme,
|
@@ -15,6 +15,7 @@ import sys
|
|
15
15
|
import time
|
16
16
|
import traceback
|
17
17
|
import webbrowser
|
18
|
+
from typing import TYPE_CHECKING
|
18
19
|
|
19
20
|
import gevent
|
20
21
|
|
@@ -24,16 +25,6 @@ from .env import Environment
|
|
24
25
|
from .html import get_html_report, process_html_filename
|
25
26
|
from .input_events import input_listener
|
26
27
|
from .log import greenlet_exception_logger, setup_logging
|
27
|
-
from .stats import (
|
28
|
-
StatsCSV,
|
29
|
-
StatsCSVFileWriter,
|
30
|
-
print_error_report,
|
31
|
-
print_percentile_stats,
|
32
|
-
print_stats,
|
33
|
-
print_stats_json,
|
34
|
-
stats_history,
|
35
|
-
stats_printer,
|
36
|
-
)
|
37
28
|
from .user.inspectuser import print_task_ratio, print_task_ratio_json
|
38
29
|
from .util.load_locustfile import load_locustfile
|
39
30
|
|
@@ -52,6 +43,9 @@ except ModuleNotFoundError as e:
|
|
52
43
|
if e.msg != "No module named 'locust_cloud'":
|
53
44
|
raise
|
54
45
|
|
46
|
+
if TYPE_CHECKING:
|
47
|
+
from collections.abc import Callable
|
48
|
+
|
55
49
|
version = locust.__version__
|
56
50
|
|
57
51
|
# Options to ignore when using a custom shape class without `use_common_options=True`
|
@@ -93,79 +87,75 @@ def create_environment(
|
|
93
87
|
)
|
94
88
|
|
95
89
|
|
96
|
-
def
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
90
|
+
def merge_locustfiles_content(
|
91
|
+
locustfiles: list[str],
|
92
|
+
) -> tuple[
|
93
|
+
dict[str, type[locust.User]],
|
94
|
+
dict[str, locust.LoadTestShape],
|
95
|
+
dict[str, list[locust.TaskSet | Callable]],
|
96
|
+
locust.LoadTestShape | None,
|
97
|
+
]:
|
98
|
+
"""
|
99
|
+
Validate content of each locustfile in locustfiles and merge data to single objects output.
|
101
100
|
|
102
|
-
|
103
|
-
|
104
|
-
|
101
|
+
Can stop locust execution on errors.
|
102
|
+
"""
|
103
|
+
available_user_classes: dict[str, type[locust.User]] = {}
|
104
|
+
available_shape_classes: dict[str, locust.LoadTestShape] = {}
|
105
|
+
# TODO: list[locust.TaskSet | Callable] should be replaced with correct type,
|
106
|
+
# supported by User class task attribute. This require additional rewrite,
|
107
|
+
# out of main refactoring.
|
108
|
+
# Check docs for real supported task attribute signature for User\TaskSet class.
|
109
|
+
available_user_tasks: dict[str, list[locust.TaskSet | Callable]] = {}
|
105
110
|
|
106
|
-
# Importing Locustfile(s) - setting available UserClasses and ShapeClasses to choose from in UI
|
107
|
-
user_classes: dict[str, locust.User] = {}
|
108
|
-
available_user_classes = {}
|
109
|
-
available_shape_classes = {}
|
110
|
-
available_user_tasks = {}
|
111
|
-
shape_class = None
|
112
111
|
for _locustfile in locustfiles:
|
113
|
-
|
112
|
+
user_classes, shape_classes = load_locustfile(_locustfile)
|
114
113
|
|
115
114
|
# Setting Available Shape Classes
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
sys.stderr.write(f"Duplicate shape classes: {shape_class_name}\n")
|
122
|
-
sys.exit(1)
|
115
|
+
for _shape_class in shape_classes:
|
116
|
+
shape_class_name = type(_shape_class).__name__
|
117
|
+
if shape_class_name in available_shape_classes.keys():
|
118
|
+
sys.stderr.write(f"Duplicate shape classes: {shape_class_name}\n")
|
119
|
+
sys.exit(1)
|
123
120
|
|
124
|
-
|
121
|
+
available_shape_classes[shape_class_name] = _shape_class
|
125
122
|
|
126
123
|
# Setting Available User Classes
|
127
|
-
for
|
128
|
-
if
|
129
|
-
previous_path = inspect.getfile(
|
130
|
-
new_path = inspect.getfile(
|
124
|
+
for class_name, class_definition in user_classes.items():
|
125
|
+
if class_name in available_user_classes.keys():
|
126
|
+
previous_path = inspect.getfile(available_user_classes[class_name])
|
127
|
+
new_path = inspect.getfile(class_definition)
|
131
128
|
if previous_path == new_path:
|
132
129
|
# The same User class was defined in two locustfiles but one probably imported the other, so we just ignore it
|
133
130
|
continue
|
134
131
|
else:
|
135
132
|
sys.stderr.write(
|
136
|
-
f"Duplicate user class names: {
|
133
|
+
f"Duplicate user class names: {class_name} is defined in both {previous_path} and {new_path}\n"
|
137
134
|
)
|
138
135
|
sys.exit(1)
|
139
136
|
|
140
|
-
|
141
|
-
|
142
|
-
available_user_tasks[key] = value.tasks or {}
|
137
|
+
available_user_classes[class_name] = class_definition
|
138
|
+
available_user_tasks[class_name] = class_definition.tasks
|
143
139
|
|
144
|
-
|
145
|
-
logging.error("stats.PERCENTILES_TO_CHART parameter should be a maximum of 6 parameters \n")
|
146
|
-
sys.exit(1)
|
140
|
+
shape_class = list(available_shape_classes.values())[0] if available_shape_classes else None
|
147
141
|
|
148
|
-
|
149
|
-
try:
|
150
|
-
if 0 < float(parameter) < 1:
|
151
|
-
return True
|
152
|
-
return False
|
153
|
-
except ValueError:
|
154
|
-
return False
|
155
|
-
|
156
|
-
for percentile in stats.PERCENTILES_TO_CHART:
|
157
|
-
if not is_valid_percentile(percentile):
|
158
|
-
logging.error(
|
159
|
-
"stats.PERCENTILES_TO_CHART parameter need to be float and value between. 0 < percentile < 1 Eg 0.95\n"
|
160
|
-
)
|
161
|
-
sys.exit(1)
|
142
|
+
return available_user_classes, available_shape_classes, available_user_tasks, shape_class
|
162
143
|
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
144
|
+
|
145
|
+
def main():
|
146
|
+
# find specified locustfile(s) and make sure it exists, using a very simplified
|
147
|
+
# command line parser that is only used to parse the -f option.
|
148
|
+
locustfiles = parse_locustfile_option()
|
149
|
+
|
150
|
+
# Importing Locustfile(s) - setting available UserClasses and ShapeClasses to choose from in UI
|
151
|
+
(
|
152
|
+
available_user_classes,
|
153
|
+
available_shape_classes,
|
154
|
+
available_user_tasks,
|
155
|
+
shape_class,
|
156
|
+
) = merge_locustfiles_content(locustfiles)
|
157
|
+
|
158
|
+
stats.validate_stats_configuration()
|
169
159
|
|
170
160
|
# parse all command line options
|
171
161
|
options = parse_options()
|
@@ -290,25 +280,25 @@ def main():
|
|
290
280
|
|
291
281
|
if options.list_commands:
|
292
282
|
print("Available Users:")
|
293
|
-
for name in
|
283
|
+
for name in available_user_classes:
|
294
284
|
print(" " + name)
|
295
285
|
sys.exit(0)
|
296
286
|
|
297
|
-
if not
|
287
|
+
if not available_user_classes:
|
298
288
|
logger.error("No User class found!")
|
299
289
|
sys.exit(1)
|
300
290
|
|
301
291
|
# make sure specified User exists
|
302
292
|
if options.user_classes:
|
303
|
-
if missing := set(options.user_classes) - set(
|
293
|
+
if missing := set(options.user_classes) - set(available_user_classes.keys()):
|
304
294
|
logger.error(f"Unknown User(s): {', '.join(missing)}\n")
|
305
295
|
sys.exit(1)
|
306
296
|
else:
|
307
|
-
names = set(options.user_classes) & set(
|
308
|
-
user_classes = [
|
297
|
+
names = set(options.user_classes) & set(available_user_classes.keys())
|
298
|
+
user_classes = [available_user_classes[n] for n in names]
|
309
299
|
else:
|
310
300
|
# list() call is needed to consume the dict_view object in Python 3
|
311
|
-
user_classes = list(
|
301
|
+
user_classes = list(available_user_classes.values())
|
312
302
|
|
313
303
|
if not shape_class and options.num_users:
|
314
304
|
fixed_count_total = sum([user_class.fixed_count for user_class in user_classes])
|
@@ -327,7 +317,8 @@ def main():
|
|
327
317
|
if soft_limit < minimum_open_file_limit:
|
328
318
|
# Increasing the limit to 10000 within a running process should work on at least MacOS.
|
329
319
|
# It does not work on all OS:es, but we should be no worse off for trying.
|
330
|
-
|
320
|
+
limits = minimum_open_file_limit, hard_limit
|
321
|
+
resource.setrlimit(resource.RLIMIT_NOFILE, limits)
|
331
322
|
except BaseException:
|
332
323
|
logger.warning(
|
333
324
|
f"""System open file limit '{soft_limit} is below minimum setting '{minimum_open_file_limit}'.
|
@@ -335,9 +326,10 @@ It's not high enough for load testing, and the OS didn't allow locust to increas
|
|
335
326
|
See https://github.com/locustio/locust/wiki/Installation#increasing-maximum-number-of-open-files-limit for more info."""
|
336
327
|
)
|
337
328
|
|
338
|
-
#
|
339
|
-
locustfile_path =
|
329
|
+
# At least one locust file exists, or system will exit earlier
|
330
|
+
locustfile_path = os.path.basename(locustfiles[0])
|
340
331
|
|
332
|
+
# create locust Environment
|
341
333
|
environment = create_environment(
|
342
334
|
user_classes,
|
343
335
|
options,
|
@@ -422,11 +414,11 @@ See https://github.com/locustio/locust/wiki/Installation#increasing-maximum-numb
|
|
422
414
|
base_csv_dir = options.csv_prefix[: -len(base_csv_file)]
|
423
415
|
if not os.path.exists(base_csv_dir) and len(base_csv_dir) != 0:
|
424
416
|
os.makedirs(base_csv_dir)
|
425
|
-
stats_csv_writer = StatsCSVFileWriter(
|
417
|
+
stats_csv_writer = stats.StatsCSVFileWriter(
|
426
418
|
environment, stats.PERCENTILES_TO_REPORT, options.csv_prefix, options.stats_history_enabled
|
427
419
|
)
|
428
420
|
else:
|
429
|
-
stats_csv_writer = StatsCSV(environment, stats.PERCENTILES_TO_REPORT)
|
421
|
+
stats_csv_writer = stats.StatsCSV(environment, stats.PERCENTILES_TO_REPORT)
|
430
422
|
|
431
423
|
# start Web UI
|
432
424
|
if not options.headless and not options.worker:
|
@@ -512,10 +504,10 @@ See https://github.com/locustio/locust/wiki/Installation#increasing-maximum-numb
|
|
512
504
|
stats_printer_greenlet = None
|
513
505
|
if not options.only_summary and (options.print_stats or (options.headless and not options.worker)):
|
514
506
|
# spawn stats printing greenlet
|
515
|
-
stats_printer_greenlet = gevent.spawn(stats_printer(runner.stats))
|
507
|
+
stats_printer_greenlet = gevent.spawn(stats.stats_printer(runner.stats))
|
516
508
|
stats_printer_greenlet.link_exception(greenlet_exception_handler)
|
517
509
|
|
518
|
-
gevent.spawn(stats_history, runner)
|
510
|
+
gevent.spawn(stats.stats_history, runner)
|
519
511
|
|
520
512
|
def start_automatic_run():
|
521
513
|
if options.master:
|
@@ -633,11 +625,11 @@ See https://github.com/locustio/locust/wiki/Installation#increasing-maximum-numb
|
|
633
625
|
if runner is not None:
|
634
626
|
runner.quit()
|
635
627
|
if options.json:
|
636
|
-
print_stats_json(runner.stats)
|
628
|
+
stats.print_stats_json(runner.stats)
|
637
629
|
elif not isinstance(runner, locust.runners.WorkerRunner):
|
638
|
-
print_stats(runner.stats, current=False)
|
639
|
-
print_percentile_stats(runner.stats)
|
640
|
-
print_error_report(runner.stats)
|
630
|
+
stats.print_stats(runner.stats, current=False)
|
631
|
+
stats.print_percentile_stats(runner.stats)
|
632
|
+
stats.print_error_report(runner.stats)
|
641
633
|
environment.events.quit.fire(exit_code=code)
|
642
634
|
sys.exit(code)
|
643
635
|
|
@@ -6,25 +6,28 @@ import json
|
|
6
6
|
import logging
|
7
7
|
import os
|
8
8
|
import signal
|
9
|
+
import sys
|
9
10
|
import time
|
10
11
|
from abc import abstractmethod
|
11
12
|
from collections import OrderedDict, defaultdict, namedtuple
|
12
|
-
from collections.abc import Callable, Iterable
|
13
13
|
from copy import copy
|
14
14
|
from html import escape
|
15
15
|
from itertools import chain
|
16
|
-
from
|
17
|
-
from typing import TYPE_CHECKING, Any, NoReturn, Protocol, TypedDict, TypeVar, cast
|
16
|
+
from typing import TYPE_CHECKING, Protocol, TypedDict, TypeVar, cast
|
18
17
|
|
19
18
|
import gevent
|
20
19
|
|
21
|
-
from .event import Events
|
22
20
|
from .exception import CatchResponseError
|
23
21
|
from .util.date import format_utc_timestamp
|
24
22
|
from .util.rounding import proper_round
|
25
23
|
|
26
24
|
if TYPE_CHECKING:
|
25
|
+
from collections.abc import Callable, Iterable
|
26
|
+
from types import FrameType
|
27
|
+
from typing import Any, NoReturn
|
28
|
+
|
27
29
|
from .env import Environment
|
30
|
+
from .event import Events
|
28
31
|
from .runners import Runner
|
29
32
|
|
30
33
|
console_logger = logging.getLogger("locust.stats_logger")
|
@@ -1183,3 +1186,39 @@ class StatsCSVFileWriter(StatsCSV):
|
|
1183
1186
|
|
1184
1187
|
def stats_history_file_name(self) -> str:
|
1185
1188
|
return self.base_filepath + "_stats_history.csv"
|
1189
|
+
|
1190
|
+
|
1191
|
+
def _is_valid_percentile(parameter) -> bool:
|
1192
|
+
"""Validate single percentile value from .stats constants."""
|
1193
|
+
try:
|
1194
|
+
if 0 < float(parameter) < 1:
|
1195
|
+
return True
|
1196
|
+
return False
|
1197
|
+
except ValueError:
|
1198
|
+
return False
|
1199
|
+
|
1200
|
+
|
1201
|
+
def validate_stats_configuration() -> None:
|
1202
|
+
"""
|
1203
|
+
This function validates .stats file's constants, that may be patched in user
|
1204
|
+
environments.
|
1205
|
+
|
1206
|
+
No return in normal conditional. Stops locust execution on error.
|
1207
|
+
"""
|
1208
|
+
if len(PERCENTILES_TO_CHART) > 6:
|
1209
|
+
sys.stderr.write("stats.PERCENTILES_TO_CHART parameter should be a maximum of 6 parameters \n")
|
1210
|
+
sys.exit(1)
|
1211
|
+
|
1212
|
+
for percentile in PERCENTILES_TO_CHART:
|
1213
|
+
if not _is_valid_percentile(percentile):
|
1214
|
+
sys.stderr.write(
|
1215
|
+
"stats.PERCENTILES_TO_CHART parameter need to be float and value between. 0 < percentile < 1 Eg 0.95\n"
|
1216
|
+
)
|
1217
|
+
sys.exit(1)
|
1218
|
+
|
1219
|
+
for percentile in PERCENTILES_TO_STATISTICS:
|
1220
|
+
if not _is_valid_percentile(percentile):
|
1221
|
+
sys.stderr.write(
|
1222
|
+
"stats.PERCENTILES_TO_STATISTICS parameter need to be float and value between. 0 < percentile < 1 Eg 0.95\n"
|
1223
|
+
)
|
1224
|
+
sys.exit(1)
|
@@ -10,21 +10,21 @@ from ..shape import LoadTestShape
|
|
10
10
|
from ..user import User
|
11
11
|
|
12
12
|
|
13
|
-
def is_user_class(item):
|
13
|
+
def is_user_class(item) -> bool:
|
14
14
|
"""
|
15
15
|
Check if a variable is a runnable (non-abstract) User class
|
16
16
|
"""
|
17
17
|
return bool(inspect.isclass(item) and issubclass(item, User) and item.abstract is False)
|
18
18
|
|
19
19
|
|
20
|
-
def is_shape_class(item):
|
20
|
+
def is_shape_class(item) -> bool:
|
21
21
|
"""
|
22
22
|
Check if a class is a LoadTestShape
|
23
23
|
"""
|
24
24
|
return bool(inspect.isclass(item) and issubclass(item, LoadTestShape) and not getattr(item, "abstract", True))
|
25
25
|
|
26
26
|
|
27
|
-
def load_locustfile(path) -> tuple[
|
27
|
+
def load_locustfile(path) -> tuple[dict[str, type[User]], list[LoadTestShape]]:
|
28
28
|
"""
|
29
29
|
Import given locustfile path and return (docstring, callables).
|
30
30
|
|
@@ -82,4 +82,4 @@ def load_locustfile(path) -> tuple[str | None, dict[str, User], list[LoadTestSha
|
|
82
82
|
# Find shape class, if any, return it
|
83
83
|
shape_classes = [value() for value in vars(imported).values() if is_shape_class(value)]
|
84
84
|
|
85
|
-
return
|
85
|
+
return user_classes, shape_classes
|
@@ -32,12 +32,10 @@ from flask_login import LoginManager, login_required
|
|
32
32
|
from gevent import pywsgi
|
33
33
|
|
34
34
|
from . import __version__ as version
|
35
|
-
from . import argument_parser
|
36
|
-
from . import stats as stats_module
|
35
|
+
from . import argument_parser, stats
|
37
36
|
from .html import DEFAULT_BUILD_PATH, get_html_report, render_template_from
|
38
37
|
from .log import get_logs, greenlet_exception_logger
|
39
38
|
from .runners import STATE_MISSING, STATE_RUNNING, MasterRunner
|
40
|
-
from .stats import StatsCSV, StatsCSVFileWriter, StatsErrorDict, sort_stats
|
41
39
|
from .user.inspectuser import get_ratio
|
42
40
|
from .util.cache import memoize
|
43
41
|
from .util.date import format_safe_timestamp
|
@@ -126,7 +124,7 @@ class WebUI:
|
|
126
124
|
web_login: bool = False,
|
127
125
|
tls_cert: str | None = None,
|
128
126
|
tls_key: str | None = None,
|
129
|
-
stats_csv_writer: StatsCSV | None = None,
|
127
|
+
stats_csv_writer: stats.StatsCSV | None = None,
|
130
128
|
delayed_start=False,
|
131
129
|
userclass_picker_is_active=False,
|
132
130
|
build_path: str | None = None,
|
@@ -145,7 +143,7 @@ class WebUI:
|
|
145
143
|
allows for adding Flask routes or Blueprints before accepting requests, avoiding errors.
|
146
144
|
"""
|
147
145
|
environment.web_ui = self
|
148
|
-
self.stats_csv_writer = stats_csv_writer or StatsCSV(environment,
|
146
|
+
self.stats_csv_writer = stats_csv_writer or stats.StatsCSV(environment, stats.PERCENTILES_TO_REPORT)
|
149
147
|
self.environment = environment
|
150
148
|
self.host = host
|
151
149
|
self.port = port
|
@@ -404,7 +402,11 @@ class WebUI:
|
|
404
402
|
@self.auth_required_if_enabled
|
405
403
|
def request_stats_full_history_csv() -> Response:
|
406
404
|
options = self.environment.parsed_options
|
407
|
-
if
|
405
|
+
if (
|
406
|
+
options
|
407
|
+
and options.stats_history_enabled
|
408
|
+
and isinstance(self.stats_csv_writer, stats.StatsCSVFileWriter)
|
409
|
+
):
|
408
410
|
return send_file(
|
409
411
|
os.path.abspath(self.stats_csv_writer.stats_history_file_name()),
|
410
412
|
mimetype="text/csv",
|
@@ -430,12 +432,12 @@ class WebUI:
|
|
430
432
|
@self.auth_required_if_enabled
|
431
433
|
@memoize(timeout=DEFAULT_CACHE_TIME, dynamic_timeout=True)
|
432
434
|
def request_stats() -> Response:
|
433
|
-
|
434
|
-
errors: list[StatsErrorDict] = []
|
435
|
+
_stats: list[dict[str, Any]] = []
|
436
|
+
errors: list[stats.StatsErrorDict] = []
|
435
437
|
|
436
438
|
if environment.runner is None:
|
437
439
|
report = {
|
438
|
-
"stats":
|
440
|
+
"stats": _stats,
|
439
441
|
"errors": errors,
|
440
442
|
"total_rps": 0.0,
|
441
443
|
"total_fail_per_sec": 0.0,
|
@@ -451,21 +453,21 @@ class WebUI:
|
|
451
453
|
|
452
454
|
return jsonify(report)
|
453
455
|
|
454
|
-
for s in chain(sort_stats(environment.runner.stats.entries), [environment.runner.stats.total]):
|
455
|
-
|
456
|
+
for s in chain(stats.sort_stats(environment.runner.stats.entries), [environment.runner.stats.total]):
|
457
|
+
_stats.append(s.to_dict())
|
456
458
|
|
457
459
|
errors = [e.serialize() for e in environment.runner.errors.values()]
|
458
460
|
|
459
461
|
# Truncate the total number of stats and errors displayed since a large number of rows will cause the app
|
460
462
|
# to render extremely slowly. Aggregate stats should be preserved.
|
461
|
-
truncated_stats =
|
462
|
-
if len(
|
463
|
-
truncated_stats += [
|
463
|
+
truncated_stats = _stats[:500]
|
464
|
+
if len(_stats) > 500:
|
465
|
+
truncated_stats += [_stats[-1]]
|
464
466
|
|
465
467
|
report = {"stats": truncated_stats, "errors": errors[:500]}
|
466
|
-
total_stats =
|
468
|
+
total_stats = _stats[-1]
|
467
469
|
|
468
|
-
if
|
470
|
+
if _stats:
|
469
471
|
report["total_rps"] = total_stats["current_rps"]
|
470
472
|
report["total_fail_per_sec"] = total_stats["current_fail_per_sec"]
|
471
473
|
report["fail_ratio"] = environment.runner.stats.total.fail_ratio
|
@@ -473,7 +475,7 @@ class WebUI:
|
|
473
475
|
f"response_time_percentile_{percentile}": environment.runner.stats.total.get_current_response_time_percentile(
|
474
476
|
percentile
|
475
477
|
)
|
476
|
-
for percentile in
|
478
|
+
for percentile in stats.PERCENTILES_TO_CHART
|
477
479
|
}
|
478
480
|
|
479
481
|
if isinstance(environment.runner, MasterRunner):
|
@@ -658,7 +660,7 @@ class WebUI:
|
|
658
660
|
else:
|
659
661
|
worker_count = 0
|
660
662
|
|
661
|
-
|
663
|
+
request_stats = self.environment.runner.stats
|
662
664
|
extra_options = argument_parser.ui_extra_args_dict()
|
663
665
|
|
664
666
|
available_user_classes = None
|
@@ -690,7 +692,7 @@ class WebUI:
|
|
690
692
|
"user_count": self.environment.runner.user_count,
|
691
693
|
"version": version,
|
692
694
|
"host": host if host else "",
|
693
|
-
"history":
|
695
|
+
"history": request_stats.history if request_stats.num_requests > 0 else [],
|
694
696
|
"override_host_warning": override_host_warning,
|
695
697
|
"num_users": options and options.num_users,
|
696
698
|
"spawn_rate": options and options.spawn_rate,
|
@@ -710,8 +712,8 @@ class WebUI:
|
|
710
712
|
"available_shape_classes": available_shape_classes,
|
711
713
|
"available_user_tasks": available_user_tasks,
|
712
714
|
"users": users,
|
713
|
-
"percentiles_to_chart":
|
714
|
-
"percentiles_to_statistics":
|
715
|
+
"percentiles_to_chart": stats.PERCENTILES_TO_CHART,
|
716
|
+
"percentiles_to_statistics": stats.PERCENTILES_TO_STATISTICS,
|
715
717
|
}
|
716
718
|
|
717
719
|
self.template_args = {**self.template_args, **new_template_args}
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|