pyutilkit 0.11.0.post1__tar.gz → 0.12.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.
- {pyutilkit-0.11.0.post1 → pyutilkit-0.12.0}/PKG-INFO +9 -8
- {pyutilkit-0.11.0.post1 → pyutilkit-0.12.0}/docs/README.md +8 -7
- {pyutilkit-0.11.0.post1 → pyutilkit-0.12.0}/pyproject.toml +14 -10
- {pyutilkit-0.11.0.post1 → pyutilkit-0.12.0}/src/pyutilkit/files.py +15 -3
- pyutilkit-0.12.0/src/pyutilkit/subprocess.py +103 -0
- {pyutilkit-0.11.0.post1 → pyutilkit-0.12.0}/src/pyutilkit/term.py +19 -8
- {pyutilkit-0.11.0.post1 → pyutilkit-0.12.0}/src/pyutilkit/timing.py +23 -5
- pyutilkit-0.11.0.post1/src/pyutilkit/subprocess.py +0 -55
- {pyutilkit-0.11.0.post1 → pyutilkit-0.12.0}/src/pyutilkit/__init__.py +0 -0
- {pyutilkit-0.11.0.post1 → pyutilkit-0.12.0}/src/pyutilkit/__version__.py +0 -0
- {pyutilkit-0.11.0.post1 → pyutilkit-0.12.0}/src/pyutilkit/classes.py +0 -0
- {pyutilkit-0.11.0.post1 → pyutilkit-0.12.0}/src/pyutilkit/data/__init__.py +0 -0
- {pyutilkit-0.11.0.post1 → pyutilkit-0.12.0}/src/pyutilkit/data/timezones.py +0 -0
- {pyutilkit-0.11.0.post1 → pyutilkit-0.12.0}/src/pyutilkit/date_utils.py +0 -0
- {pyutilkit-0.11.0.post1 → pyutilkit-0.12.0}/src/pyutilkit/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: pyutilkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: python's missing batteries
|
|
5
5
|
Keywords: utils
|
|
6
6
|
Author: Stephanos Kuma
|
|
@@ -59,6 +59,7 @@ from pyutilkit.term import SGRString, SGRCodes
|
|
|
59
59
|
|
|
60
60
|
# Get current time in any timezone
|
|
61
61
|
from zoneinfo import ZoneInfo
|
|
62
|
+
|
|
62
63
|
tokyo_time = now(ZoneInfo("Asia/Tokyo"))
|
|
63
64
|
print(f"Current time in Tokyo: {tokyo_time}")
|
|
64
65
|
|
|
@@ -81,7 +82,7 @@ success.print()
|
|
|
81
82
|
- **🎨 Terminal Formatting**: ANSI color codes with smart TTY detection, automatic style stripping for piped output, and convenient header formatting
|
|
82
83
|
- **🛡️ Error Handling**: Elegant exception handling decorator that logs errors and returns defaults instead of raising exceptions
|
|
83
84
|
- **📁 File Utilities**: Efficient SHA-256 file hashing with buffered reading for large files
|
|
84
|
-
- **🚀 Subprocess Enhancement**: Run
|
|
85
|
+
- **🚀 Subprocess Enhancement**: Run external commands with real-time output streaming, automatic timing, and structured results
|
|
85
86
|
- **🔧 Design Patterns**: Thread-safe Singleton metaclass implementation
|
|
86
87
|
- **✨ Zero Dependencies**: Pure Python using only standard library (except optional tzdata on Windows)
|
|
87
88
|
- **🧪 Fully Tested**: 100% test coverage with comprehensive type annotations
|
|
@@ -90,12 +91,12 @@ success.print()
|
|
|
90
91
|
|
|
91
92
|
- **[Installation Guide](installation.md)** - Setup instructions and requirements
|
|
92
93
|
- **[Usage Guide](usage/index.md)** - Comprehensive examples and tutorials for all modules
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
- [Classes](usage/classes.md) - Singleton pattern implementation
|
|
95
|
+
- [Date Utilities](usage/date_utils.md) - Timezone-aware datetime operations
|
|
96
|
+
- [File Utilities](usage/files.md) - Exception handling and file hashing
|
|
97
|
+
- [Subprocess](usage/subprocess.md) - Enhanced command execution
|
|
98
|
+
- [Terminal](usage/term.md) - Terminal formatting and colors
|
|
99
|
+
- [Timing](usage/timing.md) - Performance measurement and benchmarking
|
|
99
100
|
- **[Changelog](CHANGELOG.md)** - Version history and changes
|
|
100
101
|
- **[Code of Conduct](CODE_OF_CONDUCT.md)** - Community guidelines
|
|
101
102
|
|
|
@@ -40,6 +40,7 @@ from pyutilkit.term import SGRString, SGRCodes
|
|
|
40
40
|
|
|
41
41
|
# Get current time in any timezone
|
|
42
42
|
from zoneinfo import ZoneInfo
|
|
43
|
+
|
|
43
44
|
tokyo_time = now(ZoneInfo("Asia/Tokyo"))
|
|
44
45
|
print(f"Current time in Tokyo: {tokyo_time}")
|
|
45
46
|
|
|
@@ -62,7 +63,7 @@ success.print()
|
|
|
62
63
|
- **🎨 Terminal Formatting**: ANSI color codes with smart TTY detection, automatic style stripping for piped output, and convenient header formatting
|
|
63
64
|
- **🛡️ Error Handling**: Elegant exception handling decorator that logs errors and returns defaults instead of raising exceptions
|
|
64
65
|
- **📁 File Utilities**: Efficient SHA-256 file hashing with buffered reading for large files
|
|
65
|
-
- **🚀 Subprocess Enhancement**: Run
|
|
66
|
+
- **🚀 Subprocess Enhancement**: Run external commands with real-time output streaming, automatic timing, and structured results
|
|
66
67
|
- **🔧 Design Patterns**: Thread-safe Singleton metaclass implementation
|
|
67
68
|
- **✨ Zero Dependencies**: Pure Python using only standard library (except optional tzdata on Windows)
|
|
68
69
|
- **🧪 Fully Tested**: 100% test coverage with comprehensive type annotations
|
|
@@ -71,12 +72,12 @@ success.print()
|
|
|
71
72
|
|
|
72
73
|
- **[Installation Guide](installation.md)** - Setup instructions and requirements
|
|
73
74
|
- **[Usage Guide](usage/index.md)** - Comprehensive examples and tutorials for all modules
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
- [Classes](usage/classes.md) - Singleton pattern implementation
|
|
76
|
+
- [Date Utilities](usage/date_utils.md) - Timezone-aware datetime operations
|
|
77
|
+
- [File Utilities](usage/files.md) - Exception handling and file hashing
|
|
78
|
+
- [Subprocess](usage/subprocess.md) - Enhanced command execution
|
|
79
|
+
- [Terminal](usage/term.md) - Terminal formatting and colors
|
|
80
|
+
- [Timing](usage/timing.md) - Performance measurement and benchmarking
|
|
80
81
|
- **[Changelog](CHANGELOG.md)** - Version history and changes
|
|
81
82
|
- **[Code of Conduct](CODE_OF_CONDUCT.md)** - Community guidelines
|
|
82
83
|
|
|
@@ -6,7 +6,7 @@ build-backend = "uv_build"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "pyutilkit"
|
|
9
|
-
version = "0.
|
|
9
|
+
version = "0.12.0"
|
|
10
10
|
|
|
11
11
|
authors = [
|
|
12
12
|
{ name = "Stephanos Kuma", email = "stephanos@kuma.ai" },
|
|
@@ -38,35 +38,37 @@ documentation = "https://pyutilkit.readthedocs.io/en/stable/"
|
|
|
38
38
|
[dependency-groups]
|
|
39
39
|
dev = [
|
|
40
40
|
"ipdb~=0.13",
|
|
41
|
-
"ipython~=8.
|
|
41
|
+
"ipython~=8.39",
|
|
42
42
|
"ptpython~=3.0",
|
|
43
43
|
{ include-group = "lint" },
|
|
44
44
|
{ include-group = "test" },
|
|
45
45
|
{ include-group = "docs" },
|
|
46
46
|
]
|
|
47
47
|
lint = [
|
|
48
|
-
"mypy~=1
|
|
49
|
-
"ruff~=0.
|
|
50
|
-
"ty~=0.0.
|
|
48
|
+
"mypy~=2.1", # Keep mypy for CI/CD
|
|
49
|
+
"ruff~=0.16",
|
|
50
|
+
"ty~=0.0.63",
|
|
51
|
+
"typing-extensions~=4.16", # upgrade: py3.10: use typing module
|
|
51
52
|
{ include-group = "test_core" },
|
|
52
53
|
]
|
|
53
54
|
test = [
|
|
54
|
-
"pytest-cov~=7.
|
|
55
|
+
"pytest-cov~=7.1",
|
|
55
56
|
{ include-group = "test_core" },
|
|
56
57
|
]
|
|
57
58
|
test_core = [
|
|
58
59
|
"freezegun~=1.5",
|
|
59
|
-
"pytest~=9.
|
|
60
|
+
"pytest~=9.1",
|
|
60
61
|
]
|
|
61
62
|
docs = [
|
|
62
63
|
"mkdocs~=1.6",
|
|
63
|
-
"mkdocs-material~=9.
|
|
64
|
+
"mkdocs-material~=9.7",
|
|
64
65
|
"mkdocs-material-extensions~=1.3",
|
|
65
|
-
"pygments~=2.
|
|
66
|
-
"pymdown-extensions~=
|
|
66
|
+
"pygments~=2.20",
|
|
67
|
+
"pymdown-extensions~=11.0",
|
|
67
68
|
]
|
|
68
69
|
|
|
69
70
|
[tool.mypy]
|
|
71
|
+
# Keep mypy for CI/CD
|
|
70
72
|
check_untyped_defs = true
|
|
71
73
|
disallow_any_decorated = true
|
|
72
74
|
disallow_any_explicit = true
|
|
@@ -91,6 +93,7 @@ warn_unused_ignores = true
|
|
|
91
93
|
warn_unreachable = true
|
|
92
94
|
|
|
93
95
|
[[tool.mypy.overrides]]
|
|
96
|
+
# Keep mypy for CI/CD
|
|
94
97
|
module = "tests.*"
|
|
95
98
|
disallow_any_decorated = false # mock.MagicMock is Any
|
|
96
99
|
|
|
@@ -106,6 +109,7 @@ select = [
|
|
|
106
109
|
]
|
|
107
110
|
ignore = [
|
|
108
111
|
"C901", # Adding a limit to complexity is too arbitrary
|
|
112
|
+
"CPY001", # Use only a project copyright
|
|
109
113
|
"COM812", # Avoid conflicts with the formatter
|
|
110
114
|
"D10", # Not everything needs a docstring
|
|
111
115
|
"D203", # Prefer `no-blank-line-before-class` (D211)
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import logging
|
|
5
5
|
from functools import wraps
|
|
6
|
-
from typing import TYPE_CHECKING, ParamSpec, TypeVar
|
|
6
|
+
from typing import TYPE_CHECKING, Literal, ParamSpec, TypeVar, cast
|
|
7
7
|
|
|
8
8
|
if TYPE_CHECKING:
|
|
9
9
|
from collections.abc import Callable
|
|
@@ -13,21 +13,33 @@ logger = logging.getLogger(__name__)
|
|
|
13
13
|
INGEST_ERROR = "Function `%s` threw `%s` when called with args=%s and kwargs=%s"
|
|
14
14
|
R_co = TypeVar("R_co", covariant=True)
|
|
15
15
|
P = ParamSpec("P")
|
|
16
|
+
LogLevel = Literal["debug", "info", "warning", "error", "critical", "exception"]
|
|
17
|
+
LOG_LEVELS = frozenset({"debug", "info", "warning", "error", "critical", "exception"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _validate_log_level(log_level: object) -> LogLevel:
|
|
21
|
+
if not isinstance(log_level, str) or log_level not in LOG_LEVELS:
|
|
22
|
+
supported = ", ".join(sorted(LOG_LEVELS))
|
|
23
|
+
msg = f"Unsupported log level {log_level!r}; expected one of: {supported}"
|
|
24
|
+
raise ValueError(msg)
|
|
25
|
+
return cast("LogLevel", log_level)
|
|
16
26
|
|
|
17
27
|
|
|
18
28
|
def handle_exceptions(
|
|
19
29
|
*,
|
|
20
30
|
exceptions: tuple[type[Exception], ...] = (Exception,),
|
|
21
31
|
default: R_co | None = None,
|
|
22
|
-
log_level:
|
|
32
|
+
log_level: LogLevel = "info",
|
|
23
33
|
) -> Callable[[Callable[P, R_co]], Callable[P, R_co | None]]:
|
|
34
|
+
log = getattr(logger, _validate_log_level(log_level))
|
|
35
|
+
|
|
24
36
|
def decorator(func: Callable[P, R_co]) -> Callable[P, R_co | None]:
|
|
25
37
|
@wraps(func)
|
|
26
38
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R_co | None:
|
|
27
39
|
try:
|
|
28
40
|
return func(*args, **kwargs)
|
|
29
41
|
except exceptions as exc:
|
|
30
|
-
|
|
42
|
+
log(
|
|
31
43
|
INGEST_ERROR,
|
|
32
44
|
func.__name__, # ty: ignore[unresolved-attribute]
|
|
33
45
|
exc.__class__.__name__,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from queue import SimpleQueue
|
|
6
|
+
from subprocess import PIPE, Popen
|
|
7
|
+
from threading import Thread
|
|
8
|
+
from typing import TYPE_CHECKING, cast
|
|
9
|
+
|
|
10
|
+
from pyutilkit.timing import Stopwatch, Timing
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import BinaryIO
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ProcessOutput:
|
|
19
|
+
stdout: bytes
|
|
20
|
+
stderr: bytes
|
|
21
|
+
pid: int
|
|
22
|
+
returncode: int
|
|
23
|
+
elapsed: Timing
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _write_output(stream: BinaryIO, line: bytes) -> None:
|
|
27
|
+
stream.write(line)
|
|
28
|
+
stream.flush()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _drain_pipe(
|
|
32
|
+
pipe: BinaryIO,
|
|
33
|
+
output: BinaryIO,
|
|
34
|
+
captured: list[bytes],
|
|
35
|
+
errors: SimpleQueue[Exception],
|
|
36
|
+
) -> None:
|
|
37
|
+
echo = True
|
|
38
|
+
for line in pipe:
|
|
39
|
+
captured.append(line)
|
|
40
|
+
if not echo:
|
|
41
|
+
continue
|
|
42
|
+
try:
|
|
43
|
+
_write_output(output, line)
|
|
44
|
+
except Exception as exc: # noqa: BLE001
|
|
45
|
+
errors.put(exc)
|
|
46
|
+
echo = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _validate_command(command: object) -> list[str]:
|
|
50
|
+
if not isinstance(command, list):
|
|
51
|
+
msg = "command must be a list of argument strings"
|
|
52
|
+
raise TypeError(msg)
|
|
53
|
+
return cast("list[str]", command)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_command(
|
|
57
|
+
command: list[str],
|
|
58
|
+
cwd: str | Path | None = None,
|
|
59
|
+
env: dict[str, str] | None = None,
|
|
60
|
+
) -> ProcessOutput:
|
|
61
|
+
command = _validate_command(command)
|
|
62
|
+
|
|
63
|
+
stdout: list[bytes] = []
|
|
64
|
+
stderr: list[bytes] = []
|
|
65
|
+
errors: SimpleQueue[Exception] = SimpleQueue()
|
|
66
|
+
stopwatch = Stopwatch()
|
|
67
|
+
with (
|
|
68
|
+
stopwatch,
|
|
69
|
+
Popen( # noqa: S603
|
|
70
|
+
command, stdout=PIPE, stderr=PIPE, cwd=cwd, env=env
|
|
71
|
+
) as process,
|
|
72
|
+
):
|
|
73
|
+
stdout_pipe = cast("BinaryIO", process.stdout)
|
|
74
|
+
stderr_pipe = cast("BinaryIO", process.stderr)
|
|
75
|
+
readers = (
|
|
76
|
+
Thread(
|
|
77
|
+
target=_drain_pipe,
|
|
78
|
+
args=(stdout_pipe, sys.stdout.buffer, stdout, errors),
|
|
79
|
+
name=f"pyutilkit-stdout-{process.pid}",
|
|
80
|
+
),
|
|
81
|
+
Thread(
|
|
82
|
+
target=_drain_pipe,
|
|
83
|
+
args=(stderr_pipe, sys.stderr.buffer, stderr, errors),
|
|
84
|
+
name=f"pyutilkit-stderr-{process.pid}",
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
for reader in readers:
|
|
88
|
+
reader.start()
|
|
89
|
+
|
|
90
|
+
process.wait()
|
|
91
|
+
for reader in readers:
|
|
92
|
+
reader.join()
|
|
93
|
+
|
|
94
|
+
if not errors.empty():
|
|
95
|
+
raise errors.get()
|
|
96
|
+
|
|
97
|
+
return ProcessOutput(
|
|
98
|
+
stdout=b"".join(stdout),
|
|
99
|
+
stderr=b"".join(stderr),
|
|
100
|
+
pid=process.pid,
|
|
101
|
+
returncode=process.returncode,
|
|
102
|
+
elapsed=stopwatch.elapsed,
|
|
103
|
+
)
|
|
@@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
|
|
12
12
|
|
|
13
13
|
from typing_extensions import Self # upgrade: py3.10: import from typing
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
TRUTHY_VALUES = {"1", "true", "yes"}
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
@unique
|
|
@@ -90,9 +90,12 @@ class SGRString:
|
|
|
90
90
|
) -> None:
|
|
91
91
|
params = tuple(params)
|
|
92
92
|
force_prefix = (
|
|
93
|
-
force_prefix
|
|
93
|
+
force_prefix
|
|
94
|
+
or os.getenv("PY_UTIL_FORCE_PREFIX", "").lower() in TRUTHY_VALUES
|
|
95
|
+
)
|
|
96
|
+
force_sgr = (
|
|
97
|
+
force_sgr or os.getenv("PY_UTIL_FORCE_SGR", "").lower() in TRUTHY_VALUES
|
|
94
98
|
)
|
|
95
|
-
force_sgr = force_sgr or os.getenv("PY_UTIL_FORCE_SGR", "").lower() in TRUE_VAR
|
|
96
99
|
|
|
97
100
|
object.__setattr__(self, "_prefix", prefix)
|
|
98
101
|
object.__setattr__(self, "_string", str(obj))
|
|
@@ -136,7 +139,7 @@ class SGRString:
|
|
|
136
139
|
is_error=self._is_error,
|
|
137
140
|
)
|
|
138
141
|
|
|
139
|
-
def print(self, end: str =
|
|
142
|
+
def print(self, end: str = "\n", *, full_color: bool = False) -> None:
|
|
140
143
|
"""Print the command output.
|
|
141
144
|
|
|
142
145
|
The command will be printed to stdout if it's not the output of an error,
|
|
@@ -202,7 +205,15 @@ class SGRString:
|
|
|
202
205
|
half = (columns - title_length) / 2
|
|
203
206
|
prefix = f"{padding * ceil(half)}{space * left_spaces}{self._prefix}"
|
|
204
207
|
suffix = f"{self._suffix}{space * right_spaces}{padding * floor(half)}"
|
|
205
|
-
type(self)(
|
|
208
|
+
type(self)(
|
|
209
|
+
self._string,
|
|
210
|
+
prefix=prefix,
|
|
211
|
+
suffix=suffix,
|
|
212
|
+
params=self._sgr,
|
|
213
|
+
force_prefix=self._force_prefix,
|
|
214
|
+
force_sgr=self._force_sgr,
|
|
215
|
+
is_error=self._is_error,
|
|
216
|
+
).print()
|
|
206
217
|
|
|
207
218
|
|
|
208
219
|
@dataclass(frozen=True, order=True, slots=True)
|
|
@@ -258,7 +269,7 @@ class SGROutput:
|
|
|
258
269
|
is_error=is_error,
|
|
259
270
|
)
|
|
260
271
|
|
|
261
|
-
def print(self, sep: str = "", end: str =
|
|
272
|
+
def print(self, sep: str = "", end: str = "\n") -> None:
|
|
262
273
|
n = len(self._strings)
|
|
263
274
|
for index, string in enumerate(self._strings, start=1):
|
|
264
275
|
current_end = end if index == n else sep
|
|
@@ -273,8 +284,8 @@ class SGROutput:
|
|
|
273
284
|
space: str = " ",
|
|
274
285
|
) -> None:
|
|
275
286
|
n = len(self._strings)
|
|
276
|
-
if n
|
|
277
|
-
msg = "
|
|
287
|
+
if n != 1:
|
|
288
|
+
msg = "Exactly one string is required for the header"
|
|
278
289
|
raise ValueError(msg)
|
|
279
290
|
|
|
280
291
|
self._strings[0].header(
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import warnings
|
|
3
4
|
from dataclasses import dataclass
|
|
4
5
|
from time import perf_counter_ns
|
|
5
6
|
from typing import TYPE_CHECKING
|
|
@@ -14,7 +15,8 @@ METRIC_MULTIPLIER = 1_000
|
|
|
14
15
|
SECONDS_PER_MINUTE = 60
|
|
15
16
|
MINUTES_PER_HOUR = 60
|
|
16
17
|
HOURS_PER_DAY = 24
|
|
17
|
-
|
|
18
|
+
SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR
|
|
19
|
+
SECONDS_PER_DAY = SECONDS_PER_HOUR * HOURS_PER_DAY
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
@dataclass(frozen=True, order=True, slots=True)
|
|
@@ -25,6 +27,8 @@ class Timing:
|
|
|
25
27
|
self,
|
|
26
28
|
*,
|
|
27
29
|
days: int = 0,
|
|
30
|
+
hours: int = 0,
|
|
31
|
+
minutes: int = 0,
|
|
28
32
|
seconds: int = 0,
|
|
29
33
|
milliseconds: int = 0,
|
|
30
34
|
microseconds: int = 0,
|
|
@@ -35,9 +39,18 @@ class Timing:
|
|
|
35
39
|
+ METRIC_MULTIPLIER * microseconds
|
|
36
40
|
+ METRIC_MULTIPLIER**2 * milliseconds
|
|
37
41
|
+ METRIC_MULTIPLIER**3 * seconds
|
|
42
|
+
+ SECONDS_PER_MINUTE * METRIC_MULTIPLIER**3 * minutes
|
|
43
|
+
+ SECONDS_PER_HOUR * METRIC_MULTIPLIER**3 * hours
|
|
38
44
|
+ SECONDS_PER_DAY * METRIC_MULTIPLIER**3 * days
|
|
39
45
|
)
|
|
40
|
-
|
|
46
|
+
rounded_nanoseconds = round(total_nanoseconds)
|
|
47
|
+
if rounded_nanoseconds != total_nanoseconds:
|
|
48
|
+
warnings.warn(
|
|
49
|
+
"Timing received a fractional duration; "
|
|
50
|
+
"rounding to the nearest nanosecond.",
|
|
51
|
+
stacklevel=2,
|
|
52
|
+
)
|
|
53
|
+
object.__setattr__(self, "nanoseconds", rounded_nanoseconds)
|
|
41
54
|
|
|
42
55
|
def __str__(self) -> str:
|
|
43
56
|
if self.nanoseconds == 0:
|
|
@@ -149,17 +162,22 @@ class Stopwatch:
|
|
|
149
162
|
def elapsed(self) -> Timing:
|
|
150
163
|
return sum(self.laps, self._zero)
|
|
151
164
|
|
|
152
|
-
|
|
153
|
-
def average(self) -> Timing:
|
|
165
|
+
def _require_laps(self) -> None:
|
|
154
166
|
if not self.laps:
|
|
155
167
|
msg = "No laps recorded"
|
|
156
|
-
raise
|
|
168
|
+
raise ValueError(msg)
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def average(self) -> Timing:
|
|
172
|
+
self._require_laps()
|
|
157
173
|
return self.elapsed // len(self)
|
|
158
174
|
|
|
159
175
|
@property
|
|
160
176
|
def min(self) -> Timing:
|
|
177
|
+
self._require_laps()
|
|
161
178
|
return min(self.laps)
|
|
162
179
|
|
|
163
180
|
@property
|
|
164
181
|
def max(self) -> Timing:
|
|
182
|
+
self._require_laps()
|
|
165
183
|
return max(self.laps)
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import sys
|
|
4
|
-
from dataclasses import dataclass
|
|
5
|
-
from subprocess import PIPE, Popen
|
|
6
|
-
from typing import TYPE_CHECKING
|
|
7
|
-
|
|
8
|
-
from pyutilkit.timing import Stopwatch, Timing
|
|
9
|
-
|
|
10
|
-
if TYPE_CHECKING:
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@dataclass(frozen=True)
|
|
15
|
-
class ProcessOutput:
|
|
16
|
-
stdout: bytes
|
|
17
|
-
stderr: bytes
|
|
18
|
-
pid: int
|
|
19
|
-
returncode: int
|
|
20
|
-
elapsed: Timing
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def run_command(
|
|
24
|
-
command: str | list[str],
|
|
25
|
-
cwd: str | Path | None = None,
|
|
26
|
-
env: dict[str, str] | None = None,
|
|
27
|
-
) -> ProcessOutput:
|
|
28
|
-
stdout = []
|
|
29
|
-
stderr = []
|
|
30
|
-
stopwatch = Stopwatch()
|
|
31
|
-
with stopwatch:
|
|
32
|
-
process = Popen( # noqa: S603
|
|
33
|
-
command, stdout=PIPE, stderr=PIPE, cwd=cwd, env=env
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
for line in process.stdout or []:
|
|
37
|
-
sys.stdout.buffer.write(line)
|
|
38
|
-
sys.stdout.flush()
|
|
39
|
-
stdout.append(line)
|
|
40
|
-
|
|
41
|
-
for line in process.stderr or []:
|
|
42
|
-
sys.stderr.buffer.write(line)
|
|
43
|
-
sys.stderr.flush()
|
|
44
|
-
stderr.append(line)
|
|
45
|
-
|
|
46
|
-
with stopwatch:
|
|
47
|
-
process.wait()
|
|
48
|
-
|
|
49
|
-
return ProcessOutput(
|
|
50
|
-
stdout=b"".join(stdout),
|
|
51
|
-
stderr=b"".join(stderr),
|
|
52
|
-
pid=process.pid,
|
|
53
|
-
returncode=process.returncode,
|
|
54
|
-
elapsed=stopwatch.elapsed,
|
|
55
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|