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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pyutilkit
3
- Version: 0.11.0.post1
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 shell commands with real-time output streaming, automatic timing, and structured results
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
- - [Classes](usage/classes.md) - Singleton pattern implementation
94
- - [Date Utilities](usage/date_utils.md) - Timezone-aware datetime operations
95
- - [File Utilities](usage/files.md) - Exception handling and file hashing
96
- - [Subprocess](usage/subprocess.md) - Enhanced command execution
97
- - [Terminal](usage/term.md) - Terminal formatting and colors
98
- - [Timing](usage/timing.md) - Performance measurement and benchmarking
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 shell commands with real-time output streaming, automatic timing, and structured results
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
- - [Classes](usage/classes.md) - Singleton pattern implementation
75
- - [Date Utilities](usage/date_utils.md) - Timezone-aware datetime operations
76
- - [File Utilities](usage/files.md) - Exception handling and file hashing
77
- - [Subprocess](usage/subprocess.md) - Enhanced command execution
78
- - [Terminal](usage/term.md) - Terminal formatting and colors
79
- - [Timing](usage/timing.md) - Performance measurement and benchmarking
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.11.0.post1"
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.37",
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.19",
49
- "ruff~=0.15",
50
- "ty~=0.0.26",
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.0",
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.0",
60
+ "pytest~=9.1",
60
61
  ]
61
62
  docs = [
62
63
  "mkdocs~=1.6",
63
- "mkdocs-material~=9.6",
64
+ "mkdocs-material~=9.7",
64
65
  "mkdocs-material-extensions~=1.3",
65
- "pygments~=2.19",
66
- "pymdown-extensions~=10.15",
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: str = "info",
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
- getattr(logger, log_level)(
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
- TRUE_VAR = {"0", "false", "no"}
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 or os.getenv("PY_UTIL_FORCE_PREFIX", "").lower() in TRUE_VAR
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 = os.linesep, *, full_color: bool = False) -> None:
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)(self._string, prefix=prefix, suffix=suffix, params=self._sgr).print()
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 = os.linesep) -> None:
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 > 1:
277
- msg = "Only one string is allowed for the header"
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
- SECONDS_PER_DAY = SECONDS_PER_MINUTE * MINUTES_PER_HOUR * HOURS_PER_DAY
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
- object.__setattr__(self, "nanoseconds", total_nanoseconds)
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
- @property
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 ZeroDivisionError(msg)
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
- )