Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Report name #2947

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"gevent": true
},
{
"name": "Run locust",
"name": "Run current locust scenario headless, 5 users",
"type": "python",
"request": "launch",
"module": "locust",
Expand All @@ -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
}
]
}
7 changes: 7 additions & 0 deletions docs/developing-locust.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ If you install `pre-commit <https://pre-commit.com/>`_, 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 <https://github.com/features/codespaces>`_, the github cloud development environment. On your fork page, just click on *Code* then on *Create codespace on <branch name>*, and voila, your ready to code and test.

Testing your changes
====================

Expand All @@ -51,6 +53,11 @@ To only run a specific suite or specific test you can call `pytest <https://docs

$ pytest locust/test/test_main.py::DistributedIntegrationTests::test_distributed_tags

Debugging
=========

See: :ref:`running-in-debugger`.

Formatting and linting
======================

Expand Down
12 changes: 11 additions & 1 deletion docs/running-in-debugger.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,17 @@ It implicitly registers an event handler for the :ref:`request <extending_locust
You can configure exactly what is printed by specifying parameters to :py:func:`run_single_user <locust.debug.run_single_user>`.

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
Expand Down
3 changes: 2 additions & 1 deletion locust/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
191 changes: 190 additions & 1 deletion locust/util/date.py
Original file line number Diff line number Diff line change
@@ -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
19 changes: 13 additions & 6 deletions locust/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
)
obriat marked this conversation as resolved.
Show resolved Hide resolved
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'.
Expand Down
3 changes: 2 additions & 1 deletion locust/webui/src/pages/HtmlReport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export default function HtmlReport({
showDownloadLink,
startTime,
endTime,
duration,
charts,
host,
exceptionsStatistics,
Expand Down Expand Up @@ -75,7 +76,7 @@ export default function HtmlReport({
<Box sx={{ display: 'flex', columnGap: 0.5 }}>
<Typography fontWeight={600}>During:</Typography>
<Typography>
{formatLocaleString(startTime)} - {formatLocaleString(endTime)}
{formatLocaleString(startTime)} - {formatLocaleString(endTime)} ({duration})
</Typography>
</Box>

Expand Down
1 change: 1 addition & 0 deletions locust/webui/src/test/mocks/swarmState.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
1 change: 1 addition & 0 deletions locust/webui/src/types/swarm.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export interface IReport {
showDownloadLink: boolean;
startTime: string;
endTime: string;
duration: string;
host: string;
charts: ICharts;
requestsStatistics: ISwarmStat[];
Expand Down
Loading