├── hourglass.jpg ├── hourglass_blue.jpg ├── hourglass_violet.jpg ├── .gitignore ├── horology ├── __init__.py ├── tformatter.py ├── timed_context.py ├── timed_decorator.py └── timed_iterable.py ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── codeql.yaml │ └── tests.yaml └── contributing.md ├── LICENSE ├── tests ├── test_actual_delay.py ├── test_tformatter.py ├── test_iterable.py ├── test_decorator.py └── test_context.py ├── pyproject.toml ├── CHANGELOG.md └── README.md /hourglass.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjmikulski/horology/HEAD/hourglass.jpg -------------------------------------------------------------------------------- /hourglass_blue.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjmikulski/horology/HEAD/hourglass_blue.jpg -------------------------------------------------------------------------------- /hourglass_violet.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjmikulski/horology/HEAD/hourglass_violet.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | 3 | *.pyc 4 | /build/ 5 | *.egg* 6 | /dist 7 | /.coverage 8 | /cover 9 | 10 | poetry.lock 11 | -------------------------------------------------------------------------------- /horology/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Maciej J Mikulski' 2 | __version__ = '1.4.2' 3 | 4 | from horology.timed_context import Timing 5 | from horology.timed_decorator import timed 6 | from horology.timed_iterable import Timed 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: mjmikulski 7 | 8 | --- 9 | 10 | **Describe the solution you'd like** 11 | A short description of what you want to happen. 12 | 13 | 14 | **Code snippet or screenshot** 15 | Add a few lines of code here 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve horology 4 | title: '' 5 | labels: '' 6 | assignees: mjmikulski 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A short yet precise description of the problem 12 | 13 | **Code To Reproduce** 14 | ```python 15 | # paste it here 16 | ``` 17 | 18 | **Platform** 19 | - OS: Mac/Linux/Windows 20 | - python version: [e.g. 3.6.9] 21 | - horology version: [e.g. 1.0.0] 22 | 23 | **Possible solution** 24 | If you know how to solve it, write it here. 25 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yaml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ "master" ] 9 | schedule: 10 | - cron: '15 12 4 * *' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: [ 'python' ] 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v3 32 | with: 33 | languages: ${{ matrix.language }} 34 | 35 | - name: Perform CodeQL Analysis 36 | uses: github/codeql-action/analyze@v3 37 | with: 38 | category: "/language:${{matrix.language}}" 39 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: horology tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | 7 | build: 8 | 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | os: [ubuntu-latest, macos-latest, windows-latest] 14 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 15 | 16 | env: 17 | POETRY_VIRTUALENVS_CREATE: false 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Install poetry 28 | run: | 29 | pip install poetry 30 | 31 | - name: Install dependencies 32 | run: | 33 | poetry install --no-root 34 | 35 | - name: Check types 36 | run: | 37 | mypy . 38 | 39 | - name: Test with pytest 40 | run: | 41 | pytest 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2024 Maciej (MJ) Mikulski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_actual_delay.py: -------------------------------------------------------------------------------- 1 | from contextlib import redirect_stdout 2 | from io import StringIO 3 | from time import sleep 4 | 5 | import pytest 6 | 7 | from horology import Timed, Timing, timed 8 | 9 | @pytest.mark.flaky(reruns=7) 10 | class TestWithSleep: 11 | def test_context(self): 12 | with redirect_stdout(out := StringIO()): 13 | with Timing(): 14 | sleep(0.1) 15 | print_str = out.getvalue().strip() 16 | 17 | assert print_str.startswith('1') 18 | assert print_str.endswith('ms') 19 | 20 | def test_decorator(self): 21 | @timed 22 | def foo(): 23 | sleep(0.1) 24 | 25 | with redirect_stdout(out := StringIO()): 26 | foo() 27 | print_str = out.getvalue().strip() 28 | 29 | assert print_str.startswith('foo: 1') 30 | assert print_str.endswith('ms') 31 | 32 | def test_iterable(self): 33 | with redirect_stdout(out := StringIO()): 34 | for _ in Timed(range(1)): 35 | sleep(0.1) 36 | print_str = out.getvalue().strip() 37 | 38 | assert print_str.startswith('iteration 1: 1') 39 | assert print_str.endswith('ms') 40 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "horology" 3 | version = "1.4.2" 4 | description = "Conveniently measures the time of loops, contexts and functions." 5 | authors = ["mjmikulski "] 6 | license = "MIT" 7 | repository = 'https://github.com/mjmikulski/horology' 8 | readme = 'README.md' 9 | keywords = ['timing', 'profiling', 'measure time', 'duration'] 10 | classifiers = [ 11 | 'Development Status :: 5 - Production/Stable', 12 | 'Intended Audience :: Developers', 13 | 'License :: OSI Approved :: MIT License', 14 | 'Programming Language :: Python :: 3.10', 15 | 'Programming Language :: Python :: 3.11', 16 | 'Programming Language :: Python :: 3.12', 17 | 'Programming Language :: Python :: 3.13', 18 | 'Programming Language :: Python :: 3.14', 19 | 'Topic :: Utilities', 20 | 'Topic :: Software Development :: Libraries', 21 | 'Topic :: Software Development :: Testing', 22 | 'Topic :: Software Development :: Libraries :: Python Modules', 23 | "Operating System :: OS Independent"] 24 | 25 | 26 | [tool.poetry.dependencies] 27 | python = "^3.10" 28 | 29 | [tool.poetry.group.dev.dependencies] 30 | pytest = "8.4.*" 31 | mypy = "1.18.*" 32 | pytest-rerunfailures = "16.*" 33 | 34 | [tool.pytest.ini_options] 35 | addopts = "--doctest-modules" 36 | 37 | [build-system] 38 | requires = ["poetry-core>=2.1.3"] 39 | build-backend = "poetry.core.masonry.api" 40 | -------------------------------------------------------------------------------- /tests/test_tformatter.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | from horology.tformatter import UnitType, rescale_time, UNITS 6 | 7 | 8 | class TestTformatter: 9 | 10 | def test_no_rescaling(self) -> None: 11 | t, u = rescale_time(6, unit='s') 12 | assert t == 6 and u == 's' 13 | 14 | def test_simple_format(self) -> None: 15 | t, u = rescale_time(6, unit='ms') 16 | assert t == 6000 and u == 'ms' 17 | 18 | t, u = rescale_time(6, unit='min') 19 | assert t == 0.1 and u == 'min' 20 | 21 | @pytest.mark.parametrize('unit', ['ns', 'us', 'ms', 's', 'min', 'h', 'd']) 22 | @pytest.mark.parametrize('time_interval', [0.002, 2, 2000]) 23 | def test_unit_is_kept(self, unit: UnitType, time_interval: float) -> None: 24 | _, u = rescale_time(time_interval, unit=unit) 25 | assert u == unit 26 | 27 | def test_auto_format(self) -> None: 28 | t, u = rescale_time(6, unit='a') 29 | assert t == 6 and u == 's' 30 | 31 | t, u = rescale_time(0.006, unit='auto') 32 | assert t == 6 and u == 'ms' 33 | 34 | t, u = rescale_time(6000, unit='AUTO') # type: ignore 35 | assert t == 100 and u == 'min' 36 | 37 | def test_wrong_unit(self) -> None: 38 | matching_msg = "Unknown unit: lustrum. Use one of the following: " \ 39 | "['ns', 'us', 'ms', 's', 'min', 'h', 'd'] or 'auto'" 40 | with pytest.raises(ValueError, match=re.escape(matching_msg)): 41 | rescale_time(0.5, 'lustrum') # type: ignore 42 | 43 | def test_units_order(self) -> None: 44 | limit = 0.0 45 | for u in UNITS: 46 | assert u.limit > limit 47 | limit = u.limit 48 | -------------------------------------------------------------------------------- /horology/tformatter.py: -------------------------------------------------------------------------------- 1 | from math import inf 2 | from typing import Literal, NamedTuple, cast 3 | 4 | UnitType = Literal['a', 'auto', 'ns', 'us', 'ms', 's', 'min', 'h', 'd'] 5 | 6 | 7 | class Unit(NamedTuple): 8 | name: UnitType 9 | scale: float 10 | limit: float 11 | 12 | 13 | UNITS = [ 14 | Unit('ns', 10 ** -9, 10 ** -6), 15 | Unit('us', 10 ** -6, 10 ** -3), 16 | Unit('ms', 10 ** -3, 1), 17 | Unit('s', 1, 10 ** 3), 18 | Unit('min', 60, 6 * 10 ** 4), 19 | Unit('h', 3600, 36 * 10 ** 5), 20 | Unit('d', 3600 * 24, inf), 21 | ] 22 | 23 | 24 | def rescale_time( 25 | interval: float, 26 | unit: UnitType, 27 | ) -> tuple[float, UnitType]: 28 | """Rescale the time interval using the provided unit 29 | 30 | Parameters 31 | ---------- 32 | interval 33 | Time interval to be rescaled, in seconds. 34 | unit 35 | Time unit to which `interval` should be rescaled. Use 'a' or 36 | 'auto' for automatic time adjustment. 37 | 38 | Returns 39 | ------- 40 | float 41 | Time interval in new units. 42 | str 43 | Convenient unit found automatically if input unit was 'auto'. 44 | Otherwise, provided `unit` is returned unchanged. 45 | 46 | Examples 47 | -------- 48 | >>> rescale_time(0.421, 'us') 49 | (421000.0, 'us') 50 | >>> rescale_time(150, 'min') 51 | (2.5, 'min') 52 | >>> rescale_time(0.911, 'auto') 53 | (911.0, 'ms') 54 | 55 | Raises 56 | ------ 57 | ValueError 58 | If the unit provided is unknown. 59 | 60 | """ 61 | unit = cast(UnitType, unit.lower()) 62 | 63 | for u in UNITS: 64 | if unit == u.name or (unit in ('a', 'auto') and interval < u.limit): 65 | return interval / u.scale, u.name 66 | 67 | raise ValueError(f"Unknown unit: {unit}. Use one of the following: " 68 | f"{[x.name for x in UNITS]} or 'auto'") 69 | -------------------------------------------------------------------------------- /.github/contributing.md: -------------------------------------------------------------------------------- 1 | # Dear Contributor! 2 | 3 | I am happy that you think about contributing to this project. This is a good idea, keep reading! 4 | 5 | ## How can I contribute? 6 | 1. Propose new features. 7 | 2. Report or fix bugs. 8 | 3. Develop the code. 9 | 4. Create documentation. 10 | 5. Promote horology :heartpulse: 11 | 12 | ## How to report a bug or propose a new amazing feature? 13 | Create an issue. 14 | 15 | ## How to commit some code? 16 | 1. [Fork](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) horology. 17 | 2. Create a branch. 18 | 3. Create virtual env with conda using latest version of supported python, e.g.: 19 | ```bash 20 | conda create -n horology python=3.12 21 | conda activate horology 22 | ``` 23 | 4. Install poetry using pip: 24 | ```bash 25 | pip install poetry 26 | ``` 27 | 5. Use poetry to install all dev dependencies: 28 | ```bash 29 | poetry install 30 | ``` 31 | 6. Write some useful and beautiful code that follows [PEP8](https://www.python.org/dev/peps/pep-0008/). 32 | 7. Write unit tests. 33 | 8. Run mypy and pytest and fix eventual errors: 34 | ```bash 35 | mypy . 36 | pytest 37 | ``` 38 | 9. Commit and push your changes (in your fork). 39 | 10. [Create a pull request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) 40 | from your fork to horology. 41 | 11. Wait for my feedback. 42 | 12. If I accept your changes, I will merge them into the master branch and release with the next release. 43 | 44 | ## How to write some documentation? 45 | Follow numpy style, but no space before a colon ;) 46 | 47 | ## Commit messages 48 | Follow the rules when writing a commit message: 49 | - use the imperative mood in the first line of commit message, 50 | - capitalize the first line, 51 | - do not end the first line with a period, 52 | - limit the first line to 72 characters, 53 | - separate the first line from the body with one blank line. 54 | 55 | _adopted from [here](https://chris.beams.io/posts/git-commit#seven-rules)._ 56 | 57 | 58 | ## I am open 59 | Feel free to connect. My email starts with `maciej.mikulski.jr` and ends with `gmail.com`. 60 | -------------------------------------------------------------------------------- /tests/test_iterable.py: -------------------------------------------------------------------------------- 1 | from contextlib import redirect_stdout 2 | from io import StringIO 3 | from unittest.mock import Mock, patch 4 | 5 | from horology import Timed 6 | 7 | 8 | @patch('horology.timed_iterable.counter') 9 | class TestTimedIterableTest: 10 | 11 | def test_no_iter(self, counter_mock: Mock) -> None: 12 | with redirect_stdout(out := StringIO()): 13 | for _ in Timed([]): 14 | pass 15 | print_str = out.getvalue().strip() 16 | 17 | assert print_str == 'no iterations' 18 | assert counter_mock.call_count == 2 19 | 20 | def test_one_iteration(self, counter_mock: Mock) -> None: 21 | counter_mock.side_effect = [0, 0.01, 1.01] 22 | 23 | with redirect_stdout(out := StringIO()): 24 | for _ in Timed([1]): 25 | pass 26 | lines = out.getvalue().strip().split('\n') 27 | 28 | assert lines[0] == 'iteration 1: 1 s' 29 | assert lines[1] == '' 30 | assert lines[2] == 'one iteration: 1 s' 31 | 32 | def test_summary(self, counter_mock: Mock) -> None: 33 | counter_mock.side_effect = [-0.01, 0, 0.5, 2, 3, 4, 5, 6] 34 | 35 | with redirect_stdout(out := StringIO()): 36 | for _ in Timed(range(5)): 37 | pass 38 | lines = out.getvalue().strip().split('\n') 39 | 40 | assert lines[-4] == '' 41 | assert lines[-3] == 'total 5 iterations in 5.01 s' 42 | assert lines[-2] == 'min/median/max: 0.5/1/1.5 s' 43 | assert lines[-1] == 'average (std): 1 (0.354) s' 44 | 45 | assert counter_mock.call_count == 7 46 | 47 | def test_summary_time_rescaling_s(self, counter_mock: Mock) -> None: 48 | counter_mock.side_effect = [0, 0, 0.001, 0.002, 0.004] 49 | 50 | with redirect_stdout(out := StringIO()): 51 | for _ in Timed(range(3), unit='s'): 52 | pass 53 | lines = out.getvalue().strip().split('\n') 54 | 55 | assert lines[-3] == 'total 3 iterations in 0.004 s' 56 | assert lines[-2] == 'min/median/max: 0.001/0.001/0.002 s' 57 | assert lines[-1] == 'average (std): 0.00133 (0.000577) s' 58 | 59 | def test_summary_time_rescaling_ns(self, counter_mock: Mock) -> None: 60 | counter_mock.side_effect = [0, 0, 0.0015, 0.002, 0.004] 61 | 62 | with redirect_stdout(out := StringIO()): 63 | for _ in Timed(range(3), unit='ns'): 64 | pass 65 | lines = out.getvalue().strip().split('\n') 66 | 67 | assert lines[-3] == 'total 3 iterations in 4e+06 ns' 68 | assert lines[-2] == 'min/median/max: 5e+05/1.5e+06/2e+06 ns' 69 | assert lines[-1] == 'average (std): 1.33e+06 (7.64e+05) ns' 70 | 71 | def test_no_print(self, counter_mock: Mock) -> None: 72 | counter_mock.side_effect = [0, 0, 10, 20, 30] 73 | 74 | with redirect_stdout(out := StringIO()): 75 | T = Timed(['cat', 'dog', 'parrot'], iteration_print_fn=None, summary_print_fn=None) 76 | for a in T: 77 | print(a) 78 | lines = out.getvalue().strip().split('\n') 79 | 80 | assert lines == ['cat', 'dog', 'parrot'] 81 | assert T.total == 30 82 | -------------------------------------------------------------------------------- /horology/timed_context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from time import perf_counter as counter 4 | from types import TracebackType 5 | from typing import Any, Callable, Literal, Type 6 | 7 | from horology.tformatter import UnitType, rescale_time 8 | 9 | 10 | class Timing: 11 | """Context manager that measures time elapsed with the context 12 | 13 | Use `interval` property to get the time elapsed. 14 | 15 | Parameters 16 | ---------- 17 | name: str, optional 18 | Message that should be printed before the time value, e.g.: 19 | 'Doing x: ' 20 | unit: str, optional 21 | Time unit used to print elapsed time. Possible values are: 22 | ['ns', 'us', 'ms', 's', 'min', 'h', 'd']. Use 'a' or 'auto' 23 | for automatic time adjustment (default). 24 | print_fn: Callable or None, optional 25 | Function that is called to print the time elapsed in the 26 | context. Use `None` to disable printing anything. You can 27 | provide e.g. `logger.info`. By default, the built-in `print` 28 | function is used. 29 | 30 | Example 31 | ------- 32 | Basic usage 33 | ``` 34 | from horology import Timing 35 | with Timing(name='Important calculations: '): 36 | do_a_lot() 37 | ``` 38 | Possible result: 39 | ``` 40 | Important calculations: 12.4 s 41 | ``` 42 | """ 43 | 44 | def __init__( 45 | self, 46 | name: str | None = None, 47 | *, 48 | unit: UnitType = 'auto', 49 | print_fn: Callable[..., Any] | None = print 50 | ) -> None: 51 | self.name = name if name else "" 52 | self.unit = unit 53 | self._print_fn = print_fn 54 | 55 | self._start: float | None = None 56 | self._interval: float | None = None 57 | 58 | @property 59 | def interval(self) -> float: 60 | """Time elapsed in seconds 61 | 62 | If still in the context, returns time elapsed from the moment 63 | of entering to the context. If the context has been already 64 | left, returns the total time spent in the context. 65 | 66 | """ 67 | if self._start is None: 68 | raise RuntimeError('`interval` can be accessed only inside the ' 69 | 'context or after exiting it.') 70 | 71 | if self._interval: # when the context exited 72 | return self._interval 73 | else: # when still in the context 74 | return counter() - self._start 75 | 76 | def __enter__(self) -> Timing: 77 | self._start = counter() 78 | return self 79 | 80 | def __exit__( 81 | self, 82 | exc_type: Type[BaseException] | None, 83 | exc_val: BaseException | None, 84 | exc_tb: TracebackType | None, 85 | ) -> Literal[False]: 86 | self._interval = self.interval 87 | t, u = rescale_time(self.interval, self.unit) 88 | if self._print_fn is not None: 89 | print_str = f'{self.name}{t:.3g} {u}' 90 | if exc_type is not None: 91 | print_str += ' (failed)' 92 | self._print_fn(print_str) 93 | return False 94 | -------------------------------------------------------------------------------- /tests/test_decorator.py: -------------------------------------------------------------------------------- 1 | from contextlib import redirect_stdout 2 | from io import StringIO 3 | from unittest.mock import Mock, patch 4 | 5 | import pytest 6 | 7 | from horology import timed 8 | 9 | 10 | @patch('horology.timed_decorator.counter') 11 | class TestDecorator: 12 | 13 | def test_no_args(self, counter_mock: Mock) -> None: 14 | counter_mock.side_effect = [0, 0.12] 15 | 16 | @timed 17 | def foo(): 18 | pass 19 | 20 | with redirect_stdout(out := StringIO()): 21 | foo() 22 | print_str = out.getvalue().strip() 23 | 24 | assert print_str == 'foo: 120 ms' 25 | 26 | def test_with_name_and_unit(self, counter_mock: Mock) -> None: 27 | counter_mock.side_effect = [0, 21] 28 | 29 | @timed(name='Function foo elapsed ', unit='ms') 30 | def foo(): 31 | pass 32 | 33 | with redirect_stdout(out := StringIO()): 34 | foo() 35 | print_str = out.getvalue().strip() 36 | 37 | assert print_str == 'Function foo elapsed 2.1e+04 ms' 38 | 39 | def test_wrapping_transparently(self, _: Mock) -> None: 40 | @timed(name='bar elapsed: ', unit='auto') 41 | def bar(): 42 | """Very important function""" 43 | 44 | assert bar.__doc__ == 'Very important function' 45 | assert bar.__name__ == 'bar' 46 | 47 | def test_interval_property(self, counter_mock: Mock) -> None: 48 | counter_mock.side_effect = [0, 1, 2, 3] 49 | 50 | @timed 51 | def bar(): 52 | pass 53 | 54 | assert not counter_mock.called 55 | 56 | bar() 57 | assert bar.interval == 1 58 | assert counter_mock.call_count == 2 59 | 60 | assert bar.interval == 1 61 | assert counter_mock.call_count == 2 62 | 63 | bar() 64 | assert bar.interval == 1 65 | assert counter_mock.call_count == 4 66 | 67 | def test_usage_without_print(self, counter_mock: Mock) -> None: 68 | counter_mock.side_effect = [0, 0.07] 69 | 70 | @timed(print_fn=None) 71 | def bar(): 72 | pass 73 | 74 | with redirect_stdout(out := StringIO()): 75 | bar() 76 | print_str = out.getvalue().strip() 77 | 78 | assert print_str == '' 79 | 80 | def test_with_lambda(self, counter_mock: Mock) -> None: 81 | counter_mock.side_effect = [0, 0.12] 82 | 83 | foo = timed(lambda: None) 84 | 85 | with redirect_stdout(out := StringIO()): 86 | foo() 87 | print_str = out.getvalue().strip() 88 | 89 | assert print_str == ': 120 ms' 90 | 91 | def test_with_function_arguments(self, counter_mock: Mock) -> None: 92 | counter_mock.side_effect = [0, 0.12] 93 | 94 | @timed 95 | def add(x, y): 96 | return x + y 97 | 98 | result = add(5, y=7) 99 | assert result == 12 100 | assert add.interval == 0.12 101 | 102 | def test_passing_exception(self, counter_mock: Mock) -> None: 103 | counter_mock.side_effect = [0, 0.12] 104 | 105 | @timed 106 | def foo(): 107 | raise ValueError('An error occurred') 108 | 109 | with pytest.raises(ValueError, match='An error occurred'): 110 | foo() 111 | 112 | assert foo.interval == 0.12 113 | 114 | def test_printing_exception(self, counter_mock: Mock) -> None: 115 | counter_mock.side_effect = [0, 0.12] 116 | 117 | @timed 118 | def foo(): 119 | raise RuntimeError('An error occurred') 120 | 121 | with redirect_stdout(out := StringIO()): 122 | with pytest.raises(RuntimeError): 123 | foo() 124 | print_str = out.getvalue().strip() 125 | 126 | assert print_str == 'foo: 120 ms (failed)' 127 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.4.2 4 | 5 | ### Supported Python versions 6 | 7 | - Added support for 3.14. 8 | 9 | Supported python versions are 3.10-3.14. 10 | 11 | ## 1.4.1 12 | 13 | ### Tests and deployment 14 | 15 | - Dev-dependencies were updated. 16 | - CodeQL configuration was cleaned and updated to new versions. 17 | - Minor tests tweaks. 18 | 19 | ### Supported Python versions 20 | 21 | - Added support for 3.13. 22 | 23 | Supported python versions are 3.10-3.13. 24 | 25 | ## 1.4.0 26 | 27 | ### Features and enhancements 28 | 29 | - If an exception is raised in a context or in a decorated function, information about time elapsed until 30 | the failure is printed like this: `foo: 120 ms (failed)` 31 | 32 | ### Breaking API changes 33 | 34 | - Removed striping of whitespaces in unit name. So now ' h ' is not a valid unit name anymore. 35 | 36 | ### Fixes 37 | 38 | - Proper handling of exceptions was added to timed decorator. 39 | 40 | ### Tests and deployment 41 | 42 | - `ParamSpec` was used to annotate types more precisely in typed decorator. 43 | - Added 1 new test for time formatter. 44 | - Added 4 new tests for Timed context. 45 | - Added 4 new tests for timed decorator. 46 | - Restored 3 tests with actual delays with sleep and added rerun mechanism to contain their flakiness. 47 | 48 | ### Fun 49 | 50 | - New image 51 | - New MIT license badge 52 | 53 | ### Supported Python versions 54 | 55 | - Removed support for 3.8 and 3.9. 56 | - Added support for 3.12. 57 | 58 | Supported python versions are 3.10-3.12. 59 | 60 | ## 1.3.0 61 | 62 | ### Breaking API changes 63 | 64 | - Removed alternative names for time units. 65 | 66 | ### Features and enhancements 67 | 68 | - Type hints were added to the whole codebase. 69 | 70 | ### Fixes 71 | 72 | - Using `interval` property of Timing context before entering the context gives now a RuntimeError with an explanation. 73 | - Using `total` property of Timed iterable returns now zero as expected in case that the iteration was not yet started. 74 | 75 | ### Docs 76 | 77 | - Added this CHANGELOG. 78 | 79 | ### Tests and deployment 80 | 81 | - CI was migrated from CircleCI to GitHub Actions. 82 | - Automatic tests were added for macOS and windows (which were tested before only manually). 83 | - All tests were deeply refactored in this release. All sleeps were replaced with proper mocking of `perf_counter`. 84 | - Unittest and nose was replaced with pytest. 85 | - Static type checker (mypy) was added to CI. 86 | 87 | ### Supported Python versions 88 | 89 | - Removed support for 3.6 and 3.7. 90 | - Added support for 3.10 and 3.11. 91 | 92 | Supported python versions are 3.8-3.11. 93 | 94 | ## 1.2.0 95 | 96 | ### Features and enhancements 97 | 98 | - Use always 3 significant digits when formatting output strings. Such formatting is much more elegant and avoids adding 99 | decimal points for integers. 100 | 101 | ### Fixes 102 | 103 | - Wrong link in pepy badge. 104 | 105 | ### Docs 106 | 107 | - All docs were rewritten in beautiful numpy style. 108 | 109 | ### Tests and deployment 110 | 111 | - Add CI for python 3.9. 112 | - Use poetry to build and deploy 113 | 114 | ### Python versions 115 | 116 | Supported python versions are 3.6-3.9. 117 | 118 | ## 1.1.0 119 | 120 | ### Features and enhancements 121 | 122 | - Time units allow aliases, by @johnashu 123 | 124 | ### Fixes 125 | 126 | - Not rescaling total time in Timed iterable - fixed 127 | 128 | ### Docs 129 | 130 | - Add contribution guide. 131 | - Add bug report template. 132 | - Add feature request template. 133 | - Add doc strings in tformatter module. 134 | - Add badges to readme. 135 | 136 | ### Tests and deployment 137 | 138 | - Add CI for python 3.6-3.8 139 | - Add tests of Timed iterable summary 140 | - Add tests of tformatter exception 141 | 142 | ### Contributors 143 | 144 | Thanks to our 1 contributor whose commits are featured in this release: 145 | @johnashu 146 | 147 | ## 1.0.0 148 | 149 | - `horology` name was picked instead of confusing `ttt`. 150 | - The package can now be installed with `pip install horology`. 151 | -------------------------------------------------------------------------------- /tests/test_context.py: -------------------------------------------------------------------------------- 1 | from contextlib import redirect_stdout 2 | from io import StringIO 3 | from typing import Any 4 | from unittest.mock import Mock, patch 5 | 6 | import pytest 7 | 8 | from horology import Timing 9 | 10 | 11 | @patch('horology.timed_context.counter') 12 | class TestContext: 13 | def test_no_args(self, counter_mock: Mock) -> None: 14 | counter_mock.side_effect = [0, 0.12] 15 | 16 | with redirect_stdout(out := StringIO()): 17 | with Timing(): 18 | pass 19 | print_str = out.getvalue().strip() 20 | 21 | assert print_str == '120 ms' 22 | 23 | def test_if_independent_to_absolute_time_values(self, counter_mock: Mock) -> None: 24 | counter_mock.side_effect = [100.05, 100.17] 25 | 26 | with redirect_stdout(out := StringIO()): 27 | with Timing(): 28 | pass 29 | print_str = out.getvalue().strip() 30 | 31 | assert print_str == '120 ms' 32 | 33 | def test_with_name_and_unit(self, counter_mock: Mock) -> None: 34 | counter_mock.side_effect = [0, 0.12] 35 | 36 | with redirect_stdout(out := StringIO()): 37 | with Timing(name='Preprocessing: ', unit='s'): 38 | pass 39 | print_str = out.getvalue().strip() 40 | 41 | assert print_str == 'Preprocessing: 0.12 s' 42 | 43 | def test_interval_property(self, counter_mock: Mock) -> None: 44 | counter_mock.side_effect = [0, 0.12, 0.24, 0.36, 0.48] # last value never used 45 | from horology import Timing 46 | 47 | with Timing(print_fn=None, unit='ms') as t: 48 | # should increase 49 | assert t.interval == 0.12 50 | 51 | # should increase 52 | assert t.interval == 0.24 53 | 54 | # counter should not be called after exiting the context 55 | assert counter_mock.call_count == 4 56 | 57 | # check after the context was exited 58 | assert t.interval == 0.36 59 | 60 | # make sure timing was stopped after the context was exited 61 | assert t.interval == 0.36 62 | 63 | # check again that counter was not called after context has been exited 64 | assert counter_mock.call_count == 4 65 | 66 | def test_exception_handling_within_context(self, counter_mock: Mock) -> None: 67 | counter_mock.side_effect = [0, 0.5] 68 | exception_raised = False 69 | 70 | with redirect_stdout(out := StringIO()): 71 | try: 72 | with Timing(): 73 | raise ValueError('Test Exception') 74 | except ValueError: 75 | exception_raised = True 76 | print_str = out.getvalue().strip() 77 | 78 | assert exception_raised 79 | assert print_str == '500 ms (failed)' 80 | 81 | def test_custom_print_function(self, counter_mock: Mock) -> None: 82 | counter_mock.side_effect = [0, 0.12] 83 | 84 | def custom_print(*args: Any, **kwargs: Any) -> None: 85 | print('Custom print function') 86 | 87 | with redirect_stdout(out := StringIO()): 88 | with Timing(print_fn=custom_print): 89 | pass 90 | print_str = out.getvalue().strip() 91 | 92 | assert print_str == 'Custom print function' 93 | 94 | def test_no_printing_when_print_fn_is_none(self, counter_mock: Mock) -> None: 95 | counter_mock.side_effect = [0, 0.12] 96 | 97 | with redirect_stdout(out := StringIO()): 98 | with Timing(print_fn=None): 99 | pass 100 | print_str = out.getvalue().strip() 101 | 102 | assert print_str == '' 103 | 104 | def test_error_when_accessing_interval_outside_context(self, counter_mock: Mock) -> None: 105 | counter_mock.side_effect = [0, 0.12] 106 | timing_instance = Timing() 107 | 108 | # Accessing interval before context should raise an error 109 | with pytest.raises(RuntimeError): 110 | _ = timing_instance.interval 111 | 112 | # Now use the context normally 113 | with timing_instance: 114 | pass 115 | 116 | # Accessing interval after context should not raise an error 117 | _ = timing_instance.interval 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `Horology` 2 | 3 | [![PyPI version](https://badge.fury.io/py/horology.svg)](https://badge.fury.io/py/horology) 4 | [![tests](https://github.com/mjmikulski/horology/actions/workflows/tests.yaml/badge.svg)](https://github.com/mjmikulski/horology/actions/workflows/tests.yaml) 5 | [![codeql](https://github.com/mjmikulski/horology/actions/workflows/codeql.yaml/badge.svg)](https://github.com/mjmikulski/horology/actions/workflows/codeql.yaml) 6 | [![PythonVersion](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](https://pypi.org/project/horology/) 7 | [![OperatingSystems](https://img.shields.io/badge/OS-linux%20%7C%20windows%20%7C%20macos-green)](https://pypi.org/project/horology/) 8 | [![Downloads](https://pepy.tech/badge/horology/month)](https://pepy.tech/project/horology) 9 | [![License: MIT](https://img.shields.io/badge/License-MIT-cyan.svg)](https://opensource.org/licenses/MIT) 10 | 11 | 12 | Conveniently measures the time of your loops, contexts and functions. 13 | 14 | ![](hourglass_violet.jpg "Violet hourglass") 15 | 16 | ## Installation 17 | 18 | | horology version | compatible python | 19 | |------------------|-------------------| 20 | | 1.4.2 | 3.10-3.14 | 21 | | 1.4.1 | 3.10-3.13 | 22 | | 1.4 | 3.10-3.12 | 23 | | 1.3 | 3.8-3.11 | 24 | | 1.2 | 3.6-3.9 | 25 | | 1.1 | 3.6-3.8 | 26 | 27 | Horology can be installed with PIP. It has no dependencies. 28 | 29 | ``` 30 | pip install horology 31 | ``` 32 | 33 | ## Usage 34 | 35 | The following 3 tools will let you measure practically any part of your Python code. 36 | 37 | ### Timing an iterable (list, tuple, generator, etc) 38 | 39 | #### Quick example 40 | 41 | ```python 42 | from horology import Timed 43 | 44 | animals = ['cat', 'dog', 'crocodile'] 45 | 46 | for x in Timed(animals): 47 | feed(x) 48 | ``` 49 | 50 | Result: 51 | 52 | ``` 53 | iteration 1: 12.0 s 54 | iteration 2: 8.00 s 55 | iteration 3: 100 s 56 | 57 | total 3 iterations in 120 s 58 | min/median/max: 8.00/12.0/100 s 59 | average (std): 40.0 (52.0) s 60 | 61 | ``` 62 | 63 | #### Customization 64 | 65 | You can specify where (if at all) you want each iteration and summary to be printed, eg.: 66 | 67 | ```python 68 | for x in Timed(animals, unit='ms', 69 | iteration_print_fn=logger.debug, 70 | summary_print_fn=logger.info): 71 | feed(x) 72 | ``` 73 | 74 | ### Timing a function with a `@timed` decorator 75 | 76 | #### Quick example 77 | 78 | ```python 79 | from horology import timed 80 | 81 | 82 | @timed 83 | def foo(): 84 | ... 85 | ``` 86 | 87 | Result: 88 | 89 | ``` 90 | >>> foo() 91 | foo: 7.12 ms 92 | ``` 93 | 94 | #### Customization 95 | 96 | Chose time unit and name: 97 | 98 | ```python 99 | @timed(unit='s', name='Processing took ') 100 | def bar(): 101 | ... 102 | ``` 103 | 104 | Result: 105 | 106 | ``` 107 | >>> bar() 108 | Processing took 0.185 s 109 | ``` 110 | 111 | ### Timing part of code with a `Timing` context 112 | 113 | #### Quick example 114 | 115 | Just wrap your code using a `with` statement 116 | 117 | ```python 118 | from horology import Timing 119 | 120 | with Timing(name='Important calculations: '): 121 | ... 122 | ``` 123 | 124 | Result: 125 | 126 | ``` 127 | Important calculations: 12.4 s 128 | ``` 129 | 130 | #### Customization 131 | 132 | You can suppress default printing and directly use measured time (also within context) 133 | 134 | ```python 135 | with Timing(print_fn=None) as t: 136 | ... 137 | 138 | make_use_of(t.interval) 139 | ``` 140 | 141 | ## Time units 142 | 143 | Time units are by default automatically adjusted, for example you will see 144 | `foo: 7.12 ms` rather than `foo: 0.007 s`. If you don't like it, you can 145 | override this by setting the `unit` argument with one of these names: 146 | `['ns', 'us', 'ms', 's', 'min', 'h', 'd']`. 147 | 148 | ## Contributions 149 | 150 | Contributions are welcomed, see [contribution guide](.github/contributing.md). 151 | 152 | ## Internals 153 | 154 | Horology internally measures time with `perf_counter` which provides the *highest available resolution,* 155 | see [docs](https://docs.python.org/3/library/time.html#time.perf_counter). 156 | -------------------------------------------------------------------------------- /horology/timed_decorator.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from time import perf_counter as counter 3 | from typing import Any, Callable, ParamSpec, Protocol, overload 4 | 5 | from horology.tformatter import UnitType, rescale_time 6 | 7 | P = ParamSpec('P') 8 | 9 | 10 | class CallableWithInterval(Protocol[P]): 11 | """Protocol to represent a callable with interval attribute. 12 | 13 | References 14 | ---------- 15 | [PEP 612](https://peps.python.org/pep-0612/) 16 | """ 17 | interval: float 18 | __call__: Callable[P, Any] 19 | __name__: str 20 | 21 | 22 | @overload 23 | def timed(f: Callable[P, Any]) -> CallableWithInterval[P]: ... # Bare decorator usage 24 | 25 | 26 | @overload 27 | def timed( 28 | *, 29 | name: str | None = None, 30 | unit: UnitType = 'auto', 31 | print_fn: Callable[..., Any] | None = print 32 | ) -> Callable[[Callable[P, Any]], CallableWithInterval[P]]: ... # Decorator with arguments 33 | 34 | 35 | def timed( 36 | f: Callable[P, Any] | None = None, 37 | *, 38 | name: str | None = None, 39 | unit: UnitType = 'auto', 40 | print_fn: Callable[..., Any] | None = print): 41 | """Decorator that prints time of execution of the decorated function 42 | 43 | Parameters 44 | ---------- 45 | f: Callable 46 | The function which execution time should be measured. 47 | name: str or None, optional 48 | String that should be printed as the function name. By default, 49 | the f.__name__ proceeded by a colon and space is used. See 50 | examples below. 51 | unit: {'auto', 'ns', 'us', 'ms', 's', 'min', 'h', 'd'} 52 | Time unit used to print elapsed time. Use 'a' or 'auto' for 53 | automatic time adjustment (default). 54 | print_fn: Callable or None, optional 55 | Function that is called to print the time elapsed. Use `None` to 56 | disable printing anything. You can provide e.g. `logger.info`. 57 | By default, the built-in `print` function is used. 58 | 59 | Attributes 60 | ---------- 61 | interval: float 62 | Time elapsed by the function in seconds. Can be used to get the 63 | time programmatically after the execution of f. 64 | 65 | Returns 66 | ------- 67 | Callable 68 | Decorated function `f`. 69 | 70 | Examples 71 | -------- 72 | Basic usage 73 | ``` 74 | @timed 75 | def foo(): 76 | ... 77 | foo() # prints 'foo: 5.12 ms' 78 | ``` 79 | 80 | Change default name 81 | ``` 82 | @timed(name='bar elapsed ') 83 | def bar(): 84 | ... 85 | bar() # prints 'bar elapsed 2.56 ms' 86 | ``` 87 | 88 | Change default units 89 | ``` 90 | @timed(unit='ns') 91 | def baz(): 92 | ... 93 | baz() # prints 'baz: 3.28e+04 ns' 94 | ``` 95 | 96 | Suppress printing and use the attribute `interval` to get the time 97 | elapsed 98 | ``` 99 | @timed(print_fn=None) 100 | def qux(): 101 | ... 102 | qux() # prints nothing 103 | print(qux.interval) 104 | ``` 105 | 106 | """ 107 | 108 | def decorator(_f): 109 | @wraps(_f) 110 | def wrapped(*args, **kwargs): 111 | start = counter() 112 | exception = None 113 | try: 114 | return_value = _f(*args, **kwargs) 115 | except Exception as e: 116 | exception = e 117 | finally: 118 | interval = counter() - start 119 | wrapped.interval = interval 120 | 121 | if print_fn is not None: 122 | nonlocal name 123 | name = _f.__name__ + ': ' if name is None else name 124 | t, u = rescale_time(interval, unit=unit) 125 | print_str = f'{name}{t:.3g} {u}' 126 | if exception is not None: 127 | print_str += ' (failed)' 128 | print_fn(print_str) 129 | 130 | if exception is not None: 131 | raise exception 132 | 133 | return return_value 134 | 135 | return wrapped 136 | 137 | if f is None: # used with () 138 | return decorator 139 | else: # used without () 140 | return decorator(f) 141 | -------------------------------------------------------------------------------- /horology/timed_iterable.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from statistics import mean, median, stdev 4 | from time import perf_counter as counter 5 | from typing import Any, Callable, Iterable 6 | 7 | from horology.tformatter import UnitType, rescale_time 8 | 9 | 10 | class Timed: 11 | """ Wrapper to an iterable that measures time of each iteration 12 | 13 | Parameters 14 | ---------- 15 | iterable: Iterable 16 | Object that should we wrapped. 17 | unit: str, optional 18 | Time unit used to print elapsed time. Possible values: 19 | ['ns', 'us', 'ms', 's', 'min', 'h', 'd']. Use 'a' or 'auto' 20 | for automatic time adjustment (default). 21 | iteration_print_fn: Callable, optional 22 | Function that is called after each iteration to print time 23 | of that iteration. Use `None` to disable printing after each 24 | iteration. You can provide e.g. `logger.debug`. By default, 25 | the built-in `print` function is used. 26 | summary_print_fn: Callable, optional 27 | Function that is called to print the summary. Use `None` to 28 | disable printing the summary. You can provide e.g. 29 | `logger.info`. By default, the built-in `print` function is used. 30 | 31 | Attributes 32 | ---------- 33 | num_iterations: int 34 | How many iteration were executed. 35 | total: float 36 | Total time elapsed in seconds. 37 | 38 | Example 39 | ------- 40 | Basic usage 41 | ``` 42 | from horology import Timed 43 | animals = ['cat', 'dog', 'crocodile'] 44 | for x in Timed(animals): 45 | feed(x) 46 | ``` 47 | 48 | Possible result: 49 | ``` 50 | iteration 1: 12.0 s 51 | iteration 2: 8.00 s 52 | iteration 3: 100 s 53 | 54 | total 3 iterations in 120 s 55 | min/median/max: 8.00/12.0/100 s 56 | average (std): 40.0 (52.0) s 57 | ``` 58 | """ 59 | 60 | def __init__( 61 | self, 62 | iterable: Iterable, 63 | *, 64 | unit: UnitType = 'a', 65 | iteration_print_fn: Callable[..., Any] | None = print, 66 | summary_print_fn: Callable[..., Any] | None = print 67 | ) -> None: 68 | 69 | self.iterable = iterable 70 | self.unit = unit 71 | self.iteration_print_fn = iteration_print_fn or (lambda _: None) 72 | self.summary_print_fn = summary_print_fn or (lambda _: None) 73 | 74 | self.intervals: list[float] = [] 75 | self._start: float | None = None 76 | self._last: float | None = None 77 | 78 | def __iter__(self) -> Timed: 79 | self._start = counter() 80 | self.iterable = iter(self.iterable) 81 | return self 82 | 83 | def __next__(self): 84 | try: 85 | now = counter() 86 | if self._last is not None: 87 | interval = now - self._last 88 | self.intervals.append(interval) 89 | t, u = rescale_time(interval, self.unit) 90 | self.iteration_print_fn(f'iteration {self.num_iterations:4}: {t:.3g} {u}') 91 | 92 | self._last = now 93 | 94 | return next(self.iterable) 95 | 96 | except StopIteration: 97 | self.print_summary() 98 | raise StopIteration 99 | 100 | @property 101 | def num_iterations(self) -> int: 102 | return len(self.intervals) 103 | 104 | @property 105 | def n(self) -> int: 106 | "Deprecated" 107 | return self.num_iterations 108 | 109 | @property 110 | def total(self) -> float: 111 | try: 112 | return self._last - self._start # type: ignore 113 | except TypeError: 114 | return 0 115 | 116 | def print_summary(self) -> None: 117 | """ Print statistics of times elapsed in each iteration 118 | 119 | It is called automatically when the iteration over the iterable 120 | finishes. 121 | 122 | Use `summary_print_fn` argument in the constructor to control 123 | if and where the summary is printed. 124 | 125 | """ 126 | # Leave an empty line if iterations and summary are printed to 127 | # the same output. 128 | if self.iteration_print_fn == self.summary_print_fn: 129 | print_str = '\n' 130 | else: 131 | print_str = '' 132 | 133 | if self.num_iterations == 0: 134 | print_str = 'no iterations' 135 | elif self.num_iterations == 1: 136 | t, u = rescale_time(self.intervals[0], unit=self.unit) 137 | print_str += f'one iteration: {t:.3g} {u}' 138 | else: 139 | t_total, u_total = rescale_time(self.total, self.unit) 140 | 141 | t_median, u = rescale_time(median(self.intervals), self.unit) 142 | # For clarity, all values are shown using the same unit. 143 | t_min, _ = rescale_time(min(self.intervals), u) 144 | t_mean, _ = rescale_time(mean(self.intervals), u) 145 | t_max, _ = rescale_time(max(self.intervals), u) 146 | t_std, _ = rescale_time(stdev(self.intervals), u) 147 | 148 | print_str += f'total {self.num_iterations} iterations ' 149 | print_str += f'in {t_total:.3g} {u_total}\n' 150 | print_str += f'min/median/max: ' \ 151 | f'{t_min:.3g}' \ 152 | f'/{t_median:.3g}' \ 153 | f'/{t_max:.3g} {u}\n' 154 | print_str += f'average (std): ' \ 155 | f'{t_mean:.3g} ' \ 156 | f'({t_std:.3g}) {u}' 157 | 158 | self.summary_print_fn(print_str) 159 | --------------------------------------------------------------------------------