anvil-atui 1.0.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.
- anvil_atui-1.0.0/LICENSE +21 -0
- anvil_atui-1.0.0/PKG-INFO +227 -0
- anvil_atui-1.0.0/README.md +217 -0
- anvil_atui-1.0.0/anvil/__init__.py +3 -0
- anvil_atui-1.0.0/anvil/console.py +433 -0
- anvil_atui-1.0.0/anvil_atui.egg-info/PKG-INFO +227 -0
- anvil_atui-1.0.0/anvil_atui.egg-info/SOURCES.txt +9 -0
- anvil_atui-1.0.0/anvil_atui.egg-info/dependency_links.txt +1 -0
- anvil_atui-1.0.0/anvil_atui.egg-info/top_level.txt +1 -0
- anvil_atui-1.0.0/pyproject.toml +11 -0
- anvil_atui-1.0.0/setup.cfg +4 -0
anvil_atui-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Davozen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: anvil-atui
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Console output manager with spinners, progress bars, and styled UI
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Dynamic: license-file
|
|
10
|
+
|
|
11
|
+
# Anvil
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
Styled console output for Python. Zero dependencies.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install anvil
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from anvil import console as ui
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Messages
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
ui.info("loading config")
|
|
33
|
+
# → loading config
|
|
34
|
+
|
|
35
|
+
ui.success("done")
|
|
36
|
+
# ✓ done
|
|
37
|
+
|
|
38
|
+
ui.warning("file is outdated")
|
|
39
|
+
# ! file is outdated
|
|
40
|
+
|
|
41
|
+
ui.error("connection failed")
|
|
42
|
+
# ✗ connection failed
|
|
43
|
+
|
|
44
|
+
ui.step("intermediate step")
|
|
45
|
+
# · intermediate step
|
|
46
|
+
|
|
47
|
+
ui.debug("value x = 42") # only when verbose=True
|
|
48
|
+
# · value x = 42
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Sections
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
ui.title("Setup")
|
|
55
|
+
|
|
56
|
+
with ui.section("Check dependencies"):
|
|
57
|
+
ui.step("python3")
|
|
58
|
+
ui.step("git")
|
|
59
|
+
|
|
60
|
+
# output:
|
|
61
|
+
# ─────────────────────────
|
|
62
|
+
# Setup
|
|
63
|
+
# ─────────────────────────
|
|
64
|
+
# Check dependencies
|
|
65
|
+
# · python3
|
|
66
|
+
# · git
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Spinner
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
with ui.spinner("connecting to server") as sp:
|
|
73
|
+
connect()
|
|
74
|
+
sp.set_final("connected")
|
|
75
|
+
|
|
76
|
+
# output during work:
|
|
77
|
+
# ⟳ connecting to server
|
|
78
|
+
|
|
79
|
+
# output after completion:
|
|
80
|
+
# ✓ connected
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Progress
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
files = ["a.txt", "b.txt", "c.txt"]
|
|
87
|
+
with ui.progress("copying files", total=len(files)) as bar:
|
|
88
|
+
for i, f in enumerate(files, 1):
|
|
89
|
+
copy(f)
|
|
90
|
+
bar.update(i)
|
|
91
|
+
|
|
92
|
+
# output:
|
|
93
|
+
# copying files ████████░░░░░░░░░░░░░░░░ 50% (2/3)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Task
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
with ui.task("Compile project"):
|
|
100
|
+
build()
|
|
101
|
+
|
|
102
|
+
# output on success:
|
|
103
|
+
# ✓ Compile project (2.3s)
|
|
104
|
+
|
|
105
|
+
# output on failure:
|
|
106
|
+
# ✗ Compile project
|
|
107
|
+
# ✗ connection failed
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Numbered steps
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
steps = ["Download", "Extract", "Check", "Install"]
|
|
114
|
+
for i, name in enumerate(steps, 1):
|
|
115
|
+
with ui.numbered_step(i, len(steps), name):
|
|
116
|
+
do_step(i)
|
|
117
|
+
|
|
118
|
+
# output:
|
|
119
|
+
# [1/4] Download
|
|
120
|
+
# [2/4] Extract
|
|
121
|
+
# [3/4] Check
|
|
122
|
+
# [4/4] Install
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Info block
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
ui.info_block("my-package", {"mp3": 12, "txt": 3}, footer="total 15 files")
|
|
129
|
+
|
|
130
|
+
# output:
|
|
131
|
+
# ─────────────────────────
|
|
132
|
+
# my-package
|
|
133
|
+
# mp3 12 · txt 3
|
|
134
|
+
# total 15 files
|
|
135
|
+
# ─────────────────────────
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Table
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
ui.table(["File", "Size", "Status"], [
|
|
142
|
+
["a.mp3", "3.2 MB", "ok"],
|
|
143
|
+
["b.txt", "1 KB", "ok"],
|
|
144
|
+
])
|
|
145
|
+
|
|
146
|
+
# output:
|
|
147
|
+
# File Size Status
|
|
148
|
+
# ─────────────────────────
|
|
149
|
+
# a.mp3 3.2 MB ok
|
|
150
|
+
# b.txt 1 KB ok
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Box
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
ui.box("Warning: action cannot be undone!", style="warn")
|
|
157
|
+
|
|
158
|
+
# output:
|
|
159
|
+
# ┌──────────────────────────┐
|
|
160
|
+
# │ Warning: action cannot be undone! │
|
|
161
|
+
# └──────────────────────────┘
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Input
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
name = ui.ask("Enter name", default="guest")
|
|
168
|
+
# Enter name [guest]: →
|
|
169
|
+
|
|
170
|
+
ok = ui.confirm("Continue?", default=True)
|
|
171
|
+
# Continue? [Y/n]: →
|
|
172
|
+
|
|
173
|
+
choice = ui.select("Environment", ["dev", "staging", "prod"])
|
|
174
|
+
# Environment
|
|
175
|
+
# 1) dev
|
|
176
|
+
# 2) staging
|
|
177
|
+
# 3) prod
|
|
178
|
+
# Your choice [1-3, default 1]: →
|
|
179
|
+
|
|
180
|
+
secret = ui.password("Access token")
|
|
181
|
+
# Access token: →
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Timer
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
with ui.timer("full run"):
|
|
188
|
+
do_everything()
|
|
189
|
+
|
|
190
|
+
# output:
|
|
191
|
+
# · full run: 12.5s
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Run wrapper
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
def main():
|
|
198
|
+
...
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
ui.run(main)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Catches Ctrl+C and unhandled exceptions. Exits with code 130 on interrupt, 1 on error.
|
|
205
|
+
|
|
206
|
+
## Environment
|
|
207
|
+
|
|
208
|
+
| Variable | Effect |
|
|
209
|
+
|----------|--------|
|
|
210
|
+
| `NO_COLOR=1` | No color |
|
|
211
|
+
| `NO_MOTION=1` | No animation |
|
|
212
|
+
| No TTY | Animation disabled |
|
|
213
|
+
| No truecolor | 16-color fallback |
|
|
214
|
+
| No UTF-8 | ASCII fallback |
|
|
215
|
+
|
|
216
|
+
## Singleton
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
from anvil import console, get_manager
|
|
220
|
+
|
|
221
|
+
ui = console
|
|
222
|
+
ui = get_manager()
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## License
|
|
226
|
+
|
|
227
|
+
MIT - see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Anvil
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
Styled console output for Python. Zero dependencies.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install anvil
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from anvil import console as ui
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Messages
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
ui.info("loading config")
|
|
23
|
+
# → loading config
|
|
24
|
+
|
|
25
|
+
ui.success("done")
|
|
26
|
+
# ✓ done
|
|
27
|
+
|
|
28
|
+
ui.warning("file is outdated")
|
|
29
|
+
# ! file is outdated
|
|
30
|
+
|
|
31
|
+
ui.error("connection failed")
|
|
32
|
+
# ✗ connection failed
|
|
33
|
+
|
|
34
|
+
ui.step("intermediate step")
|
|
35
|
+
# · intermediate step
|
|
36
|
+
|
|
37
|
+
ui.debug("value x = 42") # only when verbose=True
|
|
38
|
+
# · value x = 42
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Sections
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
ui.title("Setup")
|
|
45
|
+
|
|
46
|
+
with ui.section("Check dependencies"):
|
|
47
|
+
ui.step("python3")
|
|
48
|
+
ui.step("git")
|
|
49
|
+
|
|
50
|
+
# output:
|
|
51
|
+
# ─────────────────────────
|
|
52
|
+
# Setup
|
|
53
|
+
# ─────────────────────────
|
|
54
|
+
# Check dependencies
|
|
55
|
+
# · python3
|
|
56
|
+
# · git
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Spinner
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
with ui.spinner("connecting to server") as sp:
|
|
63
|
+
connect()
|
|
64
|
+
sp.set_final("connected")
|
|
65
|
+
|
|
66
|
+
# output during work:
|
|
67
|
+
# ⟳ connecting to server
|
|
68
|
+
|
|
69
|
+
# output after completion:
|
|
70
|
+
# ✓ connected
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Progress
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
files = ["a.txt", "b.txt", "c.txt"]
|
|
77
|
+
with ui.progress("copying files", total=len(files)) as bar:
|
|
78
|
+
for i, f in enumerate(files, 1):
|
|
79
|
+
copy(f)
|
|
80
|
+
bar.update(i)
|
|
81
|
+
|
|
82
|
+
# output:
|
|
83
|
+
# copying files ████████░░░░░░░░░░░░░░░░ 50% (2/3)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Task
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
with ui.task("Compile project"):
|
|
90
|
+
build()
|
|
91
|
+
|
|
92
|
+
# output on success:
|
|
93
|
+
# ✓ Compile project (2.3s)
|
|
94
|
+
|
|
95
|
+
# output on failure:
|
|
96
|
+
# ✗ Compile project
|
|
97
|
+
# ✗ connection failed
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Numbered steps
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
steps = ["Download", "Extract", "Check", "Install"]
|
|
104
|
+
for i, name in enumerate(steps, 1):
|
|
105
|
+
with ui.numbered_step(i, len(steps), name):
|
|
106
|
+
do_step(i)
|
|
107
|
+
|
|
108
|
+
# output:
|
|
109
|
+
# [1/4] Download
|
|
110
|
+
# [2/4] Extract
|
|
111
|
+
# [3/4] Check
|
|
112
|
+
# [4/4] Install
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Info block
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
ui.info_block("my-package", {"mp3": 12, "txt": 3}, footer="total 15 files")
|
|
119
|
+
|
|
120
|
+
# output:
|
|
121
|
+
# ─────────────────────────
|
|
122
|
+
# my-package
|
|
123
|
+
# mp3 12 · txt 3
|
|
124
|
+
# total 15 files
|
|
125
|
+
# ─────────────────────────
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Table
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
ui.table(["File", "Size", "Status"], [
|
|
132
|
+
["a.mp3", "3.2 MB", "ok"],
|
|
133
|
+
["b.txt", "1 KB", "ok"],
|
|
134
|
+
])
|
|
135
|
+
|
|
136
|
+
# output:
|
|
137
|
+
# File Size Status
|
|
138
|
+
# ─────────────────────────
|
|
139
|
+
# a.mp3 3.2 MB ok
|
|
140
|
+
# b.txt 1 KB ok
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Box
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
ui.box("Warning: action cannot be undone!", style="warn")
|
|
147
|
+
|
|
148
|
+
# output:
|
|
149
|
+
# ┌──────────────────────────┐
|
|
150
|
+
# │ Warning: action cannot be undone! │
|
|
151
|
+
# └──────────────────────────┘
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Input
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
name = ui.ask("Enter name", default="guest")
|
|
158
|
+
# Enter name [guest]: →
|
|
159
|
+
|
|
160
|
+
ok = ui.confirm("Continue?", default=True)
|
|
161
|
+
# Continue? [Y/n]: →
|
|
162
|
+
|
|
163
|
+
choice = ui.select("Environment", ["dev", "staging", "prod"])
|
|
164
|
+
# Environment
|
|
165
|
+
# 1) dev
|
|
166
|
+
# 2) staging
|
|
167
|
+
# 3) prod
|
|
168
|
+
# Your choice [1-3, default 1]: →
|
|
169
|
+
|
|
170
|
+
secret = ui.password("Access token")
|
|
171
|
+
# Access token: →
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Timer
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
with ui.timer("full run"):
|
|
178
|
+
do_everything()
|
|
179
|
+
|
|
180
|
+
# output:
|
|
181
|
+
# · full run: 12.5s
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Run wrapper
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
def main():
|
|
188
|
+
...
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
ui.run(main)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Catches Ctrl+C and unhandled exceptions. Exits with code 130 on interrupt, 1 on error.
|
|
195
|
+
|
|
196
|
+
## Environment
|
|
197
|
+
|
|
198
|
+
| Variable | Effect |
|
|
199
|
+
|----------|--------|
|
|
200
|
+
| `NO_COLOR=1` | No color |
|
|
201
|
+
| `NO_MOTION=1` | No animation |
|
|
202
|
+
| No TTY | Animation disabled |
|
|
203
|
+
| No truecolor | 16-color fallback |
|
|
204
|
+
| No UTF-8 | ASCII fallback |
|
|
205
|
+
|
|
206
|
+
## Singleton
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
from anvil import console, get_manager
|
|
210
|
+
|
|
211
|
+
ui = console
|
|
212
|
+
ui = get_manager()
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## License
|
|
216
|
+
|
|
217
|
+
MIT - see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import traceback
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from typing import Any, Callable, Optional, Sequence, Tuple
|
|
15
|
+
|
|
16
|
+
RGB = Tuple[int, int, int]
|
|
17
|
+
|
|
18
|
+
DEFAULT_PALETTE = {
|
|
19
|
+
"accent_start": (56, 189, 248),
|
|
20
|
+
"accent_end": (16, 185, 129),
|
|
21
|
+
"err": (248, 113, 113),
|
|
22
|
+
"warn": (250, 204, 21),
|
|
23
|
+
"dim": (113, 113, 122),
|
|
24
|
+
"fg": (244, 244, 245),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_PARTIALS = " ▏▎▍▌▋▊▉█"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _lerp(a: RGB, b: RGB, t: float) -> RGB:
|
|
31
|
+
t = max(0.0, min(1.0, t))
|
|
32
|
+
return tuple(round(a[i] + (b[i] - a[i]) * t) for i in range(3))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _supports_truecolor() -> bool:
|
|
36
|
+
ct = os.environ.get("COLORTERM", "")
|
|
37
|
+
if ct in ("truecolor", "24bit"):
|
|
38
|
+
return True
|
|
39
|
+
term = os.environ.get("TERM", "")
|
|
40
|
+
return "256color" in term or "kitty" in term or "wezterm" in term or "iterm" in term.lower()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _format_duration(seconds: float) -> str:
|
|
44
|
+
if seconds < 60:
|
|
45
|
+
return f"{seconds:.1f}с"
|
|
46
|
+
m, s = divmod(int(round(seconds)), 60)
|
|
47
|
+
if m < 60:
|
|
48
|
+
return f"{m}хв {s}с"
|
|
49
|
+
h, m = divmod(m, 60)
|
|
50
|
+
return f"{h}год {m}хв"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ConsoleManager:
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
palette: Optional[dict] = None,
|
|
57
|
+
force_color: Optional[bool] = None,
|
|
58
|
+
force_unicode: Optional[bool] = None,
|
|
59
|
+
force_live: Optional[bool] = None,
|
|
60
|
+
verbose: bool = False,
|
|
61
|
+
):
|
|
62
|
+
p = {**DEFAULT_PALETTE, **(palette or {})}
|
|
63
|
+
self._accent_start = p["accent_start"]
|
|
64
|
+
self._accent_end = p["accent_end"]
|
|
65
|
+
self._err_c = p["err"]
|
|
66
|
+
self._warn_c = p["warn"]
|
|
67
|
+
self._dim_c = p["dim"]
|
|
68
|
+
self._fg_c = p["fg"]
|
|
69
|
+
|
|
70
|
+
self.color = force_color if force_color is not None else self._can_color()
|
|
71
|
+
self.truecolor = self.color and _supports_truecolor()
|
|
72
|
+
self.unicode = force_unicode if force_unicode is not None else self._can_unicode()
|
|
73
|
+
self.live = (
|
|
74
|
+
force_live
|
|
75
|
+
if force_live is not None
|
|
76
|
+
else (bool(sys.stdout.isatty()) and not os.environ.get("NO_MOTION"))
|
|
77
|
+
)
|
|
78
|
+
self.width = shutil.get_terminal_size(fallback=(80, 24)).columns
|
|
79
|
+
self.verbose = verbose
|
|
80
|
+
|
|
81
|
+
self._lock = threading.RLock()
|
|
82
|
+
self._indent = 0
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _can_color() -> bool:
|
|
86
|
+
if os.environ.get("NO_COLOR"):
|
|
87
|
+
return False
|
|
88
|
+
if not sys.stdout.isatty():
|
|
89
|
+
return False
|
|
90
|
+
term = os.environ.get("TERM", "dumb")
|
|
91
|
+
return term != "dumb"
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _can_unicode() -> bool:
|
|
95
|
+
enc = sys.stdout.encoding or ""
|
|
96
|
+
return enc.lower().startswith("utf")
|
|
97
|
+
|
|
98
|
+
def fg(self, rgb: RGB, text: str) -> str:
|
|
99
|
+
if not self.color:
|
|
100
|
+
return text
|
|
101
|
+
if self.truecolor:
|
|
102
|
+
return f"\x1b[38;2;{rgb[0]};{rgb[1]};{rgb[2]}m{text}\x1b[0m"
|
|
103
|
+
code = self._nearest_ansi16(rgb)
|
|
104
|
+
return f"\x1b[{code}m{text}\x1b[0m"
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _nearest_ansi16(rgb: RGB) -> int:
|
|
108
|
+
table = {
|
|
109
|
+
30: (0, 0, 0), 31: (205, 49, 49), 32: (13, 188, 121),
|
|
110
|
+
33: (229, 229, 16), 34: (36, 114, 200), 35: (188, 63, 188),
|
|
111
|
+
36: (17, 168, 205), 37: (229, 229, 229),
|
|
112
|
+
}
|
|
113
|
+
best = min(table.items(), key=lambda kv: sum((c1 - c2) ** 2 for c1, c2 in zip(kv[1], rgb)))
|
|
114
|
+
return best[0]
|
|
115
|
+
|
|
116
|
+
def ok(self, t: str) -> str: return self.fg(self._accent_end, t)
|
|
117
|
+
def work(self, t: str) -> str: return self.fg(self._accent_start, t)
|
|
118
|
+
def err(self, t: str) -> str: return self.fg(self._err_c, t)
|
|
119
|
+
def warn(self, t: str) -> str: return self.fg(self._warn_c, t)
|
|
120
|
+
def dim(self, t: str) -> str: return self.fg(self._dim_c, t)
|
|
121
|
+
def bold(self, t: str) -> str: return f"\x1b[1m{t}\x1b[0m" if self.color else t
|
|
122
|
+
|
|
123
|
+
def rule(self, width: Optional[int] = None) -> str:
|
|
124
|
+
w = width or min(self.width, 60)
|
|
125
|
+
ch = "─" if self.unicode else "-"
|
|
126
|
+
return self.dim(ch * w)
|
|
127
|
+
|
|
128
|
+
def check(self) -> str: return self.ok("✓") if self.unicode else self.ok("[ok]")
|
|
129
|
+
def cross(self) -> str: return self.err("✗") if self.unicode else self.err("[x]")
|
|
130
|
+
def bullet(self) -> str: return "•" if self.unicode else "*"
|
|
131
|
+
|
|
132
|
+
def _write(self, text: str = "") -> None:
|
|
133
|
+
with self._lock:
|
|
134
|
+
prefix = " " * self._indent
|
|
135
|
+
if prefix and text:
|
|
136
|
+
text = "\n".join(prefix + line if line else line for line in text.split("\n"))
|
|
137
|
+
print(text)
|
|
138
|
+
|
|
139
|
+
def info(self, text: str) -> None:
|
|
140
|
+
self._write(self.work(self.bullet()) + " " + text)
|
|
141
|
+
|
|
142
|
+
def success(self, text: str) -> None:
|
|
143
|
+
self._write(self.check() + " " + text)
|
|
144
|
+
|
|
145
|
+
def error(self, text: str) -> None:
|
|
146
|
+
self._write(self.cross() + " " + self.err(text))
|
|
147
|
+
|
|
148
|
+
def warning(self, text: str) -> None:
|
|
149
|
+
self._write(self.warn("!") + " " + text)
|
|
150
|
+
|
|
151
|
+
def debug(self, text: str) -> None:
|
|
152
|
+
if self.verbose:
|
|
153
|
+
self._write(self.dim("· " + text))
|
|
154
|
+
|
|
155
|
+
def step(self, text: str) -> None:
|
|
156
|
+
self._write(self.dim(self.bullet()) + " " + text)
|
|
157
|
+
|
|
158
|
+
def title(self, text: str) -> None:
|
|
159
|
+
self._write("")
|
|
160
|
+
self._write(self.rule())
|
|
161
|
+
self._write(self.bold(self.fg(self._fg_c, text)))
|
|
162
|
+
self._write(self.rule())
|
|
163
|
+
|
|
164
|
+
@contextmanager
|
|
165
|
+
def section(self, title: str):
|
|
166
|
+
self._write(self.bold(self.fg(self._fg_c, title)))
|
|
167
|
+
self._indent += 1
|
|
168
|
+
try:
|
|
169
|
+
yield self
|
|
170
|
+
finally:
|
|
171
|
+
self._indent -= 1
|
|
172
|
+
|
|
173
|
+
def info_block(self, name: str, fields: dict[str, Any], footer: Optional[str] = None) -> None:
|
|
174
|
+
self._write("")
|
|
175
|
+
self._write(self.rule())
|
|
176
|
+
self._write(self.bold(self.fg(self._fg_c, name)))
|
|
177
|
+
dot = " · " if self.unicode else " . "
|
|
178
|
+
line = dot.join(f"{self.dim(str(k))} {self.bold(str(v))}" for k, v in fields.items())
|
|
179
|
+
if line:
|
|
180
|
+
self._write(line)
|
|
181
|
+
if footer:
|
|
182
|
+
self._write(self.dim(footer))
|
|
183
|
+
self._write(self.rule())
|
|
184
|
+
self._write("")
|
|
185
|
+
|
|
186
|
+
def table(self, headers: Sequence[str], rows: Sequence[Sequence[Any]]) -> None:
|
|
187
|
+
cols = len(headers)
|
|
188
|
+
str_rows = [[str(c) for c in row] for row in rows]
|
|
189
|
+
widths = [len(h) for h in headers]
|
|
190
|
+
for row in str_rows:
|
|
191
|
+
for i in range(cols):
|
|
192
|
+
widths[i] = max(widths[i], len(row[i]) if i < len(row) else 0)
|
|
193
|
+
|
|
194
|
+
sep = " "
|
|
195
|
+
header_line = sep.join(self.bold(h.ljust(widths[i])) for i, h in enumerate(headers))
|
|
196
|
+
self._write(header_line)
|
|
197
|
+
self._write(self.rule(width=min(self.width, sum(widths) + sep.count(" ") * (cols - 1) + 2)))
|
|
198
|
+
for row in str_rows:
|
|
199
|
+
line = sep.join((row[i] if i < len(row) else "").ljust(widths[i]) for i in range(cols))
|
|
200
|
+
self._write(line)
|
|
201
|
+
|
|
202
|
+
def box(self, text: str, style: str = "info") -> None:
|
|
203
|
+
color_fn = {
|
|
204
|
+
"info": self.work, "warn": self.warn, "error": self.err, "ok": self.ok,
|
|
205
|
+
}.get(style, self.work)
|
|
206
|
+
lines = text.split("\n")
|
|
207
|
+
w = max(len(l) for l in lines) + 2
|
|
208
|
+
if self.unicode:
|
|
209
|
+
top, bot, side = "┌" + "─" * w + "┐", "└" + "─" * w + "┘", "│"
|
|
210
|
+
else:
|
|
211
|
+
top, bot, side = "+" + "-" * w + "+", "+" + "-" * w + "+", "|"
|
|
212
|
+
self._write(color_fn(top))
|
|
213
|
+
for l in lines:
|
|
214
|
+
self._write(color_fn(side) + " " + l.ljust(w - 1) + color_fn(side))
|
|
215
|
+
self._write(color_fn(bot))
|
|
216
|
+
|
|
217
|
+
def ask(self, prompt: str, default: Optional[str] = None) -> str:
|
|
218
|
+
suffix = f" [{default}]" if default is not None else ""
|
|
219
|
+
raw = input(self.work(f"{prompt}{suffix}: ")).strip()
|
|
220
|
+
return raw if raw else (default or "")
|
|
221
|
+
|
|
222
|
+
def confirm(self, prompt: str, default: bool = True) -> bool:
|
|
223
|
+
hint = "Y/n" if default else "y/N"
|
|
224
|
+
resp = input(self.work(f"{prompt} [{hint}]: ")).strip().lower()
|
|
225
|
+
if not resp:
|
|
226
|
+
return default
|
|
227
|
+
return resp in ("y", "yes", "т", "так")
|
|
228
|
+
|
|
229
|
+
def select(self, prompt: str, options: Sequence[str], default: int = 0) -> str:
|
|
230
|
+
self._write(prompt)
|
|
231
|
+
for i, opt in enumerate(options, 1):
|
|
232
|
+
marker = self.work(str(i)) if i - 1 != default else self.bold(self.work(str(i)))
|
|
233
|
+
self._write(f" {marker}) {opt}")
|
|
234
|
+
while True:
|
|
235
|
+
raw = input(self.work(f"Ваш вибір [1-{len(options)}, за замовчуванням {default + 1}]: ")).strip()
|
|
236
|
+
if not raw:
|
|
237
|
+
return options[default]
|
|
238
|
+
if raw.isdigit() and 1 <= int(raw) <= len(options):
|
|
239
|
+
return options[int(raw) - 1]
|
|
240
|
+
self.warning("Некоректний вибір, спробуйте ще раз.")
|
|
241
|
+
|
|
242
|
+
def password(self, prompt: str) -> str:
|
|
243
|
+
return getpass.getpass(self.work(f"{prompt}: "))
|
|
244
|
+
|
|
245
|
+
def spinner(self, text: str) -> "Spinner":
|
|
246
|
+
return Spinner(self, text)
|
|
247
|
+
|
|
248
|
+
def progress(self, text: str, total: int) -> "Progress":
|
|
249
|
+
return Progress(self, text, total)
|
|
250
|
+
|
|
251
|
+
@contextmanager
|
|
252
|
+
def task(self, text: str, swallow: bool = False):
|
|
253
|
+
t0 = time.time()
|
|
254
|
+
sp = self.spinner(text)
|
|
255
|
+
sp.start()
|
|
256
|
+
try:
|
|
257
|
+
yield sp
|
|
258
|
+
except Exception as e:
|
|
259
|
+
elapsed = time.time() - t0
|
|
260
|
+
sp.stop(final=f"{text}", failed=True)
|
|
261
|
+
self.error(f"{e}")
|
|
262
|
+
if not swallow:
|
|
263
|
+
raise
|
|
264
|
+
else:
|
|
265
|
+
elapsed = time.time() - t0
|
|
266
|
+
final = getattr(sp, "_final_text", "") or text
|
|
267
|
+
sp.stop(final=f"{final} ({_format_duration(elapsed)})")
|
|
268
|
+
|
|
269
|
+
@contextmanager
|
|
270
|
+
def numbered_step(self, current: int, total: int, text: str, swallow: bool = False):
|
|
271
|
+
label = f"{self.dim(f'[{current}/{total}]')} {text}"
|
|
272
|
+
with self.task(label, swallow=swallow) as sp:
|
|
273
|
+
yield sp
|
|
274
|
+
|
|
275
|
+
@contextmanager
|
|
276
|
+
def timer(self, label: str):
|
|
277
|
+
t0 = time.time()
|
|
278
|
+
try:
|
|
279
|
+
yield
|
|
280
|
+
finally:
|
|
281
|
+
self.step(f"{label}: {self.dim(_format_duration(time.time() - t0))}")
|
|
282
|
+
|
|
283
|
+
def run(self, main: Callable[[], Any], exit_on_error: bool = True) -> Any:
|
|
284
|
+
try:
|
|
285
|
+
return main()
|
|
286
|
+
except KeyboardInterrupt:
|
|
287
|
+
self._write("")
|
|
288
|
+
self.warning("Перервано користувачем.")
|
|
289
|
+
if exit_on_error:
|
|
290
|
+
sys.exit(130)
|
|
291
|
+
except SystemExit:
|
|
292
|
+
raise
|
|
293
|
+
except Exception as e:
|
|
294
|
+
self.error(f"Неопрацьована помилка: {e}")
|
|
295
|
+
if self.verbose:
|
|
296
|
+
self._write(self.dim(traceback.format_exc()))
|
|
297
|
+
if exit_on_error:
|
|
298
|
+
sys.exit(1)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class Spinner:
|
|
302
|
+
def __init__(self, manager: ConsoleManager, text: str):
|
|
303
|
+
self.manager = manager
|
|
304
|
+
self.text = text
|
|
305
|
+
self.frames = (
|
|
306
|
+
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
307
|
+
if manager.unicode else ["|", "/", "-", "\\"]
|
|
308
|
+
)
|
|
309
|
+
self._stop = False
|
|
310
|
+
self._thread: Optional[threading.Thread] = None
|
|
311
|
+
self._failed = False
|
|
312
|
+
self._final_text = ""
|
|
313
|
+
|
|
314
|
+
def _color_at(self, t: float) -> RGB:
|
|
315
|
+
pulse = 0.5 + 0.5 * math.sin(t * 2.2)
|
|
316
|
+
return _lerp(self.manager._accent_start, self.manager._fg_c, pulse * 0.35)
|
|
317
|
+
|
|
318
|
+
def _run(self):
|
|
319
|
+
i = 0
|
|
320
|
+
t0 = time.time()
|
|
321
|
+
while not self._stop:
|
|
322
|
+
f = self.frames[i % len(self.frames)]
|
|
323
|
+
c = self._color_at(time.time() - t0)
|
|
324
|
+
with self.manager._lock:
|
|
325
|
+
sys.stdout.write("\r\x1b[K" + self.manager.fg(c, f) + " " + self.manager.dim(self.text))
|
|
326
|
+
sys.stdout.flush()
|
|
327
|
+
time.sleep(0.06)
|
|
328
|
+
i += 1
|
|
329
|
+
|
|
330
|
+
def start(self) -> "Spinner":
|
|
331
|
+
if not self.manager.live:
|
|
332
|
+
self.manager._write(self.text + "...")
|
|
333
|
+
return self
|
|
334
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
335
|
+
self._thread.start()
|
|
336
|
+
return self
|
|
337
|
+
|
|
338
|
+
def set_final(self, text: str) -> None:
|
|
339
|
+
self._final_text = text
|
|
340
|
+
|
|
341
|
+
def fail(self) -> None:
|
|
342
|
+
self._failed = True
|
|
343
|
+
|
|
344
|
+
def stop(self, final: str = "", failed: bool = False) -> None:
|
|
345
|
+
self._stop = True
|
|
346
|
+
if self._thread:
|
|
347
|
+
self._thread.join()
|
|
348
|
+
msg = final or self._final_text or self.text
|
|
349
|
+
mark = self.manager.cross() if failed else self.manager.check()
|
|
350
|
+
with self.manager._lock:
|
|
351
|
+
if self.manager.live:
|
|
352
|
+
sys.stdout.write("\r\x1b[K" + mark + " " + msg + "\n")
|
|
353
|
+
else:
|
|
354
|
+
sys.stdout.write(mark + " " + msg + "\n")
|
|
355
|
+
sys.stdout.flush()
|
|
356
|
+
|
|
357
|
+
def __enter__(self) -> "Spinner":
|
|
358
|
+
self.start()
|
|
359
|
+
return self
|
|
360
|
+
|
|
361
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
362
|
+
self.stop(final=self._final_text, failed=self._failed or exc_type is not None)
|
|
363
|
+
return False
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class Progress:
|
|
367
|
+
def __init__(self, manager: ConsoleManager, text: str, total: int):
|
|
368
|
+
self.manager = manager
|
|
369
|
+
self.text = text
|
|
370
|
+
self.total = max(total, 1)
|
|
371
|
+
self.width = 28
|
|
372
|
+
self._current = 0
|
|
373
|
+
|
|
374
|
+
def _bar(self, frac: float) -> str:
|
|
375
|
+
exact = frac * self.width
|
|
376
|
+
full = int(exact)
|
|
377
|
+
rem = exact - full
|
|
378
|
+
edge_idx = int(round(rem * (len(_PARTIALS) - 1))) if full < self.width else 0
|
|
379
|
+
parts = []
|
|
380
|
+
for i in range(self.width):
|
|
381
|
+
pos_frac = (i + 0.5) / self.width
|
|
382
|
+
color = _lerp(self.manager._accent_start, self.manager._accent_end, pos_frac)
|
|
383
|
+
if i < full:
|
|
384
|
+
ch = "█" if self.manager.unicode else "#"
|
|
385
|
+
parts.append(self.manager.fg(color, ch))
|
|
386
|
+
elif i == full and self.manager.unicode:
|
|
387
|
+
parts.append(self.manager.fg(color, _PARTIALS[edge_idx]))
|
|
388
|
+
else:
|
|
389
|
+
ch = "░" if self.manager.unicode else "-"
|
|
390
|
+
parts.append(self.manager.dim(ch))
|
|
391
|
+
return "".join(parts)
|
|
392
|
+
|
|
393
|
+
def _render(self, current: int) -> None:
|
|
394
|
+
if not self.manager.live:
|
|
395
|
+
return
|
|
396
|
+
frac = min(current / self.total, 1.0)
|
|
397
|
+
pct = int(frac * 100)
|
|
398
|
+
bar = self._bar(frac)
|
|
399
|
+
line = (
|
|
400
|
+
f"{self.manager.dim(self.text)} {bar} "
|
|
401
|
+
f"{self.manager.bold(f'{pct:3d}%')} {self.manager.dim(f'({current}/{self.total})')}"
|
|
402
|
+
)
|
|
403
|
+
with self.manager._lock:
|
|
404
|
+
sys.stdout.write("\r\x1b[K" + line)
|
|
405
|
+
sys.stdout.flush()
|
|
406
|
+
|
|
407
|
+
def update(self, current: int) -> None:
|
|
408
|
+
self._current = current
|
|
409
|
+
self._render(current)
|
|
410
|
+
|
|
411
|
+
def tick(self, step: int = 1) -> None:
|
|
412
|
+
self.update(self._current + step)
|
|
413
|
+
|
|
414
|
+
def finish(self) -> None:
|
|
415
|
+
if self.manager.live:
|
|
416
|
+
self._render(self.total)
|
|
417
|
+
with self.manager._lock:
|
|
418
|
+
sys.stdout.write("\n")
|
|
419
|
+
sys.stdout.flush()
|
|
420
|
+
|
|
421
|
+
def __enter__(self) -> "Progress":
|
|
422
|
+
return self
|
|
423
|
+
|
|
424
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
425
|
+
self.finish()
|
|
426
|
+
return False
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
console = ConsoleManager()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def get_manager() -> ConsoleManager:
|
|
433
|
+
return console
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: anvil-atui
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Console output manager with spinners, progress bars, and styled UI
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Dynamic: license-file
|
|
10
|
+
|
|
11
|
+
# Anvil
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
Styled console output for Python. Zero dependencies.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install anvil
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from anvil import console as ui
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Messages
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
ui.info("loading config")
|
|
33
|
+
# → loading config
|
|
34
|
+
|
|
35
|
+
ui.success("done")
|
|
36
|
+
# ✓ done
|
|
37
|
+
|
|
38
|
+
ui.warning("file is outdated")
|
|
39
|
+
# ! file is outdated
|
|
40
|
+
|
|
41
|
+
ui.error("connection failed")
|
|
42
|
+
# ✗ connection failed
|
|
43
|
+
|
|
44
|
+
ui.step("intermediate step")
|
|
45
|
+
# · intermediate step
|
|
46
|
+
|
|
47
|
+
ui.debug("value x = 42") # only when verbose=True
|
|
48
|
+
# · value x = 42
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Sections
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
ui.title("Setup")
|
|
55
|
+
|
|
56
|
+
with ui.section("Check dependencies"):
|
|
57
|
+
ui.step("python3")
|
|
58
|
+
ui.step("git")
|
|
59
|
+
|
|
60
|
+
# output:
|
|
61
|
+
# ─────────────────────────
|
|
62
|
+
# Setup
|
|
63
|
+
# ─────────────────────────
|
|
64
|
+
# Check dependencies
|
|
65
|
+
# · python3
|
|
66
|
+
# · git
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Spinner
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
with ui.spinner("connecting to server") as sp:
|
|
73
|
+
connect()
|
|
74
|
+
sp.set_final("connected")
|
|
75
|
+
|
|
76
|
+
# output during work:
|
|
77
|
+
# ⟳ connecting to server
|
|
78
|
+
|
|
79
|
+
# output after completion:
|
|
80
|
+
# ✓ connected
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Progress
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
files = ["a.txt", "b.txt", "c.txt"]
|
|
87
|
+
with ui.progress("copying files", total=len(files)) as bar:
|
|
88
|
+
for i, f in enumerate(files, 1):
|
|
89
|
+
copy(f)
|
|
90
|
+
bar.update(i)
|
|
91
|
+
|
|
92
|
+
# output:
|
|
93
|
+
# copying files ████████░░░░░░░░░░░░░░░░ 50% (2/3)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Task
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
with ui.task("Compile project"):
|
|
100
|
+
build()
|
|
101
|
+
|
|
102
|
+
# output on success:
|
|
103
|
+
# ✓ Compile project (2.3s)
|
|
104
|
+
|
|
105
|
+
# output on failure:
|
|
106
|
+
# ✗ Compile project
|
|
107
|
+
# ✗ connection failed
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Numbered steps
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
steps = ["Download", "Extract", "Check", "Install"]
|
|
114
|
+
for i, name in enumerate(steps, 1):
|
|
115
|
+
with ui.numbered_step(i, len(steps), name):
|
|
116
|
+
do_step(i)
|
|
117
|
+
|
|
118
|
+
# output:
|
|
119
|
+
# [1/4] Download
|
|
120
|
+
# [2/4] Extract
|
|
121
|
+
# [3/4] Check
|
|
122
|
+
# [4/4] Install
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Info block
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
ui.info_block("my-package", {"mp3": 12, "txt": 3}, footer="total 15 files")
|
|
129
|
+
|
|
130
|
+
# output:
|
|
131
|
+
# ─────────────────────────
|
|
132
|
+
# my-package
|
|
133
|
+
# mp3 12 · txt 3
|
|
134
|
+
# total 15 files
|
|
135
|
+
# ─────────────────────────
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Table
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
ui.table(["File", "Size", "Status"], [
|
|
142
|
+
["a.mp3", "3.2 MB", "ok"],
|
|
143
|
+
["b.txt", "1 KB", "ok"],
|
|
144
|
+
])
|
|
145
|
+
|
|
146
|
+
# output:
|
|
147
|
+
# File Size Status
|
|
148
|
+
# ─────────────────────────
|
|
149
|
+
# a.mp3 3.2 MB ok
|
|
150
|
+
# b.txt 1 KB ok
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Box
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
ui.box("Warning: action cannot be undone!", style="warn")
|
|
157
|
+
|
|
158
|
+
# output:
|
|
159
|
+
# ┌──────────────────────────┐
|
|
160
|
+
# │ Warning: action cannot be undone! │
|
|
161
|
+
# └──────────────────────────┘
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Input
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
name = ui.ask("Enter name", default="guest")
|
|
168
|
+
# Enter name [guest]: →
|
|
169
|
+
|
|
170
|
+
ok = ui.confirm("Continue?", default=True)
|
|
171
|
+
# Continue? [Y/n]: →
|
|
172
|
+
|
|
173
|
+
choice = ui.select("Environment", ["dev", "staging", "prod"])
|
|
174
|
+
# Environment
|
|
175
|
+
# 1) dev
|
|
176
|
+
# 2) staging
|
|
177
|
+
# 3) prod
|
|
178
|
+
# Your choice [1-3, default 1]: →
|
|
179
|
+
|
|
180
|
+
secret = ui.password("Access token")
|
|
181
|
+
# Access token: →
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Timer
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
with ui.timer("full run"):
|
|
188
|
+
do_everything()
|
|
189
|
+
|
|
190
|
+
# output:
|
|
191
|
+
# · full run: 12.5s
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Run wrapper
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
def main():
|
|
198
|
+
...
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
ui.run(main)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Catches Ctrl+C and unhandled exceptions. Exits with code 130 on interrupt, 1 on error.
|
|
205
|
+
|
|
206
|
+
## Environment
|
|
207
|
+
|
|
208
|
+
| Variable | Effect |
|
|
209
|
+
|----------|--------|
|
|
210
|
+
| `NO_COLOR=1` | No color |
|
|
211
|
+
| `NO_MOTION=1` | No animation |
|
|
212
|
+
| No TTY | Animation disabled |
|
|
213
|
+
| No truecolor | 16-color fallback |
|
|
214
|
+
| No UTF-8 | ASCII fallback |
|
|
215
|
+
|
|
216
|
+
## Singleton
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
from anvil import console, get_manager
|
|
220
|
+
|
|
221
|
+
ui = console
|
|
222
|
+
ui = get_manager()
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## License
|
|
226
|
+
|
|
227
|
+
MIT - see [LICENSE](LICENSE).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
anvil
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "anvil-atui"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Console output manager with spinners, progress bars, and styled UI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = {text = "MIT"}
|