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

Merged
merged 10 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from 5 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
4 changes: 3 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 All @@ -36,6 +36,7 @@ def get_html_report(
if end_ts := stats.last_request_timestamp:
end_time = format_utc_timestamp(end_ts)
else:
end_ts = stats.start_time
end_time = start_time

host = None
Expand Down Expand Up @@ -88,6 +89,7 @@ def get_html_report(
],
"start_time": start_time,
"end_time": end_time,
"duration": format_duration(stats.start_time, end_ts),
"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"]),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry maybe it wasn't clear, but I think an argument for using our own solution, rather than the library, is because as you can probably see, the library handles a lot more cases than we probably need. In Locust's case, we probably never need nanoseconds, microseconds, weeks, or years. I think we could go with a simpler approach, for example:

def format_duration(start_time, end_time):
    time_diff = end_time - start_time

    days = time_diff.days
    seconds = time_diff.seconds
    hours = seconds // 3600
    minutes = (seconds % 3600) // 60
    seconds = seconds % 60

    return f"{days} days, {hours} hours, {minutes} minutes, {seconds} seconds"

(disclaimer: ChatGPT)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I keep some methods in order to keep it human friendly (plural, no output for 0 values, ...)?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea!

def format_duration(start_time, end_time):
    time_diff = end_time - start_time

    days = time_diff.days
    seconds = time_diff.seconds
    hours = seconds // 3600
    minutes = (seconds % 3600) // 60
    seconds = seconds % 60

    time_parts = [
        (days, "day"),
        (hours, "hour"),
        (minutes, "minute"),
        (seconds, "second")
    ]

    parts = [f"{value} {label}{'s' if value != 1 else ''}" for value, label in time_parts if value > 0]

    return ', '.join(parts) if parts else "0 seconds"

Think something like this would do the trick (disclaimer: haven't tested it)

Copy link
Contributor Author

@obriat obriat Oct 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since I really want to keep the last "and" 😄 , here a working proposition:

def format_duration(start_time, end_time):
    seconds = end_time - start_time
    days = seconds // 86400
    hours = seconds // 3600
    minutes = (seconds % 3600) // 60
    seconds = seconds % 60

    time_parts = [(days, "day"), (hours, "hour"), (minutes, "minute"), (seconds, "second")]

    parts = [f"{value} {label}{'s' if value != 1 else ''}" for value, label in time_parts if value > 0]

    return " and ".join(filter(None, [", ".join(parts[:-1])] + parts[-1:])) if parts else "0 seconds"

0: 0 seconds
666: 11 minutes and 6 seconds
1332: 22 minutes and 12 seconds
1998: 33 minutes and 18 seconds
2664: 44 minutes and 24 seconds
3330: 55 minutes and 30 seconds
3996: 1 hour, 6 minutes and 36 seconds
4662: 1 hour, 17 minutes and 42 seconds
5328: 1 hour, 28 minutes and 48 seconds
5994: 1 hour, 39 minutes and 54 seconds
6660: 1 hour and 51 minutes
7326: 2 hours, 2 minutes and 6 seconds
7992: 2 hours, 13 minutes and 12 seconds

One thing I don't get, is how to write a test the right way:

At the moment, swarmReportMock.duration is a string that is checked in HtmlReport.test.psx, but it seems like a static markup check not a real verification of the the computed difference between swarmReportMock.endTime and swarmReportMock.startTime .

Should this check be done here (and how) or should the math check be done in another test case (pure python?)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can do something like so:

def format_duration(start_time, end_time):
    time_diff = end_time - start_time

    days = time_diff.days
    seconds = time_diff.seconds
    hours = seconds // 3600
    minutes = (seconds % 3600) // 60
    seconds = seconds % 60

    time_parts = [
        (days, "day"),
        (hours, "hour"),
        (minutes, "minute"),
        (seconds, "second"),
    ]

    parts = [
        f"{value} {label}{'s' if value != 1 else ''}"
        for value, label in time_parts
        if value > 0
    ]
    parts[-1] = "and " + parts[-1]

    return ", ".join(parts) if parts else "0 seconds"

To avoid iterating over the list multiple times and help keep things readable?

If you have time, maybe a couple of basic unit tests for the function would also be good?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It returns an error 😞 and I didn't find the way to fix it, so I push my version (which I tested with a loop).
I'll be happy to have some tips about unit tests, I'll add them if I had some more spare times

Copy link
Collaborator

@andrewbaldwin44 andrewbaldwin44 Oct 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I think the error probably happens in the "0 seconds" case right? In which case you could do:

def format_duration(start_time, end_time):
    time_diff = end_time - start_time

    days = time_diff.days
    seconds = time_diff.seconds
    hours = seconds // 3600
    minutes = (seconds % 3600) // 60
    seconds = seconds % 60

    time_parts = [
        (days, "day"),
        (hours, "hour"),
        (minutes, "minute"),
        (seconds, "second"),
    ]

    parts = [
        f"{value} {label}{'s' if value != 1 else ''}"
        for value, label in time_parts
        if value > 0
    ]

    if parts:
        if len(parts) > 1:
            parts[-1] = "and " + parts[-1]
        return ", ".join(parts)
    else:
        return "0 seconds"

Some test cases could look like:

from datetime import datetime, timedelta
from .testcases import LocustTestCase
from .util.date import format_duration

class TestFormatDuration(LocustTestCase):
    def setUp(self):
        super().setUp()

    def test_zero_seconds(self):
        start_end_time = datetime(2023, 10, 1, 12, 0, 0)
        self.assertEqual(format_duration(start_end_time, start_end_time), "0 seconds")

    def test_seconds_only(self):
        start_time = datetime(2023, 10, 1, 12, 0, 0)
        end_time = datetime(2023, 10, 1, 12, 0, 30)
        self.assertEqual(format_duration(start_time, end_time), "30 seconds")

    def test_minutes_only(self):
        start_time = datetime(2023, 10, 1, 12, 0, 0)
        end_time = datetime(2023, 10, 1, 12, 45, 0)
        self.assertEqual(format_duration(start_time, end_time), "45 minutes")

    def test_hours_only(self):
        start_time = datetime(2023, 10, 1, 12, 0, 0)
        end_time = datetime(2023, 10, 1, 15, 0, 0)
        self.assertEqual(format_duration(start_time, end_time), "3 hours")

    def test_days_only(self):
        start_time = datetime(2023, 10, 1, 12, 0, 0)
        end_time = datetime(2023, 10, 4, 12, 0, 0)
        self.assertEqual(format_duration(start_time, end_time), "3 days")

    def test_days_hours_minutes_seconds(self):
        start_time = datetime(2023, 10, 1, 12, 0, 0)
        end_time = datetime(2023, 10, 3, 15, 45, 30)
        self.assertEqual(format_duration(start_time, end_time), "2 days, 3 hours, 45 minutes, and 30 seconds")

    def test_singular_units(self):
        start_time = datetime(2023, 10, 1, 12, 0, 0)
        end_time = datetime(2023, 10, 2, 13, 1, 1)
        self.assertEqual(format_duration(start_time, end_time), "1 day, 1 hour, 1 minute, and 1 second")

    def test_no_days(self):
        start_time = datetime(2023, 10, 1, 12, 0, 0)
        end_time = datetime(2023, 10, 1, 15, 30, 45)
        self.assertEqual(format_duration(start_time, end_time), "3 hours, 30 minutes, and 45 seconds")

    def test_no_hours(self):
        start_time = datetime(2023, 10, 1, 12, 0, 0)
        end_time = datetime(2023, 10, 2, 12, 30, 45)
        self.assertEqual(format_duration(start_time, end_time), "1 day, 30 minutes, and 45 seconds")

    def test_no_hours_no_minutes(self):
        start_time = datetime(2023, 10, 1, 12, 0, 0)
        end_time = datetime(2023, 10, 2, 12, 0, 45)
        self.assertEqual(format_duration(start_time, end_time), "1 day, and 45 seconds")

    def test_will_not_format_years(self):
        start_time = datetime(2023, 1, 1, 0, 0, 0)
        end_time = start_time + timedelta(days=400)
        self.assertEqual(format_duration(start_time, end_time), "400 days")

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I add tests, could not find a way to use parametrize with class / self 😞

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 f"{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
2 changes: 1 addition & 1 deletion locust/webui/src/pages/tests/HtmlReport.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('HtmlReport', () => {
getByText(
`${formatLocaleString(swarmReportMock.startTime)} - ${formatLocaleString(
swarmReportMock.endTime,
)}`,
)} (${swarmReportMock.duration})`,
),
).toBeTruthy();
});
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