Skip to content

Commit e6f7e83

Browse files
committed
First version of codetiming.Timer
1 parent 0970aa2 commit e6f7e83

File tree

6 files changed

+395
-1
lines changed

6 files changed

+395
-1
lines changed

.bumpversion.cfg

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[bumpversion]
2+
current_version = 0.1.0
3+
commit = True
4+
tag = True
5+
6+
[bumpversion:file:codetiming/__init__.py]
7+

README.md

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,77 @@
1-
# codetiming
1+
# `codetiming` - A flexible, customizable timer for your Python code
2+
3+
Install `codetiming` from PyPI:
4+
5+
```
6+
$ python -m pip install codetiming
7+
```
8+
9+
The source code is [available at GitHub](https://github.com/realpython/codetiming).
10+
11+
## Basic Usage
12+
13+
You can use `codetiming.Timer` in several different ways:
14+
15+
1. As a **class**:
16+
17+
```python
18+
t = Timer(name="class")
19+
t.start()
20+
# Do something
21+
t.stop()
22+
```
23+
24+
2. As a **context manager**:
25+
26+
```python
27+
with Timer(name="context manager"):
28+
# Do something
29+
```
30+
31+
3. As a **decorator**:
32+
33+
```python
34+
@Timer(name="decorator")
35+
def stuff():
36+
# Do something
37+
```
38+
39+
## Arguments
40+
41+
`Timer` accepts the following arguments when it's created, all are optional:
42+
43+
- **`name`:** An optional name for your timer
44+
- **`text`:** The text shown when your timer ends. It should contain a `{}` placeholder that will be filled by the elapsed time in seconds (default: `"Elapsed time: {:.4f} seconds"`)
45+
- **`logger`:** A function/callable that takes a string argument, and will report the elapsed time when the logger is stopped (default: `print()`)
46+
47+
You can turn off explicit reporting of the elapsed time by setting `logger=None`.
48+
49+
When using `Timer` as a class, you can capture the elapsed time when calling `.stop()`:
50+
51+
```python
52+
elapsed_time = t.stop()
53+
```
54+
55+
Named timers are made available in the class dictionary `Timer.timers`. The elapsed time will accumulate if the same name or same timer is used several times. Consider the following example:
56+
57+
```python
58+
>>> import logging
59+
>>> from codetiming import Timer
60+
61+
>>> t = Timer("example", text="Time spent: {:.2f}", logger=logging.warning)
62+
63+
>>> t.start()
64+
>>> t.stop()
65+
WARNING:root:Time spent: 3.58
66+
3.5836678670002584
67+
68+
>>> with t:
69+
... _ = list(range(100000000))
70+
...
71+
WARNING:root:Time spent: 1.73
72+
73+
>>> Timer.timers
74+
{'example': 5.312697440000193}
75+
```
76+
77+
The example shows how you can redirect the timer output to the logging module. Note that the elapsed time spent in the two different uses of `t` has been accumulated in `Timer.timers`.

codetiming/__init__.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""A flexible, customizable timer for your Python code
2+
3+
You can use `codetiming.Timer` in several different ways:
4+
5+
1. As a **class**:
6+
7+
```python
8+
t = Timer(name="class")
9+
t.start()
10+
# Do something
11+
t.stop()
12+
```
13+
14+
2. As a **context manager**:
15+
16+
```python
17+
with Timer(name="context manager"):
18+
# Do something
19+
```
20+
21+
3. As a **decorator**:
22+
23+
```python
24+
@Timer(name="decorator")
25+
def stuff():
26+
# Do something
27+
```
28+
"""
29+
30+
from contextlib import ContextDecorator
31+
from dataclasses import dataclass, field
32+
import time
33+
from typing import Any, Callable, ClassVar, Dict, Optional
34+
35+
__version__ = "0.1.0"
36+
37+
38+
class TimerError(Exception):
39+
"""A custom exception used to report errors in use of Timer class"""
40+
41+
42+
@dataclass
43+
class Timer(ContextDecorator):
44+
"""Time your code using a class, context manager, or decorator"""
45+
46+
timers: ClassVar[Dict[str, float]] = dict()
47+
name: Optional[str] = None
48+
text: str = "Elapsed time: {:0.4f} seconds"
49+
logger: Optional[Callable[[str], None]] = print
50+
_start_time: Optional[float] = field(default=None, init=False, repr=False)
51+
52+
def __post_init__(self) -> None:
53+
"""Initialization: add timer to dict of timers"""
54+
if self.name:
55+
self.timers.setdefault(self.name, 0)
56+
57+
def start(self) -> None:
58+
"""Start a new timer"""
59+
if self._start_time is not None:
60+
raise TimerError(f"Timer is running. Use .stop() to stop it")
61+
62+
self._start_time = time.perf_counter()
63+
64+
def stop(self) -> float:
65+
"""Stop the timer, and report the elapsed time"""
66+
if self._start_time is None:
67+
raise TimerError(f"Timer is not running. Use .start() to start it")
68+
69+
# Calculate elapsed time
70+
elapsed_time = time.perf_counter() - self._start_time
71+
self._start_time = None
72+
73+
# Report elapsed time
74+
if self.logger:
75+
self.logger(self.text.format(elapsed_time))
76+
if self.name:
77+
self.timers[self.name] += elapsed_time
78+
79+
return elapsed_time
80+
81+
def __enter__(self) -> "Timer":
82+
"""Start a new timer as a context manager"""
83+
self.start()
84+
return self
85+
86+
def __exit__(self, *exc_info: Any) -> None:
87+
"""Stop the context manager timer"""
88+
self.stop()

pyproject.toml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
[build-system]
2+
requires = ["flit"]
3+
build-backend = "flit.buildapi"
4+
5+
[tool.flit.metadata]
6+
module = "codetiming"
7+
author = "Real Python"
8+
author-email = "[email protected]"
9+
home-page = "https://realpython.com/"
10+
description-file = "README.md"
11+
classifiers = [
12+
"Development Status :: 4 - Beta",
13+
"Intended Audience :: Developers",
14+
"License :: OSI Approved :: MIT License",
15+
"Natural Language :: English",
16+
"Operating System :: MacOS",
17+
"Operating System :: Microsoft",
18+
"Operating System :: POSIX :: Linux",
19+
"Programming Language :: Python :: 3.6",
20+
"Programming Language :: Python :: 3.7",
21+
"Topic :: Education",
22+
"Topic :: Software Development :: Libraries :: Python Modules",
23+
"Topic :: System :: Monitoring",
24+
"Typing :: Typed",
25+
]
26+
keywords = "timer class contextmanager decorator"
27+
28+
# Requirements
29+
requires-python = ">=3.6"
30+
requires = []
31+
32+
33+
[tool.flit.metadata.requires-extra]
34+
dev = ["black", "bumpversion", "flake8", "flit", "mypy"]
35+
test = ["pytest", "pytest-cov", "tox"]
36+

tests/test_codetiming.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""Tests for codetiming.Timer
2+
3+
Based on the Pytest test runner
4+
"""
5+
# Standard library imports
6+
import re
7+
8+
# Third party imports
9+
import pytest
10+
11+
# Codetiming imports
12+
from codetiming import Timer, TimerError
13+
14+
15+
#
16+
# Test functions
17+
#
18+
TIME_PREFIX = "Wasted time:"
19+
TIME_MESSAGE = f"{TIME_PREFIX} {{:.4f}} seconds"
20+
RE_TIME_MESSAGE = re.compile(TIME_PREFIX + r" 0\.\d{4} seconds")
21+
22+
23+
@Timer(text=TIME_MESSAGE)
24+
def timewaster(num):
25+
"""Just waste a little bit of time"""
26+
sum(n ** 2 for n in range(num))
27+
28+
29+
@Timer(name="accumulator", text=TIME_MESSAGE)
30+
def accumulated_timewaste(num):
31+
"""Just waste a little bit of time"""
32+
sum(n ** 2 for n in range(num))
33+
34+
35+
class CustomLogger:
36+
"""Simple class used to test custom logging capabilities in Timer"""
37+
38+
def __init__(self):
39+
self.messages = ""
40+
41+
def __call__(self, message):
42+
self.messages += message
43+
44+
45+
#
46+
# Tests
47+
#
48+
def test_timer_as_decorator(capsys):
49+
"""Test that decorated function prints timing information"""
50+
timewaster(1000)
51+
stdout, stderr = capsys.readouterr()
52+
assert RE_TIME_MESSAGE.match(stdout)
53+
assert stdout.count("\n") == 1
54+
assert stderr == ""
55+
56+
57+
def test_timer_as_context_manager(capsys):
58+
"""Test that timed context prints timing information"""
59+
with Timer(text=TIME_MESSAGE):
60+
sum(n ** 2 for n in range(1000))
61+
stdout, stderr = capsys.readouterr()
62+
assert RE_TIME_MESSAGE.match(stdout)
63+
assert stdout.count("\n") == 1
64+
assert stderr == ""
65+
66+
67+
def test_explicit_timer(capsys):
68+
"""Test that timed section prints timing information"""
69+
t = Timer(text=TIME_MESSAGE)
70+
t.start()
71+
sum(n ** 2 for n in range(1000))
72+
t.stop()
73+
stdout, stderr = capsys.readouterr()
74+
assert RE_TIME_MESSAGE.match(stdout)
75+
assert stdout.count("\n") == 1
76+
assert stderr == ""
77+
78+
79+
def test_error_if_timer_not_running():
80+
"""Test that timer raises error if it is stopped before started"""
81+
t = Timer(text=TIME_MESSAGE)
82+
with pytest.raises(TimerError):
83+
t.stop()
84+
85+
86+
def test_access_timer_object_in_context(capsys):
87+
"""Test that we can access the timer object inside a context"""
88+
with Timer(text=TIME_MESSAGE) as t:
89+
assert isinstance(t, Timer)
90+
assert t.text.startswith(TIME_PREFIX)
91+
_, _ = capsys.readouterr() # Do not print log message to standard out
92+
93+
94+
def test_custom_logger():
95+
"""Test that we can use a custom logger"""
96+
logger = CustomLogger()
97+
with Timer(text=TIME_MESSAGE, logger=logger):
98+
sum(n ** 2 for n in range(1000))
99+
assert RE_TIME_MESSAGE.match(logger.messages)
100+
101+
102+
def test_timer_without_text(capsys):
103+
"""Test that timer with logger=None does not print anything"""
104+
with Timer(logger=None):
105+
sum(n ** 2 for n in range(1000))
106+
107+
stdout, stderr = capsys.readouterr()
108+
assert stdout == ""
109+
assert stderr == ""
110+
111+
112+
def test_accumulated_decorator(capsys):
113+
"""Test that decorated timer can accumulate"""
114+
accumulated_timewaste(1000)
115+
accumulated_timewaste(1000)
116+
117+
stdout, stderr = capsys.readouterr()
118+
lines = stdout.strip().split("\n")
119+
assert len(lines) == 2
120+
assert RE_TIME_MESSAGE.match(lines[0])
121+
assert RE_TIME_MESSAGE.match(lines[1])
122+
assert stderr == ""
123+
124+
125+
def test_accumulated_context_manager(capsys):
126+
"""Test that context manager timer can accumulate"""
127+
t = Timer(name="accumulator", text=TIME_MESSAGE)
128+
with t:
129+
sum(n ** 2 for n in range(1000))
130+
with t:
131+
sum(n ** 2 for n in range(1000))
132+
133+
stdout, stderr = capsys.readouterr()
134+
lines = stdout.strip().split("\n")
135+
assert len(lines) == 2
136+
assert RE_TIME_MESSAGE.match(lines[0])
137+
assert RE_TIME_MESSAGE.match(lines[1])
138+
assert stderr == ""
139+
140+
141+
def test_accumulated_explicit_timer(capsys):
142+
"""Test that explicit timer can accumulate"""
143+
t = Timer(name="accumulated_explicit_timer", text=TIME_MESSAGE)
144+
total = 0
145+
t.start()
146+
sum(n ** 2 for n in range(1000))
147+
total += t.stop()
148+
t.start()
149+
sum(n ** 2 for n in range(1000))
150+
total += t.stop()
151+
152+
stdout, stderr = capsys.readouterr()
153+
lines = stdout.strip().split("\n")
154+
assert len(lines) == 2
155+
assert RE_TIME_MESSAGE.match(lines[0])
156+
assert RE_TIME_MESSAGE.match(lines[1])
157+
assert stderr == ""
158+
assert total == Timer.timers["accumulated_explicit_timer"]
159+
160+
161+
def test_error_if_restarting_running_timer():
162+
"""Test that restarting a running timer raises an error"""
163+
t = Timer(text=TIME_MESSAGE)
164+
t.start()
165+
with pytest.raises(TimerError):
166+
t.start()

0 commit comments

Comments
 (0)