8000 Add custom Timers class (#23) · realpython/codetiming@04754ef · GitHub
[go: up one dir, main page]

Skip to content

Commit 04754ef

Browse files
authored
Add custom Timers class (#23)
* Create a custom class for .timers * Fix type hints in Timers class * Update imports with isort
1 parent 951e24c commit 04754ef

File tree

4 files changed

+159
-22
lines changed

4 files changed

+159
-22
lines changed

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
77

88
## [Unreleased]
99

10-
## [1.1.0] - 2020-01-14
10+
### Changed
11+
12+
- `Timer.timers` changed from regular to `dict` to a custom dictionary supporting basic statistics for named timers.
13+
14+
15+
## [1.1.0] - 2020-01-15
1116

1217
### Added
1318

codetiming/_timer.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
import time
1010
from contextlib import ContextDecorator
1111
from dataclasses import dataclass, field
12-
from typing import Any, Callable, ClassVar, Dict, Optional
12+
from typing import Any, Callable, ClassVar, Optional
13+
14+
# Codetiming imports
15+
from codetiming._timers import Timers
1316

1417

1518
class TimerError(Exception):
@@ -20,18 +23,13 @@ class TimerError(Exception):
2023
class Timer(ContextDecorator):
2124
"""Time your code using a class, context manager, or decorator"""
2225

23-
timers: ClassVar[Dict[str, float]] = dict()
26+
timers: ClassVar[Timers] = Timers()
2427
_start_time: Optional[float] = field(default=None, init=False, repr=False)
2528
name: Optional[str] = None
2629
text: str = "Elapsed time: {:0.4f} seconds"
2730
logger: Optional[Callable[[str], None]] = print
2831
last: float = field(default=math.nan, init=False, repr=False)
2932

30-
def __post_init__(self) -> None:
31-
"""Initialization: add timer to dict of timers"""
32-
if self.name:
33-
self.timers.setdefault(self.name, 0)
34-
3533
def start(self) -> None:
3634
"""Start a new timer"""
3735
if self._start_time is not None:
@@ -52,7 +50,7 @@ def stop(self) -> float:
5250
if self.logger:
5351
self.logger(self.text.format(self.last))
5452
if self.name:
55-
self.timers[self.name] += self.last
53+
self.timers.add(self.name, self.last)
5654

5755
return self.last
5856

codetiming/_timers.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Dictionary-like structure with information about timers"""
2+
3+
# Standard library imports
4+
import collections
5+
import math
6+
import statistics
7+
from typing import TYPE_CHECKING, Any, Callable, Dict, List
8+
9+
# Annotate generic UserDict
10+
if TYPE_CHECKING:
11+
UserDict = collections.UserDict[str, float] # pragma: no cover
12+
else:
13+
UserDict = collections.UserDict
14+
15+
16+
class Timers(UserDict):
17+
def __init__(self, *args: Any, **kwargs: Any) -> None:
18+
"""Add a private dictionary keeping track of all timings"""
19+
super().__init__(*args, **kwargs)
20+
self._timings: Dict[str, List[float]] = collections.defaultdict(list)
21+
22+
def add(self, name: str, value: float) -> None:
23+
"""Add a timing value to the given timer"""
24+
self._timings[name].append(value)
25+
self.data.setdefault(name, 0)
26+
self.data[name] += value
27+
28+
def clear(self) -> None:
29+
"""Clear timers"""
30+
self.data.clear()
31+
self._timings.clear()
32+
33+
def __setitem__(self, name: str, value: float) -> None:
34+
"""Disallow setting of timer values"""
35+
raise TypeError(
36+
f"{self.__class__.__name__!r} does not support item assignment. "
37+
"Use '.add()' to update values."
38+
)
39+
40+
def apply(self, func: Callable[[List[float]], float], name: str) -> float:
41+
"""Apply a function to the results of one named timer"""
42+
if name in self._timings:
43+
return func(self._timings[name])
44+
raise KeyError(name)
45+
46+
def count(self, name: str) -> float:
47+
"""Number of timings"""
48+
return self.apply(len, name=name)
49+
50+
def total(self, name: str) -> float:
51+
"""Total time for timers"""
52+
return self.apply(sum, name=name)
53+
54+
def min(self, name: str) -> float:
55+
"""Minimal value of timings"""
56+
return self.apply(lambda values: min(values or [0]), name=name)
57+
58+
def max(self, name: str) -> float:
59+
"""Maximal value of timings"""
60+
return self.apply(lambda values: max(values or [0]), name=name)
61+
62+
def mean(self, name: str) -> float:
63+
"""Mean value of timings"""
64+
return self.apply(lambda values: statistics.mean(values or [0]), name=name)
65+
66+
def median(self, name: str) -> float:
67+
"""Median value of timings"""
68+
return self.apply(lambda values: statistics.median(values or [0]), name=name)
69+
70+
def stdev(self, name: str) -> float:
71+
"""Standard deviation of timings"""
72+
if name in self._timings:
73+
value = self._timings[name]
74+
return statistics.stdev(value) if len(value) >= 2 else math.nan
75+
raise KeyError(name)

tests/test_codetiming.py

Lines changed: 72 additions & 13 deletions
52
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,19 @@
2121
RE_TIME_MESSAGE = re.compile(TIME_PREFIX + r" 0\.\d{4} seconds")
2222

2323

24+
def waste_time(num=1000):
25+
"""Just waste a little bit of time"""
26+
sum(n ** 2 for n in range(num))
27+
28+
2429
@Timer(text=TIME_MESSAGE)
25-
def timewaster(num):
30+
def decorated_timewaste(num=1000):
2631
"""Just waste a little bit of time"""
2732
sum(n ** 2 for n in range(num))
2833

2934

3035
@Timer(name="accumulator", text=TIME_MESSAGE)
31-
def accumulated_timewaste(num):
36+
def accumulated_timewaste(num=1000):
3237
"""Just waste a little bit of time"""
3338
sum(n ** 2 for n in range(num))
3439

@@ -48,7 +53,7 @@ def __call__(self, message):
4853
#
4954
def test_timer_as_decorator(capsys):
5055
"""Test that decorated function prints timing information"""
51-
timewaster(1000)
56+
decorated_timewaste()
57
stdout, stderr = capsys.readouterr()
5358
assert RE_TIME_MESSAGE.match(stdout)
5459
assert stdout.count("\n") == 1
@@ -58,7 +63,7 @@ def test_timer_as_decorator(capsys):
5863
def test_timer_as_context_manager(capsys):
5964
"""Test that timed context prints timing information"""
6065
with Timer(text=TIME_MESSAGE):
61-
sum(n ** 2 for n in range(1000))
66+
waste_time()
6267
stdout, stderr = capsys.readouterr()
6368
assert RE_TIME_MESSAGE.match(stdout)
6469
assert stdout.count("\n") == 1
@@ -69,7 +74,7 @@ def test_explicit_timer(capsys):
6974
"""Test that timed section prints timing information"""
7075
t = Timer(text=TIME_MESSAGE)
7176
t.start()
72-
sum(n ** 2 for n in range(1000))
77+
waste_time()
7378
t.stop()
7479
stdout, stderr = capsys.readouterr()
7580
assert RE_TIME_MESSAGE.match(stdout)
@@ -96,14 +101,14 @@ def test_custom_logger():
96101
"""Test that we can use a custom logger"""
97102
logger = CustomLogger()
98103
with Timer(text=TIME_MESSAGE, logger=logger):
99-
sum(n ** 2 for n in range(1000))
104+
waste_time()
100105
assert RE_TIME_MESSAGE.match(logger.messages)
101106

102107

103108
def test_timer_without_text(capsys):
104109
"""Test that timer with logger=None does not print anything"""
105110
with Timer(logger=None):
106-
sum(n ** 2 for n in range(1000))
111+
waste_time()
107112

108113
stdout, stderr = capsys.readouterr()
109114
assert stdout == ""
@@ -112,8 +117,8 @@ def test_timer_without_text(capsys):
112117

113118
def test_accumulated_decorator(capsys):
114119
"""Test that decorated timer can accumulate& 1E79 quot;""
115-
accumulated_timewaste(1000)
116-
accumulated_timewaste(1000)
120+
accumulated_timewaste()
121+
accumulated_timewaste()
117122

118123
stdout, stderr = capsys.readouterr()
119124
lines = stdout.strip().split("\n")
@@ -127,9 +132,9 @@ def test_accumulated_context_manager(capsys):
127132
"""Test that context manager timer can accumulate"""
128133
t = Timer(name="accumulator", text=TIME_MESSAGE)
129134
with t:
130-
sum(n ** 2 for n in range(1000))
135+
waste_time()
131136
with t:
132-
sum(n ** 2 for n in range(1000))
137+
waste_time()
133138

134139
stdout, stderr = capsys.readouterr()
135140
lines = stdout.strip().split("\n")
@@ -144,10 +149,10 @@ def test_accumulated_explicit_timer(capsys):
144149
t = Timer(name="accumulated_explicit_timer", text=TIME_MESSAGE)
145150
total = 0
146151
t.start()
147-
sum(n ** 2 for n in range(1000))
152+
waste_time()
148153
total += t.stop()
149154
t.start()
150-
sum(n ** 2 for n in range(1000))
155+
waste_time()
151156
total += t.stop()
152157

153158
stdout, stderr = capsys.readouterr()
@@ -179,3 +184,57 @@ def test_timer_sets_last():
179184
time.sleep(0.02)
180185

181186
assert t.last >= 0.02
187+
188+
189+
def test_timers_cleared():
190+
"""Test that timers can be cleared"""
191+
with Timer(name="timer_to_be_cleared"):
192+
waste_time()
193+
194+
assert "timer_to_be_cleared" in Timer.timers
195+
Timer.timers.clear()
196+
assert not Timer.timers
197+
198+
199+
def test_running_cleared_timers():
200+
"""Test that timers can still be run after they're cleared"""
201+
t = Timer(name="timer_to_be_cleared")
202+
Timer.timers.clear()
203+
204+
accumulated_timewaste()
205+
with t:
206+
waste_time()
207+
208+
assert "accumulator" in Timer.timers
209+
assert "timer_to_be_cleared" in Timer.timers
210+
211+
212+
def test_timers_stats():
213+
"""Test that we can get basic statistics from timers"""
214+
name = "timer_with_stats"
215+
t = Timer(name=name)
216+
for num in range(5, 10):
217+
with t:
218+
waste_time(num=100 * num)
219+
220+
stats = Timer.timers
221+
assert stats.total(name) == stats[name]
222+
assert stats.count(name) == 5
223+
assert stats.min(name) <= stats.median(name) <= stats.max(name)
224+
assert stats.mean(name) >= stats.min(name)
225+
assert stats.stdev(name) >= 0
226+
227+
228+
def test_stats_missing_timers():
229+
"""Test that getting statistics from non-existent timers raises exception"""
230+
with pytest.raises(KeyError):
231+
Timer.timers.count("non_existent_timer")
232+
233+
with pytest.raises(KeyError):
234+
Timer.timers.stdev("non_existent_timer")
235+
236+
237+
def test_setting_timers_exception():
238+
"""Test that setting .timers items raises exception"""
239+
with pytest.raises(TypeError):
240+
Timer.timers["set_timer"] = 1.23

0 commit comments

Comments
 (0)
0