pyutilkit 0.9.0__tar.gz → 0.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  # BSD 3-Clause License
2
2
 
3
- Copyright © 2024 Stephanos Kuma.
3
+ Copyright © 2025 Stephanos Kuma.
4
4
 
5
5
  Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
6
6
 
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pyutilkit
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: python's missing batteries
5
5
  Home-page: https://pyutilkit.readthedocs.io/en/stable/
6
6
  License: BSD-3-Clause
7
7
  Keywords: utils
8
8
  Author: Stephanos Kuma
9
9
  Author-email: "Stephanos Kuma" <stephanos@kuma.ai>
10
- Requires-Python: >=3.9
10
+ Requires-Python: >=3.10
11
11
  Classifier: Development Status :: 4 - Beta
12
12
  Classifier: License :: OSI Approved :: BSD License
13
13
  Classifier: Operating System :: OS Independent
@@ -21,11 +21,12 @@ Description-Content-Type: text/markdown
21
21
 
22
22
  [![build][build_badge]][build_url]
23
23
  [![lint][lint_badge]][lint_url]
24
- [![tests][test_badge]][test_url]
24
+ [![tests][tests_badge]][tests_url]
25
25
  [![license][licence_badge]][licence_url]
26
+ [![codecov][codecov_badge]][codecov_url]
27
+ [![readthedocs][readthedocs_badge]][readthedocs_url]
26
28
  [![pypi][pypi_badge]][pypi_url]
27
29
  [![downloads][pepy_badge]][pepy_url]
28
- [![code style: black][black_badge]][black_url]
29
30
  [![build automation: yam][yam_badge]][yam_url]
30
31
  [![Lint: ruff][ruff_badge]][ruff_url]
31
32
 
@@ -37,23 +38,25 @@ hopes to stop this repetition.
37
38
 
38
39
  ## Links
39
40
 
40
- - [Documentation]
41
- - [Changelog]
41
+ - [Documentation]
42
+ - [Changelog]
42
43
 
43
44
  [build_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/build.yml/badge.svg
44
45
  [build_url]: https://github.com/spapanik/pyutilkit/actions/workflows/build.yml
45
46
  [lint_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/lint.yml/badge.svg
46
47
  [lint_url]: https://github.com/spapanik/pyutilkit/actions/workflows/lint.yml
47
- [test_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml/badge.svg
48
- [test_url]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml
48
+ [tests_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml/badge.svg
49
+ [tests_url]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml
49
50
  [licence_badge]: https://img.shields.io/pypi/l/pyutilkit
50
51
  [licence_url]: https://pyutilkit.readthedocs.io/en/stable/LICENSE/
52
+ [codecov_badge]: https://codecov.io/github/spapanik/pyutilkit/graph/badge.svg?token=Q20F84BW72
53
+ [codecov_url]: https://codecov.io/github/spapanik/pyutilkit
54
+ [readthedocs_badge]: https://readthedocs.org/projects/pyutilkit/badge/?version=latest
55
+ [readthedocs_url]: https://pyutilkit.readthedocs.io/en/latest/
51
56
  [pypi_badge]: https://img.shields.io/pypi/v/pyutilkit
52
57
  [pypi_url]: https://pypi.org/project/pyutilkit
53
58
  [pepy_badge]: https://pepy.tech/badge/pyutilkit
54
59
  [pepy_url]: https://pepy.tech/project/pyutilkit
55
- [black_badge]: https://img.shields.io/badge/code%20style-black-000000.svg
56
- [black_url]: https://github.com/psf/black
57
60
  [yam_badge]: https://img.shields.io/badge/build%20automation-yamk-success
58
61
  [yam_url]: https://github.com/spapanik/yamk
59
62
  [ruff_badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json
@@ -2,11 +2,12 @@
2
2
 
3
3
  [![build][build_badge]][build_url]
4
4
  [![lint][lint_badge]][lint_url]
5
- [![tests][test_badge]][test_url]
5
+ [![tests][tests_badge]][tests_url]
6
6
  [![license][licence_badge]][licence_url]
7
+ [![codecov][codecov_badge]][codecov_url]
8
+ [![readthedocs][readthedocs_badge]][readthedocs_url]
7
9
  [![pypi][pypi_badge]][pypi_url]
8
10
  [![downloads][pepy_badge]][pepy_url]
9
- [![code style: black][black_badge]][black_url]
10
11
  [![build automation: yam][yam_badge]][yam_url]
11
12
  [![Lint: ruff][ruff_badge]][ruff_url]
12
13
 
@@ -18,23 +19,25 @@ hopes to stop this repetition.
18
19
 
19
20
  ## Links
20
21
 
21
- - [Documentation]
22
- - [Changelog]
22
+ - [Documentation]
23
+ - [Changelog]
23
24
 
24
25
  [build_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/build.yml/badge.svg
25
26
  [build_url]: https://github.com/spapanik/pyutilkit/actions/workflows/build.yml
26
27
  [lint_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/lint.yml/badge.svg
27
28
  [lint_url]: https://github.com/spapanik/pyutilkit/actions/workflows/lint.yml
28
- [test_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml/badge.svg
29
- [test_url]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml
29
+ [tests_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml/badge.svg
30
+ [tests_url]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml
30
31
  [licence_badge]: https://img.shields.io/pypi/l/pyutilkit
31
32
  [licence_url]: https://pyutilkit.readthedocs.io/en/stable/LICENSE/
33
+ [codecov_badge]: https://codecov.io/github/spapanik/pyutilkit/graph/badge.svg?token=Q20F84BW72
34
+ [codecov_url]: https://codecov.io/github/spapanik/pyutilkit
35
+ [readthedocs_badge]: https://readthedocs.org/projects/pyutilkit/badge/?version=latest
36
+ [readthedocs_url]: https://pyutilkit.readthedocs.io/en/latest/
32
37
  [pypi_badge]: https://img.shields.io/pypi/v/pyutilkit
33
38
  [pypi_url]: https://pypi.org/project/pyutilkit
34
39
  [pepy_badge]: https://pepy.tech/badge/pyutilkit
35
40
  [pepy_url]: https://pepy.tech/project/pyutilkit
36
- [black_badge]: https://img.shields.io/badge/code%20style-black-000000.svg
37
- [black_url]: https://github.com/psf/black
38
41
  [yam_badge]: https://img.shields.io/badge/build%20automation-yamk-success
39
42
  [yam_url]: https://github.com/spapanik/yamk
40
43
  [ruff_badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json
@@ -1,12 +1,14 @@
1
1
  [build-system]
2
2
  requires = [
3
- "phosphorus>=0.8",
3
+ "phosphorus>=0.10",
4
4
  ]
5
5
  build-backend = "phosphorus.construction.api"
6
6
 
7
7
  [project]
8
8
  name = "pyutilkit"
9
- version = "0.9.0"
9
+ dynamic = [
10
+ "version",
11
+ ]
10
12
 
11
13
  authors = [
12
14
  { name = "Stephanos Kuma", email = "stephanos@kuma.ai" },
@@ -25,7 +27,7 @@ classifiers = [
25
27
  "License :: OSI Approved :: BSD License",
26
28
  ]
27
29
 
28
- requires-python = ">=3.9"
30
+ requires-python = ">=3.10"
29
31
  dependencies = [
30
32
  "tzdata; os_name == 'nt'",
31
33
  ]
@@ -35,33 +37,38 @@ homepage = "https://pyutilkit.readthedocs.io/en/stable/"
35
37
  repository = "https://github.com/spapanik/pyutilkit"
36
38
  documentation = "https://pyutilkit.readthedocs.io/en/stable/"
37
39
 
38
- [tool.phosphorus.dev-dependencies]
40
+ [dependency-groups]
39
41
  dev = [
40
42
  "ipdb~=0.13",
41
- "ipython~=8.18",
43
+ "ipython~=8.37",
44
+ "ptpython~=3.0",
45
+ { include-group = "lint" },
46
+ { include-group = "test" },
47
+ { include-group = "docs" },
42
48
  ]
43
49
  lint = [
44
- "black~=24.10",
45
- "mypy~=1.13",
46
- "ruff~=0.8",
50
+ "mypy~=1.18",
51
+ "ruff~=0.13",
52
+ { include-group = "test_core" },
47
53
  ]
48
54
  test = [
55
+ "pytest-cov~=7.0",
56
+ { include-group = "test_core" },
57
+ ]
58
+ test_core = [
49
59
  "freezegun~=1.5",
50
- "pytest~=8.3",
51
- "pytest-cov~=6.0",
60
+ "pytest~=9.0",
52
61
  ]
53
62
  docs = [
54
63
  "mkdocs~=1.6",
55
- "mkdocs-material~=9.5",
64
+ "mkdocs-material~=9.6",
56
65
  "mkdocs-material-extensions~=1.3",
57
- "pygments~=2.18",
58
- "pymdown-extensions~=10.12",
66
+ "pygments~=2.19",
67
+ "pymdown-extensions~=10.15",
59
68
  ]
60
69
 
61
- [tool.black]
62
- target-version = [
63
- "py39",
64
- ]
70
+ [tool.phosphorus.dynamic]
71
+ version = { file = "src/pyutilkit/__version__.py" }
65
72
 
66
73
  [tool.mypy]
67
74
  check_untyped_defs = true
@@ -95,7 +102,7 @@ disallow_any_decorated = false # mock.MagicMock is Any
95
102
  src = [
96
103
  "src",
97
104
  ]
98
- target-version = "py39"
105
+ target-version = "py310"
99
106
 
100
107
  [tool.ruff.lint]
101
108
  select = [
@@ -103,11 +110,10 @@ select = [
103
110
  ]
104
111
  ignore = [
105
112
  "C901", # Adding a limit to complexity is too arbitrary
106
- "COM812", # Avoid magic trailing commas
113
+ "COM812", # Avoid conflicts with the formatter
107
114
  "D10", # Not everything needs a docstring
108
115
  "D203", # Prefer `no-blank-line-before-class` (D211)
109
116
  "D213", # Prefer `multi-line-summary-first-line` (D212)
110
- "E501", # Avoid clashes with black
111
117
  "PLR09", # Adding a limit to complexity is too arbitrary
112
118
  ]
113
119
 
@@ -133,8 +139,13 @@ forced-separate = [
133
139
  split-on-trailing-comma = false
134
140
 
135
141
  [tool.pytest.ini_options]
136
- addopts = "-ra -v"
137
- testpaths = "tests"
142
+ minversion = "9.0"
143
+ strict = true
144
+ addopts = ["-ra", "-v"]
145
+ testpaths = [
146
+ "tests",
147
+ "integration",
148
+ ]
138
149
 
139
150
  [tool.coverage.run]
140
151
  branch = true
@@ -142,12 +153,17 @@ source = [
142
153
  "src/",
143
154
  ]
144
155
  data_file = ".cov_cache/coverage.dat"
156
+ omit = [
157
+ "src/**/type_defs.py",
158
+ ]
145
159
 
146
160
  [tool.coverage.report]
147
161
  exclude_also = [
148
162
  "if TYPE_CHECKING:",
163
+ "raise NotImplementedError",
164
+ "raise UnreachableCodeError",
149
165
  ]
150
- fail_under = 90
166
+ fail_under = 100
151
167
  precision = 2
152
168
  show_missing = true
153
169
  skip_covered = true
@@ -0,0 +1 @@
1
+ __version__ = "0.11.0"
@@ -1,18 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import cast
3
+ import threading
4
4
 
5
5
 
6
6
  class Singleton(type):
7
- instance: type[Singleton] | None
7
+ instance: type | None
8
8
 
9
9
  def __init__(
10
10
  cls, name: str, bases: tuple[type[object], ...], namespace: dict[str, object]
11
11
  ) -> None:
12
12
  super().__init__(name, bases, namespace)
13
13
  cls.instance = None
14
+ cls._lock = threading.Lock()
14
15
 
15
- def __call__(cls) -> type[Singleton]:
16
+ def __call__(cls) -> type:
16
17
  if cls.instance is None:
17
- cls.instance = super().__call__()
18
- return cast(type[Singleton], cls.instance)
18
+ with cls._lock:
19
+ if cls.instance is None:
20
+ cls.instance = super().__call__()
21
+ return cls.instance
@@ -17,7 +17,7 @@ def get_timezones() -> set[str]:
17
17
  return available_timezones() - UNAVAILABLE_TIMEZONES
18
18
 
19
19
 
20
- def now(tz_info: ZoneInfo = UTC) -> datetime:
20
+ def now(tz_info: tzinfo = UTC) -> datetime:
21
21
  return datetime.now(UTC).astimezone(tz_info)
22
22
 
23
23
 
@@ -3,19 +3,16 @@ from __future__ import annotations
3
3
  import hashlib
4
4
  import logging
5
5
  from functools import wraps
6
- from typing import TYPE_CHECKING, TypeVar
6
+ from typing import TYPE_CHECKING, ParamSpec, TypeVar
7
7
 
8
8
  if TYPE_CHECKING:
9
9
  from collections.abc import Callable
10
10
  from pathlib import Path
11
11
 
12
- from typing_extensions import ParamSpec # py3.9: import from typing
13
-
14
- P = ParamSpec("P")
15
-
16
12
  logger = logging.getLogger(__name__)
17
13
  INGEST_ERROR = "Function `%s` threw `%s` when called with args=%s and kwargs=%s"
18
14
  R_co = TypeVar("R_co", covariant=True)
15
+ P = ParamSpec("P")
19
16
 
20
17
 
21
18
  def handle_exceptions(
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING
10
10
  if TYPE_CHECKING:
11
11
  from collections.abc import Iterable
12
12
 
13
- from typing_extensions import Self # py3.10: import from typing
13
+ from typing_extensions import Self # upgrade: py3.10: import from typing
14
14
 
15
15
  TRUE_VAR = {"0", "false", "no"}
16
16
 
@@ -67,18 +67,8 @@ class SGRCodes(IntEnum):
67
67
  return f"\033[{self.value}m"
68
68
 
69
69
 
70
- @dataclass(frozen=True, order=True)
70
+ @dataclass(frozen=True, order=True, slots=True)
71
71
  class SGRString:
72
- __slots__ = ( # py3.9: remove this line
73
- "_force_prefix",
74
- "_force_sgr",
75
- "_is_error",
76
- "_prefix",
77
- "_sgr",
78
- "_string",
79
- "_suffix",
80
- )
81
-
82
72
  _string: str
83
73
  _sgr: tuple[SGRCodes, ...]
84
74
  _prefix: str
@@ -146,7 +136,7 @@ class SGRString:
146
136
  is_error=self._is_error,
147
137
  )
148
138
 
149
- def print(self, end: str = "\n", *, full_color: bool = False) -> None:
139
+ def print(self, end: str = os.linesep, *, full_color: bool = False) -> None:
150
140
  """Print the command output.
151
141
 
152
142
  The command will be printed to stdout if it's not the output of an error,
@@ -159,7 +149,7 @@ class SGRString:
159
149
  if file.isatty():
160
150
  prefix = self._prefix
161
151
  sgr_prefix = "".join(code.sequence for code in self._sgr)
162
- sgr_suffix = SGRCodes.RESET.sequence
152
+ sgr_suffix = SGRCodes.RESET.sequence if self._sgr else ""
163
153
  suffix = self._suffix
164
154
  else:
165
155
  prefix = ""
@@ -215,14 +205,14 @@ class SGRString:
215
205
  type(self)(self._string, prefix=prefix, suffix=suffix, params=self._sgr).print()
216
206
 
217
207
 
218
- @dataclass(frozen=True, order=True)
208
+ @dataclass(frozen=True, order=True, slots=True)
219
209
  class SGROutput:
220
- __slots__ = ("_strings",) # py3.9: remove this line
221
210
  _strings: tuple[SGRString, ...]
222
211
 
223
212
  def __init__(
224
213
  self,
225
- strings: Iterable[SGRString],
214
+ strings: Iterable[object],
215
+ *,
226
216
  force_prefix: bool | None = None,
227
217
  force_sgr: bool | None = None,
228
218
  is_error: bool | None = None,
@@ -241,11 +231,14 @@ class SGROutput:
241
231
 
242
232
  @staticmethod
243
233
  def _clean_string(
244
- string: SGRString,
234
+ string: object,
235
+ *,
245
236
  force_prefix: bool | None,
246
237
  force_sgr: bool | None,
247
238
  is_error: bool | None,
248
239
  ) -> SGRString:
240
+ if not isinstance(string, SGRString):
241
+ string = SGRString(string)
249
242
  force_prefix = (
250
243
  string._force_prefix # noqa: SLF001
251
244
  if force_prefix is None
@@ -265,7 +258,7 @@ class SGROutput:
265
258
  is_error=is_error,
266
259
  )
267
260
 
268
- def print(self, sep: str = "", end: str = "\n") -> None:
261
+ def print(self, sep: str = "", end: str = os.linesep) -> None:
269
262
  n = len(self._strings)
270
263
  for index, string in enumerate(self._strings, start=1):
271
264
  current_end = end if index == n else sep
@@ -1,25 +1,24 @@
1
- from __future__ import annotations # py3.9: remove this line
1
+ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
4
  from time import perf_counter_ns
5
5
  from typing import TYPE_CHECKING
6
6
 
7
7
  if TYPE_CHECKING:
8
+ from collections.abc import Iterator
8
9
  from types import TracebackType
9
10
 
10
- from typing_extensions import Self # py3.10: import Self from typing
11
+ from typing_extensions import Self # upgrade: py3.10: import from typing
11
12
 
12
- METRIC_MULTIPLIER = 1000
13
+ METRIC_MULTIPLIER = 1_000
13
14
  SECONDS_PER_MINUTE = 60
14
15
  MINUTES_PER_HOUR = 60
15
16
  HOURS_PER_DAY = 24
16
17
  SECONDS_PER_DAY = SECONDS_PER_MINUTE * MINUTES_PER_HOUR * HOURS_PER_DAY
17
18
 
18
19
 
19
- @dataclass(frozen=True, order=True)
20
+ @dataclass(frozen=True, order=True, slots=True)
20
21
  class Timing:
21
- __slots__ = ("nanoseconds",) # py3.9: remove this line
22
-
23
22
  nanoseconds: int
24
23
 
25
24
  def __init__(
@@ -41,28 +40,35 @@ class Timing:
41
40
  object.__setattr__(self, "nanoseconds", total_nanoseconds)
42
41
 
43
42
  def __str__(self) -> str:
44
- if self.nanoseconds < METRIC_MULTIPLIER:
45
- return f"{self.nanoseconds}ns"
46
- microseconds = self.nanoseconds / METRIC_MULTIPLIER
43
+ if self.nanoseconds == 0:
44
+ return "0ns"
45
+ sign = "" if self.nanoseconds > 0 else "-"
46
+ nanoseconds = abs(self.nanoseconds)
47
+ if nanoseconds < METRIC_MULTIPLIER:
48
+ return f"{sign}{nanoseconds}ns"
49
+ microseconds = nanoseconds / METRIC_MULTIPLIER
47
50
  if microseconds < METRIC_MULTIPLIER:
48
- return f"{microseconds:.1f}µs"
51
+ return f"{sign}{microseconds:.1f}µs"
49
52
  milliseconds = microseconds / METRIC_MULTIPLIER
50
53
  if milliseconds < METRIC_MULTIPLIER:
51
- return f"{milliseconds:.1f}ms"
54
+ return f"{sign}{milliseconds:.1f}ms"
52
55
  seconds = milliseconds / METRIC_MULTIPLIER
53
56
  if seconds < SECONDS_PER_MINUTE:
54
- return f"{seconds:.2f}s"
57
+ return f"{sign}{seconds:.2f}s"
55
58
  round_seconds = int(seconds)
56
59
  minutes, seconds = divmod(round_seconds, SECONDS_PER_MINUTE)
57
60
  hours, minutes = divmod(minutes, MINUTES_PER_HOUR)
58
61
  if hours < HOURS_PER_DAY:
59
- return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
62
+ return f"{sign}{hours:02d}:{minutes:02d}:{seconds:02d}"
60
63
  days, hours = divmod(hours, HOURS_PER_DAY)
61
- return f"{days:,}d {hours:02d}:{minutes:02d}:{seconds:02d}"
64
+ return f"{sign}{days:,}d {hours:02d}:{minutes:02d}:{seconds:02d}"
62
65
 
63
66
  def __bool__(self) -> bool:
64
67
  return bool(self.nanoseconds)
65
68
 
69
+ def __neg__(self) -> Self:
70
+ return self.__class__(nanoseconds=-self.nanoseconds)
71
+
66
72
  def __add__(self, other: object) -> Timing:
67
73
  if not isinstance(other, Timing):
68
74
  return NotImplemented
@@ -73,6 +79,16 @@ class Timing:
73
79
  return NotImplemented
74
80
  return Timing(nanoseconds=self.nanoseconds + other.nanoseconds)
75
81
 
82
+ def __sub__(self, other: object) -> Timing:
83
+ if not isinstance(other, Timing):
84
+ return NotImplemented
85
+ return Timing(nanoseconds=self.nanoseconds - other.nanoseconds)
86
+
87
+ def __rsub__(self, other: object) -> Timing:
88
+ if not isinstance(other, Timing):
89
+ return NotImplemented
90
+ return Timing(nanoseconds=other.nanoseconds - self.nanoseconds)
91
+
76
92
  def __mul__(self, other: object) -> Timing:
77
93
  if not isinstance(other, int):
78
94
  return NotImplemented
@@ -97,10 +113,11 @@ class Timing:
97
113
  class Stopwatch:
98
114
  _start: int
99
115
  laps: list[Timing]
116
+ __slots__ = ("_start", "_zero", "laps")
100
117
 
101
118
  def __init__(self) -> None:
102
- self._start = 0
103
119
  self.laps: list[Timing] = []
120
+ self._zero = Timing()
104
121
 
105
122
  def __enter__(self) -> Self:
106
123
  self._start = perf_counter_ns()
@@ -112,19 +129,37 @@ class Stopwatch:
112
129
  exc_value: BaseException | None,
113
130
  traceback: TracebackType | None,
114
131
  ) -> None:
115
- _end = perf_counter_ns()
116
- self.laps.append(Timing(nanoseconds=_end - self._start))
132
+ end = perf_counter_ns()
133
+ self.laps.append(Timing(nanoseconds=end - self._start))
134
+ del self._start
117
135
 
118
136
  def __bool__(self) -> bool:
119
137
  return bool(self.elapsed)
120
138
 
139
+ def __len__(self) -> int:
140
+ return len(self.laps)
141
+
142
+ def __iter__(self) -> Iterator[Timing]:
143
+ return iter(self.laps)
144
+
145
+ def reset(self) -> None:
146
+ self.laps.clear()
147
+
121
148
  @property
122
149
  def elapsed(self) -> Timing:
123
- return sum(self.laps, Timing())
150
+ return sum(self.laps, self._zero)
124
151
 
125
152
  @property
126
153
  def average(self) -> Timing:
127
154
  if not self.laps:
128
155
  msg = "No laps recorded"
129
156
  raise ZeroDivisionError(msg)
130
- return self.elapsed // len(self.laps)
157
+ return self.elapsed // len(self)
158
+
159
+ @property
160
+ def min(self) -> Timing:
161
+ return min(self.laps)
162
+
163
+ @property
164
+ def max(self) -> Timing:
165
+ return max(self.laps)