locust 2.32.2.dev7__tar.gz → 2.32.2.dev34__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 (54) hide show
  1. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/PKG-INFO +1 -1
  2. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/_version.py +2 -2
  3. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/argument_parser.py +8 -0
  4. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/env.py +2 -0
  5. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/html.py +3 -1
  6. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/main.py +12 -4
  7. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/runners.py +3 -2
  8. locust-2.32.2.dev34/locust/util/date.py +23 -0
  9. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/web.py +38 -25
  10. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/webui/dist/report.html +1 -1
  11. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/poetry.lock +48 -1
  12. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/pyproject.toml +2 -1
  13. locust-2.32.2.dev7/locust/util/date.py +0 -5
  14. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/LICENSE +0 -0
  15. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/README.md +0 -0
  16. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/__init__.py +0 -0
  17. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/__main__.py +0 -0
  18. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/clients.py +0 -0
  19. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/contrib/__init__.py +0 -0
  20. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/contrib/fasthttp.py +0 -0
  21. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/contrib/mongodb.py +0 -0
  22. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/contrib/postgres.py +0 -0
  23. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/debug.py +0 -0
  24. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/dispatch.py +0 -0
  25. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/event.py +0 -0
  26. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/exception.py +0 -0
  27. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/input_events.py +0 -0
  28. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/log.py +0 -0
  29. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/py.typed +0 -0
  30. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/rpc/__init__.py +0 -0
  31. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/rpc/protocol.py +0 -0
  32. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/rpc/zmqrpc.py +0 -0
  33. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/shape.py +0 -0
  34. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/stats.py +0 -0
  35. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/user/__init__.py +0 -0
  36. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/user/inspectuser.py +0 -0
  37. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/user/sequential_taskset.py +0 -0
  38. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/user/task.py +0 -0
  39. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/user/users.py +0 -0
  40. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/user/wait_time.py +0 -0
  41. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/util/__init__.py +0 -0
  42. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/util/cache.py +0 -0
  43. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/util/deprecation.py +0 -0
  44. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/util/directory.py +0 -0
  45. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/util/exception_handler.py +0 -0
  46. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/util/load_locustfile.py +0 -0
  47. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/util/rounding.py +0 -0
  48. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/util/timespan.py +0 -0
  49. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/util/url.py +0 -0
  50. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/webui/dist/assets/favicon-dark.png +0 -0
  51. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/webui/dist/assets/favicon-light.png +0 -0
  52. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/webui/dist/assets/index-CV_-ndKF.js +0 -0
  53. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/webui/dist/auth.html +0 -0
  54. {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/webui/dist/index.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: locust
3
- Version: 2.32.2.dev7
3
+ Version: 2.32.2.dev34
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.32.2.dev7"
17
+ __version__ = "2.32.2.dev34"
18
18
  version = __version__
19
- __version_tuple__ = (2, 32, 2, "dev7")
19
+ __version_tuple__ = (2, 32, 2, "dev34")
20
20
  version_tuple = __version_tuple__
@@ -612,6 +612,14 @@ Typically ONLY these options (and --locustfile) need to be specified on workers,
612
612
  env_var="LOCUST_MASTER_NODE_PORT",
613
613
  )
614
614
 
615
+ web_ui_group.add_argument(
616
+ "--web-base-path",
617
+ type=str,
618
+ default="",
619
+ help="Base path for the web interface (e.g., '/locust'). Default is empty (root path).",
620
+ env_var="LOCUST_web_base_path",
621
+ )
622
+
615
623
  tag_group = parser.add_argument_group(
616
624
  "Tag options",
617
625
  "Locust tasks can be tagged using the @tag decorator. These options let specify which tasks to include or exclude during a test.",
@@ -165,6 +165,7 @@ class Environment:
165
165
  self,
166
166
  host="",
167
167
  port=8089,
168
+ web_base_path: str | None = None,
168
169
  web_login: bool = False,
169
170
  tls_cert: str | None = None,
170
171
  tls_key: str | None = None,
@@ -199,6 +200,7 @@ class Environment:
199
200
  delayed_start=delayed_start,
200
201
  userclass_picker_is_active=userclass_picker_is_active,
201
202
  build_path=build_path,
203
+ web_base_path=web_base_path,
202
204
  )
203
205
  return self.web_ui
204
206
 
@@ -12,7 +12,7 @@ from . import stats as stats_module
12
12
  from .runners import STATE_STOPPED, STATE_STOPPING, MasterRunner
13
13
  from .stats import sort_stats, update_stats_history
14
14
  from .user.inspectuser import get_ratio
15
- from .util.date import format_utc_timestamp
15
+ from .util.date import format_duration, format_utc_timestamp
16
16
 
17
17
  PERCENTILES_FOR_HTML_REPORT = [0.50, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99, 1.0]
18
18
  DEFAULT_BUILD_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "webui", "dist")
@@ -36,6 +36,7 @@ def get_html_report(
36
36
  if end_ts := stats.last_request_timestamp:
37
37
  end_time = format_utc_timestamp(end_ts)
38
38
  else:
39
+ end_ts = stats.start_time
39
40
  end_time = start_time
40
41
 
41
42
  host = None
@@ -88,6 +89,7 @@ def get_html_report(
88
89
  ],
89
90
  "start_time": start_time,
90
91
  "end_time": end_time,
92
+ "duration": format_duration(stats.start_time, end_ts),
91
93
  "host": escape(str(host)),
92
94
  "history": history,
93
95
  "show_download_link": show_download_link,
@@ -454,7 +454,9 @@ See https://github.com/locustio/locust/wiki/Installation#increasing-maximum-numb
454
454
  elif options.worker:
455
455
  try:
456
456
  runner = environment.create_worker_runner(options.master_host, options.master_port)
457
- logger.debug("Connected to locust master: %s:%s", options.master_host, options.master_port)
457
+ logger.debug(
458
+ "Connected to locust master: %s:%s%s", options.master_host, options.master_port, options.web_base_path
459
+ )
458
460
  except OSError as e:
459
461
  logger.error("Failed to connect to the Locust master: %s", e)
460
462
  sys.exit(-1)
@@ -490,26 +492,32 @@ See https://github.com/locustio/locust/wiki/Installation#increasing-maximum-numb
490
492
  if not options.headless and not options.worker:
491
493
  protocol = "https" if options.tls_cert and options.tls_key else "http"
492
494
 
495
+ if options.web_base_path and options.web_base_path[0] != "/":
496
+ logger.error(
497
+ f"Invalid format for --web-base-path argument ({options.web_base_path}): the url path must start with a slash."
498
+ )
499
+ sys.exit(1)
493
500
  if options.web_host == "*":
494
501
  # special check for "*" so that we're consistent with --master-bind-host
495
502
  web_host = ""
496
503
  else:
497
504
  web_host = options.web_host
498
505
  if web_host:
499
- logger.info(f"Starting web interface at {protocol}://{web_host}:{options.web_port}")
506
+ logger.info(f"Starting web interface at {protocol}://{web_host}:{options.web_port}{options.web_base_path}")
500
507
  if options.web_host_display_name:
501
508
  logger.info(f"Starting web interface at {options.web_host_display_name}")
502
509
  else:
503
510
  if os.name == "nt":
504
511
  logger.info(
505
- f"Starting web interface at {protocol}://localhost:{options.web_port} (accepting connections from all network interfaces)"
512
+ f"Starting web interface at {protocol}://localhost:{options.web_port}{options.web_base_path} (accepting connections from all network interfaces)"
506
513
  )
507
514
  else:
508
- logger.info(f"Starting web interface at {protocol}://0.0.0.0:{options.web_port}")
515
+ logger.info(f"Starting web interface at {protocol}://0.0.0.0:{options.web_port}{options.web_base_path}")
509
516
 
510
517
  web_ui = environment.create_web_ui(
511
518
  host=web_host,
512
519
  port=options.web_port,
520
+ web_base_path=options.web_base_path,
513
521
  web_login=options.web_login,
514
522
  tls_cert=options.tls_cert,
515
523
  tls_key=options.tls_key,
@@ -1216,6 +1216,7 @@ class WorkerRunner(DistributedRunner):
1216
1216
  self.client_id = socket.gethostname() + "_" + uuid4().hex
1217
1217
  self.master_host = master_host
1218
1218
  self.master_port = master_port
1219
+ self.web_base_path = environment.parsed_options.web_base_path if environment.parsed_options else ""
1219
1220
  self.logs: list[str] = []
1220
1221
  self.worker_cpu_warning_emitted = False
1221
1222
  self._users_dispatcher: UsersDispatcher | None = None
@@ -1475,11 +1476,11 @@ class WorkerRunner(DistributedRunner):
1475
1476
  if not success:
1476
1477
  if self.retry < 3:
1477
1478
  logger.debug(
1478
- f"Failed to connect to master {self.master_host}:{self.master_port}, retry {self.retry}/{CONNECT_RETRY_COUNT}."
1479
+ f"Failed to connect to master {self.master_host}:{self.master_port}{self.web_base_path}, retry {self.retry}/{CONNECT_RETRY_COUNT}."
1479
1480
  )
1480
1481
  else:
1481
1482
  logger.warning(
1482
- f"Failed to connect to master {self.master_host}:{self.master_port}, retry {self.retry}/{CONNECT_RETRY_COUNT}."
1483
+ f"Failed to connect to master {self.master_host}:{self.master_port}{self.web_base_path}, retry {self.retry}/{CONNECT_RETRY_COUNT}."
1483
1484
  )
1484
1485
  if self.retry > CONNECT_RETRY_COUNT:
1485
1486
  raise ConnectionError()
@@ -0,0 +1,23 @@
1
+ from datetime import datetime, timezone
2
+
3
+
4
+ def format_utc_timestamp(unix_timestamp):
5
+ return datetime.fromtimestamp(int(unix_timestamp), timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
6
+
7
+
8
+ def format_safe_timestamp(unix_timestamp):
9
+ return datetime.fromtimestamp(int(unix_timestamp)).strftime("%Y-%m-%d-%Hh%M")
10
+
11
+
12
+ def format_duration(start_time, end_time):
13
+ seconds = int(end_time) - int(start_time)
14
+ days = seconds // 86400
15
+ hours = (seconds % 86400) // 3600
16
+ minutes = (seconds % 3600) // 60
17
+ seconds = seconds % 60
18
+
19
+ time_parts = [(days, "day"), (hours, "hour"), (minutes, "minute"), (seconds, "second")]
20
+
21
+ parts = [f"{value} {label}{'s' if value != 1 else ''}" for value, label in time_parts if value > 0]
22
+
23
+ return " and ".join(filter(None, [", ".join(parts[:-1])] + parts[-1:])) if parts else "0 seconds"
@@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Any, TypedDict
15
15
 
16
16
  import gevent
17
17
  from flask import (
18
+ Blueprint,
18
19
  Flask,
19
20
  Response,
20
21
  jsonify,
@@ -40,7 +41,7 @@ from .runners import STATE_MISSING, STATE_RUNNING, MasterRunner
40
41
  from .stats import StatsCSV, StatsCSVFileWriter, StatsErrorDict, sort_stats
41
42
  from .user.inspectuser import get_ratio
42
43
  from .util.cache import memoize
43
- from .util.date import format_utc_timestamp
44
+ from .util.date import format_safe_timestamp
44
45
  from .util.timespan import parse_timespan
45
46
 
46
47
  if TYPE_CHECKING:
@@ -120,6 +121,7 @@ class WebUI:
120
121
  environment: Environment,
121
122
  host: str,
122
123
  port: int,
124
+ web_base_path: str | None = None,
123
125
  web_login: bool = False,
124
126
  tls_cert: str | None = None,
125
127
  tls_key: str | None = None,
@@ -161,20 +163,21 @@ class WebUI:
161
163
  self.auth_args = {}
162
164
  self.app.template_folder = build_path or DEFAULT_BUILD_PATH
163
165
  self.app.static_url_path = "/assets/"
166
+
167
+ app_blueprint = Blueprint("locust", __name__, url_prefix=web_base_path)
164
168
  # ensures static js files work on Windows
165
169
  mimetypes.add_type("application/javascript", ".js")
166
-
167
170
  if self.web_login:
168
171
  self._login_manager = LoginManager()
169
172
  self._login_manager.init_app(self.app)
170
- self._login_manager.login_view = "login"
173
+ self._login_manager.login_view = "locust.login"
171
174
 
172
175
  if environment.runner:
173
176
  self.update_template_args()
174
177
  if not delayed_start:
175
178
  self.start()
176
179
 
177
- @app.errorhandler(Exception)
180
+ @app_blueprint.errorhandler(Exception)
178
181
  def handle_exception(error):
179
182
  error_message = str(error)
180
183
  error_code = getattr(error, "code", 500)
@@ -184,7 +187,7 @@ class WebUI:
184
187
  )
185
188
  return make_response(error_message, error_code)
186
189
 
187
- @app.route("/assets/<path:path>")
190
+ @app_blueprint.route("/assets/<path:path>")
188
191
  def send_assets(path):
189
192
  directory = (
190
193
  os.path.join(self.app.template_folder, "assets")
@@ -194,7 +197,7 @@ class WebUI:
194
197
 
195
198
  return send_from_directory(directory, path)
196
199
 
197
- @app.route("/")
200
+ @app_blueprint.route("/")
198
201
  @self.auth_required_if_enabled
199
202
  def index() -> str | Response:
200
203
  if not environment.runner:
@@ -203,7 +206,7 @@ class WebUI:
203
206
 
204
207
  return render_template("index.html", template_args=self.template_args)
205
208
 
206
- @app.route("/swarm", methods=["POST"])
209
+ @app_blueprint.route("/swarm", methods=["POST"])
207
210
  @self.auth_required_if_enabled
208
211
  def swarm() -> Response:
209
212
  assert request.method == "POST"
@@ -317,7 +320,7 @@ class WebUI:
317
320
  else:
318
321
  return jsonify({"success": False, "message": "No runner", "host": environment.host})
319
322
 
320
- @app.route("/stop")
323
+ @app_blueprint.route("/stop")
321
324
  @self.auth_required_if_enabled
322
325
  def stop() -> Response:
323
326
  if self._swarm_greenlet is not None:
@@ -327,7 +330,7 @@ class WebUI:
327
330
  environment.runner.stop()
328
331
  return jsonify({"success": True, "message": "Test stopped"})
329
332
 
330
- @app.route("/stats/reset")
333
+ @app_blueprint.route("/stats/reset")
331
334
  @self.auth_required_if_enabled
332
335
  def reset_stats() -> str:
333
336
  environment.events.reset_stats.fire()
@@ -336,7 +339,7 @@ class WebUI:
336
339
  environment.runner.exceptions = {}
337
340
  return "ok"
338
341
 
339
- @app.route("/stats/report")
342
+ @app_blueprint.route("/stats/report")
340
343
  @self.auth_required_if_enabled
341
344
  def stats_report() -> Response:
342
345
  theme = request.args.get("theme", "")
@@ -347,17 +350,25 @@ class WebUI:
347
350
  )
348
351
  if request.args.get("download"):
349
352
  res = app.make_response(res)
350
- res.headers["Content-Disposition"] = f"attachment;filename=report_{time()}.html"
353
+ host = f"_{self.environment.host}" if self.environment.host else ""
354
+ res.headers["Content-Disposition"] = (
355
+ f"attachment;filename=Locust_{format_safe_timestamp(self.environment.stats.start_time)}_"
356
+ + f"{self.environment.locustfile}{host}.html"
357
+ )
351
358
  return res
352
359
 
353
360
  def _download_csv_suggest_file_name(suggest_filename_prefix: str) -> str:
354
361
  """Generate csv file download attachment filename suggestion.
355
362
 
356
363
  Arguments:
357
- suggest_filename_prefix: Prefix of the filename to suggest for saving the download. Will be appended with timestamp.
364
+ suggest_filename_prefix: Prefix of the filename to suggest for saving the download.
365
+ Will be appended with timestamp.
358
366
  """
359
-
360
- return f"{suggest_filename_prefix}_{time()}.csv"
367
+ host = f"_{self.environment.host}" if self.environment.host else ""
368
+ return (
369
+ f"Locust_{format_safe_timestamp(self.environment.stats.start_time)}_"
370
+ + f"{self.environment.locustfile}{host}_{suggest_filename_prefix}.csv"
371
+ )
361
372
 
362
373
  def _download_csv_response(csv_data: str, filename_prefix: str) -> Response:
363
374
  """Generate csv file download response with 'csv_data'.
@@ -374,7 +385,7 @@ class WebUI:
374
385
  )
375
386
  return response
376
387
 
377
- @app.route("/stats/requests/csv")
388
+ @app_blueprint.route("/stats/requests/csv")
378
389
  @self.auth_required_if_enabled
379
390
  def request_stats_csv() -> Response:
380
391
  data = StringIO()
@@ -382,7 +393,7 @@ class WebUI:
382
393
  self.stats_csv_writer.requests_csv(writer)
383
394
  return _download_csv_response(data.getvalue(), "requests")
384
395
 
385
- @app.route("/stats/requests_full_history/csv")
396
+ @app_blueprint.route("/stats/requests_full_history/csv")
386
397
  @self.auth_required_if_enabled
387
398
  def request_stats_full_history_csv() -> Response:
388
399
  options = self.environment.parsed_options
@@ -400,7 +411,7 @@ class WebUI:
400
411
 
401
412
  return make_response("Error: Server was not started with option to generate full history.", 404)
402
413
 
403
- @app.route("/stats/failures/csv")
414
+ @app_blueprint.route("/stats/failures/csv")
404
415
  @self.auth_required_if_enabled
405
416
  def failures_stats_csv() -> Response:
406
417
  data = StringIO()
@@ -408,7 +419,7 @@ class WebUI:
408
419
  self.stats_csv_writer.failures_csv(writer)
409
420
  return _download_csv_response(data.getvalue(), "failures")
410
421
 
411
- @app.route("/stats/requests")
422
+ @app_blueprint.route("/stats/requests")
412
423
  @self.auth_required_if_enabled
413
424
  @memoize(timeout=DEFAULT_CACHE_TIME, dynamic_timeout=True)
414
425
  def request_stats() -> Response:
@@ -478,7 +489,7 @@ class WebUI:
478
489
 
479
490
  return jsonify(report)
480
491
 
481
- @app.route("/exceptions")
492
+ @app_blueprint.route("/exceptions")
482
493
  @self.auth_required_if_enabled
483
494
  def exceptions() -> Response:
484
495
  return jsonify(
@@ -495,7 +506,7 @@ class WebUI:
495
506
  }
496
507
  )
497
508
 
498
- @app.route("/exceptions/csv")
509
+ @app_blueprint.route("/exceptions/csv")
499
510
  @self.auth_required_if_enabled
500
511
  def exceptions_csv() -> Response:
501
512
  data = StringIO()
@@ -503,7 +514,7 @@ class WebUI:
503
514
  self.stats_csv_writer.exceptions_csv(writer)
504
515
  return _download_csv_response(data.getvalue(), "exceptions")
505
516
 
506
- @app.route("/tasks")
517
+ @app_blueprint.route("/tasks")
507
518
  @self.auth_required_if_enabled
508
519
  def tasks() -> dict[str, dict[str, dict[str, float]]]:
509
520
  runner = self.environment.runner
@@ -523,15 +534,15 @@ class WebUI:
523
534
  }
524
535
  return task_data
525
536
 
526
- @app.route("/logs")
537
+ @app_blueprint.route("/logs")
527
538
  @self.auth_required_if_enabled
528
539
  def logs():
529
540
  return jsonify({"master": get_logs(), "workers": self.environment.worker_logs})
530
541
 
531
- @app.route("/login")
542
+ @app_blueprint.route("/login")
532
543
  def login():
533
544
  if not self.web_login:
534
- return redirect(url_for("index"))
545
+ return redirect(url_for("locust.index"))
535
546
 
536
547
  self.auth_args["error"] = session.get("auth_error", None)
537
548
  self.auth_args["info"] = session.get("auth_info", None)
@@ -541,7 +552,7 @@ class WebUI:
541
552
  auth_args=self.auth_args,
542
553
  )
543
554
 
544
- @app.route("/user", methods=["POST"])
555
+ @app_blueprint.route("/user", methods=["POST"])
545
556
  def update_user():
546
557
  assert request.method == "POST"
547
558
 
@@ -550,6 +561,8 @@ class WebUI:
550
561
 
551
562
  return {}, 201
552
563
 
564
+ app.register_blueprint(app_blueprint)
565
+
553
566
  @property
554
567
  def login_manager(self):
555
568
  if self.web_login:
@@ -220,7 +220,7 @@ PERFORMANCE OF THIS SOFTWARE.
220
220
  <span style="color:${f};">
221
221
  ${h}:&nbsp${Rbe({chartValueFormatter:i,value:d})}
222
222
  </span>
223
- `,""):"No data",borderWidth:0},xAxis:{type:"time",min:(e.time||[new Date().toISOString()])[0],startValue:(e.time||[])[0],axisLabel:{formatter:Lbe}},grid:{left:60,right:40},yAxis:Ebe({splitAxis:a,yAxisLabels:o}),series:NW({charts:e,lines:r,scatterplot:s}),color:n,toolbox:{right:10,feature:{dataZoom:{title:{zoom:"Zoom Select",back:"Zoom Reset"},yAxisIndex:!1},saveAsImage:{name:t.replace(/\s+/g,"_").toLowerCase()+"_"+new Date().getTime()/1e3,title:"Download as PNG",emphasis:{iconStyle:{textPosition:"left"}}}}}}),Nbe=e=>({symbol:"none",label:{formatter:t=>`Run #${t.dataIndex+1}`,padding:[0,0,8,0]},data:(e.markers||[]).map(t=>({xAxis:t}))}),zbe=e=>t=>{const{batch:r}=t;if(!r)return;const[{start:n,startValue:i,end:a}]=r,o=n>0&&a<=100||i>0;e.setOption({dataZoom:[{type:"slider",show:o}]})},Bbe=aQ;function Fbe({charts:e,title:t,lines:r,colors:n,chartValueFormatter:i,splitAxis:a,yAxisLabels:o,scatterplot:s,shouldReplaceMergeLines:l=!1}){const[u,c]=V.useState(null),f=Bbe(({theme:{isDarkMode:d}})=>d),h=V.useRef(null);return V.useEffect(()=>{if(!h.current)return;const d=bce(h.current);d.setOption(Obe({charts:e,title:t,lines:r,colors:n,chartValueFormatter:i,splitAxis:a,yAxisLabels:o,scatterplot:s})),d.on("datazoom",zbe(d));const p=()=>d.resize();return window.addEventListener("resize",p),d.group="swarmCharts",Cce("swarmCharts"),c(d),()=>{Tce(d),window.removeEventListener("resize",p)}},[h]),V.useEffect(()=>{const d=r.every(({key:p})=>!!e[p]);u&&d&&u.setOption({series:r.map(({key:p,yAxisIndex:v,...g},y)=>({...g,data:e[p],...a?{yAxisIndex:v||y}:{},...y===0?{markLine:Nbe(e)}:{}}))})},[e,u,r]),V.useEffect(()=>{if(u){const{textColor:d,axisColor:p,backgroundColor:v,splitLine:g}=f?Lz.DARK:Lz.LIGHT;u.setOption({backgroundColor:v,textStyle:{color:d},title:{textStyle:{color:d}},legend:{icon:"circle",inactiveColor:d,textStyle:{color:d}},tooltip:{backgroundColor:v,textStyle:{color:d}},xAxis:{axisLine:{lineStyle:{color:p}}},yAxis:{axisLine:{lineStyle:{color:p}},splitLine:{lineStyle:{color:g}}}})}},[u,f]),V.useEffect(()=>{u&&u.setOption({series:NW({charts:e,lines:r,scatterplot:s})},l?{replaceMerge:["series"]}:void 0)},[r]),H.jsx("div",{ref:h,style:{width:"100%",height:"300px"}})}const Vbe=dl.percentilesToChart?dl.percentilesToChart.map(e=>({name:`${e*100}th percentile`,key:`responseTimePercentile${e}`})):[],Gbe=["#ff9f00","#9966CC","#8A2BE2","#8E4585","#E0B0FF","#C8A2C8","#E6E6FA"],$be=[{title:"Total Requests per Second",lines:[{name:"RPS",key:"currentRps"},{name:"Failures/s",key:"currentFailPerSec"}],colors:["#00ca5a","#ff6d6d"]},{title:"Response Times (ms)",lines:Vbe,colors:Gbe},{title:"Number of Users",lines:[{name:"Number of Users",key:"userCount"}],colors:["#0099ff"]}];function zW({charts:e}){return H.jsx("div",{children:$be.map((t,r)=>H.jsx(Fbe,{...t,charts:e},`swarm-chart-${r}`))})}const Hbe=({ui:{charts:e}})=>({charts:e});Kd(Hbe)(zW);function Wbe(e){return(e*100).toFixed(1)+"%"}function EC({classRatio:e}){return H.jsx("ul",{children:Object.entries(e).map(([t,{ratio:r,tasks:n}])=>H.jsxs("li",{children:[`${Wbe(r)} ${t}`,n&&H.jsx(EC,{classRatio:n})]},`nested-ratio-${t}`))})}function BW({ratios:{perClass:e,total:t}}){return!e&&!t?null:H.jsxs("div",{children:[e&&H.jsxs(H.Fragment,{children:[H.jsx("h3",{children:"Ratio Per Class"}),H.jsx(EC,{classRatio:e})]}),t&&H.jsxs(H.Fragment,{children:[H.jsx("h3",{children:"Total Ratio"}),H.jsx(EC,{classRatio:t})]})]})}const Ube=({ui:{ratios:e}})=>({ratios:e});Kd(Ube)(BW);const xS={DARK:"dark",LIGHT:"light"},FW=localStorage.theme===xS.DARK||!("theme"in localStorage)&&window.matchMedia("(prefers-color-scheme: dark)").matches?xS.DARK:xS.LIGHT,jbe={isDarkMode:!1},Ybe=Oq({name:"theme",initialState:jbe,reducers:{setIsDarkMode:(e,{payload:t})=>{e.isDarkMode=t}}}),Xbe=Ybe.reducer,Zbe=e=>ZT({palette:{mode:e,primary:{main:"#15803d"},success:{main:"#00C853"}},components:{MuiCssBaseline:{styleOverrides:{":root":{"--footer-height":"40px"}}}}}),Kbe=Zbe(window.theme||FW),qbe=(window.theme||FW)==="dark",Qbe=[{key:"method",title:"Type"},{key:"name",title:"Name"},{key:"numRequests",title:"# Requests"},{key:"numFailures",title:"# Fails"},{key:"avgResponseTime",title:"Average (ms)",round:2},{key:"minResponseTime",title:"Min (ms)"},{key:"maxResponseTime",title:"Max (ms)"},{key:"avgContentLength",title:"Average size (bytes)",round:2},{key:"totalRps",title:"RPS",round:2},{key:"totalFailPerSec",title:"Failures/s",round:2}],Jbe=Aq({reducer:W3({theme:Xbe}),preloadedState:{theme:{isDarkMode:qbe}}});function eCe({locustfile:e,showDownloadLink:t,startTime:r,endTime:n,charts:i,host:a,exceptionsStatistics:o,requestsStatistics:s,failuresStatistics:l,responseTimeStatistics:u,tasks:c}){return H.jsx(ZQ,{store:Jbe,children:H.jsxs($Y,{theme:Kbe,children:[H.jsx(TZ,{}),H.jsxs(SZ,{maxWidth:"lg",sx:{my:4},children:[H.jsxs(Nn,{sx:{display:"flex",justifyContent:"space-between",alignItems:"flex-end"},children:[H.jsx(Tr,{component:"h1",noWrap:!0,sx:{fontWeight:700},variant:"h3",children:"Locust Test Report"}),t&&H.jsx(G3,{href:`?download=1&theme=${window.theme}`,children:"Download the Report"})]}),H.jsxs(Nn,{sx:{my:2},children:[H.jsxs(Nn,{sx:{display:"flex",columnGap:.5},children:[H.jsx(Tr,{fontWeight:600,children:"During:"}),H.jsxs(Tr,{children:[IC(r)," - ",IC(n)]})]}),H.jsxs(Nn,{sx:{display:"flex",columnGap:.5},children:[H.jsx(Tr,{fontWeight:600,children:"Target Host:"}),H.jsx(Tr,{children:a||"None"})]}),H.jsxs(Nn,{sx:{display:"flex",columnGap:.5},children:[H.jsx(Tr,{fontWeight:600,children:"Script:"}),H.jsx(Tr,{children:e})]})]}),H.jsxs(Nn,{sx:{display:"flex",flexDirection:"column",rowGap:4},children:[H.jsxs(Nn,{children:[H.jsx(Tr,{component:"h2",mb:1,noWrap:!0,variant:"h4",children:"Request Statistics"}),H.jsx(t4,{stats:s,tableStructure:Qbe})]}),!!u.length&&H.jsxs(Nn,{children:[H.jsx(Tr,{component:"h2",mb:1,noWrap:!0,variant:"h4",children:"Response Time Statistics"}),H.jsx(bne,{responseTimes:u})]}),H.jsxs(Nn,{children:[H.jsx(Tr,{component:"h2",mb:1,noWrap:!0,variant:"h4",children:"Failures Statistics"}),H.jsx(QF,{errors:l})]}),!!o.length&&H.jsxs(Nn,{children:[H.jsx(Tr,{component:"h2",mb:1,noWrap:!0,variant:"h4",children:"Exceptions Statistics"}),H.jsx(qF,{exceptions:o})]}),H.jsxs(Nn,{children:[H.jsx(Tr,{component:"h2",mb:1,noWrap:!0,variant:"h4",children:"Charts"}),H.jsx(zW,{charts:i})]}),H.jsxs(Nn,{children:[H.jsx(Tr,{component:"h2",mb:1,noWrap:!0,variant:"h4",children:"Final ratio"}),H.jsx(BW,{ratios:c})]})]})]})]})})}const tCe=SS.createRoot(document.getElementById("root"));tCe.render(H.jsx(p9,{fallbackRender:g9,children:H.jsx(eCe,{...x9})}));
223
+ `,""):"No data",borderWidth:0},xAxis:{type:"time",min:(e.time||[new Date().toISOString()])[0],startValue:(e.time||[])[0],axisLabel:{formatter:Lbe}},grid:{left:60,right:40},yAxis:Ebe({splitAxis:a,yAxisLabels:o}),series:NW({charts:e,lines:r,scatterplot:s}),color:n,toolbox:{right:10,feature:{dataZoom:{title:{zoom:"Zoom Select",back:"Zoom Reset"},yAxisIndex:!1},saveAsImage:{name:t.replace(/\s+/g,"_").toLowerCase()+"_"+new Date().getTime()/1e3,title:"Download as PNG",emphasis:{iconStyle:{textPosition:"left"}}}}}}),Nbe=e=>({symbol:"none",label:{formatter:t=>`Run #${t.dataIndex+1}`,padding:[0,0,8,0]},data:(e.markers||[]).map(t=>({xAxis:t}))}),zbe=e=>t=>{const{batch:r}=t;if(!r)return;const[{start:n,startValue:i,end:a}]=r,o=n>0&&a<=100||i>0;e.setOption({dataZoom:[{type:"slider",show:o}]})},Bbe=aQ;function Fbe({charts:e,title:t,lines:r,colors:n,chartValueFormatter:i,splitAxis:a,yAxisLabels:o,scatterplot:s,shouldReplaceMergeLines:l=!1}){const[u,c]=V.useState(null),f=Bbe(({theme:{isDarkMode:d}})=>d),h=V.useRef(null);return V.useEffect(()=>{if(!h.current)return;const d=bce(h.current);d.setOption(Obe({charts:e,title:t,lines:r,colors:n,chartValueFormatter:i,splitAxis:a,yAxisLabels:o,scatterplot:s})),d.on("datazoom",zbe(d));const p=()=>d.resize();return window.addEventListener("resize",p),d.group="swarmCharts",Cce("swarmCharts"),c(d),()=>{Tce(d),window.removeEventListener("resize",p)}},[h]),V.useEffect(()=>{const d=r.every(({key:p})=>!!e[p]);u&&d&&u.setOption({series:r.map(({key:p,yAxisIndex:v,...g},y)=>({...g,data:e[p],...a?{yAxisIndex:v||y}:{},...y===0?{markLine:Nbe(e)}:{}}))})},[e,u,r]),V.useEffect(()=>{if(u){const{textColor:d,axisColor:p,backgroundColor:v,splitLine:g}=f?Lz.DARK:Lz.LIGHT;u.setOption({backgroundColor:v,textStyle:{color:d},title:{textStyle:{color:d}},legend:{icon:"circle",inactiveColor:d,textStyle:{color:d}},tooltip:{backgroundColor:v,textStyle:{color:d}},xAxis:{axisLine:{lineStyle:{color:p}}},yAxis:{axisLine:{lineStyle:{color:p}},splitLine:{lineStyle:{color:g}}}})}},[u,f]),V.useEffect(()=>{u&&u.setOption({series:NW({charts:e,lines:r,scatterplot:s})},l?{replaceMerge:["series"]}:void 0)},[r]),H.jsx("div",{ref:h,style:{width:"100%",height:"300px"}})}const Vbe=dl.percentilesToChart?dl.percentilesToChart.map(e=>({name:`${e*100}th percentile`,key:`responseTimePercentile${e}`})):[],Gbe=["#ff9f00","#9966CC","#8A2BE2","#8E4585","#E0B0FF","#C8A2C8","#E6E6FA"],$be=[{title:"Total Requests per Second",lines:[{name:"RPS",key:"currentRps"},{name:"Failures/s",key:"currentFailPerSec"}],colors:["#00ca5a","#ff6d6d"]},{title:"Response Times (ms)",lines:Vbe,colors:Gbe},{title:"Number of Users",lines:[{name:"Number of Users",key:"userCount"}],colors:["#0099ff"]}];function zW({charts:e}){return H.jsx("div",{children:$be.map((t,r)=>H.jsx(Fbe,{...t,charts:e},`swarm-chart-${r}`))})}const Hbe=({ui:{charts:e}})=>({charts:e});Kd(Hbe)(zW);function Wbe(e){return(e*100).toFixed(1)+"%"}function EC({classRatio:e}){return H.jsx("ul",{children:Object.entries(e).map(([t,{ratio:r,tasks:n}])=>H.jsxs("li",{children:[`${Wbe(r)} ${t}`,n&&H.jsx(EC,{classRatio:n})]},`nested-ratio-${t}`))})}function BW({ratios:{perClass:e,total:t}}){return!e&&!t?null:H.jsxs("div",{children:[e&&H.jsxs(H.Fragment,{children:[H.jsx("h3",{children:"Ratio Per Class"}),H.jsx(EC,{classRatio:e})]}),t&&H.jsxs(H.Fragment,{children:[H.jsx("h3",{children:"Total Ratio"}),H.jsx(EC,{classRatio:t})]})]})}const Ube=({ui:{ratios:e}})=>({ratios:e});Kd(Ube)(BW);const xS={DARK:"dark",LIGHT:"light"},FW=localStorage.theme===xS.DARK||!("theme"in localStorage)&&window.matchMedia("(prefers-color-scheme: dark)").matches?xS.DARK:xS.LIGHT,jbe={isDarkMode:!1},Ybe=Oq({name:"theme",initialState:jbe,reducers:{setIsDarkMode:(e,{payload:t})=>{e.isDarkMode=t}}}),Xbe=Ybe.reducer,Zbe=e=>ZT({palette:{mode:e,primary:{main:"#15803d"},success:{main:"#00C853"}},components:{MuiCssBaseline:{styleOverrides:{":root":{"--footer-height":"40px"}}}}}),Kbe=Zbe(window.theme||FW),qbe=(window.theme||FW)==="dark",Qbe=[{key:"method",title:"Type"},{key:"name",title:"Name"},{key:"numRequests",title:"# Requests"},{key:"numFailures",title:"# Fails"},{key:"avgResponseTime",title:"Average (ms)",round:2},{key:"minResponseTime",title:"Min (ms)"},{key:"maxResponseTime",title:"Max (ms)"},{key:"avgContentLength",title:"Average size (bytes)",round:2},{key:"totalRps",title:"RPS",round:2},{key:"totalFailPerSec",title:"Failures/s",round:2}],Jbe=Aq({reducer:W3({theme:Xbe}),preloadedState:{theme:{isDarkMode:qbe}}});function eCe({locustfile:e,showDownloadLink:t,startTime:r,endTime:n,duration:i,charts:a,host:o,exceptionsStatistics:s,requestsStatistics:l,failuresStatistics:u,responseTimeStatistics:c,tasks:f}){return H.jsx(ZQ,{store:Jbe,children:H.jsxs($Y,{theme:Kbe,children:[H.jsx(TZ,{}),H.jsxs(SZ,{maxWidth:"lg",sx:{my:4},children:[H.jsxs(Nn,{sx:{display:"flex",justifyContent:"space-between",alignItems:"flex-end"},children:[H.jsx(Tr,{component:"h1",noWrap:!0,sx:{fontWeight:700},variant:"h3",children:"Locust Test Report"}),t&&H.jsx(G3,{href:`?download=1&theme=${window.theme}`,children:"Download the Report"})]}),H.jsxs(Nn,{sx:{my:2},children:[H.jsxs(Nn,{sx:{display:"flex",columnGap:.5},children:[H.jsx(Tr,{fontWeight:600,children:"During:"}),H.jsxs(Tr,{children:[IC(r)," - ",IC(n)," (",i,")"]})]}),H.jsxs(Nn,{sx:{display:"flex",columnGap:.5},children:[H.jsx(Tr,{fontWeight:600,children:"Target Host:"}),H.jsx(Tr,{children:o||"None"})]}),H.jsxs(Nn,{sx:{display:"flex",columnGap:.5},children:[H.jsx(Tr,{fontWeight:600,children:"Script:"}),H.jsx(Tr,{children:e})]})]}),H.jsxs(Nn,{sx:{display:"flex",flexDirection:"column",rowGap:4},children:[H.jsxs(Nn,{children:[H.jsx(Tr,{component:"h2",mb:1,noWrap:!0,variant:"h4",children:"Request Statistics"}),H.jsx(t4,{stats:l,tableStructure:Qbe})]}),!!c.length&&H.jsxs(Nn,{children:[H.jsx(Tr,{component:"h2",mb:1,noWrap:!0,variant:"h4",children:"Response Time Statistics"}),H.jsx(bne,{responseTimes:c})]}),H.jsxs(Nn,{children:[H.jsx(Tr,{component:"h2",mb:1,noWrap:!0,variant:"h4",children:"Failures Statistics"}),H.jsx(QF,{errors:u})]}),!!s.length&&H.jsxs(Nn,{children:[H.jsx(Tr,{component:"h2",mb:1,noWrap:!0,variant:"h4",children:"Exceptions Statistics"}),H.jsx(qF,{exceptions:s})]}),H.jsxs(Nn,{children:[H.jsx(Tr,{component:"h2",mb:1,noWrap:!0,variant:"h4",children:"Charts"}),H.jsx(zW,{charts:a})]}),H.jsxs(Nn,{children:[H.jsx(Tr,{component:"h2",mb:1,noWrap:!0,variant:"h4",children:"Final ratio"}),H.jsx(BW,{ratios:f})]})]})]})]})})}const tCe=SS.createRoot(document.getElementById("root"));tCe.render(H.jsx(p9,{fallbackRender:g9,children:H.jsx(eCe,{...x9})}));
224
224
  </script>
225
225
  </head>
226
226
  {% endraw %}
@@ -513,6 +513,20 @@ files = [
513
513
  {file = "docutils-0.18.1.tar.gz", hash = "sha256:679987caf361a7539d76e584cbeddc311e3aee937877c87346f31debc63e9d06"},
514
514
  ]
515
515
 
516
+ [[package]]
517
+ name = "exceptiongroup"
518
+ version = "1.2.2"
519
+ description = "Backport of PEP 654 (exception groups)"
520
+ optional = false
521
+ python-versions = ">=3.7"
522
+ files = [
523
+ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
524
+ {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
525
+ ]
526
+
527
+ [package.extras]
528
+ test = ["pytest (>=6)"]
529
+
516
530
  [[package]]
517
531
  name = "filelock"
518
532
  version = "3.16.1"
@@ -872,6 +886,17 @@ perf = ["ipython"]
872
886
  test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
873
887
  type = ["pytest-mypy"]
874
888
 
889
+ [[package]]
890
+ name = "iniconfig"
891
+ version = "2.0.0"
892
+ description = "brain-dead simple config-ini parsing"
893
+ optional = false
894
+ python-versions = ">=3.7"
895
+ files = [
896
+ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
897
+ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
898
+ ]
899
+
875
900
  [[package]]
876
901
  name = "itsdangerous"
877
902
  version = "2.2.0"
@@ -1632,6 +1657,28 @@ lxml = ">=2.1"
1632
1657
  [package.extras]
1633
1658
  test = ["pytest", "pytest-cov", "requests", "webob", "webtest"]
1634
1659
 
1660
+ [[package]]
1661
+ name = "pytest"
1662
+ version = "8.3.3"
1663
+ description = "pytest: simple powerful testing with Python"
1664
+ optional = false
1665
+ python-versions = ">=3.8"
1666
+ files = [
1667
+ {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"},
1668
+ {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"},
1669
+ ]
1670
+
1671
+ [package.dependencies]
1672
+ colorama = {version = "*", markers = "sys_platform == \"win32\""}
1673
+ exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
1674
+ iniconfig = "*"
1675
+ packaging = "*"
1676
+ pluggy = ">=1.5,<2"
1677
+ tomli = {version = ">=1", markers = "python_version < \"3.11\""}
1678
+
1679
+ [package.extras]
1680
+ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
1681
+
1635
1682
  [[package]]
1636
1683
  name = "pywin32"
1637
1684
  version = "308"
@@ -2474,4 +2521,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
2474
2521
  [metadata]
2475
2522
  lock-version = "2.0"
2476
2523
  python-versions = ">=3.9"
2477
- content-hash = "9e58a176725655fe7b74069776ba86492cdc74c7f34b9c2a1788da9fba924e8e"
2524
+ content-hash = "87823fddd6f62da6dd45b8f33d7e7cf6b707ea6c8bb6e06d1f7753d6ec6c6e83"
@@ -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.32.2.dev7"
8
+ version = "2.32.2.dev34"
9
9
  license = "MIT"
10
10
  readme = "README.md"
11
11
  authors = ["Jonatan Heyman", "Lars Holmberg"]
@@ -156,6 +156,7 @@ retry = "^0.9.2"
156
156
  ruff = "0.3.7"
157
157
  tox = "^4.16.0"
158
158
  types-requests = "^2.32.0.20240622"
159
+ pytest = "^8.3.3"
159
160
 
160
161
  [tool.poetry.group.docs]
161
162
  optional = true
@@ -1,5 +0,0 @@
1
- from datetime import datetime, timezone
2
-
3
-
4
- def format_utc_timestamp(unix_timestamp):
5
- return datetime.fromtimestamp(unix_timestamp, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
File without changes
File without changes