Skip to content
Merged
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
4 changes: 3 additions & 1 deletion stopwatch/lap.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,8 +30,10 @@ def start(self) -> None:

def stop(self) -> None:
"""
Stop the lap timer.
Stop the lap timer. Stopping an already stopped lap does nothing.
"""
if not self.running:
return
self._fractions.append(time.perf_counter() - self._start)
self._start = 0.0
self.running = False
54 changes: 44 additions & 10 deletions stopwatch/stopwatch.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,22 @@ def __init__(
name: str | None = None,
print_report: bool = False,
precision: int = 2,
autostart: bool = True,
) -> None:
"""
Parameters
----------
name : Optional[`str`]
The name shown in the reports.
print_report : `bool`
Print a report when leaving a `with` block. Default is False.
precision : `int`
The number of decimal places in the reports. Default is 2.
autostart : `bool`
Start measuring right away. Default is True. Pass False to
measure only the blocks wrapped in `lap()`, leaving the time
before the first one out.
"""
self.name = name
self.precision = precision
self.laps: list[Lap] = []
Expand All@@ -27,7 +42,8 @@ def __init__(
self._caller: Caller | None = (
inspect_caller() if print_report else None
)
self.start()
if autostart:
self.start()

def __enter__(self) -> Stopwatch:
return self.restart()
Expand All@@ -53,7 +69,7 @@ def elapsed(self) -> float:
@property
def running(self) -> bool:
"""`bool`: True if the stopwatch is running, False if stopped."""
return self._current_lap is not None and self._current_lap.running
return any(lap.running for lap in self.laps)

@property
def statistics(self) -> Statistics:
Expand All@@ -63,12 +79,32 @@ def statistics(self) -> Statistics:
@contextmanager
def lap(self) -> Iterator[None]:
"""
Context manager for add a new lap.
Context manager that records the block it wraps as its own lap.

Each call records a distinct lap, so nesting works and the lap is
always closed even if the block raises.

A stopwatch is already running once constructed, and the first lap
takes over that lap rather than opening a second one, so any time
between `Stopwatch()` and the first `lap()` belongs to it. Build it
with `autostart=False` to leave that time out.
"""
# calling start twice consecutively -> use stack to solve this problem
self.start()
yield
self.stop()
lap = self._take_running_lap() or self._open_lap()
try:
yield
finally:
lap.stop()

def _open_lap(self) -> Lap:
lap = Lap()
self.laps.append(lap)
lap.start()
return lap

def _take_running_lap(self) -> Lap | None:
lap = self._current_lap
self._current_lap = None
return lap if lap is not None and lap.running else None

def start(self) -> Stopwatch:
"""
Expand All@@ -80,9 +116,7 @@ def start(self) -> Stopwatch:
The started stopwatch instance.
"""
if not self.running:
self.laps.append(Lap())
self._current_lap = self.laps[-1]
self._current_lap.start()
self._current_lap = self._open_lap()
return self

def stop(self) -> Stopwatch:
Expand Down
11 changes: 11 additions & 0 deletions tests/test_lap.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,17 @@ def test_stop(self) -> None:
self.assertEqual(lap.elapsed, 1)
self.assertEqual(repr(lap), '<Lap running=False elapsed=1.0000>')

def test_stop_twice_is_a_noop(self) -> None:
with patch('time.perf_counter', self.time_mock.perf_counter):
lap = Lap()
lap.start()
self.time_mock.increment(1)
lap.stop()
self.time_mock.increment(100)
lap.stop()
self.assertEqual(lap.elapsed, 1.0)
self.assertEqual(lap._fractions, [1.0])

def test_multiples_stars(self) -> None:
with patch('time.perf_counter', self.time_mock.perf_counter):
lap = Lap()
Expand Down
53 changes: 53 additions & 0 deletions tests/test_stopwatch.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -127,6 +127,59 @@ def test_stopwatch_report_with_laps(self) -> None:
'min=0.00s, median=2.00s, max=4.00s, dev=1.41s',
)

def test_stopwatch_without_autostart_measures_only_laps(self) -> None:
with patch('time.perf_counter', self.time_mock.perf_counter):
sw = Stopwatch(autostart=False)
self.time_mock.increment(5)
self.assertFalse(sw.running)
self.assertEqual(sw.laps, [])
self.assertEqual(sw.elapsed, 0.0)
with sw.lap():
self.time_mock.increment(1)
self.assertEqual(len(sw.laps), 1)
self.assertEqual(sw.elapsed, 1.0)

def test_stopwatch_without_autostart_still_starts_as_context(self) -> None:
with patch('time.perf_counter', self.time_mock.perf_counter):
with Stopwatch(autostart=False) as sw:
self.time_mock.increment(1)
self.assertTrue(sw.running)
self.assertEqual(sw.elapsed, 1.0)

def test_stopwatch_lap_closes_on_exception(self) -> None:
with patch('time.perf_counter', self.time_mock.perf_counter):
sw = Stopwatch()
with self.assertRaises(ValueError):
with sw.lap():
self.time_mock.increment(1)
raise ValueError('boom')
self.time_mock.increment(10)
self.assertFalse(sw.running)
self.assertEqual(len(sw.laps), 1)
self.assertEqual(sw.elapsed, 1.0)

def test_stopwatch_nested_laps_keep_outer_time(self) -> None:
with patch('time.perf_counter', self.time_mock.perf_counter):
sw = Stopwatch()
with sw.lap():
self.time_mock.increment(1)
with sw.lap():
self.time_mock.increment(2)
self.time_mock.increment(4)
self.assertEqual(len(sw.laps), 2)
self.assertEqual(sw.laps[0].elapsed, 7.0)
self.assertEqual(sw.laps[1].elapsed, 2.0)
self.assertFalse(sw.running)

def test_stopwatch_instances_do_not_share_laps(self) -> None:
with patch('time.perf_counter', self.time_mock.perf_counter):
sw1 = Stopwatch()
sw2 = Stopwatch()
self.time_mock.increment(1)
sw1.stop()
self.assertIsNot(sw1.laps, sw2.laps)
self.assertFalse(hasattr(Stopwatch, 'laps'))

def test_stopwatch_statistics(self) -> None:
with patch('time.perf_counter', self.time_mock.perf_counter):
with Stopwatch() as sw:
Expand Down