pyutilkit 0.5.0__tar.gz → 0.9.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.

Potentially problematic release.


This version of pyutilkit might be problematic. Click here for more details.

@@ -0,0 +1,11 @@
1
+ # BSD 3-Clause License
2
+
3
+ Copyright © 2024 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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pyutilkit
3
- Version: 0.5.0
3
+ Version: 0.9.0
4
4
  Summary: python's missing batteries
5
5
  Home-page: https://pyutilkit.readthedocs.io/en/stable/
6
6
  License: BSD-3-Clause
@@ -9,14 +9,18 @@ Author: Stephanos Kuma
9
9
  Author-email: "Stephanos Kuma" <stephanos@kuma.ai>
10
10
  Requires-Python: >=3.9
11
11
  Classifier: Development Status :: 4 - Beta
12
+ Classifier: License :: OSI Approved :: BSD License
12
13
  Classifier: Operating System :: OS Independent
13
- Requires-Dist: pyutilkit (~=0.4)
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Requires-Dist: tzdata ; os_name == 'nt'
14
16
  Project-URL: Documentation, https://pyutilkit.readthedocs.io/en/stable/
15
17
  Project-URL: Repository, https://github.com/spapanik/pyutilkit
16
18
  Description-Content-Type: text/markdown
17
19
 
18
20
  # pyutilkit: python's missing batteries
19
21
 
22
+ [![build][build_badge]][build_url]
23
+ [![lint][lint_badge]][lint_url]
20
24
  [![tests][test_badge]][test_url]
21
25
  [![license][licence_badge]][licence_url]
22
26
  [![pypi][pypi_badge]][pypi_url]
@@ -36,10 +40,14 @@ hopes to stop this repetition.
36
40
  - [Documentation]
37
41
  - [Changelog]
38
42
 
43
+ [build_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/build.yml/badge.svg
44
+ [build_url]: https://github.com/spapanik/pyutilkit/actions/workflows/build.yml
45
+ [lint_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/lint.yml/badge.svg
46
+ [lint_url]: https://github.com/spapanik/pyutilkit/actions/workflows/lint.yml
39
47
  [test_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml/badge.svg
40
48
  [test_url]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml
41
49
  [licence_badge]: https://img.shields.io/pypi/l/pyutilkit
42
- [licence_url]: https://github.com/spapanik/pyutilkit/blob/main/docs/LICENSE.md
50
+ [licence_url]: https://pyutilkit.readthedocs.io/en/stable/LICENSE/
43
51
  [pypi_badge]: https://img.shields.io/pypi/v/pyutilkit
44
52
  [pypi_url]: https://pypi.org/project/pyutilkit
45
53
  [pepy_badge]: https://pepy.tech/badge/pyutilkit
@@ -51,4 +59,4 @@ hopes to stop this repetition.
51
59
  [ruff_badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json
52
60
  [ruff_url]: https://github.com/charliermarsh/ruff
53
61
  [Documentation]: https://pyutilkit.readthedocs.io/en/stable/
54
- [Changelog]: https://github.com/spapanik/pyutilkit/blob/main/docs/CHANGELOG.md
62
+ [Changelog]: https://pyutilkit.readthedocs.io/en/stable/CHANGELOG/
@@ -1,5 +1,7 @@
1
1
  # pyutilkit: python's missing batteries
2
2
 
3
+ [![build][build_badge]][build_url]
4
+ [![lint][lint_badge]][lint_url]
3
5
  [![tests][test_badge]][test_url]
4
6
  [![license][licence_badge]][licence_url]
5
7
  [![pypi][pypi_badge]][pypi_url]
@@ -19,10 +21,14 @@ hopes to stop this repetition.
19
21
  - [Documentation]
20
22
  - [Changelog]
21
23
 
24
+ [build_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/build.yml/badge.svg
25
+ [build_url]: https://github.com/spapanik/pyutilkit/actions/workflows/build.yml
26
+ [lint_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/lint.yml/badge.svg
27
+ [lint_url]: https://github.com/spapanik/pyutilkit/actions/workflows/lint.yml
22
28
  [test_badge]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml/badge.svg
23
29
  [test_url]: https://github.com/spapanik/pyutilkit/actions/workflows/tests.yml
24
30
  [licence_badge]: https://img.shields.io/pypi/l/pyutilkit
25
- [licence_url]: https://github.com/spapanik/pyutilkit/blob/main/docs/LICENSE.md
31
+ [licence_url]: https://pyutilkit.readthedocs.io/en/stable/LICENSE/
26
32
  [pypi_badge]: https://img.shields.io/pypi/v/pyutilkit
27
33
  [pypi_url]: https://pypi.org/project/pyutilkit
28
34
  [pepy_badge]: https://pepy.tech/badge/pyutilkit
@@ -34,4 +40,4 @@ hopes to stop this repetition.
34
40
  [ruff_badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json
35
41
  [ruff_url]: https://github.com/charliermarsh/ruff
36
42
  [Documentation]: https://pyutilkit.readthedocs.io/en/stable/
37
- [Changelog]: https://github.com/spapanik/pyutilkit/blob/main/docs/CHANGELOG.md
43
+ [Changelog]: https://pyutilkit.readthedocs.io/en/stable/CHANGELOG/
@@ -1,12 +1,12 @@
1
1
  [build-system]
2
2
  requires = [
3
- "phosphorus>=0.4",
3
+ "phosphorus>=0.8",
4
4
  ]
5
5
  build-backend = "phosphorus.construction.api"
6
6
 
7
7
  [project]
8
8
  name = "pyutilkit"
9
- version = "0.5.0"
9
+ version = "0.9.0"
10
10
 
11
11
  authors = [
12
12
  { name = "Stephanos Kuma", email = "stephanos@kuma.ai" },
@@ -21,11 +21,13 @@ keywords = [
21
21
  classifiers = [
22
22
  "Development Status :: 4 - Beta",
23
23
  "Operating System :: OS Independent",
24
+ "Programming Language :: Python :: 3 :: Only",
25
+ "License :: OSI Approved :: BSD License",
24
26
  ]
25
27
 
26
28
  requires-python = ">=3.9"
27
29
  dependencies = [
28
- "pyutilkit~=0.4",
30
+ "tzdata; os_name == 'nt'",
29
31
  ]
30
32
 
31
33
  [project.urls]
@@ -35,26 +37,25 @@ documentation = "https://pyutilkit.readthedocs.io/en/stable/"
35
37
 
36
38
  [tool.phosphorus.dev-dependencies]
37
39
  dev = [
38
- "ipdb~=0.13; python_version>='3.10'",
39
- "ipython~=8.21; python_version>='3.10'",
40
+ "ipdb~=0.13",
41
+ "ipython~=8.18",
40
42
  ]
41
43
  lint = [
42
- "black~=24.1",
43
- "mypy~=1.8",
44
- "ruff~=0.5",
45
- "typing_extensions~=4.11",
44
+ "black~=24.10",
45
+ "mypy~=1.13",
46
+ "ruff~=0.8",
46
47
  ]
47
48
  test = [
48
49
  "freezegun~=1.5",
49
- "pytest~=8.0",
50
- "pytest-cov~=5.0",
50
+ "pytest~=8.3",
51
+ "pytest-cov~=6.0",
51
52
  ]
52
53
  docs = [
53
54
  "mkdocs~=1.6",
54
55
  "mkdocs-material~=9.5",
55
56
  "mkdocs-material-extensions~=1.3",
56
- "Pygments~=2.17",
57
- "pymdown-extensions~=10.8",
57
+ "pygments~=2.18",
58
+ "pymdown-extensions~=10.12",
58
59
  ]
59
60
 
60
61
  [tool.black]
@@ -64,7 +65,11 @@ target-version = [
64
65
 
65
66
  [tool.mypy]
66
67
  check_untyped_defs = true
68
+ disallow_any_decorated = true
69
+ disallow_any_explicit = true
70
+ disallow_any_expr = false # many builtins are Any
67
71
  disallow_any_generics = true
72
+ disallow_any_unimported = true
68
73
  disallow_incomplete_defs = true
69
74
  disallow_subclassing_any = true
70
75
  disallow_untyped_calls = true
@@ -73,13 +78,18 @@ disallow_untyped_defs = true
73
78
  extra_checks = true
74
79
  ignore_missing_imports = true
75
80
  no_implicit_reexport = true
81
+ show_column_numbers = true
76
82
  show_error_codes = true
77
83
  strict_equality = true
78
- warn_return_any = true
79
84
  warn_redundant_casts = true
85
+ warn_return_any = true
86
+ warn_unused_configs = true
80
87
  warn_unused_ignores = true
81
88
  warn_unreachable = true
82
- warn_unused_configs = true
89
+
90
+ [[tool.mypy.overrides]]
91
+ module = "tests.*"
92
+ disallow_any_decorated = false # mock.MagicMock is Any
83
93
 
84
94
  [tool.ruff]
85
95
  src = [
@@ -89,74 +99,23 @@ target-version = "py39"
89
99
 
90
100
  [tool.ruff.lint]
91
101
  select = [
92
- "A",
93
- "ANN",
94
- "ARG",
95
- "ASYNC",
96
- "B",
97
- "BLE",
98
- "C4",
99
- "COM",
100
- "DTZ",
101
- "E",
102
- "EM",
103
- "ERA",
104
- "EXE",
105
- "F",
106
- "FA",
107
- "FBT",
108
- "FIX",
109
- "FLY",
110
- "FURB",
111
- "G",
112
- "I",
113
- "ICN",
114
- "INP",
115
- "ISC",
116
- "LOG",
117
- "N",
118
- "PGH",
119
- "PERF",
120
- "PIE",
121
- "PLC",
122
- "PLE",
123
- "PLW",
124
- "PT",
125
- "PTH",
126
- "Q",
127
- "RET",
128
- "RSE",
129
- "RUF",
130
- "S",
131
- "SIM",
132
- "SLF",
133
- "SLOT",
134
- "T10",
135
- "TCH",
136
- "TD",
137
- "TID",
138
- "TRY",
139
- "UP",
140
- "W",
141
- "YTT",
102
+ "ALL",
142
103
  ]
143
104
  ignore = [
144
- "ANN101",
145
- "ANN102",
146
- "ANN401",
147
- "COM812",
148
- "E501",
149
- "FIX002",
150
- "TD002",
151
- "TD003",
152
- "TRY003",
105
+ "C901", # Adding a limit to complexity is too arbitrary
106
+ "COM812", # Avoid magic trailing commas
107
+ "D10", # Not everything needs a docstring
108
+ "D203", # Prefer `no-blank-line-before-class` (D211)
109
+ "D213", # Prefer `multi-line-summary-first-line` (D212)
110
+ "E501", # Avoid clashes with black
111
+ "PLR09", # Adding a limit to complexity is too arbitrary
153
112
  ]
154
113
 
155
114
  [tool.ruff.lint.per-file-ignores]
156
115
  "tests/**" = [
157
- "FBT001",
158
- "PT011",
159
- "S101",
116
+ "FBT001", # Test arguments are handled by pytest
117
+ "PLR2004", # Tests should contain magic number comparisons
118
+ "S101", # Pytest needs assert statements
160
119
  ]
161
120
 
162
121
  [tool.ruff.lint.flake8-tidy-imports]
@@ -174,16 +133,22 @@ forced-separate = [
174
133
  split-on-trailing-comma = false
175
134
 
176
135
  [tool.pytest.ini_options]
177
- addopts = "-vv"
136
+ addopts = "-ra -v"
178
137
  testpaths = "tests"
179
138
 
180
139
  [tool.coverage.run]
140
+ branch = true
181
141
  source = [
182
142
  "src/",
183
143
  ]
184
144
  data_file = ".cov_cache/coverage.dat"
185
145
 
186
146
  [tool.coverage.report]
147
+ exclude_also = [
148
+ "if TYPE_CHECKING:",
149
+ ]
150
+ fail_under = 90
151
+ precision = 2
187
152
  show_missing = true
188
153
  skip_covered = true
189
154
  skip_empty = true
@@ -1,13 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any
3
+ from typing import cast
4
4
 
5
5
 
6
6
  class Singleton(type):
7
7
  instance: type[Singleton] | None
8
8
 
9
9
  def __init__(
10
- cls, name: str, bases: tuple[type[Any], ...], namespace: dict[str, Any]
10
+ cls, name: str, bases: tuple[type[object], ...], namespace: dict[str, object]
11
11
  ) -> None:
12
12
  super().__init__(name, bases, namespace)
13
13
  cls.instance = None
@@ -15,4 +15,4 @@ class Singleton(type):
15
15
  def __call__(cls) -> type[Singleton]:
16
16
  if cls.instance is None:
17
17
  cls.instance = super().__call__()
18
- return cls.instance
18
+ return cast(type[Singleton], cls.instance)
@@ -9,8 +9,7 @@ UTC = ZoneInfo("UTC")
9
9
 
10
10
 
11
11
  def get_timezones() -> set[str]:
12
- """
13
- Get all the available timezones
12
+ """Get all the available timezones.
14
13
 
15
14
  This takes into accounts timezones that might not be present in
16
15
  all systems.
@@ -23,8 +22,7 @@ def now(tz_info: ZoneInfo = UTC) -> datetime:
23
22
 
24
23
 
25
24
  def from_iso(date_string: str, tz_info: tzinfo = UTC) -> datetime:
26
- """
27
- Get datetime from an iso string
25
+ """Get datetime from an iso string.
28
26
 
29
27
  This to allow the Zulu timezone, which is a valid ISO timezone.
30
28
  """
@@ -37,16 +35,13 @@ def from_iso(date_string: str, tz_info: tzinfo = UTC) -> datetime:
37
35
 
38
36
 
39
37
  def from_timestamp(timestamp: float, tz_info: tzinfo = UTC) -> datetime:
40
- """
41
- Get a datetime tz-aware time object from a timestamp
42
- """
38
+ """Get a datetime tz-aware time object from a timestamp."""
43
39
  utc_dt = datetime.fromtimestamp(timestamp, tz=UTC)
44
40
  return convert_timezone(utc_dt, tz_info)
45
41
 
46
42
 
47
43
  def add_timezone(dt: datetime, tz_info: tzinfo = UTC) -> datetime:
48
- """
49
- Add a timezone to a naive datetime
44
+ """Add a timezone to a naive datetime.
50
45
 
51
46
  Raise an error in case of a tz-aware datetime
52
47
  """
@@ -57,8 +52,7 @@ def add_timezone(dt: datetime, tz_info: tzinfo = UTC) -> datetime:
57
52
 
58
53
 
59
54
  def convert_timezone(dt: datetime, tz_info: tzinfo = UTC) -> datetime:
60
- """
61
- Change the timezone of a tz-aware datetime
55
+ """Change the timezone of a tz-aware datetime.
62
56
 
63
57
  Raise an error in case of a naive datetime
64
58
  """
@@ -3,10 +3,13 @@ from __future__ import annotations
3
3
  import sys
4
4
  from dataclasses import dataclass
5
5
  from subprocess import PIPE, Popen
6
- from typing import Any
6
+ from typing import TYPE_CHECKING
7
7
 
8
8
  from pyutilkit.timing import Stopwatch, Timing
9
9
 
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+
10
13
 
11
14
  @dataclass(frozen=True)
12
15
  class ProcessOutput:
@@ -17,19 +20,18 @@ class ProcessOutput:
17
20
  elapsed: Timing
18
21
 
19
22
 
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
-
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
28
  stdout = []
29
29
  stderr = []
30
30
  stopwatch = Stopwatch()
31
31
  with stopwatch:
32
- process = Popen(command, **kwargs) # noqa: S603
32
+ process = Popen( # noqa: S603
33
+ command, stdout=PIPE, stderr=PIPE, cwd=cwd, env=env
34
+ )
33
35
 
34
36
  for line in process.stdout or []:
35
37
  sys.stdout.buffer.write(line)
@@ -0,0 +1,292 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from dataclasses import dataclass
6
+ from enum import IntEnum, unique
7
+ from math import ceil, floor
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Iterable
12
+
13
+ from typing_extensions import Self # py3.10: import from typing
14
+
15
+ TRUE_VAR = {"0", "false", "no"}
16
+
17
+
18
+ @unique
19
+ class SGRCodes(IntEnum):
20
+ RESET = 0
21
+
22
+ BOLD = 1
23
+ ITALIC = 3
24
+ UNDERLINE = 4
25
+ BLINK = 5
26
+ REVERSE = 7
27
+ CONCEAL = 8
28
+
29
+ BLACK = 30
30
+ RED = 31
31
+ GREEN = 32
32
+ YELLOW = 33
33
+ BLUE = 34
34
+ MAGENTA = 35
35
+ CYAN = 36
36
+ GREY = 37
37
+
38
+ BG_BLACK = 40
39
+ BG_RED = 41
40
+ BG_GREEN = 42
41
+ BG_YELLOW = 43
42
+ BG_BLUE = 44
43
+ BG_MAGENTA = 45
44
+ BG_CYAN = 46
45
+ BG_GREY = 47
46
+
47
+ BLACK_BRIGHT = 90
48
+ RED_BRIGHT = 91
49
+ GREEN_BRIGHT = 92
50
+ YELLOW_BRIGHT = 93
51
+ BLUE_BRIGHT = 94
52
+ MAGENTA_BRIGHT = 95
53
+ CYAN_BRIGHT = 96
54
+ WHITE_BRIGHT = 97
55
+
56
+ BG_BLACK_BRIGHT = 100
57
+ BG_RED_BRIGHT = 101
58
+ BG_GREEN_BRIGHT = 102
59
+ BG_YELLOW_BRIGHT = 103
60
+ BG_BLUE_BRIGHT = 104
61
+ BG_MAGENTA_BRIGHT = 105
62
+ BG_CYAN_BRIGHT = 106
63
+ BG_WHITE_BRIGHT = 107
64
+
65
+ @property
66
+ def sequence(self) -> str:
67
+ return f"\033[{self.value}m"
68
+
69
+
70
+ @dataclass(frozen=True, order=True)
71
+ class SGRString:
72
+ __slots__ = ( # py3.9: remove this line
73
+ "_force_prefix",
74
+ "_force_sgr",
75
+ "_is_error",
76
+ "_prefix",
77
+ "_sgr",
78
+ "_string",
79
+ "_suffix",
80
+ )
81
+
82
+ _string: str
83
+ _sgr: tuple[SGRCodes, ...]
84
+ _prefix: str
85
+ _suffix: str
86
+ _force_prefix: bool
87
+ _force_sgr: bool
88
+ _is_error: bool
89
+
90
+ def __init__(
91
+ self,
92
+ obj: object,
93
+ *,
94
+ prefix: str = "",
95
+ suffix: str = "",
96
+ params: Iterable[SGRCodes] = (),
97
+ force_prefix: bool = False,
98
+ force_sgr: bool = False,
99
+ is_error: bool = False,
100
+ ) -> None:
101
+ params = tuple(params)
102
+ force_prefix = (
103
+ force_prefix or os.getenv("PY_UTIL_FORCE_PREFIX", "").lower() in TRUE_VAR
104
+ )
105
+ force_sgr = force_sgr or os.getenv("PY_UTIL_FORCE_SGR", "").lower() in TRUE_VAR
106
+
107
+ object.__setattr__(self, "_prefix", prefix)
108
+ object.__setattr__(self, "_string", str(obj))
109
+ object.__setattr__(self, "_sgr", params)
110
+ object.__setattr__(self, "_suffix", suffix)
111
+ object.__setattr__(self, "_force_prefix", force_prefix)
112
+ object.__setattr__(self, "_force_sgr", force_sgr)
113
+ object.__setattr__(self, "_is_error", is_error)
114
+
115
+ def __str__(self) -> str:
116
+ sgr_prefix = "".join(code.sequence for code in self._sgr)
117
+ sgr_suffix = SGRCodes.RESET.sequence if self._sgr else ""
118
+ return f"{self._prefix}{sgr_prefix}{self._string}{sgr_suffix}{self._suffix}"
119
+
120
+ def __len__(self) -> int:
121
+ return len(self._prefix) + len(self._string) + len(self._suffix)
122
+
123
+ def __mul__(self, other: object) -> Self:
124
+ if not isinstance(other, int):
125
+ return NotImplemented
126
+ return type(self)(
127
+ self._string * other,
128
+ prefix=self._prefix,
129
+ suffix=self._suffix,
130
+ params=self._sgr,
131
+ force_prefix=self._force_prefix,
132
+ force_sgr=self._force_sgr,
133
+ is_error=self._is_error,
134
+ )
135
+
136
+ def __rmul__(self, other: object) -> Self:
137
+ if not isinstance(other, int):
138
+ return NotImplemented
139
+ return type(self)(
140
+ self._string * other,
141
+ prefix=self._prefix,
142
+ suffix=self._suffix,
143
+ params=self._sgr,
144
+ force_prefix=self._force_prefix,
145
+ force_sgr=self._force_sgr,
146
+ is_error=self._is_error,
147
+ )
148
+
149
+ def print(self, end: str = "\n", *, full_color: bool = False) -> None:
150
+ """Print the command output.
151
+
152
+ The command will be printed to stdout if it's not the output of an error,
153
+ otherwise to stderr.
154
+
155
+ If the output stream isn't a tty, it will strip the SGR codes and the prefix,
156
+ unless forced to keep them.
157
+ """
158
+ file = sys.stderr if self._is_error else sys.stdout
159
+ if file.isatty():
160
+ prefix = self._prefix
161
+ sgr_prefix = "".join(code.sequence for code in self._sgr)
162
+ sgr_suffix = SGRCodes.RESET.sequence
163
+ suffix = self._suffix
164
+ else:
165
+ prefix = ""
166
+ sgr_prefix = ""
167
+ sgr_suffix = ""
168
+ suffix = ""
169
+ if self._force_sgr:
170
+ sgr_prefix = "".join(code.sequence for code in self._sgr)
171
+ sgr_suffix = SGRCodes.RESET.sequence if self._sgr else ""
172
+ if self._force_prefix:
173
+ prefix = self._prefix
174
+ suffix = self._suffix
175
+
176
+ if full_color:
177
+ prefix, sgr_prefix = sgr_prefix, prefix
178
+ suffix, sgr_suffix = sgr_suffix, suffix
179
+
180
+ print(
181
+ prefix,
182
+ sgr_prefix,
183
+ self._string,
184
+ sgr_suffix,
185
+ suffix,
186
+ sep="",
187
+ end=end,
188
+ file=file,
189
+ )
190
+
191
+ def header(
192
+ self,
193
+ *,
194
+ padding: str = " ",
195
+ left_spaces: int = 1,
196
+ right_spaces: int = 1,
197
+ space: str = " ",
198
+ ) -> None:
199
+ try:
200
+ terminal_size = os.get_terminal_size()
201
+ except OSError:
202
+ # in pseudo-terminals an OSError is thrown
203
+ self.print()
204
+ return
205
+
206
+ columns = terminal_size.columns
207
+ title_length = left_spaces + len(self) + right_spaces
208
+ if title_length >= columns:
209
+ self.print()
210
+ return
211
+
212
+ half = (columns - title_length) / 2
213
+ prefix = f"{padding * ceil(half)}{space * left_spaces}{self._prefix}"
214
+ suffix = f"{self._suffix}{space * right_spaces}{padding * floor(half)}"
215
+ type(self)(self._string, prefix=prefix, suffix=suffix, params=self._sgr).print()
216
+
217
+
218
+ @dataclass(frozen=True, order=True)
219
+ class SGROutput:
220
+ __slots__ = ("_strings",) # py3.9: remove this line
221
+ _strings: tuple[SGRString, ...]
222
+
223
+ def __init__(
224
+ self,
225
+ strings: Iterable[SGRString],
226
+ force_prefix: bool | None = None,
227
+ force_sgr: bool | None = None,
228
+ is_error: bool | None = None,
229
+ ) -> None:
230
+ strings = tuple(
231
+ self._clean_string(
232
+ string,
233
+ force_prefix=force_prefix,
234
+ force_sgr=force_sgr,
235
+ is_error=is_error,
236
+ )
237
+ for string in strings
238
+ )
239
+
240
+ object.__setattr__(self, "_strings", strings)
241
+
242
+ @staticmethod
243
+ def _clean_string(
244
+ string: SGRString,
245
+ force_prefix: bool | None,
246
+ force_sgr: bool | None,
247
+ is_error: bool | None,
248
+ ) -> SGRString:
249
+ force_prefix = (
250
+ string._force_prefix # noqa: SLF001
251
+ if force_prefix is None
252
+ else force_prefix
253
+ )
254
+ force_sgr = (
255
+ string._force_sgr if force_sgr is None else force_sgr # noqa: SLF001
256
+ )
257
+ is_error = string._is_error if is_error is None else is_error # noqa: SLF001
258
+ return SGRString(
259
+ string._string, # noqa: SLF001
260
+ prefix=string._prefix, # noqa: SLF001
261
+ suffix=string._suffix, # noqa: SLF001
262
+ params=string._sgr, # noqa: SLF001
263
+ force_prefix=force_prefix,
264
+ force_sgr=force_sgr,
265
+ is_error=is_error,
266
+ )
267
+
268
+ def print(self, sep: str = "", end: str = "\n") -> None:
269
+ n = len(self._strings)
270
+ for index, string in enumerate(self._strings, start=1):
271
+ current_end = end if index == n else sep
272
+ string.print(end=current_end)
273
+
274
+ def header(
275
+ self,
276
+ *,
277
+ padding: str = " ",
278
+ left_spaces: int = 1,
279
+ right_spaces: int = 1,
280
+ space: str = " ",
281
+ ) -> None:
282
+ n = len(self._strings)
283
+ if n > 1:
284
+ msg = "Only one string is allowed for the header"
285
+ raise ValueError(msg)
286
+
287
+ self._strings[0].header(
288
+ padding=padding,
289
+ left_spaces=left_spaces,
290
+ right_spaces=right_spaces,
291
+ space=space,
292
+ )
@@ -2,13 +2,19 @@ from __future__ import annotations # py3.9: remove this line
2
2
 
3
3
  from dataclasses import dataclass
4
4
  from time import perf_counter_ns
5
- from typing import TYPE_CHECKING, Any
5
+ from typing import TYPE_CHECKING
6
6
 
7
7
  if TYPE_CHECKING:
8
8
  from types import TracebackType
9
9
 
10
10
  from typing_extensions import Self # py3.10: import Self from typing
11
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
+
12
18
 
13
19
  @dataclass(frozen=True, order=True)
14
20
  class Timing:
@@ -27,65 +33,65 @@ class Timing:
27
33
  ) -> None:
28
34
  total_nanoseconds = (
29
35
  nanoseconds
30
- + 1000 * microseconds
31
- + 1_000_000 * milliseconds
32
- + 1_000_000_000 * seconds
33
- + 86_400_000_000_000 * days
36
+ + METRIC_MULTIPLIER * microseconds
37
+ + METRIC_MULTIPLIER**2 * milliseconds
38
+ + METRIC_MULTIPLIER**3 * seconds
39
+ + SECONDS_PER_DAY * METRIC_MULTIPLIER**3 * days
34
40
  )
35
41
  object.__setattr__(self, "nanoseconds", total_nanoseconds)
36
42
 
37
43
  def __str__(self) -> str:
38
- if self.nanoseconds < 1000:
44
+ if self.nanoseconds < METRIC_MULTIPLIER:
39
45
  return f"{self.nanoseconds}ns"
40
- microseconds = self.nanoseconds / 1000
41
- if microseconds < 1000:
46
+ microseconds = self.nanoseconds / METRIC_MULTIPLIER
47
+ if microseconds < METRIC_MULTIPLIER:
42
48
  return f"{microseconds:.1f}µs"
43
- milliseconds = microseconds / 1000
44
- if milliseconds < 1000:
49
+ milliseconds = microseconds / METRIC_MULTIPLIER
50
+ if milliseconds < METRIC_MULTIPLIER:
45
51
  return f"{milliseconds:.1f}ms"
46
- seconds = milliseconds / 1000
47
- if seconds < 60:
52
+ seconds = milliseconds / METRIC_MULTIPLIER
53
+ if seconds < SECONDS_PER_MINUTE:
48
54
  return f"{seconds:.2f}s"
49
55
  round_seconds = int(seconds)
50
- minutes, seconds = divmod(round_seconds, 60)
51
- hours, minutes = divmod(minutes, 60)
52
- if hours < 24:
56
+ minutes, seconds = divmod(round_seconds, SECONDS_PER_MINUTE)
57
+ hours, minutes = divmod(minutes, MINUTES_PER_HOUR)
58
+ if hours < HOURS_PER_DAY:
53
59
  return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
54
- days, hours = divmod(hours, 24)
60
+ days, hours = divmod(hours, HOURS_PER_DAY)
55
61
  return f"{days:,}d {hours:02d}:{minutes:02d}:{seconds:02d}"
56
62
 
57
63
  def __bool__(self) -> bool:
58
64
  return bool(self.nanoseconds)
59
65
 
60
- def __add__(self, other: Any) -> Timing:
66
+ def __add__(self, other: object) -> Timing:
61
67
  if not isinstance(other, Timing):
62
68
  return NotImplemented
63
69
  return Timing(nanoseconds=self.nanoseconds + other.nanoseconds)
64
70
 
65
- def __radd__(self, other: Any) -> Timing:
71
+ def __radd__(self, other: object) -> Timing:
66
72
  if not isinstance(other, Timing):
67
73
  return NotImplemented
68
74
  return Timing(nanoseconds=self.nanoseconds + other.nanoseconds)
69
75
 
70
- def __mul__(self, other: Any) -> Timing:
76
+ def __mul__(self, other: object) -> Timing:
71
77
  if not isinstance(other, int):
72
78
  return NotImplemented
73
79
  return Timing(nanoseconds=self.nanoseconds * other)
74
80
 
75
- def __rmul__(self, other: Any) -> Timing:
81
+ def __rmul__(self, other: object) -> Timing:
76
82
  if not isinstance(other, int):
77
83
  return NotImplemented
78
84
  return Timing(nanoseconds=self.nanoseconds * other)
79
85
 
80
- def __floordiv__(self, other: Any) -> Timing:
86
+ def __floordiv__(self, other: object) -> Timing:
81
87
  if not isinstance(other, int):
82
88
  return NotImplemented
83
89
  return Timing(nanoseconds=self.nanoseconds // other)
84
90
 
85
- def __truediv__(self, other: Any) -> Timing:
91
+ def __truediv__(self, other: object) -> Timing:
86
92
  if not isinstance(other, int):
87
93
  return NotImplemented
88
- return Timing(nanoseconds=self.nanoseconds // other)
94
+ return Timing(nanoseconds=round(self.nanoseconds / other))
89
95
 
90
96
 
91
97
  class Stopwatch:
@@ -102,8 +108,8 @@ class Stopwatch:
102
108
 
103
109
  def __exit__(
104
110
  self,
105
- exc_type: type[Exception] | None,
106
- exc_value: Exception | None,
111
+ exc_type: type[BaseException] | None,
112
+ exc_value: BaseException | None,
107
113
  traceback: TracebackType | None,
108
114
  ) -> None:
109
115
  _end = perf_counter_ns()
src/pyutilkit/term.py DELETED
@@ -1,117 +0,0 @@
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:
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
- columns = os.get_terminal_size().columns
110
- text = f"{space * left_spaces}{self}{space * right_spaces}"
111
- title_length = left_spaces + len(self) + right_spaces
112
- if title_length >= columns:
113
- print(text.strip())
114
- return
115
-
116
- half = (columns - title_length) / 2
117
- print(f"{padding * ceil(half)}{text}{padding * floor(half)}")
File without changes
File without changes
File without changes
File without changes
File without changes