pyutilkit 0.11.0__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.
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.3
2
+ Name: pyutilkit
3
+ Version: 0.12.0
4
+ Summary: python's missing batteries
5
+ Keywords: utils
6
+ Author: Stephanos Kuma
7
+ Author-email: Stephanos Kuma <stephanos@kuma.ai>
8
+ License: BSD-3-Clause
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Intended Audience :: Developers
13
+ Requires-Dist: tzdata ; os_name == 'nt'
14
+ Requires-Python: >=3.10
15
+ Project-URL: homepage, https://pyutilkit.readthedocs.io/en/stable/
16
+ Project-URL: repository, https://github.com/spapanik/pyutilkit
17
+ Project-URL: documentation, https://pyutilkit.readthedocs.io/en/stable/
18
+ Description-Content-Type: text/markdown
19
+
20
+ # pyutilkit: python's missing batteries
21
+
22
+ [![build][build_badge]][build_url]
23
+ [![lint][lint_badge]][lint_url]
24
+ [![tests][tests_badge]][tests_url]
25
+ [![license][licence_badge]][licence_url]
26
+ [![codecov][codecov_badge]][codecov_url]
27
+ [![readthedocs][readthedocs_badge]][readthedocs_url]
28
+ [![pypi][pypi_badge]][pypi_url]
29
+ [![downloads][pepy_badge]][pepy_url]
30
+ [![build automation: yam][yam_badge]][yam_url]
31
+ [![Lint: ruff][ruff_badge]][ruff_url]
32
+
33
+ Python has long maintained the philosophy of "batteries included", providing users with a rich
34
+ standard library that avoids the need for third-party tools for most tasks. Some packages are so
35
+ common they have achieved similar status to the standard library. Yet, certain utilities are
36
+ reimplemented across countless projects. This lightweight library, with minimal dependencies,
37
+ aims to eliminate that repetition.
38
+
39
+ ## Quick Start
40
+
41
+ Install pyutilkit:
42
+
43
+ ```bash
44
+ pip install pyutilkit
45
+ ```
46
+
47
+ Or with uv (recommended for speed):
48
+
49
+ ```bash
50
+ uv pip install pyutilkit
51
+ ```
52
+
53
+ Basic usage example:
54
+
55
+ ```python
56
+ from pyutilkit.date_utils import now
57
+ from pyutilkit.timing import Stopwatch
58
+ from pyutilkit.term import SGRString, SGRCodes
59
+
60
+ # Get current time in any timezone
61
+ from zoneinfo import ZoneInfo
62
+
63
+ tokyo_time = now(ZoneInfo("Asia/Tokyo"))
64
+ print(f"Current time in Tokyo: {tokyo_time}")
65
+
66
+ # Measure execution time
67
+ stopwatch = Stopwatch()
68
+ with stopwatch:
69
+ # Your code here
70
+ result = sum(range(1000000))
71
+ print(f"Computation took: {stopwatch.elapsed}")
72
+
73
+ # Colorful terminal output
74
+ success = SGRString("✓ Task completed", params=[SGRCodes.GREEN, SGRCodes.BOLD])
75
+ success.print()
76
+ ```
77
+
78
+ ## Key Features
79
+
80
+ - **🕐 Timezone Utilities**: Robust datetime handling with cross-platform timezone support, ISO parsing (including Zulu timezone), and seamless timezone conversion
81
+ - **⏱️ High-Precision Timing**: Nanosecond-precision timing with human-readable formatting, lap tracking, and stopwatch functionality
82
+ - **🎨 Terminal Formatting**: ANSI color codes with smart TTY detection, automatic style stripping for piped output, and convenient header formatting
83
+ - **🛡️ Error Handling**: Elegant exception handling decorator that logs errors and returns defaults instead of raising exceptions
84
+ - **📁 File Utilities**: Efficient SHA-256 file hashing with buffered reading for large files
85
+ - **🚀 Subprocess Enhancement**: Run external commands with real-time output streaming, automatic timing, and structured results
86
+ - **🔧 Design Patterns**: Thread-safe Singleton metaclass implementation
87
+ - **✨ Zero Dependencies**: Pure Python using only standard library (except optional tzdata on Windows)
88
+ - **🧪 Fully Tested**: 100% test coverage with comprehensive type annotations
89
+
90
+ ## Documentation
91
+
92
+ - **[Installation Guide](installation.md)** - Setup instructions and requirements
93
+ - **[Usage Guide](usage/index.md)** - Comprehensive examples and tutorials for all modules
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
100
+ - **[Changelog](CHANGELOG.md)** - Version history and changes
101
+ - **[Code of Conduct](CODE_OF_CONDUCT.md)** - Community guidelines
102
+
103
+ ## Requirements
104
+
105
+ - Python 3.10 or higher
106
+ - No external dependencies (tzdata is automatically installed on Windows)
107
+
108
+ ## Links
109
+
110
+ - [Full Documentation](https://pyutilkit.readthedocs.io/en/stable/)
111
+ - [PyPI Package](https://pypi.org/project/pyutilkit)
112
+ - [GitHub Repository](https://github.com/spapanik/pyutilkit)
113
+ - [Changelog](CHANGELOG.md)
114
+ - [Report Issues](https://github.com/spapanik/pyutilkit/issues)
115
+
116
+ [build_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/build.yml/badge.svg
117
+ [build_url]: https://github.com/spapanik/pyutilkit/actions/workflows/build.yml
118
+ [lint_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/lint.yml/badge.svg
119
+ [lint_url]: https://github.com/spapanik/pyutilkit/actions/workflows/lint.yml
120
+ [tests_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml/badge.svg
121
+ [tests_url]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml
122
+ [licence_badge]: https://img.shields.io/pypi/l/pyutilkit
123
+ [licence_url]: https://pyutilkit.readthedocs.io/en/stable/LICENSE/
124
+ [codecov_badge]: https://codecov.io/github/spapanik/pyutilkit/graph/badge.svg?token=Q20F84BW72
125
+ [codecov_url]: https://codecov.io/github/spapanik/pyutilkit
126
+ [readthedocs_badge]: https://readthedocs.org/projects/pyutilkit/badge/?version=latest
127
+ [readthedocs_url]: https://pyutilkit.readthedocs.io/en/latest/
128
+ [pypi_badge]: https://img.shields.io/pypi/v/pyutilkit
129
+ [pypi_url]: https://pypi.org/project/pyutilkit
130
+ [pepy_badge]: https://pepy.tech/badge/pyutilkit
131
+ [pepy_url]: https://pepy.tech/project/pyutilkit
132
+ [yam_badge]: https://img.shields.io/badge/build%20automation-yamk-success
133
+ [yam_url]: https://github.com/spapanik/yamk
134
+ [ruff_badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json
135
+ [ruff_url]: https://github.com/charliermarsh/ruff
136
+ [Documentation]: https://pyutilkit.readthedocs.io/en/stable/
137
+ [Changelog]: https://pyutilkit.readthedocs.io/en/stable/CHANGELOG/
@@ -0,0 +1,118 @@
1
+ # pyutilkit: python's missing batteries
2
+
3
+ [![build][build_badge]][build_url]
4
+ [![lint][lint_badge]][lint_url]
5
+ [![tests][tests_badge]][tests_url]
6
+ [![license][licence_badge]][licence_url]
7
+ [![codecov][codecov_badge]][codecov_url]
8
+ [![readthedocs][readthedocs_badge]][readthedocs_url]
9
+ [![pypi][pypi_badge]][pypi_url]
10
+ [![downloads][pepy_badge]][pepy_url]
11
+ [![build automation: yam][yam_badge]][yam_url]
12
+ [![Lint: ruff][ruff_badge]][ruff_url]
13
+
14
+ Python has long maintained the philosophy of "batteries included", providing users with a rich
15
+ standard library that avoids the need for third-party tools for most tasks. Some packages are so
16
+ common they have achieved similar status to the standard library. Yet, certain utilities are
17
+ reimplemented across countless projects. This lightweight library, with minimal dependencies,
18
+ aims to eliminate that repetition.
19
+
20
+ ## Quick Start
21
+
22
+ Install pyutilkit:
23
+
24
+ ```bash
25
+ pip install pyutilkit
26
+ ```
27
+
28
+ Or with uv (recommended for speed):
29
+
30
+ ```bash
31
+ uv pip install pyutilkit
32
+ ```
33
+
34
+ Basic usage example:
35
+
36
+ ```python
37
+ from pyutilkit.date_utils import now
38
+ from pyutilkit.timing import Stopwatch
39
+ from pyutilkit.term import SGRString, SGRCodes
40
+
41
+ # Get current time in any timezone
42
+ from zoneinfo import ZoneInfo
43
+
44
+ tokyo_time = now(ZoneInfo("Asia/Tokyo"))
45
+ print(f"Current time in Tokyo: {tokyo_time}")
46
+
47
+ # Measure execution time
48
+ stopwatch = Stopwatch()
49
+ with stopwatch:
50
+ # Your code here
51
+ result = sum(range(1000000))
52
+ print(f"Computation took: {stopwatch.elapsed}")
53
+
54
+ # Colorful terminal output
55
+ success = SGRString("✓ Task completed", params=[SGRCodes.GREEN, SGRCodes.BOLD])
56
+ success.print()
57
+ ```
58
+
59
+ ## Key Features
60
+
61
+ - **🕐 Timezone Utilities**: Robust datetime handling with cross-platform timezone support, ISO parsing (including Zulu timezone), and seamless timezone conversion
62
+ - **⏱️ High-Precision Timing**: Nanosecond-precision timing with human-readable formatting, lap tracking, and stopwatch functionality
63
+ - **🎨 Terminal Formatting**: ANSI color codes with smart TTY detection, automatic style stripping for piped output, and convenient header formatting
64
+ - **🛡️ Error Handling**: Elegant exception handling decorator that logs errors and returns defaults instead of raising exceptions
65
+ - **📁 File Utilities**: Efficient SHA-256 file hashing with buffered reading for large files
66
+ - **🚀 Subprocess Enhancement**: Run external commands with real-time output streaming, automatic timing, and structured results
67
+ - **🔧 Design Patterns**: Thread-safe Singleton metaclass implementation
68
+ - **✨ Zero Dependencies**: Pure Python using only standard library (except optional tzdata on Windows)
69
+ - **🧪 Fully Tested**: 100% test coverage with comprehensive type annotations
70
+
71
+ ## Documentation
72
+
73
+ - **[Installation Guide](installation.md)** - Setup instructions and requirements
74
+ - **[Usage Guide](usage/index.md)** - Comprehensive examples and tutorials for all modules
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
81
+ - **[Changelog](CHANGELOG.md)** - Version history and changes
82
+ - **[Code of Conduct](CODE_OF_CONDUCT.md)** - Community guidelines
83
+
84
+ ## Requirements
85
+
86
+ - Python 3.10 or higher
87
+ - No external dependencies (tzdata is automatically installed on Windows)
88
+
89
+ ## Links
90
+
91
+ - [Full Documentation](https://pyutilkit.readthedocs.io/en/stable/)
92
+ - [PyPI Package](https://pypi.org/project/pyutilkit)
93
+ - [GitHub Repository](https://github.com/spapanik/pyutilkit)
94
+ - [Changelog](CHANGELOG.md)
95
+ - [Report Issues](https://github.com/spapanik/pyutilkit/issues)
96
+
97
+ [build_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/build.yml/badge.svg
98
+ [build_url]: https://github.com/spapanik/pyutilkit/actions/workflows/build.yml
99
+ [lint_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/lint.yml/badge.svg
100
+ [lint_url]: https://github.com/spapanik/pyutilkit/actions/workflows/lint.yml
101
+ [tests_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml/badge.svg
102
+ [tests_url]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml
103
+ [licence_badge]: https://img.shields.io/pypi/l/pyutilkit
104
+ [licence_url]: https://pyutilkit.readthedocs.io/en/stable/LICENSE/
105
+ [codecov_badge]: https://codecov.io/github/spapanik/pyutilkit/graph/badge.svg?token=Q20F84BW72
106
+ [codecov_url]: https://codecov.io/github/spapanik/pyutilkit
107
+ [readthedocs_badge]: https://readthedocs.org/projects/pyutilkit/badge/?version=latest
108
+ [readthedocs_url]: https://pyutilkit.readthedocs.io/en/latest/
109
+ [pypi_badge]: https://img.shields.io/pypi/v/pyutilkit
110
+ [pypi_url]: https://pypi.org/project/pyutilkit
111
+ [pepy_badge]: https://pepy.tech/badge/pyutilkit
112
+ [pepy_url]: https://pepy.tech/project/pyutilkit
113
+ [yam_badge]: https://img.shields.io/badge/build%20automation-yamk-success
114
+ [yam_url]: https://github.com/spapanik/yamk
115
+ [ruff_badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json
116
+ [ruff_url]: https://github.com/charliermarsh/ruff
117
+ [Documentation]: https://pyutilkit.readthedocs.io/en/stable/
118
+ [Changelog]: https://pyutilkit.readthedocs.io/en/stable/CHANGELOG/
@@ -1,14 +1,12 @@
1
1
  [build-system]
2
2
  requires = [
3
- "phosphorus>=0.10",
3
+ "uv_build>=0.11.0,<0.12.0",
4
4
  ]
5
- build-backend = "phosphorus.construction.api"
5
+ build-backend = "uv_build"
6
6
 
7
7
  [project]
8
8
  name = "pyutilkit"
9
- dynamic = [
10
- "version",
11
- ]
9
+ version = "0.12.0"
12
10
 
13
11
  authors = [
14
12
  { name = "Stephanos Kuma", email = "stephanos@kuma.ai" },
@@ -24,7 +22,7 @@ classifiers = [
24
22
  "Development Status :: 4 - Beta",
25
23
  "Operating System :: OS Independent",
26
24
  "Programming Language :: Python :: 3 :: Only",
27
- "License :: OSI Approved :: BSD License",
25
+ "Intended Audience :: Developers",
28
26
  ]
29
27
 
30
28
  requires-python = ">=3.10"
@@ -40,37 +38,37 @@ documentation = "https://pyutilkit.readthedocs.io/en/stable/"
40
38
  [dependency-groups]
41
39
  dev = [
42
40
  "ipdb~=0.13",
43
- "ipython~=8.37",
41
+ "ipython~=8.39",
44
42
  "ptpython~=3.0",
45
43
  { include-group = "lint" },
46
44
  { include-group = "test" },
47
45
  { include-group = "docs" },
48
46
  ]
49
47
  lint = [
50
- "mypy~=1.18",
51
- "ruff~=0.13",
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
52
52
  { include-group = "test_core" },
53
53
  ]
54
54
  test = [
55
- "pytest-cov~=7.0",
55
+ "pytest-cov~=7.1",
56
56
  { include-group = "test_core" },
57
57
  ]
58
58
  test_core = [
59
59
  "freezegun~=1.5",
60
- "pytest~=9.0",
60
+ "pytest~=9.1",
61
61
  ]
62
62
  docs = [
63
63
  "mkdocs~=1.6",
64
- "mkdocs-material~=9.6",
64
+ "mkdocs-material~=9.7",
65
65
  "mkdocs-material-extensions~=1.3",
66
- "pygments~=2.19",
67
- "pymdown-extensions~=10.15",
66
+ "pygments~=2.20",
67
+ "pymdown-extensions~=11.0",
68
68
  ]
69
69
 
70
- [tool.phosphorus.dynamic]
71
- version = { file = "src/pyutilkit/__version__.py" }
72
-
73
70
  [tool.mypy]
71
+ # Keep mypy for CI/CD
74
72
  check_untyped_defs = true
75
73
  disallow_any_decorated = true
76
74
  disallow_any_explicit = true
@@ -95,6 +93,7 @@ warn_unused_ignores = true
95
93
  warn_unreachable = true
96
94
 
97
95
  [[tool.mypy.overrides]]
96
+ # Keep mypy for CI/CD
98
97
  module = "tests.*"
99
98
  disallow_any_decorated = false # mock.MagicMock is Any
100
99
 
@@ -110,6 +109,7 @@ select = [
110
109
  ]
111
110
  ignore = [
112
111
  "C901", # Adding a limit to complexity is too arbitrary
112
+ "CPY001", # Use only a project copyright
113
113
  "COM812", # Avoid conflicts with the formatter
114
114
  "D10", # Not everything needs a docstring
115
115
  "D203", # Prefer `no-blank-line-before-class` (D211)
@@ -122,6 +122,7 @@ ignore = [
122
122
  "FBT001", # Test arguments are handled by pytest
123
123
  "PLR2004", # Tests should contain magic number comparisons
124
124
  "S101", # Pytest needs assert statements
125
+ "SLF001", # Tests need to access private members for coverage
125
126
  ]
126
127
 
127
128
  [tool.ruff.lint.flake8-tidy-imports]
@@ -138,13 +139,15 @@ forced-separate = [
138
139
  ]
139
140
  split-on-trailing-comma = false
140
141
 
141
- [tool.pytest.ini_options]
142
+ [tool.pytest]
142
143
  minversion = "9.0"
143
144
  strict = true
144
- addopts = ["-ra", "-v"]
145
+ addopts = [
146
+ "-ra",
147
+ "-v",
148
+ ]
145
149
  testpaths = [
146
150
  "tests",
147
- "integration",
148
151
  ]
149
152
 
150
153
  [tool.coverage.run]
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("pyutilkit")
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from typing import TypeVar, cast
5
+
6
+ _T = TypeVar("_T")
7
+
8
+
9
+ class Singleton(type):
10
+ instance: object
11
+ _lock: threading.Lock
12
+
13
+ def __init__(
14
+ cls, name: str, bases: tuple[type[object], ...], namespace: dict[str, object]
15
+ ) -> None:
16
+ super().__init__(name, bases, namespace)
17
+ cls.instance = None
18
+ cls._lock = threading.Lock()
19
+
20
+ def __call__(cls: type[_T]) -> _T:
21
+ mcs = cast("Singleton", cls)
22
+ if mcs.instance is None:
23
+ with mcs._lock:
24
+ if mcs.instance is None:
25
+ mcs.instance = super(Singleton, mcs).__call__()
26
+ return cast("_T", mcs.instance)
@@ -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,23 +13,35 @@ 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
- func.__name__,
44
+ func.__name__, # ty: ignore[unresolved-attribute]
33
45
  exc.__class__.__name__,
34
46
  args,
35
47
  kwargs,
@@ -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,11 +0,0 @@
1
- # BSD 3-Clause License
2
-
3
- Copyright &copy; 2025 Stephanos Kuma.
4
-
5
- Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
6
-
7
- 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
8
- 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
9
- 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
10
-
11
- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
pyutilkit-0.11.0/PKG-INFO DELETED
@@ -1,65 +0,0 @@
1
- Metadata-Version: 2.3
2
- Name: pyutilkit
3
- Version: 0.11.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.10
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
- Requires-Dist: tzdata ; os_name == 'nt'
16
- Project-URL: Documentation, https://pyutilkit.readthedocs.io/en/stable/
17
- Project-URL: Repository, https://github.com/spapanik/pyutilkit
18
- Description-Content-Type: text/markdown
19
-
20
- # pyutilkit: python's missing batteries
21
-
22
- [![build][build_badge]][build_url]
23
- [![lint][lint_badge]][lint_url]
24
- [![tests][tests_badge]][tests_url]
25
- [![license][licence_badge]][licence_url]
26
- [![codecov][codecov_badge]][codecov_url]
27
- [![readthedocs][readthedocs_badge]][readthedocs_url]
28
- [![pypi][pypi_badge]][pypi_url]
29
- [![downloads][pepy_badge]][pepy_url]
30
- [![build automation: yam][yam_badge]][yam_url]
31
- [![Lint: ruff][ruff_badge]][ruff_url]
32
-
33
- The Python has long maintained the philosophy of "batteries included", giving the user
34
- a rich standard library, avoiding the need for third party tools for most work. Some packages
35
- are so common, that the have a similar status to the standard library. Still, some code seems
36
- to be written time and again, with every project. This small library, with minimal requirements,
37
- hopes to stop this repetition.
38
-
39
- ## Links
40
-
41
- - [Documentation]
42
- - [Changelog]
43
-
44
- [build_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/build.yml/badge.svg
45
- [build_url]: https://github.com/spapanik/pyutilkit/actions/workflows/build.yml
46
- [lint_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/lint.yml/badge.svg
47
- [lint_url]: https://github.com/spapanik/pyutilkit/actions/workflows/lint.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
50
- [licence_badge]: https://img.shields.io/pypi/l/pyutilkit
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/
56
- [pypi_badge]: https://img.shields.io/pypi/v/pyutilkit
57
- [pypi_url]: https://pypi.org/project/pyutilkit
58
- [pepy_badge]: https://pepy.tech/badge/pyutilkit
59
- [pepy_url]: https://pepy.tech/project/pyutilkit
60
- [yam_badge]: https://img.shields.io/badge/build%20automation-yamk-success
61
- [yam_url]: https://github.com/spapanik/yamk
62
- [ruff_badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json
63
- [ruff_url]: https://github.com/charliermarsh/ruff
64
- [Documentation]: https://pyutilkit.readthedocs.io/en/stable/
65
- [Changelog]: https://pyutilkit.readthedocs.io/en/stable/CHANGELOG/
@@ -1,46 +0,0 @@
1
- # pyutilkit: python's missing batteries
2
-
3
- [![build][build_badge]][build_url]
4
- [![lint][lint_badge]][lint_url]
5
- [![tests][tests_badge]][tests_url]
6
- [![license][licence_badge]][licence_url]
7
- [![codecov][codecov_badge]][codecov_url]
8
- [![readthedocs][readthedocs_badge]][readthedocs_url]
9
- [![pypi][pypi_badge]][pypi_url]
10
- [![downloads][pepy_badge]][pepy_url]
11
- [![build automation: yam][yam_badge]][yam_url]
12
- [![Lint: ruff][ruff_badge]][ruff_url]
13
-
14
- The Python has long maintained the philosophy of "batteries included", giving the user
15
- a rich standard library, avoiding the need for third party tools for most work. Some packages
16
- are so common, that the have a similar status to the standard library. Still, some code seems
17
- to be written time and again, with every project. This small library, with minimal requirements,
18
- hopes to stop this repetition.
19
-
20
- ## Links
21
-
22
- - [Documentation]
23
- - [Changelog]
24
-
25
- [build_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/build.yml/badge.svg
26
- [build_url]: https://github.com/spapanik/pyutilkit/actions/workflows/build.yml
27
- [lint_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/lint.yml/badge.svg
28
- [lint_url]: https://github.com/spapanik/pyutilkit/actions/workflows/lint.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
31
- [licence_badge]: https://img.shields.io/pypi/l/pyutilkit
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/
37
- [pypi_badge]: https://img.shields.io/pypi/v/pyutilkit
38
- [pypi_url]: https://pypi.org/project/pyutilkit
39
- [pepy_badge]: https://pepy.tech/badge/pyutilkit
40
- [pepy_url]: https://pepy.tech/project/pyutilkit
41
- [yam_badge]: https://img.shields.io/badge/build%20automation-yamk-success
42
- [yam_url]: https://github.com/spapanik/yamk
43
- [ruff_badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json
44
- [ruff_url]: https://github.com/charliermarsh/ruff
45
- [Documentation]: https://pyutilkit.readthedocs.io/en/stable/
46
- [Changelog]: https://pyutilkit.readthedocs.io/en/stable/CHANGELOG/
@@ -1 +0,0 @@
1
- __version__ = "0.11.0"
@@ -1,21 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import threading
4
-
5
-
6
- class Singleton(type):
7
- instance: type | None
8
-
9
- def __init__(
10
- cls, name: str, bases: tuple[type[object], ...], namespace: dict[str, object]
11
- ) -> None:
12
- super().__init__(name, bases, namespace)
13
- cls.instance = None
14
- cls._lock = threading.Lock()
15
-
16
- def __call__(cls) -> type:
17
- if cls.instance is None:
18
- with cls._lock:
19
- if cls.instance is None:
20
- cls.instance = super().__call__()
21
- return cls.instance
@@ -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
- )