adrian-utils 0.1.1__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,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: adrian-utils
3
+ Version: 0.1.1
4
+ Summary: Generic Python utilities (DX-first terminal logger, XDG directories, number/percentage/currency formatting). Python 3.12+.
5
+ Author-email: Adrian Galilea <adriangalilea@gmail.com>
6
+ Requires-Python: >=3.12
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: rich>=14.1.0
9
+ Requires-Dist: ruff>=0.13.2
10
+
11
+ # adrian-utils
12
+
13
+ Generic Python utilities — DX-first terminal logger, XDG directories, number/percentage/currency formatting. Python 3.12+.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install adrian-utils
19
+ # or with uv
20
+ uv add adrian-utils
21
+ ```
22
+
23
+ ## Install (local dev)
24
+
25
+ ```bash
26
+ uv add -e .
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ from py_utils import log, xdg, usd, percentage
33
+
34
+ # XDG directories
35
+ config_file = xdg.config / "myapp" / "config.toml"
36
+ data_file = xdg.data / "myapp" / "database.db"
37
+ cache_dir = xdg.cache / "myapp"
38
+
39
+ # Narration
40
+ log.info("Connected to postgres:5432")
41
+
42
+ # Task with steps
43
+ with log.task("Build assets"):
44
+ log.step("Transpiling")
45
+ log.step("Bundling")
46
+
47
+ # Status
48
+ log.success("Deployed")
49
+ log.warn_once("Flag --fast is deprecated")
50
+
51
+ # Progress
52
+ with log.task("Sync"):
53
+ p = log.progress(total=3, title="Uploading")
54
+ p.tick(); p.tick(); p.tick(); p.done(success=True)
55
+
56
+ # Formatting
57
+ usd(1234.56) # "+$1,234.56" (color and + by default)
58
+ usd(1234.56, signed=False) # "$1,234.56" (no leading +)
59
+ percentage(15.234) # "+15.2%"
60
+ percentage(15.234, signed=False) # "15.2%"
61
+ ```
62
+
63
+ Colors automatically disable when stdout is not a TTY. To override:
64
+
65
+ ```python
66
+ from py_utils import set_color_enabled
67
+
68
+ set_color_enabled(True) # force color on
69
+ set_color_enabled(False) # force color off
70
+ set_color_enabled(None) # return to auto detection
71
+ ```
72
+
73
+ Run the bundled demo:
74
+
75
+ ```bash
76
+ # After `uv add -e .`
77
+ uv run python example_usage.py
78
+ ```
79
+
80
+ ## Linting / Formatting
81
+
82
+ ```bash
83
+ uv run ruff check src
84
+ uv run ruff format src
85
+ ```
86
+
87
+ ## TODO
88
+
89
+ - Port the KEV environment manager (see `ts-utils/src/platform/kev.ts` and `go-utils/kev.go`).
90
+
91
+ Part of the utils suite by Adrian Galilea.
@@ -0,0 +1,81 @@
1
+ # adrian-utils
2
+
3
+ Generic Python utilities — DX-first terminal logger, XDG directories, number/percentage/currency formatting. Python 3.12+.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install adrian-utils
9
+ # or with uv
10
+ uv add adrian-utils
11
+ ```
12
+
13
+ ## Install (local dev)
14
+
15
+ ```bash
16
+ uv add -e .
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```python
22
+ from py_utils import log, xdg, usd, percentage
23
+
24
+ # XDG directories
25
+ config_file = xdg.config / "myapp" / "config.toml"
26
+ data_file = xdg.data / "myapp" / "database.db"
27
+ cache_dir = xdg.cache / "myapp"
28
+
29
+ # Narration
30
+ log.info("Connected to postgres:5432")
31
+
32
+ # Task with steps
33
+ with log.task("Build assets"):
34
+ log.step("Transpiling")
35
+ log.step("Bundling")
36
+
37
+ # Status
38
+ log.success("Deployed")
39
+ log.warn_once("Flag --fast is deprecated")
40
+
41
+ # Progress
42
+ with log.task("Sync"):
43
+ p = log.progress(total=3, title="Uploading")
44
+ p.tick(); p.tick(); p.tick(); p.done(success=True)
45
+
46
+ # Formatting
47
+ usd(1234.56) # "+$1,234.56" (color and + by default)
48
+ usd(1234.56, signed=False) # "$1,234.56" (no leading +)
49
+ percentage(15.234) # "+15.2%"
50
+ percentage(15.234, signed=False) # "15.2%"
51
+ ```
52
+
53
+ Colors automatically disable when stdout is not a TTY. To override:
54
+
55
+ ```python
56
+ from py_utils import set_color_enabled
57
+
58
+ set_color_enabled(True) # force color on
59
+ set_color_enabled(False) # force color off
60
+ set_color_enabled(None) # return to auto detection
61
+ ```
62
+
63
+ Run the bundled demo:
64
+
65
+ ```bash
66
+ # After `uv add -e .`
67
+ uv run python example_usage.py
68
+ ```
69
+
70
+ ## Linting / Formatting
71
+
72
+ ```bash
73
+ uv run ruff check src
74
+ uv run ruff format src
75
+ ```
76
+
77
+ ## TODO
78
+
79
+ - Port the KEV environment manager (see `ts-utils/src/platform/kev.ts` and `go-utils/kev.go`).
80
+
81
+ Part of the utils suite by Adrian Galilea.
@@ -0,0 +1,13 @@
1
+ [project]
2
+ name = "adrian-utils"
3
+ version = "0.1.1"
4
+ description = "Generic Python utilities (DX-first terminal logger, XDG directories, number/percentage/currency formatting). Python 3.12+."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ authors = [
8
+ { name = "Adrian Galilea", email = "adriangalilea@gmail.com" },
9
+ ]
10
+ dependencies = [
11
+ "rich>=14.1.0",
12
+ "ruff>=0.13.2",
13
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: adrian-utils
3
+ Version: 0.1.1
4
+ Summary: Generic Python utilities (DX-first terminal logger, XDG directories, number/percentage/currency formatting). Python 3.12+.
5
+ Author-email: Adrian Galilea <adriangalilea@gmail.com>
6
+ Requires-Python: >=3.12
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: rich>=14.1.0
9
+ Requires-Dist: ruff>=0.13.2
10
+
11
+ # adrian-utils
12
+
13
+ Generic Python utilities — DX-first terminal logger, XDG directories, number/percentage/currency formatting. Python 3.12+.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install adrian-utils
19
+ # or with uv
20
+ uv add adrian-utils
21
+ ```
22
+
23
+ ## Install (local dev)
24
+
25
+ ```bash
26
+ uv add -e .
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ from py_utils import log, xdg, usd, percentage
33
+
34
+ # XDG directories
35
+ config_file = xdg.config / "myapp" / "config.toml"
36
+ data_file = xdg.data / "myapp" / "database.db"
37
+ cache_dir = xdg.cache / "myapp"
38
+
39
+ # Narration
40
+ log.info("Connected to postgres:5432")
41
+
42
+ # Task with steps
43
+ with log.task("Build assets"):
44
+ log.step("Transpiling")
45
+ log.step("Bundling")
46
+
47
+ # Status
48
+ log.success("Deployed")
49
+ log.warn_once("Flag --fast is deprecated")
50
+
51
+ # Progress
52
+ with log.task("Sync"):
53
+ p = log.progress(total=3, title="Uploading")
54
+ p.tick(); p.tick(); p.tick(); p.done(success=True)
55
+
56
+ # Formatting
57
+ usd(1234.56) # "+$1,234.56" (color and + by default)
58
+ usd(1234.56, signed=False) # "$1,234.56" (no leading +)
59
+ percentage(15.234) # "+15.2%"
60
+ percentage(15.234, signed=False) # "15.2%"
61
+ ```
62
+
63
+ Colors automatically disable when stdout is not a TTY. To override:
64
+
65
+ ```python
66
+ from py_utils import set_color_enabled
67
+
68
+ set_color_enabled(True) # force color on
69
+ set_color_enabled(False) # force color off
70
+ set_color_enabled(None) # return to auto detection
71
+ ```
72
+
73
+ Run the bundled demo:
74
+
75
+ ```bash
76
+ # After `uv add -e .`
77
+ uv run python example_usage.py
78
+ ```
79
+
80
+ ## Linting / Formatting
81
+
82
+ ```bash
83
+ uv run ruff check src
84
+ uv run ruff format src
85
+ ```
86
+
87
+ ## TODO
88
+
89
+ - Port the KEV environment manager (see `ts-utils/src/platform/kev.ts` and `go-utils/kev.go`).
90
+
91
+ Part of the utils suite by Adrian Galilea.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/adrian_utils.egg-info/PKG-INFO
4
+ src/adrian_utils.egg-info/SOURCES.txt
5
+ src/adrian_utils.egg-info/dependency_links.txt
6
+ src/adrian_utils.egg-info/requires.txt
7
+ src/adrian_utils.egg-info/top_level.txt
8
+ src/py_utils/__init__.py
9
+ src/py_utils/currency.py
10
+ src/py_utils/format.py
11
+ src/py_utils/log.py
12
+ src/py_utils/xdg.py
@@ -0,0 +1,2 @@
1
+ rich>=14.1.0
2
+ ruff>=0.13.2
@@ -0,0 +1,81 @@
1
+ """
2
+ py_utils: Generic Python utilities (Python 3.12+)
3
+
4
+ Currently includes:
5
+ - TTY-focused logger with tasks/steps/indentation, symbols, colors
6
+ - Number/percentage formatting helpers
7
+ - Currency formatting utilities with optimal decimals
8
+
9
+ This package intentionally avoids stdlib logging and JSON outputs.
10
+ It focuses on crisp terminal output for interactive use and CI-friendly
11
+ plain text when not attached to a TTY.
12
+ """
13
+
14
+ from .log import (
15
+ log,
16
+ Logger,
17
+ )
18
+
19
+ from .format import (
20
+ number,
21
+ number_plain,
22
+ with_commas,
23
+ compact,
24
+ bytes_fmt,
25
+ duration,
26
+ percentage,
27
+ percentage_change,
28
+ percentage_diff,
29
+ bps,
30
+ sign,
31
+ apply_sign,
32
+ set_color_enabled,
33
+ color_enabled,
34
+ )
35
+
36
+ from .currency import (
37
+ get_symbol,
38
+ get_optimal_decimals,
39
+ usd,
40
+ btc,
41
+ eth,
42
+ auto,
43
+ is_crypto,
44
+ is_fiat,
45
+ is_stablecoin,
46
+ bps_to_percent,
47
+ percent_to_bps,
48
+ )
49
+
50
+ __all__ = [
51
+ # logger
52
+ "log",
53
+ "Logger",
54
+ # format
55
+ "number",
56
+ "number_plain",
57
+ "with_commas",
58
+ "compact",
59
+ "bytes_fmt",
60
+ "duration",
61
+ "percentage",
62
+ "percentage_change",
63
+ "percentage_diff",
64
+ "bps",
65
+ "sign",
66
+ "apply_sign",
67
+ "set_color_enabled",
68
+ "color_enabled",
69
+ # currency
70
+ "get_symbol",
71
+ "get_optimal_decimals",
72
+ "usd",
73
+ "btc",
74
+ "eth",
75
+ "auto",
76
+ "is_crypto",
77
+ "is_fiat",
78
+ "is_stablecoin",
79
+ "bps_to_percent",
80
+ "percent_to_bps",
81
+ ]
@@ -0,0 +1,252 @@
1
+ from __future__ import annotations
2
+
3
+ from math import fabs
4
+
5
+ from .format import number_plain, color_by_sign, apply_sign
6
+
7
+
8
+ SYMBOLS: dict[str, str] = {
9
+ "BTC": "₿",
10
+ "XBT": "₿",
11
+ "ETH": "Ξ",
12
+ "USD": "$",
13
+ "EUR": "€",
14
+ "GBP": "£",
15
+ "JPY": "¥",
16
+ "CNY": "¥",
17
+ "KRW": "₩",
18
+ "INR": "₹",
19
+ "RUB": "₽",
20
+ "TRY": "₺",
21
+ "AUD": "A$",
22
+ "CAD": "C$",
23
+ "CHF": "Fr",
24
+ "HKD": "HK$",
25
+ "SGD": "S$",
26
+ "NZD": "NZ$",
27
+ "SEK": "kr",
28
+ "NOK": "kr",
29
+ "DKK": "kr",
30
+ "PLN": "zł",
31
+ "THB": "฿",
32
+ "USDT": "₮",
33
+ "USDC": "$",
34
+ "DAI": "$",
35
+ "BUSD": "$",
36
+ }
37
+
38
+
39
+ def get_symbol(code: str) -> str:
40
+ return SYMBOLS.get(code.upper(), code)
41
+
42
+
43
+ def is_stablecoin(code: str) -> bool:
44
+ return code.upper() in {
45
+ "USDT",
46
+ "USDC",
47
+ "DAI",
48
+ "BUSD",
49
+ "UST",
50
+ "TUSD",
51
+ "USDP",
52
+ "GUSD",
53
+ "FRAX",
54
+ "LUSD",
55
+ }
56
+
57
+
58
+ def is_fiat(code: str) -> bool:
59
+ return code.upper() in {
60
+ "USD",
61
+ "EUR",
62
+ "GBP",
63
+ "JPY",
64
+ "CNY",
65
+ "CAD",
66
+ "AUD",
67
+ "CHF",
68
+ "HKD",
69
+ "SGD",
70
+ "NZD",
71
+ "KRW",
72
+ "SEK",
73
+ "NOK",
74
+ "DKK",
75
+ "PLN",
76
+ "THB",
77
+ "INR",
78
+ "RUB",
79
+ "TRY",
80
+ "BRL",
81
+ "MXN",
82
+ "ARS",
83
+ "CLP",
84
+ "COP",
85
+ "PEN",
86
+ "UYU",
87
+ "ZAR",
88
+ "NGN",
89
+ "KES",
90
+ }
91
+
92
+
93
+ def is_crypto(code: str) -> bool:
94
+ return code.upper() in {
95
+ "BTC",
96
+ "XBT",
97
+ "ETH",
98
+ "BNB",
99
+ "XRP",
100
+ "ADA",
101
+ "DOGE",
102
+ "SOL",
103
+ "DOT",
104
+ "MATIC",
105
+ "SHIB",
106
+ "TRX",
107
+ "AVAX",
108
+ "UNI",
109
+ "ATOM",
110
+ "LINK",
111
+ "XMR",
112
+ "XLM",
113
+ "ALGO",
114
+ "VET",
115
+ "MANA",
116
+ "SAND",
117
+ "AXS",
118
+ "THETA",
119
+ "FTM",
120
+ "NEAR",
121
+ "HNT",
122
+ "GRT",
123
+ "ENJ",
124
+ "CHZ",
125
+ }
126
+
127
+
128
+ def get_optimal_decimals(value: float, code: str) -> int:
129
+ if value == 0:
130
+ return 8 if is_crypto(code) else 2
131
+ abs_v = fabs(value)
132
+
133
+ if code.upper() in {"BTC", "XBT"}:
134
+ if abs_v < 0.00001:
135
+ return 10
136
+ if abs_v < 0.0001:
137
+ return 9
138
+ if abs_v < 0.001:
139
+ return 8
140
+ if abs_v < 0.01:
141
+ return 7
142
+ if abs_v < 0.1:
143
+ return 6
144
+ if abs_v < 1:
145
+ return 5
146
+ return 4
147
+
148
+ if code.upper() == "ETH":
149
+ if abs_v < 0.001:
150
+ return 8
151
+ if abs_v < 0.01:
152
+ return 7
153
+ if abs_v < 0.1:
154
+ return 6
155
+ if abs_v < 1:
156
+ return 5
157
+ return 4
158
+
159
+ if code.upper() in {"USD", "USDT", "USDC", "DAI", "BUSD"}:
160
+ if abs_v < 0.01:
161
+ return 6
162
+ if abs_v < 0.1:
163
+ return 4
164
+ if abs_v < 1:
165
+ return 3
166
+ return 2
167
+
168
+ if is_crypto(code):
169
+ if abs_v < 0.00001:
170
+ return 8
171
+ if abs_v < 0.0001:
172
+ return 6
173
+ if abs_v < 0.001:
174
+ return 5
175
+ if abs_v < 0.01:
176
+ return 4
177
+ if abs_v < 0.1:
178
+ return 3
179
+ if abs_v < 1:
180
+ return 3
181
+ if abs_v < 100:
182
+ return 2
183
+ return 0
184
+
185
+ # fiat defaults
186
+ if abs_v < 0.01:
187
+ return 4
188
+ if abs_v < 0.1:
189
+ return 3
190
+ if abs_v < 1000:
191
+ return 2
192
+ return 0
193
+
194
+
195
+ def usd(value: float, *, signed: bool = True) -> str:
196
+ decimals = get_optimal_decimals(value, "USD")
197
+ body = f"${number_plain(abs(value), decimals)}"
198
+ token = apply_sign(value, body, signed=signed)
199
+ return color_by_sign(value, token)
200
+
201
+
202
+ def btc(value: float, *, signed: bool = True) -> str:
203
+ decimals = get_optimal_decimals(value, "BTC")
204
+ body = f"{number_plain(abs(value), decimals)} ₿"
205
+ token = apply_sign(value, body, signed=signed)
206
+ return color_by_sign(value, token)
207
+
208
+
209
+ def eth(value: float, *, signed: bool = True) -> str:
210
+ decimals = get_optimal_decimals(value, "ETH")
211
+ body = f"{number_plain(abs(value), decimals)} Ξ"
212
+ token = apply_sign(value, body, signed=signed)
213
+ return color_by_sign(value, token)
214
+
215
+
216
+ def auto(value: float, code: str, *, signed: bool = True) -> str:
217
+ decimals = get_optimal_decimals(value, code)
218
+ symbol = get_symbol(code)
219
+
220
+ if is_fiat(code) or is_stablecoin(code):
221
+ body = f"{symbol}{number_plain(abs(value), decimals)}"
222
+ token = apply_sign(value, body, signed=signed)
223
+ return color_by_sign(value, token)
224
+
225
+ base_body = (
226
+ f"{number_plain(abs(value), decimals)} {symbol if symbol != code else code}"
227
+ )
228
+ token = apply_sign(value, base_body, signed=signed)
229
+ return color_by_sign(value, token)
230
+
231
+
232
+ def bps_to_percent(bps: int) -> float:
233
+ return bps / 100.0
234
+
235
+
236
+ def percent_to_bps(percent: float) -> int:
237
+ return int(round(percent * 100))
238
+
239
+
240
+ __all__ = [
241
+ "get_symbol",
242
+ "is_stablecoin",
243
+ "is_fiat",
244
+ "is_crypto",
245
+ "get_optimal_decimals",
246
+ "usd",
247
+ "btc",
248
+ "eth",
249
+ "auto",
250
+ "bps_to_percent",
251
+ "percent_to_bps",
252
+ ]
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from math import isfinite
6
+ from typing import Optional
7
+
8
+
9
+ SI_SUFFIXES = [
10
+ (1_000_000_000_000_000_000, "E"),
11
+ (1_000_000_000_000_000, "P"),
12
+ (1_000_000_000_000, "T"),
13
+ (1_000_000_000, "G"),
14
+ (1_000_000, "M"),
15
+ (1_000, "K"),
16
+ ]
17
+
18
+
19
+ def _detect_color() -> bool:
20
+ if os.getenv("NO_COLOR"):
21
+ return False
22
+ if os.getenv("FORCE_COLOR"):
23
+ return True
24
+ try:
25
+ return sys.stdout.isatty()
26
+ except Exception:
27
+ return False
28
+
29
+
30
+ _COLOR_ENABLED: bool = _detect_color()
31
+
32
+
33
+ def set_color_enabled(enabled: Optional[bool]) -> None:
34
+ """Override automatic color detection (None resets to auto)."""
35
+
36
+ global _COLOR_ENABLED
37
+ if enabled is None:
38
+ _COLOR_ENABLED = _detect_color()
39
+ else:
40
+ _COLOR_ENABLED = bool(enabled)
41
+
42
+
43
+ def color_enabled() -> bool:
44
+ return _COLOR_ENABLED
45
+
46
+
47
+ def _apply_style(text: str, style: str, *, enable_color: Optional[bool] = None) -> str:
48
+ use_color = _COLOR_ENABLED if enable_color is None else bool(enable_color)
49
+ if not use_color:
50
+ return text
51
+ return f"[{style}]{text}[/]"
52
+
53
+
54
+ def _style_by_sign(
55
+ value: float, text: str, *, enable_color: Optional[bool] = None
56
+ ) -> str:
57
+ if value > 0:
58
+ return _apply_style(text, "green", enable_color=enable_color)
59
+ if value < 0:
60
+ return _apply_style(text, "red", enable_color=enable_color)
61
+ return _apply_style(text, "grey50", enable_color=enable_color)
62
+
63
+
64
+ def color_by_sign(value: float, text: str) -> str:
65
+ return _style_by_sign(value, text)
66
+
67
+
68
+ def apply_sign(value: float, body: str, *, signed: bool = True) -> str:
69
+ if value < 0:
70
+ return f"-{body}"
71
+ if value > 0 and signed:
72
+ return f"+{body}"
73
+ return body
74
+
75
+
76
+ def number(value: float, decimals: int, *, signed: bool = True) -> str:
77
+ body = number_plain(abs(value), decimals)
78
+ text = apply_sign(value, body, signed=signed)
79
+ return _style_by_sign(value, text)
80
+
81
+
82
+ def number_plain(value: float, decimals: int) -> str:
83
+ return f"{value:.{decimals}f}"
84
+
85
+
86
+ def with_commas(value: float, decimals: int | None = None) -> str:
87
+ if decimals is None:
88
+ text = f"{value:,}"
89
+ else:
90
+ text = f"{value:,.{decimals}f}"
91
+ return _style_by_sign(value, text)
92
+
93
+
94
+ def compact(value: float) -> str:
95
+ v = float(value)
96
+ if not isfinite(v) or v == 0:
97
+ return _apply_style("0", "grey50")
98
+ abs_v = abs(v)
99
+ for threshold, suffix in SI_SUFFIXES:
100
+ if abs_v >= threshold:
101
+ short = v / threshold
102
+ text = f"{short:.1f}{suffix}"
103
+ return _style_by_sign(v, text)
104
+ text = f"{v:.0f}"
105
+ return _style_by_sign(v, text)
106
+
107
+
108
+ def bytes_fmt(n: int) -> str:
109
+ if n < 1024:
110
+ return _style_by_sign(n, f"{n} B")
111
+ units = ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]
112
+ v = float(n)
113
+ for unit in units:
114
+ v /= 1024.0
115
+ if abs(v) < 1024.0:
116
+ text = f"{v:.1f} {unit}"
117
+ return _style_by_sign(n, text)
118
+ return _style_by_sign(n, f"{v:.1f} ZiB")
119
+
120
+
121
+ def duration(ms: float) -> str:
122
+ if ms >= 10_000:
123
+ text = f"{ms / 1000:.1f}s"
124
+ elif ms >= 1000:
125
+ text = f"{ms / 1000:.2f}s"
126
+ else:
127
+ text = f"{ms:.0f}ms"
128
+ return text
129
+
130
+
131
+ def _percentage_decimals(value: float) -> int:
132
+ av = abs(value)
133
+ if av < 0.1:
134
+ return 2
135
+ if av >= 100:
136
+ return 0
137
+ return 1
138
+
139
+
140
+ def percentage(value: float, *, signed: bool = True) -> str:
141
+ d = _percentage_decimals(value)
142
+ body = f"{abs(value):.{d}f}%"
143
+ text = apply_sign(value, body, signed=signed)
144
+ return _style_by_sign(value, text)
145
+
146
+
147
+ def percentage_change(old: float, new: float, *, signed: bool = True) -> str:
148
+ if old == 0:
149
+ if new == 0:
150
+ return percentage(0.0, signed=signed)
151
+ return percentage(100.0 if new > 0 else -100.0, signed=signed)
152
+ pct = ((new - old) / abs(old)) * 100.0
153
+ return percentage(pct, signed=signed)
154
+
155
+
156
+ def percentage_diff(a: float, b: float, *, signed: bool = True) -> str:
157
+ if a == 0 and b == 0:
158
+ return percentage(0.0, signed=signed)
159
+ avg = (abs(a) + abs(b)) / 2.0
160
+ if avg == 0:
161
+ return percentage(0.0, signed=signed)
162
+ pct = (abs(a - b) / avg) * 100.0
163
+ return percentage(pct, signed=signed)
164
+
165
+
166
+ def bps(basis_points: int) -> str:
167
+ return f"{basis_points} bps"
168
+
169
+
170
+ def sign(value: float) -> str:
171
+ return "+" if value > 0 else ""
172
+
173
+
174
+ __all__ = [
175
+ "number",
176
+ "number_plain",
177
+ "with_commas",
178
+ "compact",
179
+ "bytes_fmt",
180
+ "duration",
181
+ "percentage",
182
+ "percentage_change",
183
+ "percentage_diff",
184
+ "bps",
185
+ "sign",
186
+ "color_by_sign",
187
+ "apply_sign",
188
+ "set_color_enabled",
189
+ "color_enabled",
190
+ ]
@@ -0,0 +1,450 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ import time
6
+ import traceback
7
+ from contextlib import contextmanager
8
+ from dataclasses import dataclass, replace
9
+ from typing import Any, Callable, Optional
10
+ import threading
11
+
12
+ try:
13
+ from rich.console import Console
14
+ from rich.text import Text
15
+ from rich.theme import Theme
16
+ from rich.status import Status
17
+ from rich.markup import MarkupError
18
+
19
+ HAVE_RICH = True
20
+ except Exception: # pragma: no cover - optional dependency
21
+ HAVE_RICH = False
22
+ Console = None # type: ignore
23
+
24
+
25
+ # Levels
26
+ _LEVELS = {"trace": 10, "debug": 20, "info": 30, "warn": 40, "error": 50, "fatal": 60}
27
+
28
+
29
+ def _env_level() -> str:
30
+ return os.getenv("LOG_LEVEL", "info").lower()
31
+
32
+
33
+ def _isatty() -> bool:
34
+ try:
35
+ return sys.stdout.isatty()
36
+ except Exception:
37
+ return False
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class _Config:
42
+ level: str = _env_level()
43
+ color_enabled: bool = (
44
+ os.getenv("NO_COLOR") is None or os.getenv("FORCE_COLOR") is not None
45
+ )
46
+ live_updates: bool = True
47
+ show_tracebacks: bool = True
48
+ time_enabled: bool = False
49
+ symbols_enabled: bool = True
50
+ indent_width: int = 2
51
+
52
+
53
+ _DEFAULT_THEME = Theme(
54
+ {
55
+ "info": "bold blue",
56
+ "warn": "bold yellow",
57
+ "error": "bold red",
58
+ "success": "bold green",
59
+ "ready": "bold green",
60
+ "wait": "bright_white",
61
+ "trace": "magenta",
62
+ "dim": "dim",
63
+ }
64
+ )
65
+
66
+
67
+ class _State(threading.local):
68
+ def __init__(self) -> None:
69
+ super().__init__()
70
+ self.indent: int = 0
71
+ self.warn_once_keys: set[str] = set()
72
+ self.timers: dict[str, float] = {}
73
+
74
+
75
+ class Logger:
76
+ """TTY-focused logger with tasks, steps, and indentation.
77
+
78
+ This logger writes to stdout. When attached to a terminal (TTY), it uses
79
+ colors and symbols (via Rich if installed). When not attached to a TTY,
80
+ it prints plain text and avoids live updates.
81
+ """
82
+
83
+ def __init__(
84
+ self, *, prefix: str | None = None, tags: tuple[str, ...] = ()
85
+ ) -> None:
86
+ self._cfg = _Config()
87
+ self._state = _State()
88
+ self._prefix = prefix
89
+ self._tags = tags
90
+
91
+ if HAVE_RICH:
92
+ self._console = Console(theme=_DEFAULT_THEME)
93
+ else:
94
+ self._console = None
95
+
96
+ self._sync_format_color()
97
+
98
+ # ----- configuration -----
99
+ def set_level(self, level: str) -> None:
100
+ self._cfg.level = level.lower()
101
+
102
+ def get_level(self) -> str:
103
+ return self._cfg.level
104
+
105
+ def enable_color(self, enabled: bool) -> None:
106
+ self._cfg.color_enabled = enabled
107
+ self._sync_format_color()
108
+
109
+ def enable_live_updates(self, enabled: bool) -> None:
110
+ self._cfg.live_updates = enabled
111
+
112
+ def set_time_enabled(self, enabled: bool) -> None:
113
+ self._cfg.time_enabled = enabled
114
+
115
+ def set_show_tracebacks(self, enabled: bool) -> None:
116
+ self._cfg.show_tracebacks = enabled
117
+
118
+ def set_symbols(self, enabled: bool) -> None:
119
+ self._cfg.symbols_enabled = enabled
120
+
121
+ # ----- derived props -----
122
+ @property
123
+ def _active_console(self) -> Optional[Console]:
124
+ return self._console if (HAVE_RICH and self._cfg.color_enabled) else None
125
+
126
+ @property
127
+ def _is_tty(self) -> bool:
128
+ if self._active_console is not None:
129
+ try:
130
+ return self._active_console.is_terminal
131
+ except Exception:
132
+ pass
133
+ return _isatty()
134
+
135
+ def _should_log(self, level: str) -> bool:
136
+ return _LEVELS[level] >= _LEVELS.get(self._cfg.level, 30)
137
+
138
+ # ----- helpers -----
139
+ def _indent_str(self) -> str:
140
+ return " " * (self._state.indent * self._cfg.indent_width)
141
+
142
+ def _prefix_text(self) -> str:
143
+ parts = []
144
+ if self._prefix:
145
+ parts.append(self._prefix)
146
+ if self._tags:
147
+ parts.extend(self._tags)
148
+ if not parts:
149
+ return ""
150
+ return f"[{' '.join(parts)}] "
151
+
152
+ def _symbol(self, kind: str) -> str:
153
+ if not self._cfg.symbols_enabled:
154
+ return ""
155
+ return {
156
+ "info": "ℹ",
157
+ "warn": "⚠",
158
+ "error": "⨯",
159
+ "fatal": "⨯",
160
+ "success": "✓",
161
+ "fail": "⨯",
162
+ "wait": "○",
163
+ "ready": "▶",
164
+ "trace": "»",
165
+ "step": "•",
166
+ "section": "▸",
167
+ }.get(kind, "")
168
+
169
+ def _color_active(self) -> bool:
170
+ return self._cfg.color_enabled and self._is_tty
171
+
172
+ def _sync_format_color(self) -> None:
173
+ try:
174
+ from . import format as _format
175
+
176
+ _format.set_color_enabled(self._color_active())
177
+ except Exception:
178
+ pass
179
+
180
+ def _coerce_text(self, message: Any) -> Text:
181
+ if isinstance(message, Text):
182
+ return message
183
+ msg_str = str(message)
184
+ try:
185
+ return Text.from_markup(msg_str, emoji=False)
186
+ except (MarkupError, ValueError):
187
+ return Text(msg_str)
188
+
189
+ def _write_line(self, kind: str, message: Any) -> None:
190
+ prefix = self._prefix_text()
191
+ indent = self._indent_str()
192
+ sym = self._symbol(kind)
193
+
194
+ if self._active_console and self._is_tty:
195
+ style = {
196
+ "info": "info",
197
+ "warn": "warn",
198
+ "error": "error",
199
+ "fatal": "error",
200
+ "success": "success",
201
+ "fail": "error",
202
+ "wait": "wait",
203
+ "ready": "ready",
204
+ "trace": "trace",
205
+ "step": "dim",
206
+ "section": "dim",
207
+ }.get(kind, "info")
208
+
209
+ pieces: list[Text] = []
210
+ if indent:
211
+ pieces.append(Text(indent))
212
+ if prefix:
213
+ pieces.append(Text(prefix, style="dim"))
214
+ if sym:
215
+ pieces.append(Text(f"{sym} ", style=style))
216
+ pieces.append(self._coerce_text(message))
217
+ self._active_console.print(Text.assemble(*pieces))
218
+ else:
219
+ # Plain text fallback
220
+ plain = str(message)
221
+ if HAVE_RICH:
222
+ try:
223
+ plain = Text.from_markup(plain, emoji=False).plain
224
+ except (MarkupError, ValueError):
225
+ plain = str(message)
226
+ base = f"{prefix}{(sym + ' ') if sym else ''}{plain}"
227
+ sys.stdout.write(indent + base + "\n")
228
+ sys.stdout.flush()
229
+
230
+ # ----- public logging -----
231
+ def trace(self, message: str) -> None:
232
+ if self._should_log("trace"):
233
+ self._write_line("trace", message)
234
+
235
+ def debug(self, message: str) -> None:
236
+ if self._should_log("debug"):
237
+ self._write_line("trace", message) # use trace style for visual distinction
238
+
239
+ def info(self, message: str) -> None:
240
+ if self._should_log("info"):
241
+ self._write_line("info", message)
242
+
243
+ def warn(self, message: str) -> None:
244
+ if self._should_log("warn"):
245
+ self._write_line("warn", message)
246
+
247
+ def warn_once(self, message: str) -> None:
248
+ key = message
249
+ if key in self._state.warn_once_keys:
250
+ return
251
+ self._state.warn_once_keys.add(key)
252
+ self.warn(message)
253
+
254
+ def error(self, msg_or_exc: Any, *, exc: bool | None = None) -> None:
255
+ if not self._should_log("error"):
256
+ return
257
+ message = str(msg_or_exc)
258
+ self._write_line("error", message)
259
+ want_tb = exc or isinstance(msg_or_exc, BaseException)
260
+ if want_tb and self._cfg.show_tracebacks:
261
+ tb = traceback.format_exc()
262
+ for line in tb.strip().splitlines():
263
+ self._with_extra_indent(lambda: self._write_line("step", line))
264
+
265
+ def fatal(
266
+ self, msg_or_exc: Any, *, exit_code: int = 1, exc: bool | None = None
267
+ ) -> None:
268
+ self.error(msg_or_exc, exc=exc)
269
+ try:
270
+ sys.exit(exit_code)
271
+ except SystemExit:
272
+ raise
273
+
274
+ def success(self, message: str) -> None:
275
+ if self._should_log("info"):
276
+ self._write_line("success", message)
277
+
278
+ def fail(self, message: str) -> None:
279
+ if self._should_log("error"):
280
+ self._write_line("fail", message)
281
+
282
+ def event(self, message: str) -> None:
283
+ if self._should_log("info"):
284
+ self._write_line("info", message)
285
+
286
+ def wait(self, message: str) -> None:
287
+ if self._should_log("info"):
288
+ self._write_line("wait", message)
289
+
290
+ def ready(self, message: str) -> None:
291
+ if self._should_log("info"):
292
+ self._write_line("ready", message)
293
+
294
+ # ----- indentation & grouping -----
295
+ @contextmanager
296
+ def _with_extra_indent(self, fn: Optional[Callable[[], None]] = None):
297
+ self._state.indent += 1
298
+ try:
299
+ if fn is not None:
300
+ fn()
301
+ else:
302
+ yield
303
+ finally:
304
+ self._state.indent -= 1
305
+
306
+ def step(self, message: str) -> None:
307
+ self._with_extra_indent(lambda: self._write_line("step", message))
308
+
309
+ @contextmanager
310
+ def section(self, title: str):
311
+ self._write_line("section", title)
312
+ with self._with_extra_indent():
313
+ yield
314
+
315
+ @contextmanager
316
+ def task(self, title: str):
317
+ start = time.perf_counter()
318
+ self._write_line("wait", title)
319
+ self._state.indent += 1
320
+ failed = False
321
+ try:
322
+ yield
323
+ except BaseException:
324
+ failed = True
325
+ raise
326
+ finally:
327
+ self._state.indent -= 1
328
+ dur_ms = (time.perf_counter() - start) * 1000.0
329
+ from .format import duration as _dur
330
+
331
+ if failed:
332
+ self._write_line("fail", f"{title} ({_dur(dur_ms)})")
333
+ if self._cfg.show_tracebacks:
334
+ tb = traceback.format_exc()
335
+ for line in tb.strip().splitlines():
336
+ self._with_extra_indent(lambda: self._write_line("step", line))
337
+ else:
338
+ self._write_line("success", f"{title} ({_dur(dur_ms)})")
339
+
340
+ def step_run(self, title: str, fn: Callable[..., Any], *args, **kwargs) -> Any:
341
+ with self.task(title):
342
+ return fn(*args, **kwargs)
343
+
344
+ # ----- timers -----
345
+ def time(self, label: str) -> None:
346
+ self._state.timers[label] = time.perf_counter()
347
+
348
+ def time_end(self, label: str, *, level: str = "trace") -> float:
349
+ start = self._state.timers.pop(label, None)
350
+ if start is None:
351
+ self.warn(f"Timer '{label}' does not exist")
352
+ return 0.0
353
+ ms = (time.perf_counter() - start) * 1000.0
354
+ from .format import duration as _dur
355
+
356
+ if self._should_log(level):
357
+ self._write_line("trace", f"{label}: {_dur(ms)}")
358
+ return ms
359
+
360
+ # ----- progress -----
361
+ class _Progress:
362
+ def __init__(
363
+ self, logger: "Logger", total: int | None, title: str | None
364
+ ) -> None:
365
+ self.logger = logger
366
+ self.total = total
367
+ self.title = title or ""
368
+ self.count = 0
369
+ self._start = time.perf_counter()
370
+ self._status: Optional[Status] = None
371
+
372
+ if (
373
+ HAVE_RICH
374
+ and logger._active_console is not None
375
+ and logger._is_tty
376
+ and logger._cfg.live_updates
377
+ ):
378
+ msg = (
379
+ logger._indent_str()
380
+ + (logger._prefix_text() or "")
381
+ + f"{self.title}…"
382
+ )
383
+ self._status = logger._active_console.status(msg, spinner="dots")
384
+ self._status.start()
385
+
386
+ def tick(self) -> None:
387
+ self.update(1)
388
+
389
+ def update(self, n: int = 1) -> None:
390
+ self.count += n
391
+ if self._status is not None:
392
+ suffix = (
393
+ f" {self.count}/{self.total}"
394
+ if self.total is not None
395
+ else f" {self.count}"
396
+ )
397
+ msg = (
398
+ self.logger._indent_str()
399
+ + (self.logger._prefix_text() or "")
400
+ + f"{self.title}{suffix}…"
401
+ )
402
+ self._status.update(msg)
403
+
404
+ def done(self, *, success: bool = True) -> None:
405
+ if self._status is not None:
406
+ self._status.stop()
407
+ dur_ms = (time.perf_counter() - self._start) * 1000.0
408
+ from .format import duration as _dur
409
+
410
+ suffix = (
411
+ f" ({self.count}/{self.total}, {_dur(dur_ms)})"
412
+ if self.total is not None
413
+ else f" ({self.count}, {_dur(dur_ms)})"
414
+ )
415
+ line = (
416
+ f"{self.title}{suffix}"
417
+ if self.title
418
+ else (
419
+ f"{self.count}/{self.total} ({_dur(dur_ms)})"
420
+ if self.total is not None
421
+ else f"{self.count} ({_dur(dur_ms)})"
422
+ )
423
+ )
424
+ if success:
425
+ self.logger._write_line("success", line)
426
+ else:
427
+ self.logger._write_line("fail", line)
428
+
429
+ def progress(
430
+ self, total: int | None = None, title: str | None = None
431
+ ) -> "Logger._Progress":
432
+ return Logger._Progress(self, total, title)
433
+
434
+ # ----- context binding -----
435
+ def with_prefix(self, text: str) -> "Logger":
436
+ return self._child(prefix=text, tags=self._tags)
437
+
438
+ def tag(self, *tags: str) -> "Logger":
439
+ return self._child(prefix=self._prefix, tags=self._tags + tuple(tags))
440
+
441
+ def _child(self, *, prefix: str | None, tags: tuple[str, ...]) -> "Logger":
442
+ child = Logger(prefix=prefix, tags=tags)
443
+ child._cfg = replace(self._cfg)
444
+ child._state = self._state # share indent, timers, warn_once within thread
445
+ child._console = self._console
446
+ return child
447
+
448
+
449
+ # Global logger instance
450
+ log = Logger()
@@ -0,0 +1,85 @@
1
+ """XDG Base Directory Specification.
2
+
3
+ Exposes XDG base directories respecting environment variables.
4
+ https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
5
+
6
+ Usage:
7
+ from py_utils import xdg
8
+
9
+ config_file = xdg.config / "myapp" / "config.toml"
10
+ data_file = xdg.data / "myapp" / "data.db"
11
+
12
+ TODO: Consider fallbacks if env vars not set
13
+ - Option 1: Use platformdirs dependency
14
+ - Option 2: Bespoke cross-platform implementation to avoid extra dep
15
+ Current: Fails if env vars not set (offensive programming)
16
+ """
17
+
18
+ import os
19
+ from pathlib import Path
20
+
21
+
22
+ class _XDG:
23
+ """XDG base directory paths."""
24
+
25
+ @property
26
+ def config(self) -> Path:
27
+ """XDG_CONFIG_HOME - User-specific configuration files.
28
+
29
+ Raises:
30
+ RuntimeError: If XDG_CONFIG_HOME is not set
31
+ """
32
+ env_path = os.getenv("XDG_CONFIG_HOME")
33
+ assert env_path, "XDG_CONFIG_HOME not set"
34
+ return Path(env_path)
35
+
36
+ @property
37
+ def data(self) -> Path:
38
+ """XDG_DATA_HOME - User-specific data files.
39
+
40
+ Raises:
41
+ RuntimeError: If XDG_DATA_HOME is not set
42
+ """
43
+ env_path = os.getenv("XDG_DATA_HOME")
44
+ assert env_path, "XDG_DATA_HOME not set"
45
+ return Path(env_path)
46
+
47
+ @property
48
+ def cache(self) -> Path:
49
+ """XDG_CACHE_HOME - User-specific non-essential cached data.
50
+
51
+ Raises:
52
+ RuntimeError: If XDG_CACHE_HOME is not set
53
+ """
54
+ env_path = os.getenv("XDG_CACHE_HOME")
55
+ assert env_path, "XDG_CACHE_HOME not set"
56
+ return Path(env_path)
57
+
58
+ @property
59
+ def state(self) -> Path:
60
+ """XDG_STATE_HOME - User-specific state files.
61
+
62
+ Raises:
63
+ RuntimeError: If XDG_STATE_HOME is not set
64
+ """
65
+ env_path = os.getenv("XDG_STATE_HOME")
66
+ assert env_path, "XDG_STATE_HOME not set"
67
+ return Path(env_path)
68
+
69
+ @property
70
+ def runtime(self) -> Path:
71
+ """XDG_RUNTIME_DIR - User-specific runtime files.
72
+
73
+ Raises:
74
+ RuntimeError: If XDG_RUNTIME_DIR is not set
75
+ """
76
+ env_path = os.getenv("XDG_RUNTIME_DIR")
77
+ assert env_path, "XDG_RUNTIME_DIR not set"
78
+ return Path(env_path)
79
+
80
+
81
+ # Module-level instance for clean imports
82
+ # Usage: from py_utils import xdg; xdg.config
83
+ xdg = _XDG()
84
+
85
+ __all__ = ["xdg"]