diff --git a/.vscode/launch.json b/.vscode/launch.json index 3e776b9ca0..79b88c8bc7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,7 @@ "gevent": true }, { - "name": "Run locust", + "name": "Run current locust scenario headless, 5 users", "type": "python", "request": "launch", "module": "locust", @@ -22,6 +22,18 @@ ], "console": "integratedTerminal", "gevent": true + }, + { + "name": "Run current locust scenario with WebUI", + "type": "python", + "request": "launch", + "module": "locust", + "args": [ + "-f", + "${file}" + ], + "console": "integratedTerminal", + "gevent": true } ] } \ No newline at end of file diff --git a/docs/developing-locust.rst b/docs/developing-locust.rst index 094e39a96c..efe12090e3 100644 --- a/docs/developing-locust.rst +++ b/docs/developing-locust.rst @@ -33,6 +33,8 @@ If you install `pre-commit `_, linting and format check Before you open a pull request, make sure all the tests work. And if you are adding a feature, make sure it is documented (in ``docs/*.rst``). +If you're in a hurry or don't have access to a development environment, you can simply use `Codespaces `_, the github cloud development environment. On your fork page, just click on *Code* then on *Create codespace on *, and voila, your ready to code and test. + Testing your changes ==================== @@ -51,6 +53,11 @@ To only run a specific suite or specific test you can call `pytest `. -Make sure you have enabled gevent in your debugger settings. In VS Code's ``launch.json`` it looks like this: +Make sure you have enabled gevent in your debugger settings. + +Debugging Locust is quite easy with Vscode: + +- Place breakpoints +- Select a python file or a scenario (ex: ```examples/basic.py``) +- Check that the Poetry virtualenv is correctly detected (bottom right) +- Open the action *Debug using launch.json*. You will have the choice between debugging the python file, the scenario with WebUI or in headless mode +- It could be rerun with the F5 shortkey + +VS Code's ``launch.json`` looks like this: .. literalinclude:: ../.vscode/launch.json :language: json diff --git a/locust/html.py b/locust/html.py index 0f8a96a9ec..62184813eb 100644 --- a/locust/html.py +++ b/locust/html.py @@ -12,7 +12,7 @@ from .runners import STATE_STOPPED, STATE_STOPPING, MasterRunner from .stats import sort_stats, update_stats_history from .user.inspectuser import get_ratio -from .util.date import format_utc_timestamp +from .util.date import format_duration, format_utc_timestamp PERCENTILES_FOR_HTML_REPORT = [0.50, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99, 1.0] DEFAULT_BUILD_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "webui", "dist") @@ -88,6 +88,7 @@ def get_html_report( ], "start_time": start_time, "end_time": end_time, + "duration": format_duration(stats.start_time, stats.last_request_timestamp), "host": escape(str(host)), "history": history, "show_download_link": show_download_link, diff --git a/locust/util/date.py b/locust/util/date.py index 20b7a26c6c..b22213de15 100644 --- a/locust/util/date.py +++ b/locust/util/date.py @@ -1,5 +1,194 @@ -from datetime import datetime, timezone +import decimal +import numbers +import re +from datetime import datetime, timedelta, timezone def format_utc_timestamp(unix_timestamp): return datetime.fromtimestamp(unix_timestamp, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def format_safe_timestamp(unix_timestamp): + return datetime.fromtimestamp(unix_timestamp).strftime("%Y-%m-%d-%Hh%M") + + +def format_duration(start_unix_timestamp, end_unix_timestamp): + """ + Format a timespan between two timestamps as a human readable string. + Taken from xolox/python-humanfriendly + + :param start_unix_timestamp: Start timestamp. + :param end_unix_timestamp: End timestamp. + + """ + # Common time units, used for formatting of time spans. + time_units = ( + dict(divider=1e-9, singular="nanosecond", plural="nanoseconds", abbreviations=["ns"]), + dict(divider=1e-6, singular="microsecond", plural="microseconds", abbreviations=["us"]), + dict(divider=1e-3, singular="millisecond", plural="milliseconds", abbreviations=["ms"]), + dict(divider=1, singular="second", plural="seconds", abbreviations=["s", "sec", "secs"]), + dict(divider=60, singular="minute", plural="minutes", abbreviations=["m", "min", "mins"]), + dict(divider=60 * 60, singular="hour", plural="hours", abbreviations=["h"]), + dict(divider=60 * 60 * 24, singular="day", plural="days", abbreviations=["d"]), + dict(divider=60 * 60 * 24 * 7, singular="week", plural="weeks", abbreviations=["w"]), + dict(divider=60 * 60 * 24 * 7 * 52, singular="year", plural="years", abbreviations=["y"]), + ) + + num_seconds = coerce_seconds( + end_unix_timestamp - start_unix_timestamp, + ) + if num_seconds < 60: + # Fast path. + return pluralize(round_number(num_seconds), "second") + else: + # Slow path. + result = [] + num_seconds = decimal.Decimal(str(num_seconds)) + relevant_units = list(reversed(time_units[3:])) + for unit in relevant_units: + # Extract the unit count from the remaining time. + divider = decimal.Decimal(str(unit["divider"])) + count = num_seconds / divider + num_seconds %= divider + # Round the unit count appropriately. + if unit != relevant_units[-1]: + # Integer rounding for all but the smallest unit. + count = int(count) + else: + # Floating point rounding for the smallest unit. + count = round_number(count) + # Only include relevant units in the result. + if count not in (0, "0"): + result.append(pluralize(count, unit["singular"], unit["plural"])) + if len(result) == 1: + # A single count/unit combination. + return result[0] + else: + # Format the timespan in a readable way. + return concatenate(result[:3]) + + +def coerce_seconds(value): + """ + Coerce a value to the number of seconds. + + :param value: An :class:`int`, :class:`float` or + :class:`datetime.timedelta` object. + :returns: An :class:`int` or :class:`float` value. + + When `value` is a :class:`datetime.timedelta` object the + :meth:`~datetime.timedelta.total_seconds()` method is called. + """ + if isinstance(value, timedelta): + return value.total_seconds() + if not isinstance(value, numbers.Number): + msg = "Failed to coerce value to number of seconds! (%r)" + raise ValueError(format(msg, value)) + return value + + +def round_number(count, keep_width=False): + """ + Round a floating point number to two decimal places in a human friendly format. + + :param count: The number to format. + :param keep_width: :data:`True` if trailing zeros should not be stripped, + :data:`False` if they can be stripped. + :returns: The formatted number as a string. If no decimal places are + required to represent the number, they will be omitted. + + The main purpose of this function is to be used by functions like + :func:`format_length()`, :func:`format_size()` and + :func:`format_timespan()`. + + Here are some examples: + + >>> from humanfriendly import round_number + >>> round_number(1) + '1' + >>> round_number(math.pi) + '3.14' + >>> round_number(5.001) + '5' + """ + text = "%.2f" % float(count) + if not keep_width: + text = re.sub("0+$", "", text) + text = re.sub(r"\.$", "", text) + return text + + +def concatenate(items, conjunction="and", serial_comma=False): + """ + Concatenate a list of items in a human friendly way. + + :param items: + + A sequence of strings. + + :param conjunction: + + The word to use before the last item (a string, defaults to "and"). + + :param serial_comma: + + :data:`True` to use a `serial comma`_, :data:`False` otherwise + (defaults to :data:`False`). + + :returns: + + A single string. + + >>> from humanfriendly.text import concatenate + >>> concatenate(["eggs", "milk", "bread"]) + 'eggs, milk and bread' + + .. _serial comma: https://en.wikipedia.org/wiki/Serial_comma + """ + items = list(items) + if len(items) > 1: + final_item = items.pop() + formatted = ", ".join(items) + if serial_comma: + formatted += "," + return " ".join([formatted, conjunction, final_item]) + elif items: + return items[0] + else: + return "" + + +def pluralize(count, singular, plural=None): + """ + Combine a count with the singular or plural form of a word. + + :param count: The count (a number). + :param singular: The singular form of the word (a string). + :param plural: The plural form of the word (a string or :data:`None`). + :returns: The count and singular or plural word concatenated (a string). + + See :func:`pluralize_raw()` for the logic underneath :func:`pluralize()`. + """ + return "%s %s" % (count, pluralize_raw(count, singular, plural)) + + +def pluralize_raw(count, singular, plural=None): + """ + Select the singular or plural form of a word based on a count. + + :param count: The count (a number). + :param singular: The singular form of the word (a string). + :param plural: The plural form of the word (a string or :data:`None`). + :returns: The singular or plural form of the word (a string). + + When the given count is exactly 1.0 the singular form of the word is + selected, in all other cases the plural form of the word is selected. + + If the plural form of the word is not provided it is obtained by + concatenating the singular form of the word with the letter "s". Of course + this will not always be correct, which is why you have the option to + specify both forms. + """ + if not plural: + plural = singular + "s" + return singular if float(count) == 1.0 else plural diff --git a/locust/web.py b/locust/web.py index 365665539c..1be9d46622 100644 --- a/locust/web.py +++ b/locust/web.py @@ -10,7 +10,6 @@ from io import StringIO from itertools import chain from json import dumps -from time import time from typing import TYPE_CHECKING, Any import gevent @@ -40,7 +39,7 @@ from .stats import StatsCSV, StatsCSVFileWriter, StatsErrorDict, sort_stats from .user.inspectuser import get_ratio from .util.cache import memoize -from .util.date import format_utc_timestamp +from .util.date import format_safe_timestamp from .util.timespan import parse_timespan if TYPE_CHECKING: @@ -319,17 +318,25 @@ def stats_report() -> Response: ) if request.args.get("download"): res = app.make_response(res) - res.headers["Content-Disposition"] = f"attachment;filename=report_{time()}.html" + host = f"_{self.environment.host}" if self.environment.host else "" + res.headers["Content-Disposition"] = ( + f"attachment;filename=Locust_{format_safe_timestamp(self.environment.stats.start_time)}_" + + f"{self.environment.locustfile}{host}.html" + ) return res def _download_csv_suggest_file_name(suggest_filename_prefix: str) -> str: """Generate csv file download attachment filename suggestion. Arguments: - suggest_filename_prefix: Prefix of the filename to suggest for saving the download. Will be appended with timestamp. + suggest_filename_prefix: Prefix of the filename to suggest for saving the download. + Will be appended with timestamp. """ - - return f"{suggest_filename_prefix}_{time()}.csv" + host = f"_{self.environment.host}" if self.environment.host else "" + return ( + f"Locust_{format_safe_timestamp(self.environment.stats.start_time)}_" + + f"{self.environment.locustfile}{host}_{suggest_filename_prefix}.csv" + ) def _download_csv_response(csv_data: str, filename_prefix: str) -> Response: """Generate csv file download response with 'csv_data'. diff --git a/locust/webui/src/pages/HtmlReport.tsx b/locust/webui/src/pages/HtmlReport.tsx index 4d7155143c..05520bee05 100644 --- a/locust/webui/src/pages/HtmlReport.tsx +++ b/locust/webui/src/pages/HtmlReport.tsx @@ -42,6 +42,7 @@ export default function HtmlReport({ showDownloadLink, startTime, endTime, + duration, charts, host, exceptionsStatistics, @@ -75,7 +76,7 @@ export default function HtmlReport({ During: - {formatLocaleString(startTime)} - {formatLocaleString(endTime)} + {formatLocaleString(startTime)} - {formatLocaleString(endTime)} ({duration}) diff --git a/locust/webui/src/test/mocks/swarmState.mock.ts b/locust/webui/src/test/mocks/swarmState.mock.ts index 92caac14a1..e22699894a 100644 --- a/locust/webui/src/test/mocks/swarmState.mock.ts +++ b/locust/webui/src/test/mocks/swarmState.mock.ts @@ -36,6 +36,7 @@ export const swarmReportMock: IReport = { showDownloadLink: true, startTime: '2024-02-26 12:13:26', endTime: '2024-02-26 12:13:26', + duration: '0 seconds', host: 'http://0.0.0.0:8089/', exceptionsStatistics: [], requestsStatistics: [], diff --git a/locust/webui/src/types/swarm.types.ts b/locust/webui/src/types/swarm.types.ts index 08a4b14add..7c83fd8f0b 100644 --- a/locust/webui/src/types/swarm.types.ts +++ b/locust/webui/src/types/swarm.types.ts @@ -71,6 +71,7 @@ export interface IReport { showDownloadLink: boolean; startTime: string; endTime: string; + duration: string; host: string; charts: ICharts; requestsStatistics: ISwarmStat[];