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 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
@@ -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)}")
@@ -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)