locust 2.18.1.dev4__py3-none-any.whl → 2.18.1.dev16__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.
Potentially problematic release.
This version of locust might be problematic. Click here for more details.
- locust/_version.py +2 -2
- locust/log.py +15 -5
- locust/main.py +10 -7
- locust/static/css/application.css +17 -0
- locust/templates/index.html +1 -1
- locust/test/test_load_locustfile.py +31 -11
- locust/test/test_web.py +28 -6
- locust/util/load_locustfile.py +4 -8
- locust/web.py +25 -1
- locust/webui/dist/assets/index-b46361c2.js +236 -0
- locust/webui/dist/index.html +1 -1
- {locust-2.18.1.dev4.dist-info → locust-2.18.1.dev16.dist-info}/METADATA +1 -1
- {locust-2.18.1.dev4.dist-info → locust-2.18.1.dev16.dist-info}/RECORD +17 -16
- {locust-2.18.1.dev4.dist-info → locust-2.18.1.dev16.dist-info}/WHEEL +1 -1
- {locust-2.18.1.dev4.dist-info → locust-2.18.1.dev16.dist-info}/LICENSE +0 -0
- {locust-2.18.1.dev4.dist-info → locust-2.18.1.dev16.dist-info}/entry_points.txt +0 -0
- {locust-2.18.1.dev4.dist-info → locust-2.18.1.dev16.dist-info}/top_level.txt +0 -0
locust/_version.py
CHANGED
@@ -12,5 +12,5 @@ __version__: str
|
|
12
12
|
__version_tuple__: VERSION_TUPLE
|
13
13
|
version_tuple: VERSION_TUPLE
|
14
14
|
|
15
|
-
__version__ = version = '2.18.1.
|
16
|
-
__version_tuple__ = version_tuple = (2, 18, 1, '
|
15
|
+
__version__ = version = '2.18.1.dev16'
|
16
|
+
__version_tuple__ = version_tuple = (2, 18, 1, 'dev16')
|
locust/log.py
CHANGED
@@ -9,6 +9,15 @@ HOSTNAME = socket.gethostname()
|
|
9
9
|
unhandled_greenlet_exception = False
|
10
10
|
|
11
11
|
|
12
|
+
class LogReader(logging.Handler):
|
13
|
+
def __init__(self):
|
14
|
+
super().__init__()
|
15
|
+
self.logs = []
|
16
|
+
|
17
|
+
def emit(self, record):
|
18
|
+
self.logs.append(self.format(record))
|
19
|
+
|
20
|
+
|
12
21
|
def setup_logging(loglevel, logfile=None):
|
13
22
|
loglevel = loglevel.upper()
|
14
23
|
|
@@ -32,21 +41,22 @@ def setup_logging(loglevel, logfile=None):
|
|
32
41
|
"class": "logging.StreamHandler",
|
33
42
|
"formatter": "plain",
|
34
43
|
},
|
44
|
+
"log_reader": {"class": "locust.log.LogReader", "formatter": "default"},
|
35
45
|
},
|
36
46
|
"loggers": {
|
37
47
|
"locust": {
|
38
|
-
"handlers": ["console"],
|
48
|
+
"handlers": ["console", "log_reader"],
|
39
49
|
"level": loglevel,
|
40
50
|
"propagate": False,
|
41
51
|
},
|
42
52
|
"locust.stats_logger": {
|
43
|
-
"handlers": ["console_plain"],
|
53
|
+
"handlers": ["console_plain", "log_reader"],
|
44
54
|
"level": "INFO",
|
45
55
|
"propagate": False,
|
46
56
|
},
|
47
57
|
},
|
48
58
|
"root": {
|
49
|
-
"handlers": ["console"],
|
59
|
+
"handlers": ["console", "log_reader"],
|
50
60
|
"level": loglevel,
|
51
61
|
},
|
52
62
|
}
|
@@ -58,8 +68,8 @@ def setup_logging(loglevel, logfile=None):
|
|
58
68
|
"filename": logfile,
|
59
69
|
"formatter": "default",
|
60
70
|
}
|
61
|
-
LOGGING_CONFIG["loggers"]["locust"]["handlers"] = ["file"]
|
62
|
-
LOGGING_CONFIG["root"]["handlers"] = ["file"]
|
71
|
+
LOGGING_CONFIG["loggers"]["locust"]["handlers"] = ["file", "log_reader"]
|
72
|
+
LOGGING_CONFIG["root"]["handlers"] = ["file", "log_reader"]
|
63
73
|
|
64
74
|
logging.config.dictConfig(LOGGING_CONFIG)
|
65
75
|
|
locust/main.py
CHANGED
@@ -86,17 +86,20 @@ def main():
|
|
86
86
|
user_classes: Dict[str, locust.User] = {}
|
87
87
|
available_user_classes = {}
|
88
88
|
available_shape_classes = {}
|
89
|
+
shape_class = None
|
89
90
|
for _locustfile in locustfiles:
|
90
|
-
docstring, _user_classes,
|
91
|
+
docstring, _user_classes, shape_classes = load_locustfile(_locustfile)
|
91
92
|
|
92
93
|
# Setting Available Shape Classes
|
93
|
-
if
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
94
|
+
if shape_classes:
|
95
|
+
shape_class = shape_classes[0]
|
96
|
+
for shape_class in shape_classes:
|
97
|
+
shape_class_name = type(shape_class).__name__
|
98
|
+
if shape_class_name in available_shape_classes.keys():
|
99
|
+
sys.stderr.write(f"Duplicate shape classes: {shape_class_name}\n")
|
100
|
+
sys.exit(1)
|
98
101
|
|
99
|
-
|
102
|
+
available_shape_classes[shape_class_name] = shape_class
|
100
103
|
|
101
104
|
# Setting Available User Classes
|
102
105
|
for key, value in _user_classes.items():
|
@@ -470,5 +470,22 @@ body.ready .main {
|
|
470
470
|
margin-right: 10px;
|
471
471
|
color: #48a584;
|
472
472
|
}
|
473
|
+
.userClass {
|
474
|
+
border: none;
|
475
|
+
background: #fff;
|
476
|
+
height: 100px;
|
477
|
+
width: 340px;
|
478
|
+
font-size: 18px;
|
479
|
+
padding-left: 10px;
|
480
|
+
}
|
481
|
+
|
482
|
+
.shapeClass {
|
483
|
+
border: none;
|
484
|
+
background: #fff;
|
485
|
+
/* height: 100px; */
|
486
|
+
width: 340px;
|
487
|
+
font-size: 18px;
|
488
|
+
padding-left: 10px;
|
489
|
+
}
|
473
490
|
|
474
491
|
/*# sourceMappingURL=application.css.map */
|
locust/templates/index.html
CHANGED
@@ -65,7 +65,7 @@
|
|
65
65
|
{% for user in available_user_classes %}
|
66
66
|
<option value="{{user}}" selected>{{user}}</option>
|
67
67
|
{% endfor %}
|
68
|
-
</select><br>
|
68
|
+
</select><br>
|
69
69
|
<label for="available_shape_classes">ShapeClass </label>
|
70
70
|
<select name="shape_class" id="shape-classes" class="shapeClass">
|
71
71
|
{% for shape_class in available_shape_classes %}
|
@@ -37,25 +37,25 @@ class TestLoadLocustfile(LocustTestCase):
|
|
37
37
|
|
38
38
|
def test_load_locust_file_from_absolute_path(self):
|
39
39
|
with mock_locustfile() as mocked:
|
40
|
-
docstring, user_classes,
|
40
|
+
docstring, user_classes, shape_classes = main.load_locustfile(mocked.file_path)
|
41
41
|
self.assertIn("UserSubclass", user_classes)
|
42
42
|
self.assertNotIn("NotUserSubclass", user_classes)
|
43
43
|
self.assertNotIn("LoadTestShape", user_classes)
|
44
|
-
self.
|
44
|
+
self.assertEqual(shape_classes, [])
|
45
45
|
|
46
46
|
def test_load_locust_file_from_relative_path(self):
|
47
47
|
with mock_locustfile() as mocked:
|
48
|
-
docstring, user_classes,
|
48
|
+
docstring, user_classes, shape_classes = main.load_locustfile(
|
49
49
|
os.path.join(os.path.relpath(mocked.directory, os.getcwd()), mocked.filename)
|
50
50
|
)
|
51
51
|
|
52
52
|
def test_load_locust_file_with_a_dot_in_filename(self):
|
53
53
|
with mock_locustfile(filename_prefix="mocked.locust.file") as mocked:
|
54
|
-
docstring, user_classes,
|
54
|
+
docstring, user_classes, shape_classes = main.load_locustfile(mocked.file_path)
|
55
55
|
|
56
56
|
def test_return_docstring_and_user_classes(self):
|
57
57
|
with mock_locustfile() as mocked:
|
58
|
-
docstring, user_classes,
|
58
|
+
docstring, user_classes, shape_classes = main.load_locustfile(mocked.file_path)
|
59
59
|
self.assertEqual("This is a mock locust file for unit testing", docstring)
|
60
60
|
self.assertIn("UserSubclass", user_classes)
|
61
61
|
self.assertNotIn("NotUserSubclass", user_classes)
|
@@ -70,11 +70,31 @@ class TestLoadLocustfile(LocustTestCase):
|
|
70
70
|
"""
|
71
71
|
)
|
72
72
|
with mock_locustfile(content=content) as mocked:
|
73
|
-
docstring, user_classes,
|
73
|
+
docstring, user_classes, shape_classes = main.load_locustfile(mocked.file_path)
|
74
74
|
self.assertEqual("This is a mock locust file for unit testing", docstring)
|
75
75
|
self.assertIn("UserSubclass", user_classes)
|
76
76
|
self.assertNotIn("NotUserSubclass", user_classes)
|
77
|
-
self.assertEqual(
|
77
|
+
self.assertEqual(shape_classes[0].__class__.__name__, "LoadTestShape")
|
78
|
+
|
79
|
+
def test_with_multiple_shape_classes(self):
|
80
|
+
content = MOCK_LOCUSTFILE_CONTENT + textwrap.dedent(
|
81
|
+
"""\
|
82
|
+
class LoadTestShape1(LoadTestShape):
|
83
|
+
def tick(self):
|
84
|
+
pass
|
85
|
+
|
86
|
+
class LoadTestShape2(LoadTestShape):
|
87
|
+
def tick(self):
|
88
|
+
pass
|
89
|
+
"""
|
90
|
+
)
|
91
|
+
with mock_locustfile(content=content) as mocked:
|
92
|
+
docstring, user_classes, shape_classes = main.load_locustfile(mocked.file_path)
|
93
|
+
self.assertEqual("This is a mock locust file for unit testing", docstring)
|
94
|
+
self.assertIn("UserSubclass", user_classes)
|
95
|
+
self.assertNotIn("NotUserSubclass", user_classes)
|
96
|
+
self.assertEqual(shape_classes[0].__class__.__name__, "LoadTestShape1")
|
97
|
+
self.assertEqual(shape_classes[1].__class__.__name__, "LoadTestShape2")
|
78
98
|
|
79
99
|
def test_with_abstract_shape_class(self):
|
80
100
|
content = MOCK_LOCUSTFILE_CONTENT + textwrap.dedent(
|
@@ -92,10 +112,10 @@ class TestLoadLocustfile(LocustTestCase):
|
|
92
112
|
)
|
93
113
|
|
94
114
|
with mock_locustfile(content=content) as mocked:
|
95
|
-
_, user_classes,
|
115
|
+
_, user_classes, shape_classes = main.load_locustfile(mocked.file_path)
|
96
116
|
self.assertNotIn("UserBaseLoadTestShape", user_classes)
|
97
117
|
self.assertNotIn("UserLoadTestShape", user_classes)
|
98
|
-
self.assertEqual(
|
118
|
+
self.assertEqual(shape_classes[0].__class__.__name__, "UserLoadTestShape")
|
99
119
|
|
100
120
|
def test_with_not_imported_shape_class(self):
|
101
121
|
content = MOCK_LOCUSTFILE_CONTENT + textwrap.dedent(
|
@@ -107,9 +127,9 @@ class TestLoadLocustfile(LocustTestCase):
|
|
107
127
|
)
|
108
128
|
|
109
129
|
with mock_locustfile(content=content) as mocked:
|
110
|
-
_, user_classes,
|
130
|
+
_, user_classes, shape_classes = main.load_locustfile(mocked.file_path)
|
111
131
|
self.assertNotIn("UserLoadTestShape", user_classes)
|
112
|
-
self.assertEqual(
|
132
|
+
self.assertEqual(shape_classes[0].__class__.__name__, "UserLoadTestShape")
|
113
133
|
|
114
134
|
def test_create_environment(self):
|
115
135
|
options = parse_options(
|
locust/test/test_web.py
CHANGED
@@ -5,13 +5,13 @@ import os
|
|
5
5
|
import re
|
6
6
|
import textwrap
|
7
7
|
import traceback
|
8
|
+
import logging
|
8
9
|
from io import StringIO
|
9
10
|
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
10
11
|
|
11
12
|
import gevent
|
12
13
|
import requests
|
13
14
|
from pyquery import PyQuery as pq
|
14
|
-
|
15
15
|
import locust
|
16
16
|
from locust import constant, LoadTestShape
|
17
17
|
from locust.argument_parser import get_parser, parse_options
|
@@ -21,6 +21,7 @@ from locust.runners import Runner
|
|
21
21
|
from locust import stats
|
22
22
|
from locust.stats import StatsCSVFileWriter
|
23
23
|
from locust.web import WebUI
|
24
|
+
from locust.log import LogReader
|
24
25
|
|
25
26
|
from .mock_locustfile import mock_locustfile
|
26
27
|
from .testcases import LocustTestCase
|
@@ -563,7 +564,15 @@ class TestWebUI(LocustTestCase, _HeaderCheckMixin):
|
|
563
564
|
def t(self):
|
564
565
|
pass
|
565
566
|
|
566
|
-
class
|
567
|
+
class TestShape1(LoadTestShape):
|
568
|
+
def tick(self):
|
569
|
+
run_time = self.get_run_time()
|
570
|
+
if run_time < 10:
|
571
|
+
return 4, 4
|
572
|
+
else:
|
573
|
+
return None
|
574
|
+
|
575
|
+
class TestShape2(LoadTestShape):
|
567
576
|
def tick(self):
|
568
577
|
run_time = self.get_run_time()
|
569
578
|
if run_time < 10:
|
@@ -573,8 +582,8 @@ class TestWebUI(LocustTestCase, _HeaderCheckMixin):
|
|
573
582
|
|
574
583
|
self.environment.web_ui.userclass_picker_is_active = True
|
575
584
|
self.environment.available_user_classes = {"User1": User1, "User2": User2}
|
576
|
-
self.environment.available_shape_classes = {"
|
577
|
-
self.environment.shape_class =
|
585
|
+
self.environment.available_shape_classes = {"TestShape1": TestShape1(), "TestShape2": TestShape2()}
|
586
|
+
self.environment.shape_class = TestShape1()
|
578
587
|
|
579
588
|
response = requests.post(
|
580
589
|
"http://127.0.0.1:%i/swarm" % self.web_port,
|
@@ -583,14 +592,14 @@ class TestWebUI(LocustTestCase, _HeaderCheckMixin):
|
|
583
592
|
"spawn_rate": 5,
|
584
593
|
"host": "https://localhost",
|
585
594
|
"user_classes": "User1",
|
586
|
-
"shape_class": "
|
595
|
+
"shape_class": "TestShape2",
|
587
596
|
},
|
588
597
|
)
|
589
598
|
|
590
599
|
self.assertEqual(200, response.status_code)
|
591
600
|
self.assertEqual("https://localhost", response.json()["host"])
|
592
601
|
self.assertEqual(self.environment.host, "https://localhost")
|
593
|
-
assert isinstance(self.environment.shape_class,
|
602
|
+
assert isinstance(self.environment.shape_class, TestShape2)
|
594
603
|
|
595
604
|
# stop
|
596
605
|
gevent.sleep(1)
|
@@ -1005,6 +1014,19 @@ class TestWebUI(LocustTestCase, _HeaderCheckMixin):
|
|
1005
1014
|
self.assertIn("Script: <span>locust.py</span>", str(d))
|
1006
1015
|
self.assertIn("Target Host: <span>http://localhost</span>", str(d))
|
1007
1016
|
|
1017
|
+
def test_logs(self):
|
1018
|
+
log_handler = LogReader()
|
1019
|
+
log_handler.name = "log_reader"
|
1020
|
+
log_handler.setLevel(logging.INFO)
|
1021
|
+
logger = logging.getLogger("root")
|
1022
|
+
logger.addHandler(log_handler)
|
1023
|
+
log_line = "some log info"
|
1024
|
+
logger.info(log_line)
|
1025
|
+
|
1026
|
+
response = requests.get("http://127.0.0.1:%i/logs" % self.web_port)
|
1027
|
+
|
1028
|
+
self.assertIn(log_line, response.json().get("logs"))
|
1029
|
+
|
1008
1030
|
|
1009
1031
|
class TestWebUIAuth(LocustTestCase):
|
1010
1032
|
def setUp(self):
|
locust/util/load_locustfile.py
CHANGED
@@ -2,7 +2,7 @@ import importlib
|
|
2
2
|
import inspect
|
3
3
|
import os
|
4
4
|
import sys
|
5
|
-
from typing import Dict, Optional, Tuple
|
5
|
+
from typing import Dict, List, Optional, Tuple
|
6
6
|
from ..shape import LoadTestShape
|
7
7
|
from ..user import User
|
8
8
|
|
@@ -21,7 +21,7 @@ def is_shape_class(item):
|
|
21
21
|
return bool(inspect.isclass(item) and issubclass(item, LoadTestShape) and not getattr(item, "abstract", True))
|
22
22
|
|
23
23
|
|
24
|
-
def load_locustfile(path) -> Tuple[Optional[str], Dict[str, User],
|
24
|
+
def load_locustfile(path) -> Tuple[Optional[str], Dict[str, User], List[LoadTestShape]]:
|
25
25
|
"""
|
26
26
|
Import given locustfile path and return (docstring, callables).
|
27
27
|
|
@@ -65,10 +65,6 @@ def load_locustfile(path) -> Tuple[Optional[str], Dict[str, User], Optional[Load
|
|
65
65
|
user_classes = {name: value for name, value in vars(imported).items() if is_user_class(value)}
|
66
66
|
|
67
67
|
# Find shape class, if any, return it
|
68
|
-
shape_classes = [value for name, value in vars(imported).items() if is_shape_class(value)]
|
69
|
-
if shape_classes:
|
70
|
-
shape_class = shape_classes[0]()
|
71
|
-
else:
|
72
|
-
shape_class = None
|
68
|
+
shape_classes = [value() for name, value in vars(imported).items() if is_shape_class(value)]
|
73
69
|
|
74
|
-
return imported.__doc__, user_classes,
|
70
|
+
return imported.__doc__, user_classes, shape_classes
|
locust/web.py
CHANGED
@@ -136,6 +136,12 @@ class WebUI:
|
|
136
136
|
if not delayed_start:
|
137
137
|
self.start()
|
138
138
|
|
139
|
+
@app.errorhandler(Exception)
|
140
|
+
def handle_exception(error):
|
141
|
+
error_message = str(error)
|
142
|
+
logger.log(logging.CRITICAL, error_message)
|
143
|
+
return make_response(error_message, 500)
|
144
|
+
|
139
145
|
@app.route("/assets/<path:path>")
|
140
146
|
def send_assets(path):
|
141
147
|
webui_build_path = self.webui_build_path
|
@@ -229,7 +235,11 @@ class WebUI:
|
|
229
235
|
if environment.shape_class and environment.runner is not None:
|
230
236
|
environment.runner.start_shape()
|
231
237
|
return jsonify(
|
232
|
-
{
|
238
|
+
{
|
239
|
+
"success": True,
|
240
|
+
"message": f"Swarming started using shape class '{type(environment.shape_class).__name__}'",
|
241
|
+
"host": environment.host,
|
242
|
+
}
|
233
243
|
)
|
234
244
|
|
235
245
|
if self._swarm_greenlet is not None:
|
@@ -474,6 +484,20 @@ class WebUI:
|
|
474
484
|
}
|
475
485
|
return task_data
|
476
486
|
|
487
|
+
@app.route("/logs")
|
488
|
+
@self.auth_required_if_enabled
|
489
|
+
def logs():
|
490
|
+
log_reader_handler = [
|
491
|
+
handler for handler in logging.getLogger("root").handlers if handler.name == "log_reader"
|
492
|
+
]
|
493
|
+
|
494
|
+
if log_reader_handler:
|
495
|
+
logs = log_reader_handler[0].logs
|
496
|
+
else:
|
497
|
+
logs = []
|
498
|
+
|
499
|
+
return jsonify({"logs": logs})
|
500
|
+
|
477
501
|
def start(self):
|
478
502
|
self.greenlet = gevent.spawn(self.start_server)
|
479
503
|
self.greenlet.link_exception(greenlet_exception_handler)
|