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.
- adrian_utils-0.1.1/PKG-INFO +91 -0
- adrian_utils-0.1.1/README.md +81 -0
- adrian_utils-0.1.1/pyproject.toml +13 -0
- adrian_utils-0.1.1/setup.cfg +4 -0
- adrian_utils-0.1.1/src/adrian_utils.egg-info/PKG-INFO +91 -0
- adrian_utils-0.1.1/src/adrian_utils.egg-info/SOURCES.txt +12 -0
- adrian_utils-0.1.1/src/adrian_utils.egg-info/dependency_links.txt +1 -0
- adrian_utils-0.1.1/src/adrian_utils.egg-info/requires.txt +2 -0
- adrian_utils-0.1.1/src/adrian_utils.egg-info/top_level.txt +1 -0
- adrian_utils-0.1.1/src/py_utils/__init__.py +81 -0
- adrian_utils-0.1.1/src/py_utils/currency.py +252 -0
- adrian_utils-0.1.1/src/py_utils/format.py +190 -0
- adrian_utils-0.1.1/src/py_utils/log.py +450 -0
- adrian_utils-0.1.1/src/py_utils/xdg.py +85 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
py_utils
|
|
@@ -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"]
|