From 56ef5bb3868fa16a69180bbd66fe1a699a7a00e9 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sun, 12 Jul 2026 21:03:39 -0400 Subject: [PATCH] Track duration parsing precisely in nanoseconds; add parse_nanoseconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_timedelta accumulated sub-microsecond values by building a microsecond-resolution timedelta and *separately* stashing the sub-microsecond remainder, then adding both back on resolve(). For microsecond, millisecond, and second values that double-counted the fractional part -- e.g. parse_timedelta('1.6 µs') resolved to 3µs instead of 2µs. Rework _Saved_NS so td holds only whole microseconds and nanoseconds holds the exact sub-microsecond remainder, with resolve() rounding the combined total. This corrects the rounding and, because the full value is now retained, lets total_nanoseconds reconstruct the parsed value at sub-microsecond precision. Expose that via a new parse_nanoseconds() returning a Decimal count of nanoseconds, for callers -- such as pytest-perf comparing sub-microsecond timeit results -- that need finer resolution than a timedelta can hold. Ref jaraco/pytest-perf#18. Co-Authored-By: Claude Opus 4.8 --- __init__.py | 93 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 73 insertions(+), 20 deletions(-) diff --git a/__init__.py b/__init__.py index 05dafa9..3431fd7 100644 --- a/__init__.py +++ b/__init__.py @@ -9,8 +9,8 @@ 'types-python-dateutil; extra=="test"', ] -import contextlib import datetime +import decimal import functools import numbers import re @@ -465,7 +465,9 @@ def parse_timedelta(str: str) -> datetime.timedelta: ... ValueError: Cannot specify units with composite delta - Nanoseconds get rounded to the nearest microsecond: + Because a timedelta only has microsecond resolution, nanoseconds + (and other sub-microsecond values) get rounded to the nearest + microsecond. Use :func:`parse_nanoseconds` to retain that precision. >>> parse_timedelta('600 ns') datetime.timedelta(microseconds=1) @@ -473,6 +475,9 @@ def parse_timedelta(str: str) -> datetime.timedelta: >>> parse_timedelta('.002 µs, 499 ns') datetime.timedelta(microseconds=1) + >>> parse_timedelta('1.6 µs') + datetime.timedelta(microseconds=2) + Expect ValueError for other invalid inputs. >>> parse_timedelta('13 feet') @@ -483,6 +488,38 @@ def parse_timedelta(str: str) -> datetime.timedelta: return _parse_timedelta_nanos(str).resolve() +def parse_nanoseconds(str: str) -> decimal.Decimal: + """ + Parse a string representing a span of time, returning the total + number of nanoseconds as a :class:`decimal.Decimal`. + + Unlike :func:`parse_timedelta`, which is limited to the microsecond + resolution of :class:`datetime.timedelta`, this retains + sub-microsecond precision. + + >>> parse_nanoseconds('600 ns') + Decimal('600.0') + + >>> parse_nanoseconds('34.2 nsec') + Decimal('34.2') + + >>> parse_nanoseconds('1.6 µs') + Decimal('1600.0') + + >>> parse_nanoseconds('.002 µs, 499 ns') + Decimal('501.000') + + >>> parse_nanoseconds('1 ms') + Decimal('1000000.0') + + Coarser units are supported too. + + >>> parse_nanoseconds('1 day') + Decimal('86400000000000') + """ + return _parse_timedelta_nanos(str).total_nanoseconds + + def _parse_timedelta_nanos(str: str) -> _Saved_NS: parts = re.finditer(r'(?P[\d.:]+)\s?(?P[^\W\d_]+)?', str) chk_parts = _check_unmatched(parts, str) @@ -569,18 +606,23 @@ def _parse_timedelta_part(match: re.Match[str]) -> _Saved_NS: class _Saved_NS: """ - Bundle a timedelta with nanoseconds. + Bundle a timedelta with a sub-microsecond nanoseconds remainder. + + ``td`` carries whole-microsecond resolution and ``nanoseconds`` the + exact sub-microsecond remainder, so that ``total_nanoseconds`` + reconstructs the full precision of the parsed value. >>> _Saved_NS.derive('microseconds', .001) - _Saved_NS(td=datetime.timedelta(0), nanoseconds=1) + _Saved_NS(td=datetime.timedelta(0), nanoseconds=Decimal('1.000')) """ td = datetime.timedelta() - nanoseconds = 0 + nanoseconds: decimal.Decimal = decimal.Decimal(0) multiplier = dict( seconds=1000000000, milliseconds=1000000, microseconds=1000, + nanoseconds=1, ) def __init__(self, **kwargs: Any) -> None: @@ -588,31 +630,42 @@ def __init__(self, **kwargs: Any) -> None: @classmethod def derive(cls, unit: str, value: float) -> _Saved_NS: - if unit == 'nanoseconds': - return _Saved_NS(nanoseconds=value) - try: - raw_td = datetime.timedelta(**{unit: value}) - except TypeError: - raise ValueError(f"Invalid unit {unit}") - res = _Saved_NS(td=raw_td) - with contextlib.suppress(KeyError): - res.nanoseconds = int(value * cls.multiplier[unit]) % 1000 - return res + factor = cls.multiplier[unit] + except KeyError: + try: + return _Saved_NS(td=datetime.timedelta(**{unit: value})) + except TypeError: + raise ValueError(f"Invalid unit {unit}") + # Track the value exactly in nanoseconds, then split into a + # whole-microsecond timedelta and the sub-microsecond remainder. + total_ns = decimal.Decimal(str(value)) * factor + whole_us, rem_ns = divmod(total_ns, 1000) + return _Saved_NS( + td=datetime.timedelta(microseconds=int(whole_us)), nanoseconds=rem_ns + ) def __add__(self, other: _Saved_NS) -> _Saved_NS: return _Saved_NS( td=self.td + other.td, nanoseconds=self.nanoseconds + other.nanoseconds ) + @property + def total_nanoseconds(self) -> decimal.Decimal: + """ + The full parsed value expressed in nanoseconds, retaining + sub-microsecond resolution. + """ + whole_us = self.td // datetime.timedelta(microseconds=1) + return whole_us * 1000 + self.nanoseconds + def resolve(self) -> datetime.timedelta: """ - Resolve any nanoseconds into the microseconds field, - discarding any nanosecond resolution (but honoring partial - microseconds). + Resolve to a timedelta, rounding to the nearest microsecond + (discarding any nanosecond resolution). """ - addl_micros = round(self.nanoseconds / 1000) - return self.td + datetime.timedelta(microseconds=addl_micros) + micros = round(self.total_nanoseconds / 1000) + return datetime.timedelta(microseconds=micros) def __repr__(self) -> str: return f'_Saved_NS(td={self.td!r}, nanoseconds={self.nanoseconds!r})'