Skip to content

Commit 20c51f7

Browse files
authored
Add Instant class to conveniently track elapsed and start/end times (#13397)
Throughout the codebase we often need to track elapsed times, using a performance clock, and also start/end times that given seconds since epoch (via `time.time()`). `Instant` encapsulates both functionalities to simplify the code and ensure we are using the correct functions from `_pytest.timing`, which we can mock in tests for reliability.
1 parent 8173aa8 commit 20c51f7

File tree

7 files changed

+71
-41
lines changed

7 files changed

+71
-41
lines changed

src/_pytest/junitxml.py

+4-10
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111
from __future__ import annotations
1212

1313
from collections.abc import Callable
14-
from datetime import datetime
15-
from datetime import timezone
1614
import functools
1715
import os
1816
import platform
@@ -636,17 +634,15 @@ def pytest_internalerror(self, excrepr: ExceptionRepr) -> None:
636634
reporter._add_simple("error", "internal error", str(excrepr))
637635

638636
def pytest_sessionstart(self) -> None:
639-
self.suite_start_time = timing.time()
640-
self.suite_start_perf = timing.perf_counter()
637+
self.suite_start = timing.Instant()
641638

642639
def pytest_sessionfinish(self) -> None:
643640
dirname = os.path.dirname(os.path.abspath(self.logfile))
644641
# exist_ok avoids filesystem race conditions between checking path existence and requesting creation
645642
os.makedirs(dirname, exist_ok=True)
646643

647644
with open(self.logfile, "w", encoding="utf-8") as logfile:
648-
suite_stop_perf = timing.perf_counter()
649-
suite_time_delta = suite_stop_perf - self.suite_start_perf
645+
duration = self.suite_start.elapsed()
650646

651647
numtests = (
652648
self.stats["passed"]
@@ -664,10 +660,8 @@ def pytest_sessionfinish(self) -> None:
664660
failures=str(self.stats["failure"]),
665661
skipped=str(self.stats["skipped"]),
666662
tests=str(numtests),
667-
time=f"{suite_time_delta:.3f}",
668-
timestamp=datetime.fromtimestamp(self.suite_start_time, timezone.utc)
669-
.astimezone()
670-
.isoformat(),
663+
time=f"{duration.seconds:.3f}",
664+
timestamp=self.suite_start.as_utc().astimezone().isoformat(),
671665
hostname=platform.node(),
672666
)
673667
global_properties = self._get_global_properties_node()

src/_pytest/pytester.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -1150,7 +1150,7 @@ def runpytest_inprocess(
11501150

11511151
if syspathinsert:
11521152
self.syspathinsert()
1153-
now = timing.perf_counter()
1153+
instant = timing.Instant()
11541154
capture = _get_multicapture("sys")
11551155
capture.start_capturing()
11561156
try:
@@ -1180,7 +1180,7 @@ class reprec: # type: ignore
11801180

11811181
assert reprec.ret is not None
11821182
res = RunResult(
1183-
reprec.ret, out.splitlines(), err.splitlines(), timing.perf_counter() - now
1183+
reprec.ret, out.splitlines(), err.splitlines(), instant.elapsed().seconds
11841184
)
11851185
res.reprec = reprec # type: ignore
11861186
return res
@@ -1408,7 +1408,7 @@ def run(
14081408
print(" in:", Path.cwd())
14091409

14101410
with p1.open("w", encoding="utf8") as f1, p2.open("w", encoding="utf8") as f2:
1411-
now = timing.perf_counter()
1411+
instant = timing.Instant()
14121412
popen = self.popen(
14131413
cmdargs,
14141414
stdin=stdin,
@@ -1445,7 +1445,7 @@ def handle_timeout() -> None:
14451445

14461446
with contextlib.suppress(ValueError):
14471447
ret = ExitCode(ret)
1448-
return RunResult(ret, out, err, timing.perf_counter() - now)
1448+
return RunResult(ret, out, err, instant.elapsed().seconds)
14491449

14501450
def _dump_lines(self, lines, fp):
14511451
try:

src/_pytest/runner.py

+5-9
Original file line numberDiff line numberDiff line change
@@ -339,23 +339,19 @@ def from_call(
339339
function, instead of being wrapped in the CallInfo.
340340
"""
341341
excinfo = None
342-
start = timing.time()
343-
precise_start = timing.perf_counter()
342+
instant = timing.Instant()
344343
try:
345344
result: TResult | None = func()
346345
except BaseException:
347346
excinfo = ExceptionInfo.from_current()
348347
if reraise is not None and isinstance(excinfo.value, reraise):
349348
raise
350349
result = None
351-
# use the perf counter
352-
precise_stop = timing.perf_counter()
353-
duration = precise_stop - precise_start
354-
stop = timing.time()
350+
duration = instant.elapsed()
355351
return cls(
356-
start=start,
357-
stop=stop,
358-
duration=duration,
352+
start=duration.start.time,
353+
stop=duration.stop.time,
354+
duration=duration.seconds,
359355
when=when,
360356
result=result,
361357
excinfo=excinfo,

src/_pytest/terminal.py

+8-10
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ def __init__(self, config: Config, file: TextIO | None = None) -> None:
391391
self._progress_nodeids_reported: set[str] = set()
392392
self._timing_nodeids_reported: set[str] = set()
393393
self._show_progress_info = self._determine_show_progress_info()
394-
self._collect_report_last_write: float | None = None
394+
self._collect_report_last_write = timing.Instant()
395395
self._already_displayed_warnings: int | None = None
396396
self._keyboardinterrupt_memo: ExceptionRepr | None = None
397397

@@ -769,7 +769,6 @@ def pytest_collection(self) -> None:
769769
if self.isatty:
770770
if self.config.option.verbose >= 0:
771771
self.write("collecting ... ", flush=True, bold=True)
772-
self._collect_report_last_write = timing.perf_counter()
773772
elif self.config.option.verbose >= 1:
774773
self.write("collecting ... ", flush=True, bold=True)
775774

@@ -788,14 +787,13 @@ def report_collect(self, final: bool = False) -> None:
788787
return
789788

790789
if not final:
791-
# Only write "collecting" report every 0.5s.
792-
t = timing.perf_counter()
790+
# Only write the "collecting" report every `REPORT_COLLECTING_RESOLUTION`.
793791
if (
794-
self._collect_report_last_write is not None
795-
and self._collect_report_last_write > t - REPORT_COLLECTING_RESOLUTION
792+
self._collect_report_last_write.elapsed().seconds
793+
< REPORT_COLLECTING_RESOLUTION
796794
):
797795
return
798-
self._collect_report_last_write = t
796+
self._collect_report_last_write = timing.Instant()
799797

800798
errors = len(self.stats.get("error", []))
801799
skipped = len(self.stats.get("skipped", []))
@@ -823,7 +821,7 @@ def report_collect(self, final: bool = False) -> None:
823821
@hookimpl(trylast=True)
824822
def pytest_sessionstart(self, session: Session) -> None:
825823
self._session = session
826-
self._sessionstarttime = timing.perf_counter()
824+
self._session_start = timing.Instant()
827825
if not self.showheader:
828826
return
829827
self.write_sep("=", "test session starts", bold=True)
@@ -1202,7 +1200,7 @@ def summary_stats(self) -> None:
12021200
if self.verbosity < -1:
12031201
return
12041202

1205-
session_duration = timing.perf_counter() - self._sessionstarttime
1203+
session_duration = self._session_start.elapsed()
12061204
(parts, main_color) = self.build_summary_stats_line()
12071205
line_parts = []
12081206

@@ -1217,7 +1215,7 @@ def summary_stats(self) -> None:
12171215
msg = ", ".join(line_parts)
12181216

12191217
main_markup = {main_color: True}
1220-
duration = f" in {format_session_duration(session_duration)}"
1218+
duration = f" in {format_session_duration(session_duration.seconds)}"
12211219
duration_with_markup = self._tw.markup(duration, **main_markup)
12221220
if display_sep:
12231221
fullwidth += len(duration_with_markup) - len(duration)

src/_pytest/timing.py

+42
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import dataclasses
1212
from datetime import datetime
13+
from datetime import timezone
1314
from time import perf_counter
1415
from time import sleep
1516
from time import time
@@ -20,6 +21,47 @@
2021
from pytest import MonkeyPatch
2122

2223

24+
@dataclasses.dataclass(frozen=True)
25+
class Instant:
26+
"""
27+
Represents an instant in time, used to both get the timestamp value and to measure
28+
the duration of a time span.
29+
30+
Inspired by Rust's `std::time::Instant`.
31+
"""
32+
33+
# Creation time of this instant, using time.time(), to measure actual time.
34+
# Note: using a `lambda` to correctly get the mocked time via `MockTiming`.
35+
time: float = dataclasses.field(default_factory=lambda: time(), init=False)
36+
37+
# Performance counter tick of the instant, used to measure precise elapsed time.
38+
# Note: using a `lambda` to correctly get the mocked time via `MockTiming`.
39+
perf_count: float = dataclasses.field(
40+
default_factory=lambda: perf_counter(), init=False
41+
)
42+
43+
def elapsed(self) -> Duration:
44+
"""Measure the duration since `Instant` was created."""
45+
return Duration(start=self, stop=Instant())
46+
47+
def as_utc(self) -> datetime:
48+
"""Instant as UTC datetime."""
49+
return datetime.fromtimestamp(self.time, timezone.utc)
50+
51+
52+
@dataclasses.dataclass(frozen=True)
53+
class Duration:
54+
"""A span of time as measured by `Instant.elapsed()`."""
55+
56+
start: Instant
57+
stop: Instant
58+
59+
@property
60+
def seconds(self) -> float:
61+
"""Elapsed time of the duration in seconds, measured using a performance counter for precise timing."""
62+
return self.stop.perf_count - self.start.perf_count
63+
64+
2365
@dataclasses.dataclass
2466
class MockTiming:
2567
"""Mocks _pytest.timing with a known object that can be used to control timing in tests

testing/_py/test_local.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from py import error
1313
from py.path import local
1414

15-
import _pytest.timing
1615
import pytest
1716

1817

@@ -747,7 +746,8 @@ def test_setmtime(self):
747746
name = tempfile.mktemp()
748747
open(name, "w").close()
749748
try:
750-
mtime = int(_pytest.timing.time()) - 100
749+
# Do not use _pytest.timing here, as we do not want time mocking to affect this test.
750+
mtime = int(time.time()) - 100
751751
path = local(name)
752752
assert path.mtime() != mtime
753753
path.setmtime(mtime)
@@ -1405,15 +1405,16 @@ def test_atime(self, tmpdir):
14051405
import time
14061406

14071407
path = tmpdir.ensure("samplefile")
1408-
now = _pytest.timing.perf_counter()
1408+
# Do not use _pytest.timing here, as we do not want time mocking to affect this test.
1409+
now = time.time()
14091410
atime1 = path.atime()
14101411
# we could wait here but timer resolution is very
14111412
# system dependent
14121413
path.read_binary()
14131414
time.sleep(ATIME_RESOLUTION)
14141415
atime2 = path.atime()
14151416
time.sleep(ATIME_RESOLUTION)
1416-
duration = _pytest.timing.perf_counter() - now
1417+
duration = time.time() - now
14171418
assert (atime2 - atime1) <= duration
14181419

14191420
def test_commondir(self, path1):

testing/test_pytester.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -451,13 +451,12 @@ def test_pytester_run_with_timeout(pytester: Pytester) -> None:
451451

452452
timeout = 120
453453

454-
start = _pytest.timing.perf_counter()
454+
instant = _pytest.timing.Instant()
455455
result = pytester.runpytest_subprocess(testfile, timeout=timeout)
456-
end = _pytest.timing.perf_counter()
457-
duration = end - start
456+
duration = instant.elapsed()
458457

459458
assert result.ret == ExitCode.OK
460-
assert duration < timeout
459+
assert duration.seconds < timeout
461460

462461

463462
def test_pytester_run_timeout_expires(pytester: Pytester) -> None:

0 commit comments

Comments
 (0)