pyutilkit 0.6.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.
- PKG-INFO +55 -0
- docs/README.md +37 -0
- pyproject.toml +192 -0
- src/pyutilkit/__init__.py +0 -0
- src/pyutilkit/classes.py +18 -0
- src/pyutilkit/data/__init__.py +0 -0
- src/pyutilkit/data/timezones.py +195 -0
- src/pyutilkit/date_utils.py +68 -0
- src/pyutilkit/files.py +55 -0
- src/pyutilkit/py.typed +0 -0
- src/pyutilkit/subprocess.py +53 -0
- src/pyutilkit/term.py +123 -0
- src/pyutilkit/timing.py +130 -0
PKG-INFO
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pyutilkit
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: python's missing batteries
|
|
5
|
+
Home-page: https://pyutilkit.readthedocs.io/en/stable/
|
|
6
|
+
License: BSD-3-Clause
|
|
7
|
+
Keywords: utils
|
|
8
|
+
Author: Stephanos Kuma
|
|
9
|
+
Author-email: "Stephanos Kuma" <stephanos@kuma.ai>
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Project-URL: Documentation, https://pyutilkit.readthedocs.io/en/stable/
|
|
16
|
+
Project-URL: Repository, https://github.com/spapanik/pyutilkit
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# pyutilkit: python's missing batteries
|
|
20
|
+
|
|
21
|
+
[![tests][test_badge]][test_url]
|
|
22
|
+
[![license][licence_badge]][licence_url]
|
|
23
|
+
[![pypi][pypi_badge]][pypi_url]
|
|
24
|
+
[![downloads][pepy_badge]][pepy_url]
|
|
25
|
+
[![code style: black][black_badge]][black_url]
|
|
26
|
+
[![build automation: yam][yam_badge]][yam_url]
|
|
27
|
+
[![Lint: ruff][ruff_badge]][ruff_url]
|
|
28
|
+
|
|
29
|
+
The Python has long maintained the philosophy of "batteries included", giving the user
|
|
30
|
+
a rich standard library, avoiding the need for third party tools for most work. Some packages
|
|
31
|
+
are so common, that the have a similar status to the standard library. Still, some code seems
|
|
32
|
+
to be written time and again, with every project. This small library, with minimal requirements,
|
|
33
|
+
hopes to stop this repetition.
|
|
34
|
+
|
|
35
|
+
## Links
|
|
36
|
+
|
|
37
|
+
- [Documentation]
|
|
38
|
+
- [Changelog]
|
|
39
|
+
|
|
40
|
+
[test_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml/badge.svg
|
|
41
|
+
[test_url]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml
|
|
42
|
+
[licence_badge]: https://img.shields.io/pypi/l/pyutilkit
|
|
43
|
+
[licence_url]: https://github.com/spapanik/pyutilkit/blob/main/docs/LICENSE.md
|
|
44
|
+
[pypi_badge]: https://img.shields.io/pypi/v/pyutilkit
|
|
45
|
+
[pypi_url]: https://pypi.org/project/pyutilkit
|
|
46
|
+
[pepy_badge]: https://pepy.tech/badge/pyutilkit
|
|
47
|
+
[pepy_url]: https://pepy.tech/project/pyutilkit
|
|
48
|
+
[black_badge]: https://img.shields.io/badge/code%20style-black-000000.svg
|
|
49
|
+
[black_url]: https://github.com/psf/black
|
|
50
|
+
[yam_badge]: https://img.shields.io/badge/build%20automation-yamk-success
|
|
51
|
+
[yam_url]: https://github.com/spapanik/yamk
|
|
52
|
+
[ruff_badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json
|
|
53
|
+
[ruff_url]: https://github.com/charliermarsh/ruff
|
|
54
|
+
[Documentation]: https://pyutilkit.readthedocs.io/en/stable/
|
|
55
|
+
[Changelog]: https://github.com/spapanik/pyutilkit/blob/main/docs/CHANGELOG.md
|
docs/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# pyutilkit: python's missing batteries
|
|
2
|
+
|
|
3
|
+
[![tests][test_badge]][test_url]
|
|
4
|
+
[![license][licence_badge]][licence_url]
|
|
5
|
+
[![pypi][pypi_badge]][pypi_url]
|
|
6
|
+
[![downloads][pepy_badge]][pepy_url]
|
|
7
|
+
[![code style: black][black_badge]][black_url]
|
|
8
|
+
[![build automation: yam][yam_badge]][yam_url]
|
|
9
|
+
[![Lint: ruff][ruff_badge]][ruff_url]
|
|
10
|
+
|
|
11
|
+
The Python has long maintained the philosophy of "batteries included", giving the user
|
|
12
|
+
a rich standard library, avoiding the need for third party tools for most work. Some packages
|
|
13
|
+
are so common, that the have a similar status to the standard library. Still, some code seems
|
|
14
|
+
to be written time and again, with every project. This small library, with minimal requirements,
|
|
15
|
+
hopes to stop this repetition.
|
|
16
|
+
|
|
17
|
+
## Links
|
|
18
|
+
|
|
19
|
+
- [Documentation]
|
|
20
|
+
- [Changelog]
|
|
21
|
+
|
|
22
|
+
[test_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml/badge.svg
|
|
23
|
+
[test_url]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml
|
|
24
|
+
[licence_badge]: https://img.shields.io/pypi/l/pyutilkit
|
|
25
|
+
[licence_url]: https://github.com/spapanik/pyutilkit/blob/main/docs/LICENSE.md
|
|
26
|
+
[pypi_badge]: https://img.shields.io/pypi/v/pyutilkit
|
|
27
|
+
[pypi_url]: https://pypi.org/project/pyutilkit
|
|
28
|
+
[pepy_badge]: https://pepy.tech/badge/pyutilkit
|
|
29
|
+
[pepy_url]: https://pepy.tech/project/pyutilkit
|
|
30
|
+
[black_badge]: https://img.shields.io/badge/code%20style-black-000000.svg
|
|
31
|
+
[black_url]: https://github.com/psf/black
|
|
32
|
+
[yam_badge]: https://img.shields.io/badge/build%20automation-yamk-success
|
|
33
|
+
[yam_url]: https://github.com/spapanik/yamk
|
|
34
|
+
[ruff_badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json
|
|
35
|
+
[ruff_url]: https://github.com/charliermarsh/ruff
|
|
36
|
+
[Documentation]: https://pyutilkit.readthedocs.io/en/stable/
|
|
37
|
+
[Changelog]: https://github.com/spapanik/pyutilkit/blob/main/docs/CHANGELOG.md
|
pyproject.toml
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = [
|
|
3
|
+
"phosphorus>=0.5",
|
|
4
|
+
]
|
|
5
|
+
build-backend = "phosphorus.construction.api"
|
|
6
|
+
|
|
7
|
+
[project]
|
|
8
|
+
name = "pyutilkit"
|
|
9
|
+
version = "0.6.0"
|
|
10
|
+
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Stephanos Kuma", email = "stephanos@kuma.ai" },
|
|
13
|
+
]
|
|
14
|
+
license = { text = "BSD-3-Clause" }
|
|
15
|
+
|
|
16
|
+
readme = "docs/README.md"
|
|
17
|
+
description = "python's missing batteries"
|
|
18
|
+
keywords = [
|
|
19
|
+
"utils",
|
|
20
|
+
]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 4 - Beta",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
"License :: OSI Approved :: BSD License",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
requires-python = ">=3.9"
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
homepage = "https://pyutilkit.readthedocs.io/en/stable/"
|
|
32
|
+
repository = "https://github.com/spapanik/pyutilkit"
|
|
33
|
+
documentation = "https://pyutilkit.readthedocs.io/en/stable/"
|
|
34
|
+
|
|
35
|
+
[tool.phosphorus.dev-dependencies]
|
|
36
|
+
dev = [
|
|
37
|
+
"ipdb~=0.13",
|
|
38
|
+
"ipython~=8.18",
|
|
39
|
+
]
|
|
40
|
+
lint = [
|
|
41
|
+
"black~=24.8",
|
|
42
|
+
"mypy~=1.11",
|
|
43
|
+
"ruff~=0.6",
|
|
44
|
+
]
|
|
45
|
+
test = [
|
|
46
|
+
"freezegun~=1.5",
|
|
47
|
+
"pytest~=8.3",
|
|
48
|
+
"pytest-cov~=5.0",
|
|
49
|
+
]
|
|
50
|
+
docs = [
|
|
51
|
+
"mkdocs~=1.6",
|
|
52
|
+
"mkdocs-material~=9.5",
|
|
53
|
+
"mkdocs-material-extensions~=1.3",
|
|
54
|
+
"pygments~=2.17",
|
|
55
|
+
"pymdown-extensions~=10.9",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
[tool.black]
|
|
59
|
+
target-version = [
|
|
60
|
+
"py39",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[tool.mypy]
|
|
64
|
+
check_untyped_defs = true
|
|
65
|
+
disallow_any_generics = true
|
|
66
|
+
disallow_incomplete_defs = true
|
|
67
|
+
disallow_subclassing_any = true
|
|
68
|
+
disallow_untyped_calls = true
|
|
69
|
+
disallow_untyped_decorators = true
|
|
70
|
+
disallow_untyped_defs = true
|
|
71
|
+
extra_checks = true
|
|
72
|
+
ignore_missing_imports = true
|
|
73
|
+
no_implicit_reexport = true
|
|
74
|
+
show_error_codes = true
|
|
75
|
+
strict_equality = true
|
|
76
|
+
warn_return_any = true
|
|
77
|
+
warn_redundant_casts = true
|
|
78
|
+
warn_unused_ignores = true
|
|
79
|
+
warn_unreachable = true
|
|
80
|
+
warn_unused_configs = true
|
|
81
|
+
|
|
82
|
+
[tool.ruff]
|
|
83
|
+
src = [
|
|
84
|
+
"src",
|
|
85
|
+
]
|
|
86
|
+
target-version = "py39"
|
|
87
|
+
|
|
88
|
+
[tool.ruff.lint]
|
|
89
|
+
select = [
|
|
90
|
+
"A",
|
|
91
|
+
"ANN",
|
|
92
|
+
"ARG",
|
|
93
|
+
"ASYNC",
|
|
94
|
+
"B",
|
|
95
|
+
"BLE",
|
|
96
|
+
"C4",
|
|
97
|
+
"COM",
|
|
98
|
+
"DTZ",
|
|
99
|
+
"E",
|
|
100
|
+
"EM",
|
|
101
|
+
"ERA",
|
|
102
|
+
"EXE",
|
|
103
|
+
"F",
|
|
104
|
+
"FA",
|
|
105
|
+
"FBT",
|
|
106
|
+
"FIX",
|
|
107
|
+
"FLY",
|
|
108
|
+
"FURB",
|
|
109
|
+
"G",
|
|
110
|
+
"I",
|
|
111
|
+
"ICN",
|
|
112
|
+
"INP",
|
|
113
|
+
"ISC",
|
|
114
|
+
"LOG",
|
|
115
|
+
"N",
|
|
116
|
+
"PGH",
|
|
117
|
+
"PERF",
|
|
118
|
+
"PIE",
|
|
119
|
+
"PL",
|
|
120
|
+
"PT",
|
|
121
|
+
"PTH",
|
|
122
|
+
"PYI",
|
|
123
|
+
"Q",
|
|
124
|
+
"RET",
|
|
125
|
+
"RSE",
|
|
126
|
+
"RUF",
|
|
127
|
+
"S",
|
|
128
|
+
"SIM",
|
|
129
|
+
"SLF",
|
|
130
|
+
"SLOT",
|
|
131
|
+
"T10",
|
|
132
|
+
"TCH",
|
|
133
|
+
"TD",
|
|
134
|
+
"TID",
|
|
135
|
+
"TRY",
|
|
136
|
+
"UP",
|
|
137
|
+
"W",
|
|
138
|
+
"YTT",
|
|
139
|
+
]
|
|
140
|
+
ignore = [
|
|
141
|
+
"ANN101",
|
|
142
|
+
"ANN102",
|
|
143
|
+
"ANN401",
|
|
144
|
+
"COM812",
|
|
145
|
+
"E501",
|
|
146
|
+
"FIX002",
|
|
147
|
+
"PLR09",
|
|
148
|
+
"TD002",
|
|
149
|
+
"TD003",
|
|
150
|
+
"TRY003",
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
[tool.ruff.lint.per-file-ignores]
|
|
154
|
+
"tests/**" = [
|
|
155
|
+
"FBT001",
|
|
156
|
+
"PLR2004",
|
|
157
|
+
"PT011",
|
|
158
|
+
"S101",
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
[tool.ruff.lint.flake8-tidy-imports]
|
|
162
|
+
ban-relative-imports = "all"
|
|
163
|
+
|
|
164
|
+
[tool.ruff.lint.flake8-tidy-imports.banned-api]
|
|
165
|
+
"mock".msg = "Use unittest.mock"
|
|
166
|
+
"pytz".msg = "Use zoneinfo"
|
|
167
|
+
|
|
168
|
+
[tool.ruff.lint.isort]
|
|
169
|
+
combine-as-imports = true
|
|
170
|
+
forced-separate = [
|
|
171
|
+
"tests",
|
|
172
|
+
]
|
|
173
|
+
split-on-trailing-comma = false
|
|
174
|
+
|
|
175
|
+
[tool.pytest.ini_options]
|
|
176
|
+
addopts = "-ra -v --cov"
|
|
177
|
+
testpaths = "tests"
|
|
178
|
+
|
|
179
|
+
[tool.coverage.run]
|
|
180
|
+
branch = true
|
|
181
|
+
source = [
|
|
182
|
+
"src/",
|
|
183
|
+
]
|
|
184
|
+
data_file = ".cov_cache/coverage.dat"
|
|
185
|
+
|
|
186
|
+
[tool.coverage.report]
|
|
187
|
+
exclude_also = [
|
|
188
|
+
"if TYPE_CHECKING:",
|
|
189
|
+
]
|
|
190
|
+
show_missing = true
|
|
191
|
+
skip_covered = true
|
|
192
|
+
skip_empty = true
|
|
File without changes
|
src/pyutilkit/classes.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Singleton(type):
|
|
7
|
+
instance: type[Singleton] | None
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
cls, name: str, bases: tuple[type[Any], ...], namespace: dict[str, Any]
|
|
11
|
+
) -> None:
|
|
12
|
+
super().__init__(name, bases, namespace)
|
|
13
|
+
cls.instance = None
|
|
14
|
+
|
|
15
|
+
def __call__(cls) -> type[Singleton]:
|
|
16
|
+
if cls.instance is None:
|
|
17
|
+
cls.instance = super().__call__()
|
|
18
|
+
return cls.instance
|
|
File without changes
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
DEPRECATED_TIMEZONES = {
|
|
2
|
+
"Africa/Asmera",
|
|
3
|
+
"Africa/Timbuktu",
|
|
4
|
+
"America/Argentina/ComodRivadavia",
|
|
5
|
+
"America/Atka",
|
|
6
|
+
"America/Buenos_Aires",
|
|
7
|
+
"America/Catamarca",
|
|
8
|
+
"America/Coral_Harbour",
|
|
9
|
+
"America/Cordoba",
|
|
10
|
+
"America/Ensenada",
|
|
11
|
+
"America/Fort_Wayne",
|
|
12
|
+
"America/Godthab",
|
|
13
|
+
"America/Indianapolis",
|
|
14
|
+
"America/Jujuy",
|
|
15
|
+
"America/Knox_IN",
|
|
16
|
+
"America/Louisville",
|
|
17
|
+
"America/Mendoza",
|
|
18
|
+
"America/Montreal",
|
|
19
|
+
"America/Porto_Acre",
|
|
20
|
+
"America/Rosario",
|
|
21
|
+
"America/Santa_Isabel",
|
|
22
|
+
"America/Shiprock",
|
|
23
|
+
"America/Virgin",
|
|
24
|
+
"Antarctica/South_Pole",
|
|
25
|
+
"Asia/Ashkhabad",
|
|
26
|
+
"Asia/Calcutta",
|
|
27
|
+
"Asia/Chongqing",
|
|
28
|
+
"Asia/Chungking",
|
|
29
|
+
"Asia/Dacca",
|
|
30
|
+
"Asia/Harbin",
|
|
31
|
+
"Asia/Istanbul",
|
|
32
|
+
"Asia/Kashgar",
|
|
33
|
+
"Asia/Katmandu",
|
|
34
|
+
"Asia/Macao",
|
|
35
|
+
"Asia/Rangoon",
|
|
36
|
+
"Asia/Saigon",
|
|
37
|
+
"Asia/Tel_Aviv",
|
|
38
|
+
"Asia/Thimbu",
|
|
39
|
+
"Asia/Ujung_Pandang",
|
|
40
|
+
"Asia/Ulan_Bator",
|
|
41
|
+
"Atlantic/Faeroe",
|
|
42
|
+
"Atlantic/Jan_Mayen",
|
|
43
|
+
"Australia/ACT",
|
|
44
|
+
"Australia/Canberra",
|
|
45
|
+
"Australia/Currie",
|
|
46
|
+
"Australia/LHI",
|
|
47
|
+
"Australia/NSW",
|
|
48
|
+
"Australia/North",
|
|
49
|
+
"Australia/Queensland",
|
|
50
|
+
"Australia/South",
|
|
51
|
+
"Australia/Tasmania",
|
|
52
|
+
"Australia/Victoria",
|
|
53
|
+
"Australia/West",
|
|
54
|
+
"Australia/Yancowinna",
|
|
55
|
+
"Brazil/Acre",
|
|
56
|
+
"Brazil/DeNoronha",
|
|
57
|
+
"Brazil/East",
|
|
58
|
+
"Brazil/West",
|
|
59
|
+
"CET",
|
|
60
|
+
"CST6CDT",
|
|
61
|
+
"Canada/Atlantic",
|
|
62
|
+
"Canada/Central",
|
|
63
|
+
"Canada/Eastern",
|
|
64
|
+
"Canada/Mountain",
|
|
65
|
+
"Canada/Newfoundland",
|
|
66
|
+
"Canada/Pacific",
|
|
67
|
+
"Canada/Saskatchewan",
|
|
68
|
+
"Canada/Yukon",
|
|
69
|
+
"Chile/Continental",
|
|
70
|
+
"Chile/EasterIsland",
|
|
71
|
+
"Cuba",
|
|
72
|
+
"EET",
|
|
73
|
+
"EST",
|
|
74
|
+
"EST5EDT",
|
|
75
|
+
"Egypt",
|
|
76
|
+
"Eire",
|
|
77
|
+
"Etc/GMT",
|
|
78
|
+
"Etc/GMT+0",
|
|
79
|
+
"Etc/GMT+1",
|
|
80
|
+
"Etc/GMT+10",
|
|
81
|
+
"Etc/GMT+11",
|
|
82
|
+
"Etc/GMT+12",
|
|
83
|
+
"Etc/GMT+2",
|
|
84
|
+
"Etc/GMT+3",
|
|
85
|
+
"Etc/GMT+4",
|
|
86
|
+
"Etc/GMT+5",
|
|
87
|
+
"Etc/GMT+6",
|
|
88
|
+
"Etc/GMT+7",
|
|
89
|
+
"Etc/GMT+8",
|
|
90
|
+
"Etc/GMT+9",
|
|
91
|
+
"Etc/GMT-0",
|
|
92
|
+
"Etc/GMT-1",
|
|
93
|
+
"Etc/GMT-10",
|
|
94
|
+
"Etc/GMT-11",
|
|
95
|
+
"Etc/GMT-12",
|
|
96
|
+
"Etc/GMT-13",
|
|
97
|
+
"Etc/GMT-14",
|
|
98
|
+
"Etc/GMT-2",
|
|
99
|
+
"Etc/GMT-3",
|
|
100
|
+
"Etc/GMT-4",
|
|
101
|
+
"Etc/GMT-5",
|
|
102
|
+
"Etc/GMT-6",
|
|
103
|
+
"Etc/GMT-7",
|
|
104
|
+
"Etc/GMT-8",
|
|
105
|
+
"Etc/GMT-9",
|
|
106
|
+
"Etc/GMT0",
|
|
107
|
+
"Etc/Greenwich",
|
|
108
|
+
"Etc/UCT",
|
|
109
|
+
"Etc/UTC",
|
|
110
|
+
"Etc/Universal",
|
|
111
|
+
"Etc/Zulu",
|
|
112
|
+
"Europe/Belfast",
|
|
113
|
+
"Europe/Nicosia",
|
|
114
|
+
"Europe/Tiraspol",
|
|
115
|
+
"Factory",
|
|
116
|
+
"GB",
|
|
117
|
+
"GB-Eire",
|
|
118
|
+
"GMT",
|
|
119
|
+
"GMT+0",
|
|
120
|
+
"GMT-0",
|
|
121
|
+
"GMT0",
|
|
122
|
+
"Greenwich",
|
|
123
|
+
"HST",
|
|
124
|
+
"Hongkong",
|
|
125
|
+
"Iceland",
|
|
126
|
+
"Iran",
|
|
127
|
+
"Israel",
|
|
128
|
+
"Jamaica",
|
|
129
|
+
"Japan",
|
|
130
|
+
"Kwajalein",
|
|
131
|
+
"Libya",
|
|
132
|
+
"MET",
|
|
133
|
+
"MST",
|
|
134
|
+
"MST7MDT",
|
|
135
|
+
"Mexico/BajaNorte",
|
|
136
|
+
"Mexico/BajaSur",
|
|
137
|
+
"Mexico/General",
|
|
138
|
+
"NZ",
|
|
139
|
+
"NZ-CHAT",
|
|
140
|
+
"Navajo",
|
|
141
|
+
"PRC",
|
|
142
|
+
"PST8PDT",
|
|
143
|
+
"Pacific/Johnston",
|
|
144
|
+
"Pacific/Ponape",
|
|
145
|
+
"Pacific/Samoa",
|
|
146
|
+
"Pacific/Truk",
|
|
147
|
+
"Pacific/Yap",
|
|
148
|
+
"Poland",
|
|
149
|
+
"Portugal",
|
|
150
|
+
"ROC",
|
|
151
|
+
"ROK",
|
|
152
|
+
"Singapore",
|
|
153
|
+
"Turkey",
|
|
154
|
+
"UCT",
|
|
155
|
+
"US/Alaska",
|
|
156
|
+
"US/Aleutian",
|
|
157
|
+
"US/Arizona",
|
|
158
|
+
"US/Central",
|
|
159
|
+
"US/East-Indiana",
|
|
160
|
+
"US/Eastern",
|
|
161
|
+
"US/Hawaii",
|
|
162
|
+
"US/Indiana-Starke",
|
|
163
|
+
"US/Michigan",
|
|
164
|
+
"US/Mountain",
|
|
165
|
+
"US/Pacific",
|
|
166
|
+
"US/Samoa",
|
|
167
|
+
"Universal",
|
|
168
|
+
"W-SU",
|
|
169
|
+
"WET",
|
|
170
|
+
"Zulu",
|
|
171
|
+
}
|
|
172
|
+
# Timezones that are removed from the IANA db, but still appear in some systems
|
|
173
|
+
NON_IANA_TIMEZONES = {
|
|
174
|
+
"SystemV/AST4",
|
|
175
|
+
"SystemV/AST4ADT",
|
|
176
|
+
"SystemV/CST6",
|
|
177
|
+
"SystemV/CST6CDT",
|
|
178
|
+
"SystemV/EST5",
|
|
179
|
+
"SystemV/EST5EDT",
|
|
180
|
+
"SystemV/HST10",
|
|
181
|
+
"SystemV/MST7",
|
|
182
|
+
"SystemV/MST7MDT",
|
|
183
|
+
"SystemV/PST8",
|
|
184
|
+
"SystemV/PST8PDT",
|
|
185
|
+
"SystemV/YST9",
|
|
186
|
+
"SystemV/YST9YDT",
|
|
187
|
+
"localtime",
|
|
188
|
+
}
|
|
189
|
+
# Timezones that don't appear in all systems yet
|
|
190
|
+
PROPOSED_TIMEZONES = {"Pacific/Kanton"}
|
|
191
|
+
|
|
192
|
+
# All unavailable timezones
|
|
193
|
+
UNAVAILABLE_TIMEZONES = set.union(
|
|
194
|
+
DEPRECATED_TIMEZONES, NON_IANA_TIMEZONES, PROPOSED_TIMEZONES
|
|
195
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, tzinfo
|
|
4
|
+
from zoneinfo import ZoneInfo, available_timezones
|
|
5
|
+
|
|
6
|
+
from pyutilkit.data.timezones import UNAVAILABLE_TIMEZONES
|
|
7
|
+
|
|
8
|
+
UTC = ZoneInfo("UTC")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_timezones() -> set[str]:
|
|
12
|
+
"""
|
|
13
|
+
Get all the available timezones
|
|
14
|
+
|
|
15
|
+
This takes into accounts timezones that might not be present in
|
|
16
|
+
all systems.
|
|
17
|
+
"""
|
|
18
|
+
return available_timezones() - UNAVAILABLE_TIMEZONES
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def now(tz_info: ZoneInfo = UTC) -> datetime:
|
|
22
|
+
return datetime.now(UTC).astimezone(tz_info)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def from_iso(date_string: str, tz_info: tzinfo = UTC) -> datetime:
|
|
26
|
+
"""
|
|
27
|
+
Get datetime from an iso string
|
|
28
|
+
|
|
29
|
+
This to allow the Zulu timezone, which is a valid ISO timezone.
|
|
30
|
+
"""
|
|
31
|
+
date_string = date_string.replace("Z", "+00:00")
|
|
32
|
+
dt = datetime.fromisoformat(date_string)
|
|
33
|
+
try:
|
|
34
|
+
return add_timezone(dt, tz_info)
|
|
35
|
+
except ValueError:
|
|
36
|
+
return convert_timezone(dt, tz_info)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def from_timestamp(timestamp: float, tz_info: tzinfo = UTC) -> datetime:
|
|
40
|
+
"""
|
|
41
|
+
Get a datetime tz-aware time object from a timestamp
|
|
42
|
+
"""
|
|
43
|
+
utc_dt = datetime.fromtimestamp(timestamp, tz=UTC)
|
|
44
|
+
return convert_timezone(utc_dt, tz_info)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def add_timezone(dt: datetime, tz_info: tzinfo = UTC) -> datetime:
|
|
48
|
+
"""
|
|
49
|
+
Add a timezone to a naive datetime
|
|
50
|
+
|
|
51
|
+
Raise an error in case of a tz-aware datetime
|
|
52
|
+
"""
|
|
53
|
+
if dt.tzinfo is not None:
|
|
54
|
+
msg = f"{dt} is already tz-aware"
|
|
55
|
+
raise ValueError(msg)
|
|
56
|
+
return dt.replace(tzinfo=tz_info)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def convert_timezone(dt: datetime, tz_info: tzinfo = UTC) -> datetime:
|
|
60
|
+
"""
|
|
61
|
+
Change the timezone of a tz-aware datetime
|
|
62
|
+
|
|
63
|
+
Raise an error in case of a naive datetime
|
|
64
|
+
"""
|
|
65
|
+
if dt.tzinfo is None:
|
|
66
|
+
msg = f"{dt} is a naive datetime"
|
|
67
|
+
raise ValueError(msg)
|
|
68
|
+
return dt.astimezone(tz_info)
|
src/pyutilkit/files.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from typing_extensions import ParamSpec # py3.9: import from typing
|
|
13
|
+
|
|
14
|
+
P = ParamSpec("P")
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
INGEST_ERROR = "Function `%s` threw `%s` when called with args=%s and kwargs=%s"
|
|
18
|
+
R_co = TypeVar("R_co", covariant=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def handle_exceptions(
|
|
22
|
+
*,
|
|
23
|
+
exceptions: tuple[type[Exception], ...] = (Exception,),
|
|
24
|
+
default: R_co | None = None,
|
|
25
|
+
log_level: str = "info",
|
|
26
|
+
) -> Callable[[Callable[P, R_co]], Callable[P, R_co | None]]:
|
|
27
|
+
def decorator(func: Callable[P, R_co]) -> Callable[P, R_co | None]:
|
|
28
|
+
@wraps(func)
|
|
29
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R_co | None:
|
|
30
|
+
try:
|
|
31
|
+
return func(*args, **kwargs)
|
|
32
|
+
except exceptions as exc:
|
|
33
|
+
getattr(logger, log_level)(
|
|
34
|
+
INGEST_ERROR,
|
|
35
|
+
func.__name__,
|
|
36
|
+
exc.__class__.__name__,
|
|
37
|
+
args,
|
|
38
|
+
kwargs,
|
|
39
|
+
exc_info=True,
|
|
40
|
+
)
|
|
41
|
+
return default
|
|
42
|
+
|
|
43
|
+
return wrapper
|
|
44
|
+
|
|
45
|
+
return decorator
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def hash_file(path: Path, buffer_size: int = 2**16) -> str:
|
|
49
|
+
sha256 = hashlib.sha256()
|
|
50
|
+
|
|
51
|
+
with path.open("rb") as f:
|
|
52
|
+
while data := f.read(buffer_size):
|
|
53
|
+
sha256.update(data)
|
|
54
|
+
|
|
55
|
+
return sha256.hexdigest()
|
src/pyutilkit/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from subprocess import PIPE, Popen
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pyutilkit.timing import Stopwatch, Timing
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class ProcessOutput:
|
|
13
|
+
stdout: bytes
|
|
14
|
+
stderr: bytes
|
|
15
|
+
pid: int
|
|
16
|
+
returncode: int
|
|
17
|
+
elapsed: Timing
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_command(command: str | list[str], **kwargs: Any) -> ProcessOutput:
|
|
21
|
+
if kwargs.setdefault("stdout", PIPE) != PIPE:
|
|
22
|
+
msg = "stdout must be set to PIPE"
|
|
23
|
+
raise ValueError(msg)
|
|
24
|
+
if kwargs.setdefault("stderr", PIPE) != PIPE:
|
|
25
|
+
msg = "stderr must be set to PIPE"
|
|
26
|
+
raise ValueError(msg)
|
|
27
|
+
|
|
28
|
+
stdout = []
|
|
29
|
+
stderr = []
|
|
30
|
+
stopwatch = Stopwatch()
|
|
31
|
+
with stopwatch:
|
|
32
|
+
process = Popen(command, **kwargs) # noqa: S603
|
|
33
|
+
|
|
34
|
+
for line in process.stdout or []:
|
|
35
|
+
sys.stdout.buffer.write(line)
|
|
36
|
+
sys.stdout.flush()
|
|
37
|
+
stdout.append(line)
|
|
38
|
+
|
|
39
|
+
for line in process.stderr or []:
|
|
40
|
+
sys.stderr.buffer.write(line)
|
|
41
|
+
sys.stderr.flush()
|
|
42
|
+
stderr.append(line)
|
|
43
|
+
|
|
44
|
+
with stopwatch:
|
|
45
|
+
process.wait()
|
|
46
|
+
|
|
47
|
+
return ProcessOutput(
|
|
48
|
+
stdout=b"".join(stdout),
|
|
49
|
+
stderr=b"".join(stderr),
|
|
50
|
+
pid=process.pid,
|
|
51
|
+
returncode=process.returncode,
|
|
52
|
+
elapsed=stopwatch.elapsed,
|
|
53
|
+
)
|
src/pyutilkit/term.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from enum import IntEnum, unique
|
|
5
|
+
from math import ceil, floor
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
|
|
11
|
+
from typing_extensions import Self # py3.10: import from typing
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@unique
|
|
15
|
+
class SGRCodes(IntEnum):
|
|
16
|
+
RESET = 0
|
|
17
|
+
|
|
18
|
+
BOLD = 1
|
|
19
|
+
ITALIC = 3
|
|
20
|
+
UNDERLINE = 4
|
|
21
|
+
BLINK = 5
|
|
22
|
+
REVERSE = 7
|
|
23
|
+
CONCEAL = 8
|
|
24
|
+
|
|
25
|
+
BLACK = 30
|
|
26
|
+
RED = 31
|
|
27
|
+
GREEN = 32
|
|
28
|
+
YELLOW = 33
|
|
29
|
+
BLUE = 34
|
|
30
|
+
MAGENTA = 35
|
|
31
|
+
CYAN = 36
|
|
32
|
+
GREY = 37
|
|
33
|
+
|
|
34
|
+
BG_BLACK = 40
|
|
35
|
+
BG_RED = 41
|
|
36
|
+
BG_GREEN = 42
|
|
37
|
+
BG_YELLOW = 43
|
|
38
|
+
BG_BLUE = 44
|
|
39
|
+
BG_MAGENTA = 45
|
|
40
|
+
BG_CYAN = 46
|
|
41
|
+
BG_GREY = 47
|
|
42
|
+
|
|
43
|
+
BLACK_BRIGHT = 90
|
|
44
|
+
RED_BRIGHT = 91
|
|
45
|
+
GREEN_BRIGHT = 92
|
|
46
|
+
YELLOW_BRIGHT = 93
|
|
47
|
+
BLUE_BRIGHT = 94
|
|
48
|
+
MAGENTA_BRIGHT = 95
|
|
49
|
+
CYAN_BRIGHT = 96
|
|
50
|
+
WHITE_BRIGHT = 97
|
|
51
|
+
|
|
52
|
+
BG_BLACK_BRIGHT = 100
|
|
53
|
+
BG_RED_BRIGHT = 101
|
|
54
|
+
BG_GREEN_BRIGHT = 102
|
|
55
|
+
BG_YELLOW_BRIGHT = 103
|
|
56
|
+
BG_BLUE_BRIGHT = 104
|
|
57
|
+
BG_MAGENTA_BRIGHT = 105
|
|
58
|
+
BG_CYAN_BRIGHT = 106
|
|
59
|
+
BG_WHITE_BRIGHT = 107
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def sequence(self) -> str:
|
|
63
|
+
return f"\033[{self.value}m"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SGRString(str):
|
|
67
|
+
_sgr: tuple[SGRCodes, ...]
|
|
68
|
+
_string: str
|
|
69
|
+
__slots__ = ("_sgr", "_string")
|
|
70
|
+
|
|
71
|
+
def __new__(cls, obj: Any, *, params: Iterable[SGRCodes] = ()) -> Self:
|
|
72
|
+
string = super().__new__(cls, obj)
|
|
73
|
+
object.__setattr__(string, "_string", str(obj))
|
|
74
|
+
object.__setattr__(string, "_sgr", tuple(params))
|
|
75
|
+
return string
|
|
76
|
+
|
|
77
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
78
|
+
msg = "SGRString is immutable"
|
|
79
|
+
raise AttributeError(msg)
|
|
80
|
+
|
|
81
|
+
def __delattr__(self, name: str) -> None:
|
|
82
|
+
msg = "SGRString is immutable"
|
|
83
|
+
raise AttributeError(msg)
|
|
84
|
+
|
|
85
|
+
def __str__(self) -> str:
|
|
86
|
+
if not self._sgr or os.getenv("USE_SGR_CODES") in {"0", "false", "no"}:
|
|
87
|
+
return self._string
|
|
88
|
+
prefix = "".join(code.sequence for code in self._sgr)
|
|
89
|
+
return f"{prefix}{self._string}{SGRCodes.RESET.sequence}"
|
|
90
|
+
|
|
91
|
+
def __mul__(self, other: Any) -> Self:
|
|
92
|
+
if not isinstance(other, int):
|
|
93
|
+
return NotImplemented
|
|
94
|
+
return type(self)(self._string * other, params=self._sgr)
|
|
95
|
+
|
|
96
|
+
def __rmul__(self, other: Any) -> Self:
|
|
97
|
+
if not isinstance(other, int):
|
|
98
|
+
return NotImplemented
|
|
99
|
+
return type(self)(self._string * other, params=self._sgr)
|
|
100
|
+
|
|
101
|
+
def header(
|
|
102
|
+
self,
|
|
103
|
+
*,
|
|
104
|
+
padding: str = " ",
|
|
105
|
+
left_spaces: int = 1,
|
|
106
|
+
right_spaces: int = 1,
|
|
107
|
+
space: str = " ",
|
|
108
|
+
) -> None:
|
|
109
|
+
try:
|
|
110
|
+
# in pseudo-terminals it throws OSError
|
|
111
|
+
terminal_size = os.get_terminal_size()
|
|
112
|
+
except OSError:
|
|
113
|
+
columns = 80
|
|
114
|
+
else:
|
|
115
|
+
columns = terminal_size.columns
|
|
116
|
+
text = f"{space * left_spaces}{self}{space * right_spaces}"
|
|
117
|
+
title_length = left_spaces + len(self) + right_spaces
|
|
118
|
+
if title_length >= columns:
|
|
119
|
+
print(text.strip())
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
half = (columns - title_length) / 2
|
|
123
|
+
print(f"{padding * ceil(half)}{text}{padding * floor(half)}")
|
src/pyutilkit/timing.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations # py3.9: remove this line
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from time import perf_counter_ns
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
|
|
10
|
+
from typing_extensions import Self # py3.10: import Self from typing
|
|
11
|
+
|
|
12
|
+
METRIC_MULTIPLIER = 1000
|
|
13
|
+
SECONDS_PER_MINUTE = 60
|
|
14
|
+
MINUTES_PER_HOUR = 60
|
|
15
|
+
HOURS_PER_DAY = 24
|
|
16
|
+
SECONDS_PER_DAY = SECONDS_PER_MINUTE * MINUTES_PER_HOUR * HOURS_PER_DAY
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, order=True)
|
|
20
|
+
class Timing:
|
|
21
|
+
__slots__ = ("nanoseconds",) # py3.9: remove this line
|
|
22
|
+
|
|
23
|
+
nanoseconds: int
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
days: int = 0,
|
|
29
|
+
seconds: int = 0,
|
|
30
|
+
milliseconds: int = 0,
|
|
31
|
+
microseconds: int = 0,
|
|
32
|
+
nanoseconds: int = 0,
|
|
33
|
+
) -> None:
|
|
34
|
+
total_nanoseconds = (
|
|
35
|
+
nanoseconds
|
|
36
|
+
+ METRIC_MULTIPLIER * microseconds
|
|
37
|
+
+ METRIC_MULTIPLIER**2 * milliseconds
|
|
38
|
+
+ METRIC_MULTIPLIER**3 * seconds
|
|
39
|
+
+ SECONDS_PER_DAY * METRIC_MULTIPLIER**3 * days
|
|
40
|
+
)
|
|
41
|
+
object.__setattr__(self, "nanoseconds", total_nanoseconds)
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str:
|
|
44
|
+
if self.nanoseconds < METRIC_MULTIPLIER:
|
|
45
|
+
return f"{self.nanoseconds}ns"
|
|
46
|
+
microseconds = self.nanoseconds / METRIC_MULTIPLIER
|
|
47
|
+
if microseconds < METRIC_MULTIPLIER:
|
|
48
|
+
return f"{microseconds:.1f}µs"
|
|
49
|
+
milliseconds = microseconds / METRIC_MULTIPLIER
|
|
50
|
+
if milliseconds < METRIC_MULTIPLIER:
|
|
51
|
+
return f"{milliseconds:.1f}ms"
|
|
52
|
+
seconds = milliseconds / METRIC_MULTIPLIER
|
|
53
|
+
if seconds < SECONDS_PER_MINUTE:
|
|
54
|
+
return f"{seconds:.2f}s"
|
|
55
|
+
round_seconds = int(seconds)
|
|
56
|
+
minutes, seconds = divmod(round_seconds, SECONDS_PER_MINUTE)
|
|
57
|
+
hours, minutes = divmod(minutes, MINUTES_PER_HOUR)
|
|
58
|
+
if hours < HOURS_PER_DAY:
|
|
59
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
60
|
+
days, hours = divmod(hours, HOURS_PER_DAY)
|
|
61
|
+
return f"{days:,}d {hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
62
|
+
|
|
63
|
+
def __bool__(self) -> bool:
|
|
64
|
+
return bool(self.nanoseconds)
|
|
65
|
+
|
|
66
|
+
def __add__(self, other: Any) -> Timing:
|
|
67
|
+
if not isinstance(other, Timing):
|
|
68
|
+
return NotImplemented
|
|
69
|
+
return Timing(nanoseconds=self.nanoseconds + other.nanoseconds)
|
|
70
|
+
|
|
71
|
+
def __radd__(self, other: Any) -> Timing:
|
|
72
|
+
if not isinstance(other, Timing):
|
|
73
|
+
return NotImplemented
|
|
74
|
+
return Timing(nanoseconds=self.nanoseconds + other.nanoseconds)
|
|
75
|
+
|
|
76
|
+
def __mul__(self, other: Any) -> Timing:
|
|
77
|
+
if not isinstance(other, int):
|
|
78
|
+
return NotImplemented
|
|
79
|
+
return Timing(nanoseconds=self.nanoseconds * other)
|
|
80
|
+
|
|
81
|
+
def __rmul__(self, other: Any) -> Timing:
|
|
82
|
+
if not isinstance(other, int):
|
|
83
|
+
return NotImplemented
|
|
84
|
+
return Timing(nanoseconds=self.nanoseconds * other)
|
|
85
|
+
|
|
86
|
+
def __floordiv__(self, other: Any) -> Timing:
|
|
87
|
+
if not isinstance(other, int):
|
|
88
|
+
return NotImplemented
|
|
89
|
+
return Timing(nanoseconds=self.nanoseconds // other)
|
|
90
|
+
|
|
91
|
+
def __truediv__(self, other: Any) -> Timing:
|
|
92
|
+
if not isinstance(other, int):
|
|
93
|
+
return NotImplemented
|
|
94
|
+
return Timing(nanoseconds=self.nanoseconds // other)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Stopwatch:
|
|
98
|
+
_start: int
|
|
99
|
+
laps: list[Timing]
|
|
100
|
+
|
|
101
|
+
def __init__(self) -> None:
|
|
102
|
+
self._start = 0
|
|
103
|
+
self.laps: list[Timing] = []
|
|
104
|
+
|
|
105
|
+
def __enter__(self) -> Self:
|
|
106
|
+
self._start = perf_counter_ns()
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def __exit__(
|
|
110
|
+
self,
|
|
111
|
+
exc_type: type[BaseException] | None,
|
|
112
|
+
exc_value: BaseException | None,
|
|
113
|
+
traceback: TracebackType | None,
|
|
114
|
+
) -> None:
|
|
115
|
+
_end = perf_counter_ns()
|
|
116
|
+
self.laps.append(Timing(nanoseconds=_end - self._start))
|
|
117
|
+
|
|
118
|
+
def __bool__(self) -> bool:
|
|
119
|
+
return bool(self.elapsed)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def elapsed(self) -> Timing:
|
|
123
|
+
return sum(self.laps, Timing())
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def average(self) -> Timing:
|
|
127
|
+
if not self.laps:
|
|
128
|
+
msg = "No laps recorded"
|
|
129
|
+
raise ZeroDivisionError(msg)
|
|
130
|
+
return self.elapsed // len(self.laps)
|