diff --git a/stopwatch/lap.py b/stopwatch/lap.py index febacf4..3fae30d 100644 --- a/stopwatch/lap.py +++ b/stopwatch/lap.py @@ -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 diff --git a/stopwatch/stopwatch.py b/stopwatch/stopwatch.py index 93b6ac0..481301c 100644 --- a/stopwatch/stopwatch.py +++ b/stopwatch/stopwatch.py @@ -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] = [] @@ -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() @@ -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: @@ -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: """ @@ -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: diff --git a/tests/test_lap.py b/tests/test_lap.py index 02a98a2..70c6eb2 100644 --- a/tests/test_lap.py +++ b/tests/test_lap.py @@ -31,6 +31,17 @@ def test_stop(self) -> None: self.assertEqual(lap.elapsed, 1) self.assertEqual(repr(lap), '') + 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() diff --git a/tests/test_stopwatch.py b/tests/test_stopwatch.py index b41433e..1d17dc1 100644 --- a/tests/test_stopwatch.py +++ b/tests/test_stopwatch.py @@ -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: