prttprint 2.0.1__py3-none-any.whl
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.
PRTTprint.py
ADDED
|
@@ -0,0 +1,3915 @@
|
|
|
1
|
+
"""
|
|
2
|
+
rypy v2.0.0 — большая библиотека полезных шорткатов для CLI.
|
|
3
|
+
|
|
4
|
+
Основное:
|
|
5
|
+
Цвета: c, p, cprint, gprint, gprint3, ok, info, warn, err, debug,
|
|
6
|
+
gradient, gradient3, hex_color, glow_print, glow_print3
|
|
7
|
+
Рамки: section, box, banner, kv, recap, notify, notify_center,
|
|
8
|
+
title_box, center_box, box_center, center
|
|
9
|
+
Таблицы: table, Table (fluent), to_table, table_from_csv
|
|
10
|
+
Данные: columns, tree, diff, chart, flatten, group_by, deep_merge,
|
|
11
|
+
env_all, read_csv, write_csv
|
|
12
|
+
Прогресс: progress, progress3, progress_multi, PBar, bar, bar3,
|
|
13
|
+
bar_wave, circle_bar, spinner, live, table_live, status
|
|
14
|
+
Спиннеры: spin, sp, spin_done, spin_fail, spinner_pause
|
|
15
|
+
Шаги: step, task, step_run, run, run_all, do, pause, wait
|
|
16
|
+
Бары: hp_bar, mp_bar, xp_bar, status_line, hud
|
|
17
|
+
Декорации: rule, double_rule, dashed_rule, dots_rule,
|
|
18
|
+
gradient_rule, rainbow_rule, wave_rule, space, clear
|
|
19
|
+
Значки: badge, tag, sparkle_text, arrow_text
|
|
20
|
+
Анимации: animate, glow, typewriter, wave_text, progress_glow,
|
|
21
|
+
divider_animated, line_reveal, fade_in, flip_banner,
|
|
22
|
+
hearts_rain, stars_rain, fireworks,
|
|
23
|
+
rain, rain_line, rain_multi, line_effect
|
|
24
|
+
Интерактив: ask, confirm, menu, prompt, password, copyable,
|
|
25
|
+
wait_key, prompt_choice, confirm_or_exit,
|
|
26
|
+
spinner_selection, Ask, wizard
|
|
27
|
+
Звуки: sound, sound_on, sound_off, sound_list, SOUNDS (20 звуков)
|
|
28
|
+
Продвинутое: Keyboard, Stream, Dashboard, Parser
|
|
29
|
+
Форматы: human_size, human_time, human_delta, human_eta, human_ago,
|
|
30
|
+
money, plural
|
|
31
|
+
Время: timer, debug_time
|
|
32
|
+
Ретраи: retry
|
|
33
|
+
JSON: load_json, save_json, Store
|
|
34
|
+
Дебаг: dbg, color_traceback
|
|
35
|
+
Shell: run_shell
|
|
36
|
+
Игровое: dice, chance, pick_weighted
|
|
37
|
+
Мелочи: env, chunk, pick, uniq, first, last, clamp, slugify,
|
|
38
|
+
is_tty, supports_unicode, visible_len
|
|
39
|
+
Утилиты: init, bootstrap, import_all, term_width,
|
|
40
|
+
cheatsheet, docs, test_all, new
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import atexit
|
|
46
|
+
import csv
|
|
47
|
+
import functools
|
|
48
|
+
import inspect
|
|
49
|
+
import io
|
|
50
|
+
import itertools
|
|
51
|
+
import json
|
|
52
|
+
import linecache
|
|
53
|
+
import os
|
|
54
|
+
import random
|
|
55
|
+
import re as _re
|
|
56
|
+
import shutil
|
|
57
|
+
import subprocess
|
|
58
|
+
import sys
|
|
59
|
+
import threading
|
|
60
|
+
import time
|
|
61
|
+
import traceback as _tb
|
|
62
|
+
|
|
63
|
+
from contextlib import contextmanager
|
|
64
|
+
from datetime import datetime, timedelta
|
|
65
|
+
from pathlib import Path
|
|
66
|
+
from typing import Any, Callable, Iterable, Iterator
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
import msvcrt
|
|
70
|
+
_HAS_MSVCRT = True
|
|
71
|
+
except ImportError:
|
|
72
|
+
_HAS_MSVCRT = False
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
import termios
|
|
76
|
+
import tty
|
|
77
|
+
_HAS_TERMIOS = True
|
|
78
|
+
except ImportError:
|
|
79
|
+
_HAS_TERMIOS = False
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
import winsound as _winsound
|
|
83
|
+
_HAS_WINSOUND = True
|
|
84
|
+
except ImportError:
|
|
85
|
+
_winsound = None
|
|
86
|
+
_HAS_WINSOUND = False
|
|
87
|
+
|
|
88
|
+
__version__ = '2.0.1'
|
|
89
|
+
|
|
90
|
+
__all__ = [
|
|
91
|
+
'__version__',
|
|
92
|
+
# Цвета и вывод
|
|
93
|
+
'c', 'p', 'cprint', 'gprint', 'gprint3', 'gradient', 'gradient3',
|
|
94
|
+
'hex_color', 'glow_print', 'glow_print3',
|
|
95
|
+
'ok', 'info', 'warn', 'err', 'debug', 'enable_colors',
|
|
96
|
+
'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'gray', 'bold',
|
|
97
|
+
# Разделы и рамки
|
|
98
|
+
'section', 'box', 'banner', 'kv', 'recap', 'notify', 'notify_center',
|
|
99
|
+
'title_box', 'center_box', 'box_center', 'center',
|
|
100
|
+
# Таблицы
|
|
101
|
+
'table', 'Table', 'to_table', 'table_from_csv',
|
|
102
|
+
# Данные
|
|
103
|
+
'columns', 'tree', 'diff', 'chart',
|
|
104
|
+
'flatten', 'group_by', 'deep_merge', 'env_all',
|
|
105
|
+
'read_csv', 'write_csv',
|
|
106
|
+
# Шаги и раннеры
|
|
107
|
+
'step', 'task', 'step_run', 'run', 'run_all', 'do', 'pause', 'wait',
|
|
108
|
+
# Спиннеры
|
|
109
|
+
'spinner', 'spinner_pause', 'circle_bar', 'live', 'table_live', 'status',
|
|
110
|
+
'spin', 'sp', 'spin_done', 'spin_fail',
|
|
111
|
+
'progress', 'progress3', 'progress_multi', 'PBar', 'progress_glow',
|
|
112
|
+
'SPINNER_FRAMES', 'FANCY_PRESETS', 'CIRCLE_PRESETS',
|
|
113
|
+
'PLAIN_PRESETS', 'EMOJI_PRESETS', 'ASCII_PRESETS', 'pick_preset',
|
|
114
|
+
# Бары
|
|
115
|
+
'bar', 'bar3', 'bar_wave', 'hp_bar', 'mp_bar', 'xp_bar',
|
|
116
|
+
'status_line', 'hud',
|
|
117
|
+
# Декорации
|
|
118
|
+
'rule', 'double_rule', 'dashed_rule', 'dots_rule',
|
|
119
|
+
'gradient_rule', 'rainbow_rule', 'wave_rule',
|
|
120
|
+
'space', 'clear',
|
|
121
|
+
# Значки
|
|
122
|
+
'badge', 'tag', 'sparkle_text', 'arrow_text',
|
|
123
|
+
# Анимации
|
|
124
|
+
'animate', 'glow', 'typewriter', 'wave_text',
|
|
125
|
+
'divider_animated', 'line_reveal', 'fade_in', 'flip_banner',
|
|
126
|
+
'hearts_rain', 'stars_rain', 'fireworks',
|
|
127
|
+
'rain', 'rain_line', 'rain_multi', 'line_effect',
|
|
128
|
+
# Звуки
|
|
129
|
+
'sound', 'sound_on', 'sound_off', 'sound_list', 'SOUNDS',
|
|
130
|
+
# Логгер
|
|
131
|
+
'Log',
|
|
132
|
+
# Форматы
|
|
133
|
+
'human_size', 'human_time', 'human_delta', 'human_eta',
|
|
134
|
+
'human_ago', 'money', 'plural',
|
|
135
|
+
# Время
|
|
136
|
+
'timer', 'debug_time',
|
|
137
|
+
# Ретраи
|
|
138
|
+
'retry',
|
|
139
|
+
# JSON и хранилище
|
|
140
|
+
'load_json', 'save_json', 'Store',
|
|
141
|
+
# Дебаг
|
|
142
|
+
'dbg', 'color_traceback',
|
|
143
|
+
# Интерактив
|
|
144
|
+
'ask', 'confirm', 'menu', 'prompt', 'password', 'copyable',
|
|
145
|
+
'wait_key', 'prompt_choice', 'confirm_or_exit',
|
|
146
|
+
'spinner_selection', 'Ask', 'wizard',
|
|
147
|
+
# Продвинутое
|
|
148
|
+
'Keyboard', 'Stream', 'Dashboard', 'Parser',
|
|
149
|
+
# Shell
|
|
150
|
+
'run_shell',
|
|
151
|
+
# Игровое
|
|
152
|
+
'dice', 'chance', 'pick_weighted',
|
|
153
|
+
# Мелочи
|
|
154
|
+
'env', 'chunk', 'pick', 'uniq', 'first', 'last', 'clamp', 'slugify',
|
|
155
|
+
'is_tty', 'supports_unicode', 'visible_len',
|
|
156
|
+
# Утилиты
|
|
157
|
+
'init', 'bootstrap', 'import_all', 'term_width',
|
|
158
|
+
'cheatsheet', 'docs', 'test_all', 'new',
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ============================================================
|
|
163
|
+
# 0. УТИЛИТЫ ОКРУЖЕНИЯ
|
|
164
|
+
# ============================================================
|
|
165
|
+
|
|
166
|
+
def term_width(default: int = 80) -> int:
|
|
167
|
+
"""Ширина терминала."""
|
|
168
|
+
try:
|
|
169
|
+
return shutil.get_terminal_size((default, 24)).columns
|
|
170
|
+
except OSError:
|
|
171
|
+
return default
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
from wcwidth import wcswidth as _wcswidth
|
|
176
|
+
|
|
177
|
+
def _visible_len(s: str) -> int:
|
|
178
|
+
s = _re.sub(r'\033\[[0-9;]*m', '', s)
|
|
179
|
+
w = _wcswidth(s)
|
|
180
|
+
return w if w >= 0 else len(s)
|
|
181
|
+
|
|
182
|
+
_HAS_WCWIDTH = True
|
|
183
|
+
except ImportError:
|
|
184
|
+
def _visible_len(s: str) -> int:
|
|
185
|
+
return len(_re.sub(r'\033\[[0-9;]*m', '', s))
|
|
186
|
+
|
|
187
|
+
_HAS_WCWIDTH = False
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def visible_len(s: str) -> int:
|
|
191
|
+
return _visible_len(s)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def is_tty() -> bool:
|
|
195
|
+
return sys.stdout.isatty()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def supports_unicode() -> bool:
|
|
199
|
+
enc = (sys.stdout.encoding or '').lower()
|
|
200
|
+
return 'utf' in enc
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ============================================================
|
|
204
|
+
# 1. ЦВЕТА
|
|
205
|
+
# ============================================================
|
|
206
|
+
|
|
207
|
+
_ENABLED = (
|
|
208
|
+
False if os.environ.get('NO_COLOR')
|
|
209
|
+
else True if os.environ.get('FORCE_COLOR')
|
|
210
|
+
else sys.stdout.isatty() or os.name == 'nt'
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
_RESET = '\033[0m'
|
|
214
|
+
|
|
215
|
+
_NAMED = {
|
|
216
|
+
'black': 30, 'red': 31, 'green': 32, 'yellow': 33,
|
|
217
|
+
'blue': 34, 'magenta': 35, 'cyan': 36, 'white': 37,
|
|
218
|
+
'gray': 90, 'grey': 90,
|
|
219
|
+
'bright_red': 91, 'bright_green': 92, 'bright_yellow': 93,
|
|
220
|
+
'bright_blue': 94, 'bright_magenta': 95, 'bright_cyan': 96,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
_RGB_APPROX = {
|
|
224
|
+
'black': (0, 0, 0), 'red': (255, 0, 0), 'green': (0, 128, 0),
|
|
225
|
+
'yellow': (255, 255, 0), 'blue': (0, 0, 255), 'magenta': (255, 0, 255),
|
|
226
|
+
'cyan': (0, 255, 255), 'white': (255, 255, 255),
|
|
227
|
+
'gray': (128, 128, 128), 'grey': (128, 128, 128),
|
|
228
|
+
'bright_red': (255, 85, 85), 'bright_green': (85, 255, 85),
|
|
229
|
+
'bright_yellow': (255, 255, 85), 'bright_blue': (85, 85, 255),
|
|
230
|
+
'bright_magenta': (255, 85, 255), 'bright_cyan': (85, 255, 255),
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
_STYLES = {'bold': 1, 'dim': 2, 'italic': 3, 'underline': 4,
|
|
234
|
+
'blink': 5, 'reverse': 7, 'strike': 9}
|
|
235
|
+
|
|
236
|
+
_KEYWORDS = {
|
|
237
|
+
'red': ('error', 'ошибк', 'fail', 'провал', 'не ', 'нет '),
|
|
238
|
+
'yellow': ('warn', 'предупр', 'внимание', 'осторожно'),
|
|
239
|
+
'green': ('ok', 'успеш', 'готово', 'done', 'success', '✓'),
|
|
240
|
+
'cyan': ('info', 'инфо', 'загруз', 'loading'),
|
|
241
|
+
'gray': ('debug', 'отладк', 'trace'),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
_LEVEL_STYLES = {
|
|
245
|
+
'ok': ('✓', 'green', 'bold'),
|
|
246
|
+
'info': ('ℹ', 'cyan', ''),
|
|
247
|
+
'warn': ('⚠', 'yellow', 'bold'),
|
|
248
|
+
'err': ('✗', 'red', 'bold'),
|
|
249
|
+
'debug': ('·', 'gray', 'dim'),
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
_SPINNER_DEFAULTS = {
|
|
253
|
+
'color': 'cyan',
|
|
254
|
+
'show_time': False,
|
|
255
|
+
'preset': None,
|
|
256
|
+
'kind': 'fancy',
|
|
257
|
+
'speed': 0.08,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
_SOUND_ENABLED = True
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _hex_to_rgb(s: str) -> tuple[int, int, int]:
|
|
264
|
+
s = s.lstrip('#')
|
|
265
|
+
if len(s) == 3:
|
|
266
|
+
s = ''.join(ch * 2 for ch in s)
|
|
267
|
+
return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _any_to_rgb(color) -> tuple[int, int, int]:
|
|
271
|
+
if isinstance(color, (tuple, list)) and len(color) == 3:
|
|
272
|
+
return tuple(int(x) for x in color)
|
|
273
|
+
if isinstance(color, str):
|
|
274
|
+
if color.startswith('#'):
|
|
275
|
+
return _hex_to_rgb(color)
|
|
276
|
+
if color in _RGB_APPROX:
|
|
277
|
+
return _RGB_APPROX[color]
|
|
278
|
+
return (255, 255, 255)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _to_codes(color) -> list[str]:
|
|
282
|
+
if color is None:
|
|
283
|
+
return []
|
|
284
|
+
if isinstance(color, (tuple, list)) and len(color) == 3:
|
|
285
|
+
r, g, b = color
|
|
286
|
+
return [f'38;2;{r};{g};{b}']
|
|
287
|
+
if isinstance(color, str) and color.startswith('#'):
|
|
288
|
+
r, g, b = _hex_to_rgb(color)
|
|
289
|
+
return [f'38;2;{r};{g};{b}']
|
|
290
|
+
if color in _NAMED:
|
|
291
|
+
return [str(_NAMED[color])]
|
|
292
|
+
return []
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _bg_codes(color) -> list[str]:
|
|
296
|
+
if color is None:
|
|
297
|
+
return []
|
|
298
|
+
if isinstance(color, (tuple, list)) and len(color) == 3:
|
|
299
|
+
r, g, b = color
|
|
300
|
+
return [f'48;2;{r};{g};{b}']
|
|
301
|
+
if isinstance(color, str) and color.startswith('#'):
|
|
302
|
+
r, g, b = _hex_to_rgb(color)
|
|
303
|
+
return [f'48;2;{r};{g};{b}']
|
|
304
|
+
if color in _NAMED:
|
|
305
|
+
return [str(_NAMED[color] + 10)]
|
|
306
|
+
return []
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def c(text: Any, color=None, bg=None, *styles: str,
|
|
310
|
+
bold: bool = False, dim: bool = False,
|
|
311
|
+
italic: bool = False, underline: bool = False,
|
|
312
|
+
enabled: bool | None = None) -> str:
|
|
313
|
+
"""Красит строку ANSI-кодами."""
|
|
314
|
+
on = _ENABLED if enabled is None else enabled
|
|
315
|
+
if not on:
|
|
316
|
+
return str(text)
|
|
317
|
+
codes: list[str] = []
|
|
318
|
+
for flag, code in (('bold', bold), ('dim', dim),
|
|
319
|
+
('italic', italic), ('underline', underline)):
|
|
320
|
+
if flag and code:
|
|
321
|
+
codes.append(str(_STYLES[flag]))
|
|
322
|
+
for s in styles:
|
|
323
|
+
if s in _STYLES:
|
|
324
|
+
codes.append(str(_STYLES[s]))
|
|
325
|
+
codes += _to_codes(color)
|
|
326
|
+
codes += _bg_codes(bg)
|
|
327
|
+
if not codes:
|
|
328
|
+
return str(text)
|
|
329
|
+
return f'\033[{";".join(codes)}m{text}{_RESET}'
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def gradient(text: str, start, end) -> str:
|
|
333
|
+
"""2-цветный градиент."""
|
|
334
|
+
if not _ENABLED:
|
|
335
|
+
return text
|
|
336
|
+
r1, g1, b1 = _any_to_rgb(start)
|
|
337
|
+
r2, g2, b2 = _any_to_rgb(end)
|
|
338
|
+
n = max(len(text) - 1, 1)
|
|
339
|
+
out = []
|
|
340
|
+
for i, ch in enumerate(text):
|
|
341
|
+
t = i / n
|
|
342
|
+
r = int(r1 + (r2 - r1) * t)
|
|
343
|
+
g = int(g1 + (g2 - g1) * t)
|
|
344
|
+
b = int(b1 + (b2 - b1) * t)
|
|
345
|
+
out.append(f'\033[38;2;{r};{g};{b}m{ch}')
|
|
346
|
+
return ''.join(out) + _RESET
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def gradient3(text: str, start, middle, end) -> str:
|
|
350
|
+
"""3-цветный градиент."""
|
|
351
|
+
if not _ENABLED:
|
|
352
|
+
return text
|
|
353
|
+
n = len(text)
|
|
354
|
+
if n == 0:
|
|
355
|
+
return text
|
|
356
|
+
r1, g1, b1 = _any_to_rgb(start)
|
|
357
|
+
r2, g2, b2 = _any_to_rgb(middle)
|
|
358
|
+
r3, g3, b3 = _any_to_rgb(end)
|
|
359
|
+
out = []
|
|
360
|
+
for i, ch in enumerate(text):
|
|
361
|
+
t = i / max(n - 1, 1)
|
|
362
|
+
if t <= 0.5:
|
|
363
|
+
u = t * 2
|
|
364
|
+
r = int(r1 + (r2 - r1) * u)
|
|
365
|
+
g = int(g1 + (g2 - g1) * u)
|
|
366
|
+
b = int(b1 + (b2 - b1) * u)
|
|
367
|
+
else:
|
|
368
|
+
u = (t - 0.5) * 2
|
|
369
|
+
r = int(r2 + (r3 - r2) * u)
|
|
370
|
+
g = int(g2 + (g3 - g2) * u)
|
|
371
|
+
b = int(b2 + (b3 - b2) * u)
|
|
372
|
+
out.append(f'\033[38;2;{r};{g};{b}m{ch}')
|
|
373
|
+
return ''.join(out) + _RESET
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def hex_color(hex_str: str) -> str:
|
|
377
|
+
r, g, b = _hex_to_rgb(hex_str)
|
|
378
|
+
return f'38;2;{r};{g};{b}'
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _autocolor(msg: Any, color: str) -> str:
|
|
382
|
+
text = str(msg)
|
|
383
|
+
for kw in _KEYWORDS.get(color, ()):
|
|
384
|
+
if kw.lower() in text.lower():
|
|
385
|
+
pattern = _re.compile(_re.escape(kw), _re.IGNORECASE)
|
|
386
|
+
return pattern.sub(
|
|
387
|
+
lambda m: c(m.group(0), color, bold=True), text, count=1
|
|
388
|
+
)
|
|
389
|
+
return text
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _emit(kind: str, msg: Any) -> None:
|
|
393
|
+
icon, color, style = _LEVEL_STYLES[kind]
|
|
394
|
+
styles = (style,) if style else ()
|
|
395
|
+
line = f'{c(icon, color, *styles)} {_autocolor(msg, color)}'
|
|
396
|
+
out = sys.stderr if kind == 'err' else sys.stdout
|
|
397
|
+
print(line, file=out)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def ok(msg: Any) -> None:
|
|
401
|
+
_emit('ok', msg)
|
|
402
|
+
_sound('ok')
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def info(msg: Any) -> None:
|
|
406
|
+
_emit('info', msg)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def warn(msg: Any) -> None:
|
|
410
|
+
_emit('warn', msg)
|
|
411
|
+
_sound('warning')
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def err(msg: Any) -> None:
|
|
415
|
+
_emit('err', msg)
|
|
416
|
+
_sound('error')
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def debug(msg: Any) -> None:
|
|
420
|
+
_emit('debug', msg)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def enable_colors(flag: bool = True) -> None:
|
|
424
|
+
global _ENABLED
|
|
425
|
+
_ENABLED = flag
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
atexit.register(lambda: sys.stdout.write(_RESET) if _ENABLED else None)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# ============================================================
|
|
432
|
+
# 2. PRINT С ЦВЕТОМ
|
|
433
|
+
# ============================================================
|
|
434
|
+
|
|
435
|
+
def p(*args, color=None, bg=None, bold=False, dim=False,
|
|
436
|
+
italic=False, underline=False, sep=' ', end='\n', flush=False) -> None:
|
|
437
|
+
text = sep.join(str(a) for a in args)
|
|
438
|
+
if color or bg or bold or dim or italic or underline:
|
|
439
|
+
text = c(text, color, bg, bold=bold, dim=dim,
|
|
440
|
+
italic=italic, underline=underline)
|
|
441
|
+
print(text, end=end, flush=flush)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
cprint = p
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def gprint(text: str, start: str = 'red', end: str = 'blue',
|
|
448
|
+
bold=False, underline=False) -> None:
|
|
449
|
+
line = gradient(text, start, end)
|
|
450
|
+
if bold or underline:
|
|
451
|
+
line = c(line, None, bold=bold, underline=underline)
|
|
452
|
+
print(line)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def gprint3(text: str, start='red', middle='yellow', end='green',
|
|
456
|
+
bold: bool = False, underline: bool = False) -> None:
|
|
457
|
+
line = gradient3(text, start, middle, end)
|
|
458
|
+
if bold or underline:
|
|
459
|
+
line = c(line, None, bold=bold, underline=underline)
|
|
460
|
+
print(line)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
red = lambda s: c(s, 'red')
|
|
464
|
+
green = lambda s: c(s, 'green')
|
|
465
|
+
yellow = lambda s: c(s, 'yellow')
|
|
466
|
+
blue = lambda s: c(s, 'blue')
|
|
467
|
+
magenta = lambda s: c(s, 'magenta')
|
|
468
|
+
cyan = lambda s: c(s, 'cyan')
|
|
469
|
+
white = lambda s: c(s, 'white')
|
|
470
|
+
gray = lambda s: c(s, 'gray')
|
|
471
|
+
bold = lambda s: c(s, None, bold=True)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# ============================================================
|
|
475
|
+
# 3. ЗВУКИ — 20 ВСТРОЕННЫХ ЭФФЕКТОВ
|
|
476
|
+
# ============================================================
|
|
477
|
+
|
|
478
|
+
SOUNDS = {
|
|
479
|
+
'click': [(800, 30)],
|
|
480
|
+
'tick': [(1200, 20)],
|
|
481
|
+
'type': [(2000, 10)],
|
|
482
|
+
'toggle': [(600, 40), (900, 40)],
|
|
483
|
+
'switch': [(400, 50), (800, 50)],
|
|
484
|
+
'ok': [(880, 80), (1320, 120)],
|
|
485
|
+
'success': [(660, 100), (880, 100), (1320, 200)],
|
|
486
|
+
'done': [(1320, 150)],
|
|
487
|
+
'notify': [(1200, 100), (1400, 100)],
|
|
488
|
+
'message': [(1000, 80)],
|
|
489
|
+
'error': [(400, 200), (300, 300)],
|
|
490
|
+
'fail': [(300, 200), (200, 400)],
|
|
491
|
+
'denied': [(200, 150), (150, 150), (100, 300)],
|
|
492
|
+
'warning': [(880, 150), (660, 300)],
|
|
493
|
+
'hit': [(2000, 30), (1500, 30)],
|
|
494
|
+
'explosion': [(100, 400)],
|
|
495
|
+
'coin': [(1320, 50), (1760, 80)],
|
|
496
|
+
'level_up': [(660, 100), (880, 100), (1100, 100), (1320, 300)],
|
|
497
|
+
'game_over': [(440, 200), (330, 300), (220, 500)],
|
|
498
|
+
'beep': [(1000, 100)],
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _play_beep(freq: int, duration_ms: int) -> None:
|
|
503
|
+
if _HAS_WINSOUND:
|
|
504
|
+
try:
|
|
505
|
+
_winsound.Beep(freq, duration_ms)
|
|
506
|
+
return
|
|
507
|
+
except (RuntimeError, ValueError):
|
|
508
|
+
pass
|
|
509
|
+
sys.stdout.write('\a')
|
|
510
|
+
sys.stdout.flush()
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _sound(name: str, **kw) -> None:
|
|
514
|
+
if _SOUND_ENABLED:
|
|
515
|
+
sound(name, **kw)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def sound(name: str = 'click',
|
|
519
|
+
freq: int | None = None,
|
|
520
|
+
duration: int | None = None,
|
|
521
|
+
repeat: int = 1) -> None:
|
|
522
|
+
"""Проигрывает встроенный звук."""
|
|
523
|
+
if freq is not None:
|
|
524
|
+
seq = [(freq, duration or 100)]
|
|
525
|
+
else:
|
|
526
|
+
seq = SOUNDS.get(name) or [(800, 30)]
|
|
527
|
+
for _ in range(repeat):
|
|
528
|
+
for f, d in seq:
|
|
529
|
+
_play_beep(f, d)
|
|
530
|
+
time.sleep(d / 1000.0)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def sound_off() -> None:
|
|
534
|
+
global _SOUND_ENABLED
|
|
535
|
+
_SOUND_ENABLED = False
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def sound_on() -> None:
|
|
539
|
+
global _SOUND_ENABLED
|
|
540
|
+
_SOUND_ENABLED = True
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def sound_list() -> None:
|
|
544
|
+
"""Печатает список всех доступных звуков."""
|
|
545
|
+
cprint('Доступные звуки:', color='bright_cyan', bold=True)
|
|
546
|
+
space()
|
|
547
|
+
for name, seq in SOUNDS.items():
|
|
548
|
+
desc = ' → '.join(f'{f}Гц/{d}мс' for f, d in seq)
|
|
549
|
+
print(f' {c(name, "yellow"):<24} {c(desc, "gray")}')
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
# ============================================================
|
|
553
|
+
# 4. РАМКИ
|
|
554
|
+
# ============================================================
|
|
555
|
+
|
|
556
|
+
_BOXES = {
|
|
557
|
+
'simple': ('┌', '┬', '┐', '├', '┼', '┤', '└', '┴', '┘', '─', '│'),
|
|
558
|
+
'rounded': ('╭', '┬', '╮', '├', '┼', '┤', '╰', '┴', '╯', '─', '│'),
|
|
559
|
+
'double': ('╔', '╦', '╗', '╠', '╬', '╣', '╚', '╩', '╝', '═', '║'),
|
|
560
|
+
'ascii': ('+', '+', '+', '+', '+', '+', '+', '+', '+', '-', '|'),
|
|
561
|
+
'markdown': ('|', '|', '|', '|', '|', '|', '|', '|', '|', '-', '|'),
|
|
562
|
+
'none': ('', '', '', '', '', '', '', '', '', '', ' '),
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def section(title: str = '', char: str = '═',
|
|
567
|
+
width: int | None = None, color: str = 'gray',
|
|
568
|
+
title_color: str = 'cyan') -> None:
|
|
569
|
+
width = width or min(term_width() - 2, 60)
|
|
570
|
+
line = c(char * width, color)
|
|
571
|
+
print()
|
|
572
|
+
print(line)
|
|
573
|
+
if title:
|
|
574
|
+
print(c(f' {title}', title_color, bold=True))
|
|
575
|
+
print(line)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def box(text: str, style: str = 'rounded', color: str = 'cyan',
|
|
579
|
+
title: str = '', padding: int = 1) -> None:
|
|
580
|
+
lines = str(text).splitlines() or ['']
|
|
581
|
+
title_line = c(f' {title} ', color, bold=True) if title else None
|
|
582
|
+
content_width = max(_visible_len(l) for l in lines)
|
|
583
|
+
if title_line:
|
|
584
|
+
content_width = max(content_width, _visible_len(title) + 2)
|
|
585
|
+
width = content_width + padding * 2
|
|
586
|
+
tl, tt, tr, ml, mm, mr, bl, bt, br, hz, vt = _BOXES.get(style, _BOXES['rounded'])
|
|
587
|
+
if title_line:
|
|
588
|
+
t_inner = _visible_len(title) + 2
|
|
589
|
+
left_seg = hz * ((width - t_inner) // 2)
|
|
590
|
+
right_seg = hz * (width - t_inner - len(left_seg))
|
|
591
|
+
print(c(tl + left_seg, color) + title_line + c(right_seg + tr, color))
|
|
592
|
+
else:
|
|
593
|
+
print(c(tl + hz * width + tr, color))
|
|
594
|
+
for line in lines:
|
|
595
|
+
pad_right = width - _visible_len(line) - padding
|
|
596
|
+
print(c(vt, color) + ' ' * padding + line + ' ' * pad_right + c(vt, color))
|
|
597
|
+
print(c(bl + hz * width + br, color))
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def banner(text: str, style: str = 'double',
|
|
601
|
+
color: str = 'cyan', text_color: str = 'bright_white') -> None:
|
|
602
|
+
width = max(_visible_len(text) + 6, 30)
|
|
603
|
+
tl, tt, tr, ml, mm, mr, bl, bt, br, hz, vt = _BOXES.get(style, _BOXES['double'])
|
|
604
|
+
print(c(tl + hz * width + tr, color))
|
|
605
|
+
print(c(vt, color) + ' ' * width + c(vt, color))
|
|
606
|
+
pad = (width - _visible_len(text)) // 2
|
|
607
|
+
rest = width - pad - _visible_len(text)
|
|
608
|
+
print(c(vt, color) + ' ' * pad + c(text, text_color, bold=True)
|
|
609
|
+
+ ' ' * rest + c(vt, color))
|
|
610
|
+
print(c(vt, color) + ' ' * width + c(vt, color))
|
|
611
|
+
print(c(bl + hz * width + br, color))
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def kv(data: dict, style: str = 'rounded',
|
|
615
|
+
key_color: str = 'cyan', value_color: str | None = None,
|
|
616
|
+
boxed: bool = True) -> None:
|
|
617
|
+
if not data:
|
|
618
|
+
return
|
|
619
|
+
items = list(data.items())
|
|
620
|
+
key_w = max(_visible_len(str(k)) for k, _ in items)
|
|
621
|
+
val_w = max(_visible_len(str(v)) for _, v in items)
|
|
622
|
+
if not boxed:
|
|
623
|
+
for k, v in items:
|
|
624
|
+
val = c(str(v), value_color) if value_color else str(v)
|
|
625
|
+
print(f'{c(str(k).ljust(key_w), key_color)} {val}')
|
|
626
|
+
return
|
|
627
|
+
tl, tt, tr, ml, mm, mr, bl, bt, br, hz, vt = _BOXES.get(style, _BOXES['rounded'])
|
|
628
|
+
print(c(tl + hz * (key_w + 2) + tt + hz * (val_w + 2) + tr, 'gray'))
|
|
629
|
+
for k, v in items:
|
|
630
|
+
val = c(str(v), value_color) if value_color else str(v)
|
|
631
|
+
pad_k = ' ' * (key_w - _visible_len(str(k)))
|
|
632
|
+
pad_v = ' ' * (val_w - _visible_len(str(v)))
|
|
633
|
+
print(c(vt, 'gray') + ' ' + c(str(k), key_color) + pad_k + ' '
|
|
634
|
+
+ c(vt, 'gray') + ' ' + val + pad_v + ' ' + c(vt, 'gray'))
|
|
635
|
+
print(c(bl + hz * (key_w + 2) + bt + hz * (val_w + 2) + br, 'gray'))
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def recap(**kwargs: Any) -> None:
|
|
639
|
+
icons = {
|
|
640
|
+
'ok': ('✓', 'green'), 'passed': ('✓', 'green'),
|
|
641
|
+
'warn': ('⚠', 'yellow'), 'warnings': ('⚠', 'yellow'),
|
|
642
|
+
'errors': ('✗', 'red'), 'failed': ('✗', 'red'),
|
|
643
|
+
'skipped': ('·', 'gray'),
|
|
644
|
+
'duration': ('⏱', 'cyan'), 'time': ('⏱', 'cyan'),
|
|
645
|
+
'total': ('Σ', 'magenta'),
|
|
646
|
+
}
|
|
647
|
+
lines = []
|
|
648
|
+
for k, v in kwargs.items():
|
|
649
|
+
icon, col = icons.get(k, ('•', 'white'))
|
|
650
|
+
lines.append((icon, col, f'{v} {k.replace("_", " ")}'))
|
|
651
|
+
if not lines:
|
|
652
|
+
return
|
|
653
|
+
w = max(_visible_len(f'{i} {t}') for i, _, t in lines) + 4
|
|
654
|
+
tl, tt, tr, ml, mm, mr, bl, bt, br, hz, vt = _BOXES['rounded']
|
|
655
|
+
print(c(tl + hz * w + tr, 'gray'))
|
|
656
|
+
for icon, col, text in lines:
|
|
657
|
+
line = c(icon, col, bold=True) + ' ' + text
|
|
658
|
+
pad = ' ' * (w - _visible_len(line) - 1)
|
|
659
|
+
print(c(vt, 'gray') + ' ' + line + pad + c(vt, 'gray'))
|
|
660
|
+
print(c(bl + hz * w + br, 'gray'))
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def notify(text: str, level: str = 'info') -> None:
|
|
664
|
+
colors = {'ok': 'green', 'info': 'cyan', 'warn': 'yellow', 'err': 'red'}
|
|
665
|
+
icons = {'ok': '✓', 'info': 'ℹ', 'warn': '⚠', 'err': '✗'}
|
|
666
|
+
color = colors.get(level, 'cyan')
|
|
667
|
+
icon = icons.get(level, '•')
|
|
668
|
+
line_text = f'{icon} {text}'
|
|
669
|
+
w = _visible_len(line_text) + 4
|
|
670
|
+
tl, tt, tr, ml, mm, mr, bl, bt, br, hz, vt = _BOXES['rounded']
|
|
671
|
+
print(c(tl + hz * w + tr, color))
|
|
672
|
+
print(c(vt, color) + ' ' + c(icon, color, bold=True) + ' ' + text
|
|
673
|
+
+ ' ' * (w - _visible_len(line_text) - 1) + c(vt, color))
|
|
674
|
+
print(c(bl + hz * w + br, color))
|
|
675
|
+
if level == 'ok':
|
|
676
|
+
_sound('ok')
|
|
677
|
+
elif level == 'warn':
|
|
678
|
+
_sound('warning')
|
|
679
|
+
elif level == 'err':
|
|
680
|
+
_sound('error')
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def center(text: str, width: int | None = None,
|
|
684
|
+
color: str | None = None, bold: bool = False) -> None:
|
|
685
|
+
width = width or term_width()
|
|
686
|
+
text_w = _visible_len(text)
|
|
687
|
+
pad = max(0, (width - text_w) // 2)
|
|
688
|
+
line = ' ' * pad + text
|
|
689
|
+
if color or bold:
|
|
690
|
+
line = c(line, color, bold=bold)
|
|
691
|
+
print(line)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def center_box(text: str, style: str = 'rounded',
|
|
695
|
+
color: str = 'bright_cyan',
|
|
696
|
+
width: int | None = None) -> None:
|
|
697
|
+
width = width or min(term_width() - 2, 60)
|
|
698
|
+
inner = width - 2
|
|
699
|
+
tl, tt, tr, ml, mm, mr, bl, bt, br, hz, vt = _BOXES.get(style, _BOXES['rounded'])
|
|
700
|
+
text_w = _visible_len(text)
|
|
701
|
+
pad_left = max(0, (inner - text_w - 2) // 2)
|
|
702
|
+
pad_right = inner - pad_left - text_w - 2
|
|
703
|
+
block_pad = max(0, (term_width() - width) // 2)
|
|
704
|
+
prefix = ' ' * block_pad
|
|
705
|
+
print(prefix + c(tl + hz * inner + tr, color))
|
|
706
|
+
print(prefix + c(vt, color) + ' ' * pad_left + ' '
|
|
707
|
+
+ c(text, 'bright_white', bold=True) + ' ' + ' ' * pad_right + c(vt, color))
|
|
708
|
+
print(prefix + c(bl + hz * inner + br, color))
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def box_center(lines, style: str = 'rounded',
|
|
712
|
+
color: str = 'bright_cyan',
|
|
713
|
+
text_color: str = 'bright_white',
|
|
714
|
+
padding: int = 1) -> None:
|
|
715
|
+
"""Многострочная рамка по центру экрана.
|
|
716
|
+
|
|
717
|
+
Пример:
|
|
718
|
+
box_center(['Добро пожаловать!', '', 'Нажми Enter'])
|
|
719
|
+
"""
|
|
720
|
+
if isinstance(lines, str):
|
|
721
|
+
lines = lines.splitlines()
|
|
722
|
+
lines = list(lines) or ['']
|
|
723
|
+
content_w = max(_visible_len(l) for l in lines)
|
|
724
|
+
width = content_w + padding * 2
|
|
725
|
+
inner = width
|
|
726
|
+
tl, tt, tr, ml, mm, mr, bl, bt, br, hz, vt = _BOXES.get(style, _BOXES['rounded'])
|
|
727
|
+
block_pad = max(0, (term_width() - width - 2) // 2)
|
|
728
|
+
prefix = ' ' * block_pad
|
|
729
|
+
print(prefix + c(tl + hz * inner + tr, color))
|
|
730
|
+
for line in lines:
|
|
731
|
+
pad_r = width - _visible_len(line) - padding
|
|
732
|
+
print(prefix + c(vt, color) + ' ' * padding
|
|
733
|
+
+ c(line, text_color) + ' ' * pad_r + c(vt, color))
|
|
734
|
+
print(prefix + c(bl + hz * inner + br, color))
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def title_box(text: str, subtitle: str = '',
|
|
738
|
+
color: str = 'bright_magenta') -> None:
|
|
739
|
+
print()
|
|
740
|
+
center(f'╔{"═" * (len(text) + 4)}╗', color=color)
|
|
741
|
+
center(f'║ {text} ║', color=color, bold=True)
|
|
742
|
+
center(f'╚{"═" * (len(text) + 4)}╝', color=color)
|
|
743
|
+
if subtitle:
|
|
744
|
+
center(subtitle, color='gray')
|
|
745
|
+
print()
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def notify_center(text: str, level: str = 'info',
|
|
749
|
+
duration: float = 1.5) -> None:
|
|
750
|
+
"""Всплывающее уведомление по центру экрана.
|
|
751
|
+
|
|
752
|
+
Пример:
|
|
753
|
+
notify_center('Файл сохранён', 'ok')
|
|
754
|
+
"""
|
|
755
|
+
colors = {'ok': 'green', 'info': 'cyan', 'warn': 'yellow', 'err': 'red'}
|
|
756
|
+
icons = {'ok': '✓', 'info': 'ℹ', 'warn': '⚠', 'err': '✗'}
|
|
757
|
+
color = colors.get(level, 'cyan')
|
|
758
|
+
icon = icons.get(level, '•')
|
|
759
|
+
line_text = f'{icon} {text}'
|
|
760
|
+
w = _visible_len(line_text) + 4
|
|
761
|
+
tl, tt, tr, ml, mm, mr, bl, bt, br, hz, vt = _BOXES['rounded']
|
|
762
|
+
|
|
763
|
+
print()
|
|
764
|
+
print(c(' ' * ((term_width() - w - 2) // 2) + tl + hz * w + tr, color))
|
|
765
|
+
print(c(' ' * ((term_width() - w - 2) // 2) + vt, color)
|
|
766
|
+
+ ' ' + c(icon, color, bold=True) + ' ' + text
|
|
767
|
+
+ ' ' * (w - _visible_len(line_text) - 1) + c(vt, color))
|
|
768
|
+
print(c(' ' * ((term_width() - w - 2) // 2) + bl + hz * w + br, color))
|
|
769
|
+
|
|
770
|
+
if level == 'ok':
|
|
771
|
+
_sound('ok')
|
|
772
|
+
elif level == 'warn':
|
|
773
|
+
_sound('warning')
|
|
774
|
+
elif level == 'err':
|
|
775
|
+
_sound('error')
|
|
776
|
+
|
|
777
|
+
time.sleep(duration)
|
|
778
|
+
# Стираем — поднимаемся на 3 строки
|
|
779
|
+
for _ in range(3):
|
|
780
|
+
sys.stdout.write('\033[1A\033[K')
|
|
781
|
+
sys.stdout.flush()
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
# ============================================================
|
|
785
|
+
# 5. ТАБЛИЦЫ
|
|
786
|
+
# ============================================================
|
|
787
|
+
|
|
788
|
+
def _pad(text: str, width: int, align: str) -> str:
|
|
789
|
+
if align == 'right':
|
|
790
|
+
return text.rjust(width)
|
|
791
|
+
if align == 'center':
|
|
792
|
+
return text.center(width)
|
|
793
|
+
return text.ljust(width)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def table(rows: Iterable[dict],
|
|
797
|
+
headers: list[str] | None = None,
|
|
798
|
+
*,
|
|
799
|
+
style: str = 'rounded',
|
|
800
|
+
align: str | dict = 'left',
|
|
801
|
+
colors: dict | None = None,
|
|
802
|
+
highlight: Callable[[dict], bool] | None = None,
|
|
803
|
+
formatters: dict | None = None,
|
|
804
|
+
max_width: int | None = None,
|
|
805
|
+
auto_width: bool = False,
|
|
806
|
+
footer: dict | None = None,
|
|
807
|
+
padding: int = 1,
|
|
808
|
+
_return: bool = False) -> str | None:
|
|
809
|
+
"""Печатает таблицу из списка словарей."""
|
|
810
|
+
rows = list(rows)
|
|
811
|
+
if not rows:
|
|
812
|
+
msg = c('(пусто)', 'gray')
|
|
813
|
+
if _return:
|
|
814
|
+
return msg
|
|
815
|
+
print(msg)
|
|
816
|
+
return msg
|
|
817
|
+
if headers is None:
|
|
818
|
+
headers = list(rows[0].keys())
|
|
819
|
+
colors = colors or {}
|
|
820
|
+
head_color = colors.get('header', 'cyan')
|
|
821
|
+
border_color = colors.get('border', 'gray')
|
|
822
|
+
zebra_color = colors.get('zebra')
|
|
823
|
+
highlight_color = colors.get('highlight', 'yellow')
|
|
824
|
+
footer_color = colors.get('footer', 'bright_white')
|
|
825
|
+
formatters = formatters or {}
|
|
826
|
+
if isinstance(align, str):
|
|
827
|
+
align = {h: align for h in headers}
|
|
828
|
+
if auto_width and max_width is None:
|
|
829
|
+
avail = term_width() - (len(headers) * 2 + 4)
|
|
830
|
+
max_width = max(6, avail // max(1, len(headers)))
|
|
831
|
+
|
|
832
|
+
def fmt_cell(h: str, v: Any) -> str:
|
|
833
|
+
s = formatters[h](v) if h in formatters else ('' if v is None else str(v))
|
|
834
|
+
if max_width and _visible_len(s) > max_width:
|
|
835
|
+
s = s[:max_width - 1] + '…'
|
|
836
|
+
return s
|
|
837
|
+
|
|
838
|
+
body = [{h: fmt_cell(h, r.get(h, '')) for h in headers} for r in rows]
|
|
839
|
+
widths = {h: _visible_len(str(h)) for h in headers}
|
|
840
|
+
for r in body:
|
|
841
|
+
for h in headers:
|
|
842
|
+
widths[h] = max(widths[h], _visible_len(r[h]))
|
|
843
|
+
if footer:
|
|
844
|
+
for h in headers:
|
|
845
|
+
if h in footer:
|
|
846
|
+
widths[h] = max(widths[h], _visible_len(str(footer[h])))
|
|
847
|
+
|
|
848
|
+
tl, tt, tr, ml, mm, mr, bl, bt, br, hz, vt = _BOXES.get(style, _BOXES['rounded'])
|
|
849
|
+
|
|
850
|
+
def hline(left, mid, right):
|
|
851
|
+
parts = [hz * (widths[h] + padding * 2) for h in headers]
|
|
852
|
+
return c(left + mid.join(parts) + right, border_color)
|
|
853
|
+
|
|
854
|
+
out = [hline(tl, tt, tr)]
|
|
855
|
+
head_cells = [
|
|
856
|
+
' ' * padding + c(_pad(str(h), widths[h], 'center'), head_color, bold=True)
|
|
857
|
+
+ ' ' * padding
|
|
858
|
+
for h in headers
|
|
859
|
+
]
|
|
860
|
+
out.append(c(vt, border_color) + c(vt, border_color).join(head_cells)
|
|
861
|
+
+ c(vt, border_color))
|
|
862
|
+
if style not in ('markdown', 'none'):
|
|
863
|
+
out.append(hline(ml, mm, mr))
|
|
864
|
+
elif style == 'markdown':
|
|
865
|
+
out.append('|' + '|'.join('-' * (widths[h] + padding * 2) for h in headers) + '|')
|
|
866
|
+
for idx, (r, row_orig) in enumerate(zip(body, rows)):
|
|
867
|
+
is_hi = highlight and highlight(row_orig)
|
|
868
|
+
cells = []
|
|
869
|
+
for h in headers:
|
|
870
|
+
cell = _pad(r[h], widths[h], align.get(h, 'left'))
|
|
871
|
+
cell = ' ' * padding + cell + ' ' * padding
|
|
872
|
+
if is_hi:
|
|
873
|
+
cell = c(cell, highlight_color, bold=True)
|
|
874
|
+
elif zebra_color and idx % 2 == 1:
|
|
875
|
+
cell = c(cell, zebra_color)
|
|
876
|
+
cells.append(cell)
|
|
877
|
+
out.append(c(vt, border_color) + c(vt, border_color).join(cells)
|
|
878
|
+
+ c(vt, border_color))
|
|
879
|
+
if footer:
|
|
880
|
+
out.append(hline(ml, mm, mr))
|
|
881
|
+
cells = [
|
|
882
|
+
' ' * padding + c(_pad(str(footer.get(h, '')), widths[h],
|
|
883
|
+
align.get(h, 'left')), footer_color, bold=True)
|
|
884
|
+
+ ' ' * padding
|
|
885
|
+
for h in headers
|
|
886
|
+
]
|
|
887
|
+
out.append(c(vt, border_color) + c(vt, border_color).join(cells)
|
|
888
|
+
+ c(vt, border_color))
|
|
889
|
+
out.append(hline(bl, bt, br))
|
|
890
|
+
text = '\n'.join(out)
|
|
891
|
+
if _return:
|
|
892
|
+
return text
|
|
893
|
+
print(text)
|
|
894
|
+
return text
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
class Table:
|
|
898
|
+
"""Fluent-обёртка над table().
|
|
899
|
+
|
|
900
|
+
Пример:
|
|
901
|
+
Table(users) \\
|
|
902
|
+
.style('double') \\
|
|
903
|
+
.align('age', 'center') \\
|
|
904
|
+
.format('score', lambda v: f'{v:,}') \\
|
|
905
|
+
.highlight(lambda r: r['score'] > 1000) \\
|
|
906
|
+
.footer({'name': 'Итого', 'score': 5000}) \\
|
|
907
|
+
.show()
|
|
908
|
+
"""
|
|
909
|
+
def __init__(self, rows: list[dict]):
|
|
910
|
+
self._rows = list(rows)
|
|
911
|
+
self._style = 'rounded'
|
|
912
|
+
self._align: dict = {}
|
|
913
|
+
self._colors: dict = {}
|
|
914
|
+
self._highlight = None
|
|
915
|
+
self._formatters: dict = {}
|
|
916
|
+
self._footer = None
|
|
917
|
+
self._max_width = None
|
|
918
|
+
self._auto_width = False
|
|
919
|
+
|
|
920
|
+
def style(self, s: str) -> 'Table':
|
|
921
|
+
self._style = s
|
|
922
|
+
return self
|
|
923
|
+
|
|
924
|
+
def align(self, column: str, align: str) -> 'Table':
|
|
925
|
+
self._align[column] = align
|
|
926
|
+
return self
|
|
927
|
+
|
|
928
|
+
def color(self, key: str, color: str) -> 'Table':
|
|
929
|
+
self._colors[key] = color
|
|
930
|
+
return self
|
|
931
|
+
|
|
932
|
+
def format(self, column: str, fn: Callable) -> 'Table':
|
|
933
|
+
self._formatters[column] = fn
|
|
934
|
+
return self
|
|
935
|
+
|
|
936
|
+
def highlight(self, fn: Callable) -> 'Table':
|
|
937
|
+
self._highlight = fn
|
|
938
|
+
return self
|
|
939
|
+
|
|
940
|
+
def footer(self, data: dict) -> 'Table':
|
|
941
|
+
self._footer = data
|
|
942
|
+
return self
|
|
943
|
+
|
|
944
|
+
def max_width(self, n: int) -> 'Table':
|
|
945
|
+
self._max_width = n
|
|
946
|
+
return self
|
|
947
|
+
|
|
948
|
+
def auto_width(self, flag: bool = True) -> 'Table':
|
|
949
|
+
self._auto_width = flag
|
|
950
|
+
return self
|
|
951
|
+
|
|
952
|
+
def show(self) -> str:
|
|
953
|
+
return table(
|
|
954
|
+
self._rows,
|
|
955
|
+
style=self._style,
|
|
956
|
+
align=self._align or 'left',
|
|
957
|
+
colors=self._colors,
|
|
958
|
+
highlight=self._highlight,
|
|
959
|
+
formatters=self._formatters,
|
|
960
|
+
footer=self._footer,
|
|
961
|
+
max_width=self._max_width,
|
|
962
|
+
auto_width=self._auto_width,
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
# Алиас
|
|
966
|
+
def render(self) -> str:
|
|
967
|
+
return self.show()
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def to_table(data: dict) -> list[dict]:
|
|
971
|
+
return [{'key': k, 'value': v} for k, v in data.items()]
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def table_from_csv(path: str, **kwargs) -> str | None:
|
|
975
|
+
"""Печатает CSV-файл как таблицу.
|
|
976
|
+
|
|
977
|
+
Пример:
|
|
978
|
+
table_from_csv('users.csv', style='double')
|
|
979
|
+
"""
|
|
980
|
+
rows = read_csv(path)
|
|
981
|
+
if not rows:
|
|
982
|
+
warn(f'Файл {path} пуст')
|
|
983
|
+
return None
|
|
984
|
+
return table(rows, **kwargs)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
# ============================================================
|
|
988
|
+
# 6. ДАННЫЕ
|
|
989
|
+
# ============================================================
|
|
990
|
+
|
|
991
|
+
def columns(items: list, gap: int = 2, color: str | None = None,
|
|
992
|
+
indent: int = 0) -> None:
|
|
993
|
+
if not items:
|
|
994
|
+
return
|
|
995
|
+
items = [str(i) for i in items]
|
|
996
|
+
max_w = max(_visible_len(i) for i in items)
|
|
997
|
+
cell = max_w + gap
|
|
998
|
+
width = term_width() - indent
|
|
999
|
+
n_cols = max(1, width // cell)
|
|
1000
|
+
n_rows = (len(items) + n_cols - 1) // n_cols
|
|
1001
|
+
prefix = ' ' * indent
|
|
1002
|
+
for row in range(n_rows):
|
|
1003
|
+
parts = []
|
|
1004
|
+
for col in range(n_cols):
|
|
1005
|
+
idx = col * n_rows + row
|
|
1006
|
+
if idx >= len(items):
|
|
1007
|
+
continue
|
|
1008
|
+
item = items[idx]
|
|
1009
|
+
pad = ' ' * (cell - _visible_len(item))
|
|
1010
|
+
parts.append((c(item, color) if color else item) + pad)
|
|
1011
|
+
print(prefix + ''.join(parts).rstrip())
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def tree(data: dict, prefix: str = '', color: str = 'cyan',
|
|
1015
|
+
branch_color: str = 'gray') -> None:
|
|
1016
|
+
items = list(data.items())
|
|
1017
|
+
for i, (name, child) in enumerate(items):
|
|
1018
|
+
last = i == len(items) - 1
|
|
1019
|
+
branch = '└── ' if last else '├── '
|
|
1020
|
+
print(prefix + c(branch, branch_color) + c(str(name), color))
|
|
1021
|
+
if isinstance(child, dict):
|
|
1022
|
+
tree(child, prefix + (' ' if last else '│ '), color, branch_color)
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def diff(a: dict, b: dict, arrow: str = '→') -> None:
|
|
1026
|
+
for k in sorted(set(a) | set(b), key=str):
|
|
1027
|
+
va = a.get(k, '<нет>')
|
|
1028
|
+
vb = b.get(k, '<нет>')
|
|
1029
|
+
if va == vb:
|
|
1030
|
+
print(f' {c(str(k), "gray")}: {c(str(va), "gray")}')
|
|
1031
|
+
else:
|
|
1032
|
+
print(f' {c(str(k), "yellow")}: '
|
|
1033
|
+
f'{c(str(va), "red")} {c(arrow, "gray")} {c(str(vb), "green")}')
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
_SPARK_CHARS = '▁▂▃▄▅▆▇█'
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def chart(values: list[float], label: str = '', color: str = 'cyan') -> None:
|
|
1040
|
+
if not values:
|
|
1041
|
+
return
|
|
1042
|
+
lo, hi = min(values), max(values)
|
|
1043
|
+
span = hi - lo or 1
|
|
1044
|
+
chars = []
|
|
1045
|
+
for v in values:
|
|
1046
|
+
idx = int((v - lo) / span * (len(_SPARK_CHARS) - 1))
|
|
1047
|
+
chars.append(_SPARK_CHARS[max(0, min(len(_SPARK_CHARS) - 1, idx))])
|
|
1048
|
+
prefix = c(f'{label:<8}', 'gray') if label else ''
|
|
1049
|
+
print(prefix + c(''.join(chars), color))
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def flatten(nested):
|
|
1053
|
+
for item in nested:
|
|
1054
|
+
if isinstance(item, (list, tuple, set)):
|
|
1055
|
+
yield from flatten(item)
|
|
1056
|
+
else:
|
|
1057
|
+
yield item
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def group_by(items: Iterable, key: Callable) -> dict:
|
|
1061
|
+
from collections import defaultdict
|
|
1062
|
+
result = defaultdict(list)
|
|
1063
|
+
for item in items:
|
|
1064
|
+
result[key(item)].append(item)
|
|
1065
|
+
return dict(result)
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def deep_merge(a: dict, b: dict) -> dict:
|
|
1069
|
+
result = dict(a)
|
|
1070
|
+
for k, v in b.items():
|
|
1071
|
+
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
|
|
1072
|
+
result[k] = deep_merge(result[k], v)
|
|
1073
|
+
else:
|
|
1074
|
+
result[k] = v
|
|
1075
|
+
return result
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def env_all(prefix: str = '') -> dict:
|
|
1079
|
+
return {k[len(prefix):]: v for k, v in os.environ.items() if k.startswith(prefix)}
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def read_csv(path: str) -> list[dict]:
|
|
1083
|
+
with open(path, encoding='utf-8', newline='') as f:
|
|
1084
|
+
return list(csv.DictReader(f))
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def write_csv(path: str, rows: list[dict]) -> Path:
|
|
1088
|
+
if not rows:
|
|
1089
|
+
Path(path).write_text('', encoding='utf-8')
|
|
1090
|
+
return Path(path)
|
|
1091
|
+
p = Path(path)
|
|
1092
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
1093
|
+
with open(p, 'w', encoding='utf-8', newline='') as f:
|
|
1094
|
+
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
|
|
1095
|
+
w.writeheader()
|
|
1096
|
+
w.writerows(rows)
|
|
1097
|
+
return p
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
# ============================================================
|
|
1101
|
+
# 7. ПРЕСЕТЫ СПИННЕРА
|
|
1102
|
+
# ============================================================
|
|
1103
|
+
|
|
1104
|
+
SPINNER_FRAMES = {
|
|
1105
|
+
'dots': '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' * 3,
|
|
1106
|
+
'dots_dense': '⣾⣽⣻⢿⡿⣟⣯⣷' * 3,
|
|
1107
|
+
'smooth': '⠁⠂⠄⡀⢀⠠⠐⠈' * 3,
|
|
1108
|
+
'smooth_wave': ('⠁⠂⠄⡀⢀⠠⠐⠈'
|
|
1109
|
+
'⠘⠚⠒⠓⠋⠉⠑⠈'
|
|
1110
|
+
'⠙⠚⠖⠦⠤⠠⠒⠊'),
|
|
1111
|
+
'pulse': '⣀⣄⣤⣦⣶⣷⣿⣷⣶' * 3,
|
|
1112
|
+
'bars': '▁▂▃▄▅▆▇████▇▆▅▄▃▂▁' * 2,
|
|
1113
|
+
'grow': '▏▎▍▌▋▊▉██▉▊▋▌▍▎' * 2,
|
|
1114
|
+
'circle': '◐◓◑◒' * 6,
|
|
1115
|
+
'circle_thin': '◜◠◝◞◡◟' * 4,
|
|
1116
|
+
'circle_dot': '◜◝◞◟' * 6,
|
|
1117
|
+
'clock': '◴◷◶◵' * 6,
|
|
1118
|
+
'breathe': '◜◝◞◟' * 6,
|
|
1119
|
+
'gauge': '○◔◑◕●◕◑◔' * 3,
|
|
1120
|
+
'radar': '◜◠◝◞◡◟◜◠◝◞◡◟' * 2,
|
|
1121
|
+
'compass': '←↖↑↗→↘↓↙' * 3,
|
|
1122
|
+
'arrow': '←↖↑↗→↘↓↙' * 3,
|
|
1123
|
+
'arrows': '↻↺' * 12,
|
|
1124
|
+
'arrows_bold': '⟲⟳' * 12,
|
|
1125
|
+
'arrows_dash': '⬅⬉⬆⬈➡⬊⬇⬋',
|
|
1126
|
+
'arrows_dbl': '⇐⇑⇒⇓' * 6,
|
|
1127
|
+
'moon': '🌑🌒🌓🌔🌕🌖🌗🌘' * 3,
|
|
1128
|
+
'moon_rev': '🌘🌗🌖🌕🌔🌓🌒🌑' * 3,
|
|
1129
|
+
'earth': '🌍🌎🌏' * 8,
|
|
1130
|
+
'clock_emoji': '🕐🕑🕒🕓🕔🕕🕖🕗🕘🕙🕚🕛',
|
|
1131
|
+
'clock_half': '🕜🕝🕞🕟🕠🕡🕢🕣🕤🕥🕦🕧',
|
|
1132
|
+
'weather': '☀🌤⛅🌥☁🌦🌧⛈' * 3,
|
|
1133
|
+
'star': '·✧✦✩✪✫✬✭✮✯',
|
|
1134
|
+
'hearts': '♡♥' * 12,
|
|
1135
|
+
'music': '♪♫' * 12,
|
|
1136
|
+
'traffic': '🔴🟡🟢🟡' * 6,
|
|
1137
|
+
'hourglass': '⏳⌛' * 12,
|
|
1138
|
+
'fire': '🔥 ',
|
|
1139
|
+
'sparkle': '✨ ',
|
|
1140
|
+
'rocket': '🚀 ',
|
|
1141
|
+
'gem': '💎 ',
|
|
1142
|
+
'diamond_e': '💠 ',
|
|
1143
|
+
'crystal': '🔮 ',
|
|
1144
|
+
'atom': '⚛ ',
|
|
1145
|
+
'gear': '⚙ ',
|
|
1146
|
+
'lightning': '⚡ ',
|
|
1147
|
+
'braille1': '⠁⠂⠄⡀⢀⠠⠐⠈' * 3,
|
|
1148
|
+
'braille3': '⠁⠉⠙⠚⠒⠂⠒⠲⠴⠤⠄⠤⠦⠖⠚⠒',
|
|
1149
|
+
'braille5': '⢄⢂⢁⡁⡈⡐⡠⣀⣀' * 3,
|
|
1150
|
+
'braille7': '⡀⡄⡆⡇⣇⣧⣷⣿⣿' * 3,
|
|
1151
|
+
'blocks1': '▁▂▃▄▅▆▇█▇▆▅▄▃▂' * 2,
|
|
1152
|
+
'blocks2': '▏▎▍▌▋▊▉█▉▊▋▌▍▎' * 2,
|
|
1153
|
+
'blocks3': '░▒▓█▓▒░' * 4,
|
|
1154
|
+
'diamond': '◇◈◆◈◇' * 5,
|
|
1155
|
+
'tri': '◢◣◤◥' * 6,
|
|
1156
|
+
'line': '|/-\\' * 6,
|
|
1157
|
+
'ascii_dots': '.oO@*' * 5,
|
|
1158
|
+
'ascii_light': '=+*#%@' * 4,
|
|
1159
|
+
'wave': '▁▂▃▄▅▆▇█▇▆▅▄▃▂▁' * 2,
|
|
1160
|
+
'sine': '▁▂▄▆█▆▄▂▁' * 3,
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
FANCY_PRESETS = [
|
|
1164
|
+
'dots_dense', 'smooth', 'smooth_wave', 'pulse',
|
|
1165
|
+
'circle', 'circle_thin', 'circle_dot', 'clock',
|
|
1166
|
+
'breathe', 'moon', 'arrow', 'radar', 'gauge',
|
|
1167
|
+
'wave', 'sine', 'sparkle',
|
|
1168
|
+
]
|
|
1169
|
+
|
|
1170
|
+
CIRCLE_PRESETS = ['circle', 'circle_thin', 'circle_dot', 'clock',
|
|
1171
|
+
'breathe', 'gauge', 'radar', 'compass']
|
|
1172
|
+
|
|
1173
|
+
PLAIN_PRESETS = ['dots', 'dots_dense', 'smooth', 'smooth_wave',
|
|
1174
|
+
'pulse', 'bars', 'grow', 'line', 'arrow']
|
|
1175
|
+
|
|
1176
|
+
EMOJI_PRESETS = ['moon', 'moon_rev', 'earth', 'clock_emoji', 'clock_half',
|
|
1177
|
+
'weather', 'star', 'hearts', 'music', 'traffic',
|
|
1178
|
+
'hourglass', 'fire', 'sparkle', 'rocket', 'gem', 'crystal']
|
|
1179
|
+
|
|
1180
|
+
ASCII_PRESETS = ['line', 'ascii_dots', 'ascii_light', 'braille1', 'braille3']
|
|
1181
|
+
|
|
1182
|
+
_random_preset: str | None = None
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def pick_preset(kind: str = 'fancy') -> str:
|
|
1186
|
+
pools = {
|
|
1187
|
+
'fancy': FANCY_PRESETS, 'circle': CIRCLE_PRESETS,
|
|
1188
|
+
'plain': PLAIN_PRESETS, 'emoji': EMOJI_PRESETS,
|
|
1189
|
+
'ascii': ASCII_PRESETS, 'any': list(SPINNER_FRAMES.keys()),
|
|
1190
|
+
}
|
|
1191
|
+
return random.choice(pools.get(kind, FANCY_PRESETS))
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
# ============================================================
|
|
1195
|
+
# 8. ПЛАНИРОВЩИК КАДРОВ
|
|
1196
|
+
# ============================================================
|
|
1197
|
+
|
|
1198
|
+
def _frame_scheduler(stop: threading.Event, speed: float,
|
|
1199
|
+
render_fn: Callable):
|
|
1200
|
+
next_tick = time.perf_counter()
|
|
1201
|
+
while not stop.is_set():
|
|
1202
|
+
render_fn()
|
|
1203
|
+
next_tick += speed
|
|
1204
|
+
while True:
|
|
1205
|
+
delta = next_tick - time.perf_counter()
|
|
1206
|
+
if delta <= 0:
|
|
1207
|
+
next_tick = time.perf_counter()
|
|
1208
|
+
break
|
|
1209
|
+
if delta > 0.002:
|
|
1210
|
+
time.sleep(delta - 0.001)
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
# ============================================================
|
|
1214
|
+
# 9. WITH-БЛОК SPINNER
|
|
1215
|
+
# ============================================================
|
|
1216
|
+
|
|
1217
|
+
class _SpinnerCtx:
|
|
1218
|
+
"""Контекст спиннера с методами pause/resume."""
|
|
1219
|
+
def __init__(self, stop_event, paused_event):
|
|
1220
|
+
self._stop = stop_event
|
|
1221
|
+
self._paused = paused_event
|
|
1222
|
+
|
|
1223
|
+
def pause(self):
|
|
1224
|
+
"""Пауза анимации."""
|
|
1225
|
+
self._paused.set()
|
|
1226
|
+
|
|
1227
|
+
def resume(self):
|
|
1228
|
+
"""Продолжить."""
|
|
1229
|
+
self._paused.clear()
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
@contextmanager
|
|
1233
|
+
def spinner(text: str = 'Загрузка', preset: str | None = None,
|
|
1234
|
+
kind: str | None = None, frames: str | None = None,
|
|
1235
|
+
color: str | None = None, speed: float | None = None,
|
|
1236
|
+
done: str = '✓', done_color: str = 'green',
|
|
1237
|
+
keep: bool = True, show_time: bool | None = None,
|
|
1238
|
+
random_preset: bool = True):
|
|
1239
|
+
"""Универсальная крутилка. Возвращает ctx с .pause() и .resume()."""
|
|
1240
|
+
global _random_preset
|
|
1241
|
+
|
|
1242
|
+
color = color if color is not None else _SPINNER_DEFAULTS['color']
|
|
1243
|
+
speed = speed if speed is not None else _SPINNER_DEFAULTS['speed']
|
|
1244
|
+
show_time = show_time if show_time is not None else _SPINNER_DEFAULTS['show_time']
|
|
1245
|
+
kind = kind if kind is not None else _SPINNER_DEFAULTS['kind']
|
|
1246
|
+
if preset is None:
|
|
1247
|
+
preset = _SPINNER_DEFAULTS['preset']
|
|
1248
|
+
|
|
1249
|
+
if frames is None:
|
|
1250
|
+
if preset is None:
|
|
1251
|
+
if random_preset:
|
|
1252
|
+
if _random_preset is None:
|
|
1253
|
+
_random_preset = pick_preset(kind)
|
|
1254
|
+
preset = _random_preset
|
|
1255
|
+
else:
|
|
1256
|
+
preset = pick_preset(kind)
|
|
1257
|
+
frames = SPINNER_FRAMES.get(preset, SPINNER_FRAMES['dots_dense'])
|
|
1258
|
+
|
|
1259
|
+
stop = threading.Event()
|
|
1260
|
+
paused = threading.Event()
|
|
1261
|
+
ctx = _SpinnerCtx(stop, paused)
|
|
1262
|
+
start = time.time()
|
|
1263
|
+
frame_iter = itertools.cycle(frames)
|
|
1264
|
+
|
|
1265
|
+
def render():
|
|
1266
|
+
if paused.is_set():
|
|
1267
|
+
return
|
|
1268
|
+
try:
|
|
1269
|
+
ch = next(frame_iter)
|
|
1270
|
+
except StopIteration:
|
|
1271
|
+
return
|
|
1272
|
+
suffix = ' ' + c(f'{time.time() - start:.1f}s', 'gray') if show_time else ''
|
|
1273
|
+
sys.stdout.write(f'\r{c(ch, color)} {text}{suffix}\033[K')
|
|
1274
|
+
sys.stdout.flush()
|
|
1275
|
+
|
|
1276
|
+
th = threading.Thread(target=_frame_scheduler,
|
|
1277
|
+
args=(stop, speed, render), daemon=True)
|
|
1278
|
+
th.start()
|
|
1279
|
+
error = False
|
|
1280
|
+
try:
|
|
1281
|
+
yield ctx
|
|
1282
|
+
except Exception:
|
|
1283
|
+
error = True
|
|
1284
|
+
raise
|
|
1285
|
+
finally:
|
|
1286
|
+
stop.set()
|
|
1287
|
+
th.join()
|
|
1288
|
+
elapsed = time.time() - start
|
|
1289
|
+
clear = ' ' * (_visible_len(text) + 14)
|
|
1290
|
+
if keep:
|
|
1291
|
+
icon = c('✗', 'red', bold=True) if error else c(done, done_color, bold=True)
|
|
1292
|
+
time_str = c(f' ({elapsed:.1f}s)', 'gray') if show_time else ''
|
|
1293
|
+
sys.stdout.write(f'\r{clear}\r{icon} {text}{time_str}\n')
|
|
1294
|
+
else:
|
|
1295
|
+
sys.stdout.write(f'\r{clear}\r')
|
|
1296
|
+
sys.stdout.flush()
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
# Алиас для явного использования
|
|
1300
|
+
spinner_pause = None # будет подменён ниже функцией
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def spinner_pause(paused: bool = True) -> None:
|
|
1304
|
+
"""Глобальный флаг паузы — не используется.
|
|
1305
|
+
Используй `with spinner('X') as s: s.pause()`.
|
|
1306
|
+
"""
|
|
1307
|
+
warn('spinner_pause() устарел. Используй: with spinner(...) as s: s.pause()')
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
# ============================================================
|
|
1311
|
+
# 10. СПИННЕР ЧЕРЕЗ F-СТРОКУ
|
|
1312
|
+
# ============================================================
|
|
1313
|
+
|
|
1314
|
+
_FSPIN_STOP = threading.Event()
|
|
1315
|
+
_FSPIN_THREAD: threading.Thread | None = None
|
|
1316
|
+
_FSPIN_LINE = ''
|
|
1317
|
+
_FSPIN_COLOR = 'cyan'
|
|
1318
|
+
_FSPIN_PRESET: str | None = None
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _fspin_loop():
|
|
1322
|
+
frames = SPINNER_FRAMES.get(_FSPIN_PRESET or 'dots_dense',
|
|
1323
|
+
SPINNER_FRAMES['dots_dense'])
|
|
1324
|
+
next_tick = time.perf_counter()
|
|
1325
|
+
for ch in itertools.cycle(frames):
|
|
1326
|
+
if _FSPIN_STOP.is_set():
|
|
1327
|
+
break
|
|
1328
|
+
text = f'{c(ch, _FSPIN_COLOR)} {_FSPIN_LINE}'
|
|
1329
|
+
sys.stdout.write(f'\r{text}\033[K')
|
|
1330
|
+
sys.stdout.flush()
|
|
1331
|
+
next_tick += _SPINNER_DEFAULTS['speed']
|
|
1332
|
+
while True:
|
|
1333
|
+
delta = next_tick - time.perf_counter()
|
|
1334
|
+
if delta <= 0:
|
|
1335
|
+
next_tick = time.perf_counter()
|
|
1336
|
+
break
|
|
1337
|
+
if delta > 0.002:
|
|
1338
|
+
time.sleep(delta - 0.001)
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
def _fspin_start(text: str, color: str | None = None,
|
|
1342
|
+
preset: str | None = None):
|
|
1343
|
+
global _FSPIN_THREAD, _FSPIN_STOP, _FSPIN_LINE, _FSPIN_COLOR, _FSPIN_PRESET
|
|
1344
|
+
if _FSPIN_THREAD and _FSPIN_THREAD.is_alive():
|
|
1345
|
+
_FSPIN_STOP.set()
|
|
1346
|
+
_FSPIN_THREAD.join()
|
|
1347
|
+
_FSPIN_STOP = threading.Event()
|
|
1348
|
+
_FSPIN_LINE = text
|
|
1349
|
+
_FSPIN_COLOR = color if color is not None else _SPINNER_DEFAULTS['color']
|
|
1350
|
+
_FSPIN_PRESET = preset or _SPINNER_DEFAULTS['preset'] or 'dots_dense'
|
|
1351
|
+
_FSPIN_THREAD = threading.Thread(target=_fspin_loop, daemon=True)
|
|
1352
|
+
_FSPIN_THREAD.start()
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
def _fspin_finish(icon: str, icon_color: str, text: str = '',
|
|
1356
|
+
duration: float | None = None):
|
|
1357
|
+
global _FSPIN_THREAD, _FSPIN_LINE
|
|
1358
|
+
if _FSPIN_THREAD and _FSPIN_THREAD.is_alive():
|
|
1359
|
+
_FSPIN_STOP.set()
|
|
1360
|
+
_FSPIN_THREAD.join()
|
|
1361
|
+
line = text or _FSPIN_LINE
|
|
1362
|
+
dt = f' {c(f"({duration:.2f}s)", "gray")}' if duration is not None else ''
|
|
1363
|
+
sys.stdout.write(f'\r\033[K{c(icon, icon_color, bold=True)} {line}{dt}\n')
|
|
1364
|
+
sys.stdout.flush()
|
|
1365
|
+
_FSPIN_LINE = ''
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def spin(text: str = '', color: str | None = None,
|
|
1369
|
+
preset: str | None = None) -> str:
|
|
1370
|
+
"""Спиннер через f-строку."""
|
|
1371
|
+
_fspin_start(text, color, preset)
|
|
1372
|
+
return ''
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
sp = spin
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
def spin_done(done: str = '✓', color: str = 'green',
|
|
1379
|
+
newline: bool = True) -> None:
|
|
1380
|
+
global _FSPIN_THREAD, _FSPIN_LINE
|
|
1381
|
+
if not _FSPIN_THREAD or not _FSPIN_THREAD.is_alive():
|
|
1382
|
+
return
|
|
1383
|
+
_FSPIN_STOP.set()
|
|
1384
|
+
_FSPIN_THREAD.join()
|
|
1385
|
+
icon = c(done, color, bold=True)
|
|
1386
|
+
sys.stdout.write(f'\r\033[K{icon} {_FSPIN_LINE}')
|
|
1387
|
+
if newline:
|
|
1388
|
+
sys.stdout.write('\n')
|
|
1389
|
+
sys.stdout.flush()
|
|
1390
|
+
_FSPIN_LINE = ''
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def spin_fail(msg: str = '', color: str = 'red',
|
|
1394
|
+
newline: bool = True) -> None:
|
|
1395
|
+
global _FSPIN_THREAD, _FSPIN_LINE
|
|
1396
|
+
if not _FSPIN_THREAD or not _FSPIN_THREAD.is_alive():
|
|
1397
|
+
return
|
|
1398
|
+
_FSPIN_STOP.set()
|
|
1399
|
+
_FSPIN_THREAD.join()
|
|
1400
|
+
icon = c('✗', color, bold=True)
|
|
1401
|
+
text = _FSPIN_LINE
|
|
1402
|
+
if msg:
|
|
1403
|
+
text = f'{text} — {msg}' if text else msg
|
|
1404
|
+
sys.stdout.write(f'\r\033[K{icon} {text}')
|
|
1405
|
+
if newline:
|
|
1406
|
+
sys.stdout.write('\n')
|
|
1407
|
+
sys.stdout.flush()
|
|
1408
|
+
_FSPIN_LINE = ''
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
# ============================================================
|
|
1412
|
+
# 11. STEP_RUN / DO / PAUSE / WAIT
|
|
1413
|
+
# ============================================================
|
|
1414
|
+
|
|
1415
|
+
def step_run(*tasks, label: str | None = None,
|
|
1416
|
+
color: str | None = None,
|
|
1417
|
+
show_time: bool | None = None,
|
|
1418
|
+
stop_on_error: bool = True,
|
|
1419
|
+
raise_on_error: bool = False):
|
|
1420
|
+
"""Универсальный раннер с крутилкой."""
|
|
1421
|
+
color = color if color is not None else _SPINNER_DEFAULTS['color']
|
|
1422
|
+
show_time = show_time if show_time is not None else _SPINNER_DEFAULTS['show_time']
|
|
1423
|
+
|
|
1424
|
+
steps: list[tuple[str, Callable | None, tuple]] = []
|
|
1425
|
+
if not tasks:
|
|
1426
|
+
steps = [('', None, ())]
|
|
1427
|
+
else:
|
|
1428
|
+
i = 0
|
|
1429
|
+
while i < len(tasks):
|
|
1430
|
+
item = tasks[i]
|
|
1431
|
+
if isinstance(item, (list, tuple)):
|
|
1432
|
+
lbl = str(item[0]) if item else ''
|
|
1433
|
+
fn = item[1] if len(item) > 1 and callable(item[1]) else None
|
|
1434
|
+
steps.append((lbl, fn, ()))
|
|
1435
|
+
i += 1
|
|
1436
|
+
elif callable(item):
|
|
1437
|
+
steps.append((label or getattr(item, '__name__', 'running'),
|
|
1438
|
+
item, ()))
|
|
1439
|
+
i += 1
|
|
1440
|
+
else:
|
|
1441
|
+
lbl = str(item)
|
|
1442
|
+
if i + 1 < len(tasks):
|
|
1443
|
+
nxt = tasks[i + 1]
|
|
1444
|
+
if callable(nxt) and not isinstance(nxt, str):
|
|
1445
|
+
fn = nxt
|
|
1446
|
+
args = []
|
|
1447
|
+
j = i + 2
|
|
1448
|
+
while j < len(tasks) and not callable(tasks[j]) and \
|
|
1449
|
+
not isinstance(tasks[j], (list, tuple)):
|
|
1450
|
+
if j + 1 < len(tasks) and callable(tasks[j + 1]) and \
|
|
1451
|
+
isinstance(tasks[j], str):
|
|
1452
|
+
break
|
|
1453
|
+
args.append(tasks[j])
|
|
1454
|
+
j += 1
|
|
1455
|
+
steps.append((lbl, fn, tuple(args)))
|
|
1456
|
+
i = j
|
|
1457
|
+
continue
|
|
1458
|
+
steps.append((lbl, None, ()))
|
|
1459
|
+
i += 1
|
|
1460
|
+
|
|
1461
|
+
results = []
|
|
1462
|
+
for text, fn, args in steps:
|
|
1463
|
+
display = text or (getattr(fn, '__name__', '') if fn else '')
|
|
1464
|
+
if label and text:
|
|
1465
|
+
display = f'{label} {text}'
|
|
1466
|
+
start = time.perf_counter()
|
|
1467
|
+
_fspin_start(display, color)
|
|
1468
|
+
try:
|
|
1469
|
+
if fn is None:
|
|
1470
|
+
result = None
|
|
1471
|
+
else:
|
|
1472
|
+
result = fn(*args)
|
|
1473
|
+
dt = time.perf_counter() - start
|
|
1474
|
+
_fspin_finish('✓', 'green', display, dt if show_time else None)
|
|
1475
|
+
results.append(result)
|
|
1476
|
+
except Exception as e:
|
|
1477
|
+
dt = time.perf_counter() - start
|
|
1478
|
+
_fspin_finish('✗', 'red',
|
|
1479
|
+
f'{display} — {type(e).__name__}: {e}',
|
|
1480
|
+
dt if show_time else None)
|
|
1481
|
+
if stop_on_error:
|
|
1482
|
+
if raise_on_error:
|
|
1483
|
+
raise
|
|
1484
|
+
return results if len(results) > 1 else (results[0] if results else None)
|
|
1485
|
+
results.append(e)
|
|
1486
|
+
if len(results) == 1:
|
|
1487
|
+
return results[0]
|
|
1488
|
+
return results
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def run(label: str, fn: Callable, *args, **kwargs):
|
|
1492
|
+
return step_run(label, fn, *args, **kwargs)
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def run_all(*pairs, **kwargs):
|
|
1496
|
+
return step_run(*pairs, **kwargs)
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
def do(text: str, fn: Callable, *args, **kwargs):
|
|
1500
|
+
return step_run(text, fn, *args, **kwargs)
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def pause(text: str = 'Пауза', seconds: float = 1.0):
|
|
1504
|
+
step_run(text, time.sleep, seconds)
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
def wait(seconds: float = 1.0, text: str = '') -> None:
|
|
1508
|
+
"""Простой отсчёт с обновляющейся строкой.
|
|
1509
|
+
|
|
1510
|
+
Пример:
|
|
1511
|
+
wait(5, 'Подождите') # печатает «Подождите... 5с»
|
|
1512
|
+
"""
|
|
1513
|
+
label = text or 'Ждём'
|
|
1514
|
+
start = time.time()
|
|
1515
|
+
end = start + seconds
|
|
1516
|
+
while True:
|
|
1517
|
+
left = end - time.time()
|
|
1518
|
+
if left <= 0:
|
|
1519
|
+
break
|
|
1520
|
+
sys.stdout.write(
|
|
1521
|
+
f'\r{c("⏱", "cyan")} {label}... {c(f"{left:5.1f}с", "yellow")}\033[K'
|
|
1522
|
+
)
|
|
1523
|
+
sys.stdout.flush()
|
|
1524
|
+
time.sleep(0.1)
|
|
1525
|
+
sys.stdout.write(
|
|
1526
|
+
f'\r{c("✓", "green", bold=True)} {label} ({seconds:g}с)\n'
|
|
1527
|
+
)
|
|
1528
|
+
sys.stdout.flush()
|
|
1529
|
+
_sound('done')
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
def step(n: int, text: str, ok_: bool = True) -> None:
|
|
1533
|
+
icon = c('✓', 'green', bold=True) if ok_ else c('✗', 'red', bold=True)
|
|
1534
|
+
print(f'{c(f"[{n}]", "cyan")} {text} {icon}')
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
def task(n: int, total: int, text: str, ok_: bool = True,
|
|
1538
|
+
color: str = 'cyan') -> None:
|
|
1539
|
+
icon = c('✓', 'green', bold=True) if ok_ else c('✗', 'red', bold=True)
|
|
1540
|
+
print(f'{c(f"[{n}/{total}]", color, dim=True)} {text} {icon}')
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
# ============================================================
|
|
1544
|
+
# 12. ПРОГРЕСС И БАРЫ
|
|
1545
|
+
# ============================================================
|
|
1546
|
+
|
|
1547
|
+
class circle_bar:
|
|
1548
|
+
STEPS = ['○', '◔', '◑', '◕', '●']
|
|
1549
|
+
|
|
1550
|
+
def __init__(self, total: int, prefix: str = '', width: int = 10,
|
|
1551
|
+
color: str = 'cyan', bg_color: str = 'gray'):
|
|
1552
|
+
self.total, self.prefix, self.width = total, prefix, width
|
|
1553
|
+
self.color, self.bg_color = color, bg_color
|
|
1554
|
+
self.start = time.time()
|
|
1555
|
+
self._last_len = 0
|
|
1556
|
+
|
|
1557
|
+
def _ring(self, pct: float) -> str:
|
|
1558
|
+
filled_exact = pct * self.width
|
|
1559
|
+
filled = int(filled_exact)
|
|
1560
|
+
frac = filled_exact - filled
|
|
1561
|
+
partial = min(int(frac * (len(self.STEPS) - 1)), len(self.STEPS) - 1)
|
|
1562
|
+
parts = []
|
|
1563
|
+
for i in range(self.width):
|
|
1564
|
+
if i < filled:
|
|
1565
|
+
parts.append(c('●', self.color))
|
|
1566
|
+
elif i == filled and frac > 0:
|
|
1567
|
+
parts.append(c(self.STEPS[partial], self.color))
|
|
1568
|
+
else:
|
|
1569
|
+
parts.append(c('○', self.bg_color))
|
|
1570
|
+
return ''.join(parts)
|
|
1571
|
+
|
|
1572
|
+
def update(self, n: int, suffix: str = '') -> None:
|
|
1573
|
+
pct = n / self.total
|
|
1574
|
+
elapsed = time.time() - self.start
|
|
1575
|
+
eta = (elapsed / n) * (self.total - n) if n else 0
|
|
1576
|
+
line = f'{self.prefix} [{self._ring(pct)}] {n}/{self.total} {pct*100:5.1f}%'
|
|
1577
|
+
if eta and n < self.total:
|
|
1578
|
+
line += f' ETA {human_time(eta, short=True):>6}'
|
|
1579
|
+
if suffix:
|
|
1580
|
+
line += f' {suffix}'
|
|
1581
|
+
pad = max(0, self._last_len - _visible_len(line))
|
|
1582
|
+
sys.stdout.write('\r' + line + ' ' * pad)
|
|
1583
|
+
sys.stdout.flush()
|
|
1584
|
+
self._last_len = _visible_len(line)
|
|
1585
|
+
|
|
1586
|
+
def close(self) -> None:
|
|
1587
|
+
sys.stdout.write('\n')
|
|
1588
|
+
sys.stdout.flush()
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
def progress(iterable: Iterable, total: int | None = None,
|
|
1592
|
+
prefix: str = '', width: int = 30,
|
|
1593
|
+
show_eta: bool = True) -> Iterator:
|
|
1594
|
+
items = list(iterable) if total is None else iterable
|
|
1595
|
+
total = total or len(items)
|
|
1596
|
+
start = time.time()
|
|
1597
|
+
for i, item in enumerate(items, 1):
|
|
1598
|
+
yield item
|
|
1599
|
+
done = int(width * i / total)
|
|
1600
|
+
pct = 100 * i / total
|
|
1601
|
+
elapsed = time.time() - start
|
|
1602
|
+
eta = (elapsed / i) * (total - i) if i else 0
|
|
1603
|
+
bar = '█' * done + '░' * (width - done)
|
|
1604
|
+
tail = f'{pct:5.1f}%'
|
|
1605
|
+
if show_eta:
|
|
1606
|
+
tail += f' ETA {human_time(eta, short=True):>6}'
|
|
1607
|
+
sys.stdout.write(f'\r{prefix} [{bar}] {i}/{total} {tail}\033[K')
|
|
1608
|
+
sys.stdout.flush()
|
|
1609
|
+
print()
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def progress3(iterable: Iterable, total: int | None = None,
|
|
1613
|
+
prefix: str = '', width: int = 30,
|
|
1614
|
+
colors: tuple = ('red', 'yellow', 'green'),
|
|
1615
|
+
show_eta: bool = True) -> Iterator:
|
|
1616
|
+
"""Прогресс-бар с 3-цветной заливкой."""
|
|
1617
|
+
items = list(iterable) if total is None else iterable
|
|
1618
|
+
total = total or len(items)
|
|
1619
|
+
start = time.time()
|
|
1620
|
+
r1, g1, b1 = _any_to_rgb(colors[0])
|
|
1621
|
+
r2, g2, b2 = _any_to_rgb(colors[1])
|
|
1622
|
+
r3, g3, b3 = _any_to_rgb(colors[2])
|
|
1623
|
+
|
|
1624
|
+
for i, item in enumerate(items, 1):
|
|
1625
|
+
yield item
|
|
1626
|
+
done = int(width * i / total)
|
|
1627
|
+
pct = 100 * i / total
|
|
1628
|
+
elapsed = time.time() - start
|
|
1629
|
+
eta = (elapsed / i) * (total - i) if i else 0
|
|
1630
|
+
|
|
1631
|
+
parts = []
|
|
1632
|
+
for k in range(width):
|
|
1633
|
+
if k < done:
|
|
1634
|
+
t = k / max(width - 1, 1)
|
|
1635
|
+
if t <= 0.5:
|
|
1636
|
+
u = t * 2
|
|
1637
|
+
r = int(r1 + (r2 - r1) * u)
|
|
1638
|
+
g = int(g1 + (g2 - g1) * u)
|
|
1639
|
+
b = int(b1 + (b2 - b1) * u)
|
|
1640
|
+
else:
|
|
1641
|
+
u = (t - 0.5) * 2
|
|
1642
|
+
r = int(r2 + (r3 - r2) * u)
|
|
1643
|
+
g = int(g2 + (g3 - g2) * u)
|
|
1644
|
+
b = int(b2 + (b3 - b2) * u)
|
|
1645
|
+
parts.append(f'\033[38;2;{r};{g};{b}m█')
|
|
1646
|
+
else:
|
|
1647
|
+
parts.append('░')
|
|
1648
|
+
bar = ''.join(parts) + _RESET
|
|
1649
|
+
|
|
1650
|
+
tail = f'{pct:5.1f}%'
|
|
1651
|
+
if show_eta:
|
|
1652
|
+
tail += f' ETA {human_time(eta, short=True):>6}'
|
|
1653
|
+
sys.stdout.write(f'\r{prefix} [{bar}] {i}/{total} {tail}\033[K')
|
|
1654
|
+
sys.stdout.flush()
|
|
1655
|
+
print()
|
|
1656
|
+
|
|
1657
|
+
|
|
1658
|
+
def progress_multi(labels: list[str], width: int = 30) -> '_ProgressMulti':
|
|
1659
|
+
"""Параллельные прогресс-бары (как в docker compose up).
|
|
1660
|
+
|
|
1661
|
+
Пример:
|
|
1662
|
+
with progress_multi(['Скачивание', 'Распаковка', 'Индексация']) as pm:
|
|
1663
|
+
for i in range(1, 101):
|
|
1664
|
+
pm.update('Скачивание', i)
|
|
1665
|
+
pm.update('Распаковка', i // 2)
|
|
1666
|
+
time.sleep(0.02)
|
|
1667
|
+
"""
|
|
1668
|
+
return _ProgressMulti(labels, width)
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
class _ProgressMulti:
|
|
1672
|
+
def __init__(self, labels: list[str], width: int = 30):
|
|
1673
|
+
self.labels = list(labels)
|
|
1674
|
+
self.width = width
|
|
1675
|
+
self.values = {l: 0 for l in labels}
|
|
1676
|
+
self.total = {l: 100 for l in labels}
|
|
1677
|
+
self._lines = 0
|
|
1678
|
+
self._start = time.time()
|
|
1679
|
+
|
|
1680
|
+
def update(self, label: str, value: float, total: float | None = None):
|
|
1681
|
+
if label not in self.labels:
|
|
1682
|
+
return
|
|
1683
|
+
self.values[label] = value
|
|
1684
|
+
if total is not None:
|
|
1685
|
+
self.total[label] = total
|
|
1686
|
+
self._render()
|
|
1687
|
+
|
|
1688
|
+
def _render(self):
|
|
1689
|
+
# Стираем предыдущий рендер
|
|
1690
|
+
if self._lines:
|
|
1691
|
+
sys.stdout.write(f'\033[{self._lines}A')
|
|
1692
|
+
for _ in range(self._lines):
|
|
1693
|
+
sys.stdout.write('\033[K\n')
|
|
1694
|
+
sys.stdout.write(f'\033[{self._lines}A')
|
|
1695
|
+
|
|
1696
|
+
for label in self.labels:
|
|
1697
|
+
val = self.values[label]
|
|
1698
|
+
tot = self.total[label] or 1
|
|
1699
|
+
pct = val / tot
|
|
1700
|
+
done = int(self.width * pct)
|
|
1701
|
+
bar = c('█' * done, 'bright_cyan') + c('░' * (self.width - done), 'gray')
|
|
1702
|
+
line = f'{label:<15} [{bar}] {val:>5.0f}/{tot:<5.0f} {pct*100:5.1f}%'
|
|
1703
|
+
sys.stdout.write(line + '\033[K\n')
|
|
1704
|
+
self._lines = len(self.labels)
|
|
1705
|
+
sys.stdout.flush()
|
|
1706
|
+
|
|
1707
|
+
def __enter__(self):
|
|
1708
|
+
return self
|
|
1709
|
+
|
|
1710
|
+
def __exit__(self, *args):
|
|
1711
|
+
self._render()
|
|
1712
|
+
|
|
1713
|
+
|
|
1714
|
+
class PBar:
|
|
1715
|
+
def __init__(self, total: int, prefix: str = '', width: int = 30,
|
|
1716
|
+
show_eta: bool = True):
|
|
1717
|
+
self.total, self.prefix, self.width = total, prefix, width
|
|
1718
|
+
self.show_eta = show_eta
|
|
1719
|
+
self.start = time.time()
|
|
1720
|
+
|
|
1721
|
+
def update(self, n: int, suffix: str = '') -> None:
|
|
1722
|
+
done = int(self.width * n / self.total)
|
|
1723
|
+
pct = 100 * n / self.total
|
|
1724
|
+
elapsed = time.time() - self.start
|
|
1725
|
+
eta = (elapsed / n) * (self.total - n) if n else 0
|
|
1726
|
+
bar = '█' * done + '░' * (self.width - done)
|
|
1727
|
+
tail = f'{pct:5.1f}%'
|
|
1728
|
+
if self.show_eta:
|
|
1729
|
+
tail += f' ETA {human_time(eta, short=True):>6}'
|
|
1730
|
+
line = f'{self.prefix} [{bar}] {n}/{self.total} {tail}'
|
|
1731
|
+
if suffix:
|
|
1732
|
+
line += f' {suffix}'
|
|
1733
|
+
sys.stdout.write('\r' + line + '\033[K')
|
|
1734
|
+
sys.stdout.flush()
|
|
1735
|
+
|
|
1736
|
+
def close(self) -> None:
|
|
1737
|
+
sys.stdout.write('\n')
|
|
1738
|
+
sys.stdout.flush()
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
def progress_glow(iterable, prefix: str = '', width: int = 30,
|
|
1742
|
+
colors: list[str] | None = None):
|
|
1743
|
+
colors = colors or ['cyan', 'bright_cyan', 'bright_blue',
|
|
1744
|
+
'magenta', 'bright_magenta']
|
|
1745
|
+
items = list(iterable)
|
|
1746
|
+
total = len(items)
|
|
1747
|
+
start = time.time()
|
|
1748
|
+
for i, item in enumerate(items, 1):
|
|
1749
|
+
yield item
|
|
1750
|
+
done = int(width * i / total)
|
|
1751
|
+
pct = 100 * i / total
|
|
1752
|
+
elapsed = time.time() - start
|
|
1753
|
+
eta = (elapsed / i) * (total - i) if i else 0
|
|
1754
|
+
col = colors[(i // 2) % len(colors)]
|
|
1755
|
+
bar = c('█' * done, col) + c('░' * (width - done), 'gray')
|
|
1756
|
+
tail = f'{pct:5.1f}% ETA {human_time(eta, short=True):>6}'
|
|
1757
|
+
sys.stdout.write(f'\r{prefix} [{bar}] {i}/{total} {tail}\033[K')
|
|
1758
|
+
sys.stdout.flush()
|
|
1759
|
+
print()
|
|
1760
|
+
|
|
1761
|
+
|
|
1762
|
+
@contextmanager
|
|
1763
|
+
def live(prefix: str = '', preset: str | None = None,
|
|
1764
|
+
kind: str | None = None, speed: float | None = None,
|
|
1765
|
+
color: str | None = None, keep: bool = True,
|
|
1766
|
+
done: str = '✓', done_color: str = 'green'):
|
|
1767
|
+
kind = kind if kind is not None else _SPINNER_DEFAULTS['kind']
|
|
1768
|
+
speed = speed if speed is not None else _SPINNER_DEFAULTS['speed']
|
|
1769
|
+
color = color if color is not None else _SPINNER_DEFAULTS['color']
|
|
1770
|
+
if preset is None:
|
|
1771
|
+
preset = _SPINNER_DEFAULTS['preset'] or _random_preset or pick_preset(kind)
|
|
1772
|
+
frames = SPINNER_FRAMES.get(preset, SPINNER_FRAMES['dots_dense'])
|
|
1773
|
+
|
|
1774
|
+
stop = threading.Event()
|
|
1775
|
+
state = {'text': prefix}
|
|
1776
|
+
lock = threading.Lock()
|
|
1777
|
+
frame_iter = itertools.cycle(frames)
|
|
1778
|
+
|
|
1779
|
+
def render():
|
|
1780
|
+
with lock:
|
|
1781
|
+
text = state['text']
|
|
1782
|
+
try:
|
|
1783
|
+
ch = next(frame_iter)
|
|
1784
|
+
except StopIteration:
|
|
1785
|
+
return
|
|
1786
|
+
sys.stdout.write(f'\r{c(ch, color)} {text}\033[K')
|
|
1787
|
+
sys.stdout.flush()
|
|
1788
|
+
|
|
1789
|
+
class _Ctx:
|
|
1790
|
+
def set(self, text: str):
|
|
1791
|
+
with lock:
|
|
1792
|
+
state['text'] = text
|
|
1793
|
+
|
|
1794
|
+
th = threading.Thread(target=_frame_scheduler,
|
|
1795
|
+
args=(stop, speed, render), daemon=True)
|
|
1796
|
+
th.start()
|
|
1797
|
+
try:
|
|
1798
|
+
yield _Ctx()
|
|
1799
|
+
finally:
|
|
1800
|
+
stop.set()
|
|
1801
|
+
th.join()
|
|
1802
|
+
with lock:
|
|
1803
|
+
text = state['text']
|
|
1804
|
+
clear = ' ' * (_visible_len(text) + 4)
|
|
1805
|
+
sys.stdout.write(f'\r{clear}\r{c(done, done_color, bold=True)} {text}\n')
|
|
1806
|
+
sys.stdout.flush()
|
|
1807
|
+
|
|
1808
|
+
|
|
1809
|
+
class _LiveTable:
|
|
1810
|
+
def __init__(self, headers, style='rounded'):
|
|
1811
|
+
self.headers = headers
|
|
1812
|
+
self.style = style
|
|
1813
|
+
self.rows = []
|
|
1814
|
+
self._lock = threading.Lock()
|
|
1815
|
+
self._lines = 0
|
|
1816
|
+
|
|
1817
|
+
def add(self, row):
|
|
1818
|
+
with self._lock:
|
|
1819
|
+
self.rows.append(list(row))
|
|
1820
|
+
|
|
1821
|
+
def clear(self):
|
|
1822
|
+
with self._lock:
|
|
1823
|
+
self.rows.clear()
|
|
1824
|
+
|
|
1825
|
+
def render(self):
|
|
1826
|
+
with self._lock:
|
|
1827
|
+
rows = list(self.rows)
|
|
1828
|
+
data = [dict(zip(self.headers, r)) for r in rows] or \
|
|
1829
|
+
[{h: '' for h in self.headers}]
|
|
1830
|
+
if self._lines:
|
|
1831
|
+
sys.stdout.write(f'\033[{self._lines}A')
|
|
1832
|
+
for _ in range(self._lines):
|
|
1833
|
+
sys.stdout.write('\033[K\n')
|
|
1834
|
+
sys.stdout.write(f'\033[{self._lines}A')
|
|
1835
|
+
buf = io.StringIO()
|
|
1836
|
+
old = sys.stdout
|
|
1837
|
+
sys.stdout = buf
|
|
1838
|
+
try:
|
|
1839
|
+
table(data, style=self.style, headers=self.headers, _return=False)
|
|
1840
|
+
finally:
|
|
1841
|
+
sys.stdout = old
|
|
1842
|
+
out = buf.getvalue()
|
|
1843
|
+
self._lines = out.count('\n')
|
|
1844
|
+
sys.stdout.write(out)
|
|
1845
|
+
sys.stdout.flush()
|
|
1846
|
+
|
|
1847
|
+
|
|
1848
|
+
@contextmanager
|
|
1849
|
+
def table_live(headers: list[str], style: str = 'rounded',
|
|
1850
|
+
refresh: float = 0.3):
|
|
1851
|
+
t = _LiveTable(headers, style)
|
|
1852
|
+
stop = threading.Event()
|
|
1853
|
+
next_tick = time.perf_counter()
|
|
1854
|
+
|
|
1855
|
+
def loop():
|
|
1856
|
+
nonlocal next_tick
|
|
1857
|
+
while not stop.is_set():
|
|
1858
|
+
t.render()
|
|
1859
|
+
next_tick += refresh
|
|
1860
|
+
delta = next_tick - time.perf_counter()
|
|
1861
|
+
if delta > 0:
|
|
1862
|
+
time.sleep(delta)
|
|
1863
|
+
else:
|
|
1864
|
+
next_tick = time.perf_counter()
|
|
1865
|
+
|
|
1866
|
+
th = threading.Thread(target=loop, daemon=True)
|
|
1867
|
+
th.start()
|
|
1868
|
+
try:
|
|
1869
|
+
yield t
|
|
1870
|
+
finally:
|
|
1871
|
+
stop.set()
|
|
1872
|
+
th.join()
|
|
1873
|
+
t.render()
|
|
1874
|
+
|
|
1875
|
+
|
|
1876
|
+
@contextmanager
|
|
1877
|
+
def status(text: str, color: str = 'cyan', interval: float = 0.15,
|
|
1878
|
+
icon: str = '●'):
|
|
1879
|
+
stop = threading.Event()
|
|
1880
|
+
state = {'text': text}
|
|
1881
|
+
sys.stdout.write('\n')
|
|
1882
|
+
sys.stdout.flush()
|
|
1883
|
+
|
|
1884
|
+
def render():
|
|
1885
|
+
line = c(f'{icon} {state["text"]}', color)
|
|
1886
|
+
sys.stdout.write(f'\033[1A\r\033[K{line}\033[1B\r')
|
|
1887
|
+
sys.stdout.flush()
|
|
1888
|
+
|
|
1889
|
+
th = threading.Thread(target=_frame_scheduler,
|
|
1890
|
+
args=(stop, interval, render), daemon=True)
|
|
1891
|
+
th.start()
|
|
1892
|
+
try:
|
|
1893
|
+
yield
|
|
1894
|
+
finally:
|
|
1895
|
+
stop.set()
|
|
1896
|
+
th.join()
|
|
1897
|
+
sys.stdout.write('\033[1A\r\033[K')
|
|
1898
|
+
sys.stdout.flush()
|
|
1899
|
+
|
|
1900
|
+
|
|
1901
|
+
# ============================================================
|
|
1902
|
+
# 13. БАРЫ И HUD
|
|
1903
|
+
# ============================================================
|
|
1904
|
+
|
|
1905
|
+
def bar(value: float, max_value: float, width: int = 20,
|
|
1906
|
+
color: str = 'green', empty_color: str = 'gray',
|
|
1907
|
+
filled: str = '█', empty: str = '░') -> str:
|
|
1908
|
+
if max_value <= 0:
|
|
1909
|
+
return c(empty * width, empty_color)
|
|
1910
|
+
ratio = max(0, min(1, value / max_value))
|
|
1911
|
+
n = int(ratio * width)
|
|
1912
|
+
return c(filled * n, color) + c(empty * (width - n), empty_color)
|
|
1913
|
+
|
|
1914
|
+
|
|
1915
|
+
def bar3(value: float, max_value: float, width: int = 20,
|
|
1916
|
+
start: str = 'red', middle: str = 'yellow', end: str = 'green',
|
|
1917
|
+
empty_color: str = 'gray',
|
|
1918
|
+
filled: str = '█', empty: str = '░') -> str:
|
|
1919
|
+
"""3-цветный градиентный бар."""
|
|
1920
|
+
if max_value <= 0:
|
|
1921
|
+
return c(empty * width, empty_color)
|
|
1922
|
+
ratio = max(0, min(1, value / max_value))
|
|
1923
|
+
n_filled = int(ratio * width)
|
|
1924
|
+
r1, g1, b1 = _any_to_rgb(start)
|
|
1925
|
+
r2, g2, b2 = _any_to_rgb(middle)
|
|
1926
|
+
r3, g3, b3 = _any_to_rgb(end)
|
|
1927
|
+
parts = []
|
|
1928
|
+
for i in range(width):
|
|
1929
|
+
if i < n_filled:
|
|
1930
|
+
t = i / max(width - 1, 1)
|
|
1931
|
+
if t <= 0.5:
|
|
1932
|
+
u = t * 2
|
|
1933
|
+
r = int(r1 + (r2 - r1) * u)
|
|
1934
|
+
g = int(g1 + (g2 - g1) * u)
|
|
1935
|
+
b = int(b1 + (b2 - b1) * u)
|
|
1936
|
+
else:
|
|
1937
|
+
u = (t - 0.5) * 2
|
|
1938
|
+
r = int(r2 + (r3 - r2) * u)
|
|
1939
|
+
g = int(g2 + (g3 - g2) * u)
|
|
1940
|
+
b = int(b2 + (b3 - b2) * u)
|
|
1941
|
+
parts.append(f'\033[38;2;{r};{g};{b}m{filled}')
|
|
1942
|
+
else:
|
|
1943
|
+
parts.append(c(empty, empty_color))
|
|
1944
|
+
return ''.join(parts) + _RESET
|
|
1945
|
+
|
|
1946
|
+
|
|
1947
|
+
def hp_bar(value: float, max_value: float, width: int = 20) -> str:
|
|
1948
|
+
if max_value <= 0:
|
|
1949
|
+
return c('░' * width, 'gray')
|
|
1950
|
+
pct = value / max_value
|
|
1951
|
+
if pct > 0.6:
|
|
1952
|
+
col = 'green'
|
|
1953
|
+
elif pct > 0.3:
|
|
1954
|
+
col = 'yellow'
|
|
1955
|
+
elif pct > 0.1:
|
|
1956
|
+
col = 'bright_yellow'
|
|
1957
|
+
else:
|
|
1958
|
+
col = 'bright_red'
|
|
1959
|
+
return bar(value, max_value, width, col)
|
|
1960
|
+
|
|
1961
|
+
|
|
1962
|
+
def mp_bar(value: float, max_value: float, width: int = 20) -> str:
|
|
1963
|
+
return bar(value, max_value, width, 'bright_blue', 'gray')
|
|
1964
|
+
|
|
1965
|
+
|
|
1966
|
+
def xp_bar(value: float, max_value: float, width: int = 20) -> str:
|
|
1967
|
+
return bar(value, max_value, width, 'bright_yellow', 'gray')
|
|
1968
|
+
|
|
1969
|
+
|
|
1970
|
+
def status_line(items: dict, sep: str = ' ',
|
|
1971
|
+
colors: dict | None = None) -> None:
|
|
1972
|
+
colors = colors or {}
|
|
1973
|
+
parts = []
|
|
1974
|
+
for k, v in items.items():
|
|
1975
|
+
key_color = colors.get(k, 'cyan')
|
|
1976
|
+
parts.append(f'{c(k, key_color)}: {c(v, "bright_white")}')
|
|
1977
|
+
print(sep.join(parts))
|
|
1978
|
+
|
|
1979
|
+
|
|
1980
|
+
def hud(items: dict, width: int = 15, sep: str = ' ') -> None:
|
|
1981
|
+
parts = []
|
|
1982
|
+
for key, val in items.items():
|
|
1983
|
+
if isinstance(val, (tuple, list)) and len(val) == 2:
|
|
1984
|
+
v, m = val
|
|
1985
|
+
if key.upper() == 'HP':
|
|
1986
|
+
b = hp_bar(v, m, width)
|
|
1987
|
+
elif key.upper() == 'MP':
|
|
1988
|
+
b = mp_bar(v, m, width)
|
|
1989
|
+
elif key.upper() == 'XP':
|
|
1990
|
+
b = xp_bar(v, m, width)
|
|
1991
|
+
else:
|
|
1992
|
+
b = bar(v, m, width, 'cyan')
|
|
1993
|
+
label = c(f'{key}', 'bright_cyan', bold=True)
|
|
1994
|
+
val_text = c(f'{v}/{m}', 'gray')
|
|
1995
|
+
parts.append(f'{label} [{b}] {val_text}')
|
|
1996
|
+
else:
|
|
1997
|
+
label = c(f'{key}', 'bright_cyan', bold=True)
|
|
1998
|
+
parts.append(f'{label}: {c(val, "bright_white")}')
|
|
1999
|
+
print(sep.join(parts))
|
|
2000
|
+
|
|
2001
|
+
|
|
2002
|
+
class _BarWave:
|
|
2003
|
+
"""Анимированный бар с бегущей волной."""
|
|
2004
|
+
def __init__(self, total: int, prefix: str = '', width: int = 30,
|
|
2005
|
+
color: str = 'bright_cyan'):
|
|
2006
|
+
self.total = total
|
|
2007
|
+
self.prefix = prefix
|
|
2008
|
+
self.width = width
|
|
2009
|
+
self.color = color
|
|
2010
|
+
self.start = time.time()
|
|
2011
|
+
self._last = 0
|
|
2012
|
+
self._phase = 0
|
|
2013
|
+
|
|
2014
|
+
def update(self, n: int, suffix: str = '') -> None:
|
|
2015
|
+
pct = n / self.total
|
|
2016
|
+
done = int(self.width * pct)
|
|
2017
|
+
# Волна света проходит по заполненной части
|
|
2018
|
+
wave_pos = self._phase % (done or 1)
|
|
2019
|
+
parts = []
|
|
2020
|
+
for i in range(self.width):
|
|
2021
|
+
if i < done:
|
|
2022
|
+
if abs(i - wave_pos) <= 2:
|
|
2023
|
+
parts.append(c('█', 'bright_white'))
|
|
2024
|
+
else:
|
|
2025
|
+
parts.append(c('█', self.color))
|
|
2026
|
+
else:
|
|
2027
|
+
parts.append(c('░', 'gray'))
|
|
2028
|
+
bar = ''.join(parts)
|
|
2029
|
+
elapsed = time.time() - self.start
|
|
2030
|
+
eta = (elapsed / n) * (self.total - n) if n else 0
|
|
2031
|
+
line = f'{self.prefix} [{bar}] {n}/{self.total} {pct*100:5.1f}% ETA {human_time(eta, short=True):>6}'
|
|
2032
|
+
if suffix:
|
|
2033
|
+
line += f' {suffix}'
|
|
2034
|
+
pad = max(0, self._last - _visible_len(line))
|
|
2035
|
+
sys.stdout.write('\r' + line + ' ' * pad)
|
|
2036
|
+
sys.stdout.flush()
|
|
2037
|
+
self._last = _visible_len(line)
|
|
2038
|
+
self._phase += 1
|
|
2039
|
+
|
|
2040
|
+
def close(self) -> None:
|
|
2041
|
+
sys.stdout.write('\n')
|
|
2042
|
+
sys.stdout.flush()
|
|
2043
|
+
|
|
2044
|
+
|
|
2045
|
+
def bar_wave(total: int, prefix: str = '', width: int = 30,
|
|
2046
|
+
color: str = 'bright_cyan') -> _BarWave:
|
|
2047
|
+
"""Возвращает анимированный бар с бегущей волной.
|
|
2048
|
+
|
|
2049
|
+
Пример:
|
|
2050
|
+
bar = bar_wave(100, prefix='Загрузка')
|
|
2051
|
+
for i in range(1, 101):
|
|
2052
|
+
bar.update(i); time.sleep(0.03)
|
|
2053
|
+
bar.close()
|
|
2054
|
+
"""
|
|
2055
|
+
return _BarWave(total, prefix, width, color)
|
|
2056
|
+
|
|
2057
|
+
|
|
2058
|
+
# ============================================================
|
|
2059
|
+
# 14. ДЕКОРАЦИИ
|
|
2060
|
+
# ============================================================
|
|
2061
|
+
|
|
2062
|
+
def rule(char: str = '─', color: str = 'gray', width: int | None = None) -> None:
|
|
2063
|
+
width = width or term_width() - 2
|
|
2064
|
+
print(c(char * width, color))
|
|
2065
|
+
|
|
2066
|
+
|
|
2067
|
+
def double_rule(color: str = 'bright_cyan', width: int | None = None) -> None:
|
|
2068
|
+
rule('═', color, width)
|
|
2069
|
+
|
|
2070
|
+
|
|
2071
|
+
def dashed_rule(color: str = 'gray', width: int | None = None) -> None:
|
|
2072
|
+
rule('╌', color, width)
|
|
2073
|
+
|
|
2074
|
+
|
|
2075
|
+
def dots_rule(color: str = 'gray', width: int | None = None) -> None:
|
|
2076
|
+
rule('·', color, width)
|
|
2077
|
+
|
|
2078
|
+
|
|
2079
|
+
def gradient_rule(width: int | None = None) -> None:
|
|
2080
|
+
width = width or term_width() - 2
|
|
2081
|
+
print(gradient('█' * width, 'red', 'blue'))
|
|
2082
|
+
|
|
2083
|
+
|
|
2084
|
+
def rainbow_rule(width: int | None = None) -> None:
|
|
2085
|
+
width = width or term_width() - 2
|
|
2086
|
+
colors = ['red', 'bright_red', 'yellow', 'bright_yellow',
|
|
2087
|
+
'green', 'bright_green', 'cyan', 'bright_cyan',
|
|
2088
|
+
'blue', 'bright_blue', 'magenta', 'bright_magenta']
|
|
2089
|
+
chunk_size = max(1, width // len(colors))
|
|
2090
|
+
print(''.join(c('█' * chunk_size, col) for col in colors))
|
|
2091
|
+
|
|
2092
|
+
|
|
2093
|
+
def wave_rule(color: str = 'bright_cyan', width: int | None = None) -> None:
|
|
2094
|
+
width = width or term_width() - 2
|
|
2095
|
+
print(c('〜' * (width // 2), color))
|
|
2096
|
+
|
|
2097
|
+
|
|
2098
|
+
def space(n: int = 1) -> None:
|
|
2099
|
+
print('\n' * n, end='')
|
|
2100
|
+
|
|
2101
|
+
|
|
2102
|
+
def clear() -> None:
|
|
2103
|
+
os.system('cls' if os.name == 'nt' else 'clear')
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
# ============================================================
|
|
2107
|
+
# 15. ЗНАЧКИ
|
|
2108
|
+
# ============================================================
|
|
2109
|
+
|
|
2110
|
+
def badge(text: str, color: str = 'bright_cyan', bg: str = 'black') -> str:
|
|
2111
|
+
return c(f' {text} ', color, bg=bg, bold=True)
|
|
2112
|
+
|
|
2113
|
+
|
|
2114
|
+
def tag(text: str, color: str = 'cyan') -> str:
|
|
2115
|
+
return c(f'[{text}]', color, bold=True)
|
|
2116
|
+
|
|
2117
|
+
|
|
2118
|
+
def sparkle_text(text: str, color: str = 'bright_yellow') -> str:
|
|
2119
|
+
return f'{c("✨", color)} {c(text, "bright_white", bold=True)} {c("✨", color)}'
|
|
2120
|
+
|
|
2121
|
+
|
|
2122
|
+
def arrow_text(text: str, color: str = 'bright_cyan') -> str:
|
|
2123
|
+
return f'{c("▶", color)} {text}'
|
|
2124
|
+
|
|
2125
|
+
|
|
2126
|
+
# ============================================================
|
|
2127
|
+
# 16. АНИМАЦИИ
|
|
2128
|
+
# ============================================================
|
|
2129
|
+
|
|
2130
|
+
def animate(text: str, duration: float = 2.0,
|
|
2131
|
+
frames: str | None = None,
|
|
2132
|
+
color: str = 'bright_cyan',
|
|
2133
|
+
speed: float = 0.08) -> None:
|
|
2134
|
+
if frames is None:
|
|
2135
|
+
frames = SPINNER_FRAMES['dots_dense']
|
|
2136
|
+
end = time.time() + duration
|
|
2137
|
+
next_tick = time.perf_counter()
|
|
2138
|
+
for ch in itertools.cycle(frames):
|
|
2139
|
+
if time.time() >= end:
|
|
2140
|
+
break
|
|
2141
|
+
sys.stdout.write(f'\r{c(ch, color)} {text}\033[K')
|
|
2142
|
+
sys.stdout.flush()
|
|
2143
|
+
next_tick += speed
|
|
2144
|
+
while True:
|
|
2145
|
+
delta = next_tick - time.perf_counter()
|
|
2146
|
+
if delta <= 0:
|
|
2147
|
+
next_tick = time.perf_counter()
|
|
2148
|
+
break
|
|
2149
|
+
if delta > 0.002:
|
|
2150
|
+
time.sleep(delta - 0.001)
|
|
2151
|
+
sys.stdout.write(f'\r\033[K{c("✓", "green", bold=True)} {text}\n')
|
|
2152
|
+
sys.stdout.flush()
|
|
2153
|
+
|
|
2154
|
+
|
|
2155
|
+
def glow(text: str, colors: list[str] | None = None,
|
|
2156
|
+
cycles: int = 3, speed: float = 0.05) -> None:
|
|
2157
|
+
colors = colors or ['red', 'bright_red', 'yellow', 'bright_yellow',
|
|
2158
|
+
'green', 'bright_green', 'cyan', 'bright_cyan',
|
|
2159
|
+
'blue', 'bright_blue', 'magenta', 'bright_magenta']
|
|
2160
|
+
n = len(colors)
|
|
2161
|
+
for _ in range(cycles * n):
|
|
2162
|
+
sys.stdout.write('\r')
|
|
2163
|
+
for i, ch in enumerate(text):
|
|
2164
|
+
col = colors[(i + _) % n]
|
|
2165
|
+
sys.stdout.write(c(ch, col, bold=True))
|
|
2166
|
+
sys.stdout.flush()
|
|
2167
|
+
time.sleep(speed)
|
|
2168
|
+
sys.stdout.write('\r')
|
|
2169
|
+
sys.stdout.write(c(text, 'bright_yellow', bold=True))
|
|
2170
|
+
sys.stdout.write('\n')
|
|
2171
|
+
sys.stdout.flush()
|
|
2172
|
+
|
|
2173
|
+
|
|
2174
|
+
def glow_print(text: str, start: str = 'cyan', end: str = 'magenta',
|
|
2175
|
+
duration: float = 1.5, speed: float = 0.04,
|
|
2176
|
+
final: str | None = None, final_color: str | None = None,
|
|
2177
|
+
newline: bool = True) -> None:
|
|
2178
|
+
n = len(text)
|
|
2179
|
+
if n == 0:
|
|
2180
|
+
return
|
|
2181
|
+
r1, g1, b1 = _any_to_rgb(start)
|
|
2182
|
+
r2, g2, b2 = _any_to_rgb(end)
|
|
2183
|
+
end_time = time.time() + duration
|
|
2184
|
+
frame = 0
|
|
2185
|
+
next_tick = time.perf_counter()
|
|
2186
|
+
while time.time() < end_time:
|
|
2187
|
+
phase = (frame % 48) / 48.0
|
|
2188
|
+
sys.stdout.write('\r')
|
|
2189
|
+
for i, ch in enumerate(text):
|
|
2190
|
+
t = (i / max(n - 1, 1) + phase) % 1.0
|
|
2191
|
+
t = t * 2 if t <= 0.5 else (1 - t) * 2
|
|
2192
|
+
r = int(r1 + (r2 - r1) * t)
|
|
2193
|
+
g = int(g1 + (g2 - g1) * t)
|
|
2194
|
+
b = int(b1 + (b2 - b1) * t)
|
|
2195
|
+
sys.stdout.write(f'\033[38;2;{r};{g};{b}m{ch}')
|
|
2196
|
+
sys.stdout.write('\033[0m')
|
|
2197
|
+
sys.stdout.flush()
|
|
2198
|
+
frame += 1
|
|
2199
|
+
next_tick += speed
|
|
2200
|
+
while True:
|
|
2201
|
+
delta = next_tick - time.perf_counter()
|
|
2202
|
+
if delta <= 0:
|
|
2203
|
+
next_tick = time.perf_counter()
|
|
2204
|
+
break
|
|
2205
|
+
if delta > 0.002:
|
|
2206
|
+
time.sleep(delta - 0.001)
|
|
2207
|
+
sys.stdout.write('\r\033[K')
|
|
2208
|
+
if final is not None:
|
|
2209
|
+
sys.stdout.write(c(f'✓ {final}', final_color or 'bright_green', bold=True))
|
|
2210
|
+
else:
|
|
2211
|
+
sys.stdout.write(c(text, end, bold=True))
|
|
2212
|
+
if newline:
|
|
2213
|
+
sys.stdout.write('\n')
|
|
2214
|
+
sys.stdout.flush()
|
|
2215
|
+
|
|
2216
|
+
|
|
2217
|
+
def glow_print3(text: str, start: str = 'cyan', middle: str = 'magenta',
|
|
2218
|
+
end: str = 'yellow', duration: float = 1.5,
|
|
2219
|
+
speed: float = 0.04, final: str | None = None,
|
|
2220
|
+
final_color: str | None = None,
|
|
2221
|
+
newline: bool = True) -> None:
|
|
2222
|
+
n = len(text)
|
|
2223
|
+
if n == 0:
|
|
2224
|
+
return
|
|
2225
|
+
r1, g1, b1 = _any_to_rgb(start)
|
|
2226
|
+
r2, g2, b2 = _any_to_rgb(middle)
|
|
2227
|
+
r3, g3, b3 = _any_to_rgb(end)
|
|
2228
|
+
end_time = time.time() + duration
|
|
2229
|
+
frame = 0
|
|
2230
|
+
next_tick = time.perf_counter()
|
|
2231
|
+
while time.time() < end_time:
|
|
2232
|
+
phase = (frame % 48) / 48.0
|
|
2233
|
+
sys.stdout.write('\r')
|
|
2234
|
+
for i, ch in enumerate(text):
|
|
2235
|
+
t = (i / max(n - 1, 1) + phase) % 1.0
|
|
2236
|
+
t = t * 2 if t <= 0.5 else (1 - t) * 2
|
|
2237
|
+
if t <= 0.5:
|
|
2238
|
+
u = t * 2
|
|
2239
|
+
r = int(r1 + (r2 - r1) * u)
|
|
2240
|
+
g = int(g1 + (g2 - g1) * u)
|
|
2241
|
+
b = int(b1 + (b2 - b1) * u)
|
|
2242
|
+
else:
|
|
2243
|
+
u = (t - 0.5) * 2
|
|
2244
|
+
r = int(r2 + (r3 - r2) * u)
|
|
2245
|
+
g = int(g2 + (g3 - g2) * u)
|
|
2246
|
+
b = int(b2 + (b3 - b2) * u)
|
|
2247
|
+
sys.stdout.write(f'\033[38;2;{r};{g};{b}m{ch}')
|
|
2248
|
+
sys.stdout.write('\033[0m')
|
|
2249
|
+
sys.stdout.flush()
|
|
2250
|
+
frame += 1
|
|
2251
|
+
next_tick += speed
|
|
2252
|
+
while True:
|
|
2253
|
+
delta = next_tick - time.perf_counter()
|
|
2254
|
+
if delta <= 0:
|
|
2255
|
+
next_tick = time.perf_counter()
|
|
2256
|
+
break
|
|
2257
|
+
if delta > 0.002:
|
|
2258
|
+
time.sleep(delta - 0.001)
|
|
2259
|
+
sys.stdout.write('\r\033[K')
|
|
2260
|
+
if final is not None:
|
|
2261
|
+
sys.stdout.write(c(f'✓ {final}', final_color or 'bright_green', bold=True))
|
|
2262
|
+
else:
|
|
2263
|
+
sys.stdout.write(gradient3(text, start, middle, end))
|
|
2264
|
+
if newline:
|
|
2265
|
+
sys.stdout.write('\n')
|
|
2266
|
+
sys.stdout.flush()
|
|
2267
|
+
|
|
2268
|
+
|
|
2269
|
+
def typewriter(text: str, delay: float = 0.03,
|
|
2270
|
+
color: str = 'bright_white') -> None:
|
|
2271
|
+
for ch in text:
|
|
2272
|
+
sys.stdout.write(c(ch, color))
|
|
2273
|
+
sys.stdout.flush()
|
|
2274
|
+
time.sleep(delay)
|
|
2275
|
+
print()
|
|
2276
|
+
|
|
2277
|
+
|
|
2278
|
+
def wave_text(text: str, delay: float = 0.02) -> None:
|
|
2279
|
+
for ch in text:
|
|
2280
|
+
sys.stdout.write(c(ch, 'bright_cyan'))
|
|
2281
|
+
sys.stdout.flush()
|
|
2282
|
+
time.sleep(delay)
|
|
2283
|
+
print()
|
|
2284
|
+
|
|
2285
|
+
|
|
2286
|
+
def divider_animated(duration: float = 0.5,
|
|
2287
|
+
color: str = 'bright_cyan',
|
|
2288
|
+
char: str = '─') -> None:
|
|
2289
|
+
width = term_width() - 2
|
|
2290
|
+
for _ in range(width):
|
|
2291
|
+
sys.stdout.write(c(char, color))
|
|
2292
|
+
sys.stdout.flush()
|
|
2293
|
+
time.sleep(duration / width)
|
|
2294
|
+
print()
|
|
2295
|
+
|
|
2296
|
+
|
|
2297
|
+
def line_reveal(text: str, color: str = 'bright_white',
|
|
2298
|
+
delay: float = 0.02) -> None:
|
|
2299
|
+
for ch in text:
|
|
2300
|
+
sys.stdout.write(c(ch, color))
|
|
2301
|
+
sys.stdout.flush()
|
|
2302
|
+
time.sleep(delay)
|
|
2303
|
+
print()
|
|
2304
|
+
|
|
2305
|
+
|
|
2306
|
+
def fade_in(text: str, color: str = 'bright_white') -> None:
|
|
2307
|
+
parts = 4
|
|
2308
|
+
step = max(1, len(text) // parts)
|
|
2309
|
+
for i in range(0, len(text), step):
|
|
2310
|
+
sys.stdout.write(c(text[i:i + step], color))
|
|
2311
|
+
sys.stdout.flush()
|
|
2312
|
+
time.sleep(0.08)
|
|
2313
|
+
print()
|
|
2314
|
+
|
|
2315
|
+
|
|
2316
|
+
def flip_banner(text: str, color: str = 'bright_magenta') -> None:
|
|
2317
|
+
frames = ['─', '═', '━', '▬']
|
|
2318
|
+
for fr in frames:
|
|
2319
|
+
sys.stdout.write('\r')
|
|
2320
|
+
sys.stdout.write(c(fr * (len(text) + 4), color))
|
|
2321
|
+
sys.stdout.flush()
|
|
2322
|
+
time.sleep(0.1)
|
|
2323
|
+
print()
|
|
2324
|
+
center(f'{c("★", "bright_yellow")} {c(text, color, bold=True)} '
|
|
2325
|
+
f'{c("★", "bright_yellow")}')
|
|
2326
|
+
for fr in reversed(frames):
|
|
2327
|
+
sys.stdout.write('\r')
|
|
2328
|
+
sys.stdout.write(c(fr * (len(text) + 4), color))
|
|
2329
|
+
sys.stdout.flush()
|
|
2330
|
+
time.sleep(0.1)
|
|
2331
|
+
print()
|
|
2332
|
+
|
|
2333
|
+
|
|
2334
|
+
def hearts_rain(count: int = 10, delay: float = 0.05) -> None:
|
|
2335
|
+
for _ in range(count):
|
|
2336
|
+
sys.stdout.write(c(random.choice('❤♥♡'),
|
|
2337
|
+
random.choice(['red', 'bright_red',
|
|
2338
|
+
'magenta', 'bright_magenta'])))
|
|
2339
|
+
sys.stdout.flush()
|
|
2340
|
+
time.sleep(delay)
|
|
2341
|
+
print()
|
|
2342
|
+
|
|
2343
|
+
|
|
2344
|
+
def stars_rain(count: int = 15, delay: float = 0.05) -> None:
|
|
2345
|
+
for _ in range(count):
|
|
2346
|
+
sys.stdout.write(c(random.choice('★☆✦✧✩✪✫'), 'bright_yellow'))
|
|
2347
|
+
sys.stdout.flush()
|
|
2348
|
+
time.sleep(delay)
|
|
2349
|
+
print()
|
|
2350
|
+
|
|
2351
|
+
|
|
2352
|
+
def fireworks(times: int = 3) -> None:
|
|
2353
|
+
for _ in range(times):
|
|
2354
|
+
spark = random.choice(['✨', '💥', '🎆', '🎇', '⭐'])
|
|
2355
|
+
color = random.choice(['red', 'yellow', 'green', 'cyan',
|
|
2356
|
+
'magenta', 'bright_red'])
|
|
2357
|
+
sys.stdout.write(c(spark, color, bold=True))
|
|
2358
|
+
sys.stdout.flush()
|
|
2359
|
+
time.sleep(0.15)
|
|
2360
|
+
print()
|
|
2361
|
+
_sound('hit')
|
|
2362
|
+
|
|
2363
|
+
|
|
2364
|
+
def rain(chars: str = '★☆✦✧✩✪✫', count: int = 20,
|
|
2365
|
+
duration: float = 2.0, color: str = 'bright_yellow',
|
|
2366
|
+
speed: float = 0.06, height: int = 8) -> None:
|
|
2367
|
+
width = min(term_width() - 1, 70)
|
|
2368
|
+
if width < 5 or count < 1 or height < 1:
|
|
2369
|
+
return
|
|
2370
|
+
end_time = time.time() + duration
|
|
2371
|
+
next_tick = time.perf_counter()
|
|
2372
|
+
drops = [{'x': random.randint(0, width - 1),
|
|
2373
|
+
'y': random.uniform(0, height),
|
|
2374
|
+
'ch': random.choice(chars),
|
|
2375
|
+
'speed': random.uniform(0.6, 1.4)} for _ in range(count)]
|
|
2376
|
+
sys.stdout.write('\n' * height)
|
|
2377
|
+
sys.stdout.write(f'\033[{height}A')
|
|
2378
|
+
sys.stdout.flush()
|
|
2379
|
+
try:
|
|
2380
|
+
while time.time() < end_time:
|
|
2381
|
+
for d in drops:
|
|
2382
|
+
y_int = int(d['y'])
|
|
2383
|
+
if 0 <= y_int < height:
|
|
2384
|
+
sys.stdout.write(f'\033[{height}A')
|
|
2385
|
+
if y_int > 0:
|
|
2386
|
+
sys.stdout.write(f'\033[{y_int}B')
|
|
2387
|
+
sys.stdout.write('\r')
|
|
2388
|
+
if d['x'] > 0:
|
|
2389
|
+
sys.stdout.write(f'\033[{d["x"]}C')
|
|
2390
|
+
sys.stdout.write(c(d['ch'], color, bold=True))
|
|
2391
|
+
sys.stdout.write('\r')
|
|
2392
|
+
sys.stdout.write(f'\033[{height}A')
|
|
2393
|
+
if y_int > 0:
|
|
2394
|
+
sys.stdout.write(f'\033[{y_int}B')
|
|
2395
|
+
d['y'] += d['speed']
|
|
2396
|
+
if d['y'] >= height:
|
|
2397
|
+
d['x'] = random.randint(0, width - 1)
|
|
2398
|
+
d['y'] = 0
|
|
2399
|
+
d['ch'] = random.choice(chars)
|
|
2400
|
+
d['speed'] = random.uniform(0.6, 1.4)
|
|
2401
|
+
sys.stdout.write(f'\033[{height}A')
|
|
2402
|
+
sys.stdout.write(f'\033[{height}B')
|
|
2403
|
+
sys.stdout.flush()
|
|
2404
|
+
next_tick += speed
|
|
2405
|
+
while True:
|
|
2406
|
+
delta = next_tick - time.perf_counter()
|
|
2407
|
+
if delta <= 0:
|
|
2408
|
+
next_tick = time.perf_counter()
|
|
2409
|
+
break
|
|
2410
|
+
if delta > 0.002:
|
|
2411
|
+
time.sleep(delta - 0.001)
|
|
2412
|
+
finally:
|
|
2413
|
+
sys.stdout.write(f'\033[{height}A')
|
|
2414
|
+
for _ in range(height):
|
|
2415
|
+
sys.stdout.write('\033[K\n')
|
|
2416
|
+
sys.stdout.write(f'\033[{height}A')
|
|
2417
|
+
sys.stdout.flush()
|
|
2418
|
+
|
|
2419
|
+
|
|
2420
|
+
def rain_line(chars: str = '★☆✦✧✩✪✫', width: int = 40,
|
|
2421
|
+
cycles: int = 3, color: str = 'bright_yellow',
|
|
2422
|
+
speed: float = 0.04, tail: int = 4) -> None:
|
|
2423
|
+
if width < 5 or cycles < 1:
|
|
2424
|
+
return
|
|
2425
|
+
for _ in range(cycles):
|
|
2426
|
+
for pos in range(width + tail):
|
|
2427
|
+
sys.stdout.write('\r')
|
|
2428
|
+
for x in range(width):
|
|
2429
|
+
if x == pos:
|
|
2430
|
+
sys.stdout.write(c(random.choice(chars),
|
|
2431
|
+
'bright_white', bold=True))
|
|
2432
|
+
elif 0 < pos - x <= tail:
|
|
2433
|
+
shade = tail - (pos - x)
|
|
2434
|
+
col = color if shade > tail // 2 else 'gray'
|
|
2435
|
+
sys.stdout.write(c(random.choice(chars), col))
|
|
2436
|
+
else:
|
|
2437
|
+
sys.stdout.write(' ')
|
|
2438
|
+
sys.stdout.write('\033[K')
|
|
2439
|
+
sys.stdout.flush()
|
|
2440
|
+
time.sleep(speed)
|
|
2441
|
+
sys.stdout.write('\r' + ' ' * width + '\r')
|
|
2442
|
+
sys.stdout.flush()
|
|
2443
|
+
|
|
2444
|
+
|
|
2445
|
+
def rain_multi(chars: str = '★☆✦✧✩✪✫', cols: int = 8,
|
|
2446
|
+
height: int = 5, duration: float = 2.0,
|
|
2447
|
+
color: str = 'bright_yellow', speed: float = 0.1) -> None:
|
|
2448
|
+
if cols < 1 or height < 1:
|
|
2449
|
+
return
|
|
2450
|
+
col_width = max(2, term_width() // (cols + 1))
|
|
2451
|
+
grid = [[random.choice(chars) for _ in range(cols)]
|
|
2452
|
+
for _ in range(height)]
|
|
2453
|
+
for _ in range(height):
|
|
2454
|
+
sys.stdout.write(' ' * (col_width * cols) + '\n')
|
|
2455
|
+
sys.stdout.write(f'\033[{height}A')
|
|
2456
|
+
end_time = time.time() + duration
|
|
2457
|
+
next_tick = time.perf_counter()
|
|
2458
|
+
try:
|
|
2459
|
+
while time.time() < end_time:
|
|
2460
|
+
for r in range(height):
|
|
2461
|
+
sys.stdout.write('\r')
|
|
2462
|
+
for c_idx in range(cols):
|
|
2463
|
+
sys.stdout.write(c(grid[r][c_idx], color) +
|
|
2464
|
+
' ' * (col_width - 1))
|
|
2465
|
+
sys.stdout.write('\033[K\n')
|
|
2466
|
+
sys.stdout.write(f'\033[{height}A')
|
|
2467
|
+
sys.stdout.flush()
|
|
2468
|
+
new_row = [random.choice(chars) for _ in range(cols)]
|
|
2469
|
+
grid = [new_row] + grid[:-1]
|
|
2470
|
+
next_tick += speed
|
|
2471
|
+
while True:
|
|
2472
|
+
delta = next_tick - time.perf_counter()
|
|
2473
|
+
if delta <= 0:
|
|
2474
|
+
next_tick = time.perf_counter()
|
|
2475
|
+
break
|
|
2476
|
+
if delta > 0.002:
|
|
2477
|
+
time.sleep(delta - 0.001)
|
|
2478
|
+
finally:
|
|
2479
|
+
for _ in range(height):
|
|
2480
|
+
sys.stdout.write('\033[1A\r\033[K')
|
|
2481
|
+
sys.stdout.write('\r\033[K')
|
|
2482
|
+
sys.stdout.flush()
|
|
2483
|
+
|
|
2484
|
+
|
|
2485
|
+
def line_effect(text: str, effect: str = 'type') -> None:
|
|
2486
|
+
if effect == 'type':
|
|
2487
|
+
typewriter(text)
|
|
2488
|
+
elif effect == 'fade':
|
|
2489
|
+
fade_in(text)
|
|
2490
|
+
elif effect == 'glow':
|
|
2491
|
+
glow(text)
|
|
2492
|
+
elif effect == 'reveal':
|
|
2493
|
+
line_reveal(text)
|
|
2494
|
+
else:
|
|
2495
|
+
print(text)
|
|
2496
|
+
|
|
2497
|
+
|
|
2498
|
+
# ============================================================
|
|
2499
|
+
# 17. ИНТЕРАКТИВ
|
|
2500
|
+
# ============================================================
|
|
2501
|
+
|
|
2502
|
+
def ask(prompt_text: str, default: str = '') -> str:
|
|
2503
|
+
return input(f'{prompt_text} [{default}]: ').strip() or default
|
|
2504
|
+
|
|
2505
|
+
|
|
2506
|
+
def confirm(prompt_text: str, default: bool = False) -> bool:
|
|
2507
|
+
suffix = 'Y/n' if default else 'y/N'
|
|
2508
|
+
ans = input(f'{prompt_text} [{suffix}]: ').strip().lower()
|
|
2509
|
+
if not ans:
|
|
2510
|
+
return default
|
|
2511
|
+
return ans in ('y', 'yes', 'д', 'да')
|
|
2512
|
+
|
|
2513
|
+
|
|
2514
|
+
def menu(items: list[str], prompt_text: str = 'Выбор', title: str = ''):
|
|
2515
|
+
if title:
|
|
2516
|
+
print(c(f'\n{title}', 'cyan', bold=True))
|
|
2517
|
+
for i, item in enumerate(items, 1):
|
|
2518
|
+
print(f' {c(i, "cyan")}. {item}')
|
|
2519
|
+
try:
|
|
2520
|
+
ans = input(f'{prompt_text}: ').strip()
|
|
2521
|
+
if not ans:
|
|
2522
|
+
return None
|
|
2523
|
+
idx = int(ans)
|
|
2524
|
+
if 1 <= idx <= len(items):
|
|
2525
|
+
return idx - 1
|
|
2526
|
+
except (ValueError, EOFError):
|
|
2527
|
+
pass
|
|
2528
|
+
return None
|
|
2529
|
+
|
|
2530
|
+
|
|
2531
|
+
def prompt(label: str, default: str = '', arrow: str = '❯',
|
|
2532
|
+
color: str = 'green') -> str:
|
|
2533
|
+
hint = f' [{default}]' if default else ''
|
|
2534
|
+
text = input(f'{c(arrow, color, bold=True)} {label}{c(hint, "gray")}: ')
|
|
2535
|
+
return text.strip() or default
|
|
2536
|
+
|
|
2537
|
+
|
|
2538
|
+
def password(prompt_text: str = 'Пароль', icon: str = '🔒',
|
|
2539
|
+
color: str = 'yellow') -> str:
|
|
2540
|
+
import getpass
|
|
2541
|
+
return getpass.getpass(f'{c(icon, color)} {prompt_text}: ')
|
|
2542
|
+
|
|
2543
|
+
|
|
2544
|
+
def copyable(text: str, label: str = '', clipboard: bool = False) -> None:
|
|
2545
|
+
if label:
|
|
2546
|
+
print(f'{c(label, "gray")}: {c(text, "cyan", bold=True)}')
|
|
2547
|
+
else:
|
|
2548
|
+
print(c(text, 'cyan', bold=True))
|
|
2549
|
+
if clipboard:
|
|
2550
|
+
try:
|
|
2551
|
+
import pyperclip
|
|
2552
|
+
pyperclip.copy(text)
|
|
2553
|
+
ok('скопировано в буфер')
|
|
2554
|
+
except ImportError:
|
|
2555
|
+
info('для копирования: pip install pyperclip')
|
|
2556
|
+
|
|
2557
|
+
|
|
2558
|
+
def wait_key(prompt_text: str = 'Нажми любую клавишу...') -> str:
|
|
2559
|
+
print(c(prompt_text, 'gray'), end=' ', flush=True)
|
|
2560
|
+
if os.name == 'nt' and _HAS_MSVCRT:
|
|
2561
|
+
ch = msvcrt.getch().decode('utf-8', errors='ignore')
|
|
2562
|
+
print()
|
|
2563
|
+
return ch
|
|
2564
|
+
elif _HAS_TERMIOS:
|
|
2565
|
+
fd = sys.stdin.fileno()
|
|
2566
|
+
old = termios.tcgetattr(fd)
|
|
2567
|
+
try:
|
|
2568
|
+
tty.setraw(fd)
|
|
2569
|
+
ch = sys.stdin.read(1)
|
|
2570
|
+
finally:
|
|
2571
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
2572
|
+
print()
|
|
2573
|
+
return ch
|
|
2574
|
+
else:
|
|
2575
|
+
return input()
|
|
2576
|
+
|
|
2577
|
+
|
|
2578
|
+
def prompt_choice(prompt_text: str, choices: list,
|
|
2579
|
+
default: Any = None) -> Any:
|
|
2580
|
+
for i, item in enumerate(choices, 1):
|
|
2581
|
+
print(f' {c(i, "cyan")}. {item}')
|
|
2582
|
+
while True:
|
|
2583
|
+
ans = input(f'{c("❯", "green", bold=True)} {prompt_text}: ').strip()
|
|
2584
|
+
if not ans and default is not None:
|
|
2585
|
+
return default
|
|
2586
|
+
try:
|
|
2587
|
+
idx = int(ans) - 1
|
|
2588
|
+
if 0 <= idx < len(choices):
|
|
2589
|
+
return choices[idx]
|
|
2590
|
+
except ValueError:
|
|
2591
|
+
pass
|
|
2592
|
+
warn(f'Введи число от 1 до {len(choices)}')
|
|
2593
|
+
|
|
2594
|
+
|
|
2595
|
+
def confirm_or_exit(prompt_text: str, code: int = 0) -> None:
|
|
2596
|
+
if not confirm(prompt_text, default=False):
|
|
2597
|
+
warn('Отменено пользователем')
|
|
2598
|
+
sys.exit(code)
|
|
2599
|
+
|
|
2600
|
+
|
|
2601
|
+
# ============================================================
|
|
2602
|
+
# 18. SPINNER_SELECTION — меню со стрелками
|
|
2603
|
+
# ============================================================
|
|
2604
|
+
|
|
2605
|
+
def spinner_selection(prompt_text: str = 'Выбор',
|
|
2606
|
+
items: list[str] | None = None,
|
|
2607
|
+
default: int = 0,
|
|
2608
|
+
color: str = 'bright_cyan') -> int | None:
|
|
2609
|
+
"""Меню выбора стрелками ↑↓ + Enter.
|
|
2610
|
+
|
|
2611
|
+
Пример:
|
|
2612
|
+
idx = spinner_selection('Что делаем?', ['Создать', 'Открыть', 'Выход'])
|
|
2613
|
+
"""
|
|
2614
|
+
if not items:
|
|
2615
|
+
items = ['Да', 'Нет']
|
|
2616
|
+
if not (os.name == 'nt' and _HAS_MSVCRT) and not _HAS_TERMIOS:
|
|
2617
|
+
# Fallback: обычный menu
|
|
2618
|
+
return menu(items, prompt_text)
|
|
2619
|
+
|
|
2620
|
+
current = default
|
|
2621
|
+
n = len(items)
|
|
2622
|
+
|
|
2623
|
+
def render():
|
|
2624
|
+
for i, item in enumerate(items):
|
|
2625
|
+
marker = '❯' if i == current else ' '
|
|
2626
|
+
line = f' {c(marker, "green", bold=True)} '
|
|
2627
|
+
if i == current:
|
|
2628
|
+
line += c(item, color, bold=True, bg='black')
|
|
2629
|
+
else:
|
|
2630
|
+
line += c(item, 'gray')
|
|
2631
|
+
sys.stdout.write('\033[K' + line + '\n')
|
|
2632
|
+
sys.stdout.write(f'\033[{n}A')
|
|
2633
|
+
|
|
2634
|
+
print(c(prompt_text, 'bright_white', bold=True))
|
|
2635
|
+
for _ in range(n):
|
|
2636
|
+
sys.stdout.write('\n')
|
|
2637
|
+
sys.stdout.write(f'\033[{n}A')
|
|
2638
|
+
sys.stdout.flush()
|
|
2639
|
+
render()
|
|
2640
|
+
|
|
2641
|
+
try:
|
|
2642
|
+
while True:
|
|
2643
|
+
key = _read_key()
|
|
2644
|
+
if key in ('up', 'k'):
|
|
2645
|
+
current = (current - 1) % n
|
|
2646
|
+
_sound('tick')
|
|
2647
|
+
elif key in ('down', 'j'):
|
|
2648
|
+
current = (current + 1) % n
|
|
2649
|
+
_sound('tick')
|
|
2650
|
+
elif key == 'enter':
|
|
2651
|
+
_sound('click')
|
|
2652
|
+
sys.stdout.write(f'\033[{n}B')
|
|
2653
|
+
sys.stdout.flush()
|
|
2654
|
+
return current
|
|
2655
|
+
elif key == 'esc':
|
|
2656
|
+
sys.stdout.write(f'\033[{n}B')
|
|
2657
|
+
sys.stdout.flush()
|
|
2658
|
+
return None
|
|
2659
|
+
render()
|
|
2660
|
+
except KeyboardInterrupt:
|
|
2661
|
+
sys.stdout.write(f'\033[{n}B')
|
|
2662
|
+
sys.stdout.flush()
|
|
2663
|
+
return None
|
|
2664
|
+
|
|
2665
|
+
|
|
2666
|
+
def _read_key() -> str:
|
|
2667
|
+
"""Читает одну клавишу. Возвращает 'up', 'down', 'enter', 'esc', или сам символ."""
|
|
2668
|
+
if os.name == 'nt' and _HAS_MSVCRT:
|
|
2669
|
+
ch = msvcrt.getch()
|
|
2670
|
+
if ch in (b'\x00', b'\xe0'):
|
|
2671
|
+
ch2 = msvcrt.getch()
|
|
2672
|
+
if ch2 == b'H':
|
|
2673
|
+
return 'up'
|
|
2674
|
+
if ch2 == b'P':
|
|
2675
|
+
return 'down'
|
|
2676
|
+
return 'other'
|
|
2677
|
+
if ch == b'\r':
|
|
2678
|
+
return 'enter'
|
|
2679
|
+
if ch == b'\x1b':
|
|
2680
|
+
return 'esc'
|
|
2681
|
+
try:
|
|
2682
|
+
return ch.decode('utf-8', errors='ignore').lower()
|
|
2683
|
+
except Exception:
|
|
2684
|
+
return ''
|
|
2685
|
+
elif _HAS_TERMIOS:
|
|
2686
|
+
fd = sys.stdin.fileno()
|
|
2687
|
+
old = termios.tcgetattr(fd)
|
|
2688
|
+
try:
|
|
2689
|
+
tty.setraw(fd)
|
|
2690
|
+
ch = sys.stdin.read(1)
|
|
2691
|
+
if ch == '\x1b':
|
|
2692
|
+
ch2 = sys.stdin.read(1)
|
|
2693
|
+
if ch2 == '[':
|
|
2694
|
+
ch3 = sys.stdin.read(1)
|
|
2695
|
+
if ch3 == 'A':
|
|
2696
|
+
return 'up'
|
|
2697
|
+
if ch3 == 'B':
|
|
2698
|
+
return 'down'
|
|
2699
|
+
return 'esc'
|
|
2700
|
+
if ch == '\r' or ch == '\n':
|
|
2701
|
+
return 'enter'
|
|
2702
|
+
return ch.lower()
|
|
2703
|
+
finally:
|
|
2704
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
2705
|
+
return ''
|
|
2706
|
+
|
|
2707
|
+
|
|
2708
|
+
# ============================================================
|
|
2709
|
+
# 19. ASK — цепочка вопросов
|
|
2710
|
+
# ============================================================
|
|
2711
|
+
|
|
2712
|
+
class Ask:
|
|
2713
|
+
"""Цепочка вопросов. Fluent API.
|
|
2714
|
+
|
|
2715
|
+
Пример:
|
|
2716
|
+
answers = (Ask()
|
|
2717
|
+
.text('Имя', default='Аня')
|
|
2718
|
+
.choice('Класс', ['Воин', 'Маг', 'Вор'])
|
|
2719
|
+
.confirm('Начать?', default=True)
|
|
2720
|
+
.number('Возраст', min=1, max=120)
|
|
2721
|
+
.run())
|
|
2722
|
+
"""
|
|
2723
|
+
def __init__(self):
|
|
2724
|
+
self._steps: list[tuple] = []
|
|
2725
|
+
|
|
2726
|
+
def text(self, label: str, default: str = '') -> 'Ask':
|
|
2727
|
+
self._steps.append(('text', label, default))
|
|
2728
|
+
return self
|
|
2729
|
+
|
|
2730
|
+
def choice(self, label: str, options: list, default: int = 0) -> 'Ask':
|
|
2731
|
+
self._steps.append(('choice', label, options, default))
|
|
2732
|
+
return self
|
|
2733
|
+
|
|
2734
|
+
def confirm(self, label: str, default: bool = False) -> 'Ask':
|
|
2735
|
+
self._steps.append(('confirm', label, default))
|
|
2736
|
+
return self
|
|
2737
|
+
|
|
2738
|
+
def number(self, label: str, default: int = 0,
|
|
2739
|
+
min: int | None = None, max: int | None = None) -> 'Ask':
|
|
2740
|
+
self._steps.append(('number', label, default, min, max))
|
|
2741
|
+
return self
|
|
2742
|
+
|
|
2743
|
+
def password(self, label: str = 'Пароль') -> 'Ask':
|
|
2744
|
+
self._steps.append(('password', label))
|
|
2745
|
+
return self
|
|
2746
|
+
|
|
2747
|
+
def run(self) -> dict:
|
|
2748
|
+
result = {}
|
|
2749
|
+
for step in self._steps:
|
|
2750
|
+
kind = step[0]
|
|
2751
|
+
label = step[1]
|
|
2752
|
+
if kind == 'text':
|
|
2753
|
+
result[label] = ask(label, step[2])
|
|
2754
|
+
elif kind == 'choice':
|
|
2755
|
+
options, default = step[2], step[3]
|
|
2756
|
+
result[label] = prompt_choice(label, options, options[default])
|
|
2757
|
+
elif kind == 'confirm':
|
|
2758
|
+
result[label] = confirm(label, step[2])
|
|
2759
|
+
elif kind == 'number':
|
|
2760
|
+
default, lo, hi = step[2], step[3], step[4]
|
|
2761
|
+
while True:
|
|
2762
|
+
raw = ask(label, str(default))
|
|
2763
|
+
try:
|
|
2764
|
+
val = int(raw)
|
|
2765
|
+
if lo is not None and val < lo:
|
|
2766
|
+
warn(f'Минимум {lo}')
|
|
2767
|
+
continue
|
|
2768
|
+
if hi is not None and val > hi:
|
|
2769
|
+
warn(f'Максимум {hi}')
|
|
2770
|
+
continue
|
|
2771
|
+
result[label] = val
|
|
2772
|
+
break
|
|
2773
|
+
except ValueError:
|
|
2774
|
+
warn('Введи число')
|
|
2775
|
+
elif kind == 'password':
|
|
2776
|
+
result[label] = password(label)
|
|
2777
|
+
return result
|
|
2778
|
+
|
|
2779
|
+
|
|
2780
|
+
# ============================================================
|
|
2781
|
+
# 20. WIZARD — пошаговый мастер
|
|
2782
|
+
# ============================================================
|
|
2783
|
+
|
|
2784
|
+
def wizard(steps: list[tuple], title: str = 'Мастер настройки') -> dict:
|
|
2785
|
+
"""Пошаговый мастер настройки.
|
|
2786
|
+
|
|
2787
|
+
steps — список (label, kind, options_dict):
|
|
2788
|
+
kind = 'text' | 'choice' | 'confirm' | 'number'
|
|
2789
|
+
|
|
2790
|
+
Пример:
|
|
2791
|
+
wizard([
|
|
2792
|
+
('Имя', 'text', {'default': 'Аня'}),
|
|
2793
|
+
('Email', 'text', {}),
|
|
2794
|
+
('Возраст', 'number', {'min': 18}),
|
|
2795
|
+
('Подписка', 'confirm', {'default': True}),
|
|
2796
|
+
])
|
|
2797
|
+
"""
|
|
2798
|
+
result = {}
|
|
2799
|
+
total = len(steps)
|
|
2800
|
+
for i, (label, kind, opts) in enumerate(steps, 1):
|
|
2801
|
+
section(f'[{i}/{total}] {label}')
|
|
2802
|
+
opts = opts or {}
|
|
2803
|
+
if kind == 'text':
|
|
2804
|
+
result[label] = ask(label, opts.get('default', ''))
|
|
2805
|
+
elif kind == 'choice':
|
|
2806
|
+
options = opts.get('options', [])
|
|
2807
|
+
result[label] = prompt_choice(label, options)
|
|
2808
|
+
elif kind == 'confirm':
|
|
2809
|
+
result[label] = confirm(label, opts.get('default', False))
|
|
2810
|
+
elif kind == 'number':
|
|
2811
|
+
while True:
|
|
2812
|
+
raw = ask(label, str(opts.get('default', 0)))
|
|
2813
|
+
try:
|
|
2814
|
+
val = int(raw)
|
|
2815
|
+
if 'min' in opts and val < opts['min']:
|
|
2816
|
+
warn(f'Минимум {opts["min"]}'); continue
|
|
2817
|
+
if 'max' in opts and val > opts['max']:
|
|
2818
|
+
warn(f'Максимум {opts["max"]}'); continue
|
|
2819
|
+
result[label] = val
|
|
2820
|
+
break
|
|
2821
|
+
except ValueError:
|
|
2822
|
+
warn('Введи число')
|
|
2823
|
+
space()
|
|
2824
|
+
ok(f'{title}: завершено')
|
|
2825
|
+
return result
|
|
2826
|
+
|
|
2827
|
+
|
|
2828
|
+
# ============================================================
|
|
2829
|
+
# 21. ПРОДВИНУТОЕ: Keyboard, Stream, Dashboard, Parser
|
|
2830
|
+
# ============================================================
|
|
2831
|
+
|
|
2832
|
+
class Keyboard:
|
|
2833
|
+
"""Глобальный обработчик клавиш (в отдельном потоке).
|
|
2834
|
+
|
|
2835
|
+
Пример:
|
|
2836
|
+
kb = Keyboard()
|
|
2837
|
+
kb.on('q', lambda: exit_game())
|
|
2838
|
+
kb.start()
|
|
2839
|
+
...
|
|
2840
|
+
kb.stop()
|
|
2841
|
+
"""
|
|
2842
|
+
def __init__(self):
|
|
2843
|
+
self._handlers: dict[str, Callable] = {}
|
|
2844
|
+
self._stop = threading.Event()
|
|
2845
|
+
self._thread: threading.Thread | None = None
|
|
2846
|
+
|
|
2847
|
+
def on(self, key: str, handler: Callable) -> 'Keyboard':
|
|
2848
|
+
self._handlers[key] = handler
|
|
2849
|
+
return self
|
|
2850
|
+
|
|
2851
|
+
def start(self) -> None:
|
|
2852
|
+
if self._thread and self._thread.is_alive():
|
|
2853
|
+
return
|
|
2854
|
+
self._stop.clear()
|
|
2855
|
+
self._thread = threading.Thread(target=self._loop, daemon=True)
|
|
2856
|
+
self._thread.start()
|
|
2857
|
+
|
|
2858
|
+
def stop(self) -> None:
|
|
2859
|
+
self._stop.set()
|
|
2860
|
+
if self._thread:
|
|
2861
|
+
self._thread.join(timeout=1)
|
|
2862
|
+
|
|
2863
|
+
def _loop(self) -> None:
|
|
2864
|
+
while not self._stop.is_set():
|
|
2865
|
+
key = _read_key()
|
|
2866
|
+
if not key:
|
|
2867
|
+
time.sleep(0.02)
|
|
2868
|
+
continue
|
|
2869
|
+
handler = self._handlers.get(key)
|
|
2870
|
+
if handler:
|
|
2871
|
+
try:
|
|
2872
|
+
handler()
|
|
2873
|
+
except Exception:
|
|
2874
|
+
pass
|
|
2875
|
+
|
|
2876
|
+
|
|
2877
|
+
class Stream:
|
|
2878
|
+
"""Обработка потока с обновляемой строкой статуса.
|
|
2879
|
+
|
|
2880
|
+
Пример:
|
|
2881
|
+
with Stream('Чтение логов') as s:
|
|
2882
|
+
for line in open('app.log'):
|
|
2883
|
+
s.update(line.rstrip())
|
|
2884
|
+
if 'ERROR' in line:
|
|
2885
|
+
s.warn(line.rstrip())
|
|
2886
|
+
"""
|
|
2887
|
+
def __init__(self, label: str = 'Обработка'):
|
|
2888
|
+
self.label = label
|
|
2889
|
+
self._lock = threading.Lock()
|
|
2890
|
+
self._current = ''
|
|
2891
|
+
self._stop = threading.Event()
|
|
2892
|
+
self._thread: threading.Thread | None = None
|
|
2893
|
+
self._count = 0
|
|
2894
|
+
|
|
2895
|
+
def __enter__(self):
|
|
2896
|
+
self._thread = threading.Thread(target=self._loop, daemon=True)
|
|
2897
|
+
self._thread.start()
|
|
2898
|
+
return self
|
|
2899
|
+
|
|
2900
|
+
def __exit__(self, *args):
|
|
2901
|
+
self._stop.set()
|
|
2902
|
+
if self._thread:
|
|
2903
|
+
self._thread.join(timeout=0.5)
|
|
2904
|
+
sys.stdout.write('\r\033[K')
|
|
2905
|
+
sys.stdout.flush()
|
|
2906
|
+
|
|
2907
|
+
def _loop(self):
|
|
2908
|
+
frames = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
|
|
2909
|
+
frame_iter = itertools.cycle(frames)
|
|
2910
|
+
while not self._stop.is_set():
|
|
2911
|
+
with self._lock:
|
|
2912
|
+
cur = self._current
|
|
2913
|
+
ch = next(frame_iter)
|
|
2914
|
+
line = f'{c(ch, "bright_cyan")} {self.label}: {cur} [{self._count}]'
|
|
2915
|
+
sys.stdout.write(f'\r{line}\033[K')
|
|
2916
|
+
sys.stdout.flush()
|
|
2917
|
+
time.sleep(0.08)
|
|
2918
|
+
|
|
2919
|
+
def update(self, text: str):
|
|
2920
|
+
with self._lock:
|
|
2921
|
+
self._current = text
|
|
2922
|
+
self._count += 1
|
|
2923
|
+
|
|
2924
|
+
def warn(self, text: str):
|
|
2925
|
+
sys.stdout.write('\r\033[K')
|
|
2926
|
+
warn(text)
|
|
2927
|
+
self._count += 1
|
|
2928
|
+
|
|
2929
|
+
|
|
2930
|
+
class Dashboard:
|
|
2931
|
+
"""Многопанельный мониторинг.
|
|
2932
|
+
|
|
2933
|
+
Пример:
|
|
2934
|
+
with Dashboard(refresh=0.5) as db:
|
|
2935
|
+
db.panel('CPU', lambda: chart([random.random() for _ in range(20)]))
|
|
2936
|
+
db.panel('RAM', lambda: chart([random.random() for _ in range(20)]))
|
|
2937
|
+
time.sleep(5)
|
|
2938
|
+
"""
|
|
2939
|
+
def __init__(self, refresh: float = 0.5):
|
|
2940
|
+
self.refresh = refresh
|
|
2941
|
+
self.panels: list[tuple[str, Callable]] = []
|
|
2942
|
+
self._stop = threading.Event()
|
|
2943
|
+
self._thread: threading.Thread | None = None
|
|
2944
|
+
self._lines = 0
|
|
2945
|
+
|
|
2946
|
+
def panel(self, name: str, fn: Callable) -> 'Dashboard':
|
|
2947
|
+
self.panels.append((name, fn))
|
|
2948
|
+
return self
|
|
2949
|
+
|
|
2950
|
+
def __enter__(self):
|
|
2951
|
+
self._thread = threading.Thread(target=self._loop, daemon=True)
|
|
2952
|
+
self._thread.start()
|
|
2953
|
+
return self
|
|
2954
|
+
|
|
2955
|
+
def __exit__(self, *args):
|
|
2956
|
+
self._stop.set()
|
|
2957
|
+
if self._thread:
|
|
2958
|
+
self._thread.join(timeout=0.5)
|
|
2959
|
+
print()
|
|
2960
|
+
|
|
2961
|
+
def _loop(self):
|
|
2962
|
+
next_tick = time.perf_counter()
|
|
2963
|
+
while not self._stop.is_set():
|
|
2964
|
+
self._render()
|
|
2965
|
+
next_tick += self.refresh
|
|
2966
|
+
delta = next_tick - time.perf_counter()
|
|
2967
|
+
if delta > 0:
|
|
2968
|
+
time.sleep(delta)
|
|
2969
|
+
else:
|
|
2970
|
+
next_tick = time.perf_counter()
|
|
2971
|
+
|
|
2972
|
+
def _render(self):
|
|
2973
|
+
if self._lines:
|
|
2974
|
+
sys.stdout.write(f'\033[{self._lines}A')
|
|
2975
|
+
for _ in range(self._lines):
|
|
2976
|
+
sys.stdout.write('\033[K\n')
|
|
2977
|
+
sys.stdout.write(f'\033[{self._lines}A')
|
|
2978
|
+
|
|
2979
|
+
out_lines = 0
|
|
2980
|
+
for name, fn in self.panels:
|
|
2981
|
+
sys.stdout.write(c(f'▶ {name}', 'bright_cyan', bold=True) + '\n')
|
|
2982
|
+
out_lines += 1
|
|
2983
|
+
buf = io.StringIO()
|
|
2984
|
+
old = sys.stdout
|
|
2985
|
+
sys.stdout = buf
|
|
2986
|
+
try:
|
|
2987
|
+
fn()
|
|
2988
|
+
finally:
|
|
2989
|
+
sys.stdout = old
|
|
2990
|
+
content = buf.getvalue()
|
|
2991
|
+
for line in content.splitlines():
|
|
2992
|
+
sys.stdout.write(line + '\033[K\n')
|
|
2993
|
+
out_lines += 1
|
|
2994
|
+
sys.stdout.write('\033[K\n')
|
|
2995
|
+
out_lines += 1
|
|
2996
|
+
self._lines = out_lines
|
|
2997
|
+
sys.stdout.flush()
|
|
2998
|
+
|
|
2999
|
+
|
|
3000
|
+
class Parser:
|
|
3001
|
+
"""Простой парсер CLI-аргументов.
|
|
3002
|
+
|
|
3003
|
+
Пример:
|
|
3004
|
+
cli = Parser('mytool')
|
|
3005
|
+
cli.flag('--verbose', '-v', help='Подробный вывод')
|
|
3006
|
+
cli.option('--output', '-o', default='out.txt')
|
|
3007
|
+
cli.command('build', handler=lambda a: print('build'))
|
|
3008
|
+
cli.command('run', handler=lambda a: print('run'))
|
|
3009
|
+
args = cli.parse()
|
|
3010
|
+
"""
|
|
3011
|
+
def __init__(self, name: str = 'app'):
|
|
3012
|
+
self.name = name
|
|
3013
|
+
self.flags: list[tuple] = []
|
|
3014
|
+
self.options: list[tuple] = []
|
|
3015
|
+
self.commands: dict[str, Callable] = {}
|
|
3016
|
+
|
|
3017
|
+
def flag(self, *names: str, help: str = '') -> 'Parser':
|
|
3018
|
+
self.flags.append((names, help))
|
|
3019
|
+
return self
|
|
3020
|
+
|
|
3021
|
+
def option(self, *names: str, default=None, help: str = '') -> 'Parser':
|
|
3022
|
+
self.options.append((names, default, help))
|
|
3023
|
+
return self
|
|
3024
|
+
|
|
3025
|
+
def command(self, name: str, handler: Callable) -> 'Parser':
|
|
3026
|
+
self.commands[name] = handler
|
|
3027
|
+
return self
|
|
3028
|
+
|
|
3029
|
+
def parse(self, argv: list[str] | None = None) -> dict:
|
|
3030
|
+
argv = list(argv if argv is not None else sys.argv[1:])
|
|
3031
|
+
result: dict = {'flags': set(), 'options': {}, 'positional': [],
|
|
3032
|
+
'command': None}
|
|
3033
|
+
|
|
3034
|
+
i = 0
|
|
3035
|
+
while i < len(argv):
|
|
3036
|
+
arg = argv[i]
|
|
3037
|
+
matched = False
|
|
3038
|
+
for names, _ in self.flags:
|
|
3039
|
+
if arg in names:
|
|
3040
|
+
result['flags'].add(names[0].lstrip('-'))
|
|
3041
|
+
matched = True
|
|
3042
|
+
break
|
|
3043
|
+
if matched:
|
|
3044
|
+
i += 1
|
|
3045
|
+
continue
|
|
3046
|
+
for names, default, _ in self.options:
|
|
3047
|
+
if arg in names:
|
|
3048
|
+
key = names[0].lstrip('-')
|
|
3049
|
+
if i + 1 < len(argv):
|
|
3050
|
+
result['options'][key] = argv[i + 1]
|
|
3051
|
+
i += 2
|
|
3052
|
+
else:
|
|
3053
|
+
result['options'][key] = True
|
|
3054
|
+
i += 1
|
|
3055
|
+
matched = True
|
|
3056
|
+
break
|
|
3057
|
+
if '=' in arg and arg.split('=')[0] in names:
|
|
3058
|
+
key = names[0].lstrip('-')
|
|
3059
|
+
result['options'][key] = arg.split('=', 1)[1]
|
|
3060
|
+
matched = True
|
|
3061
|
+
i += 1
|
|
3062
|
+
break
|
|
3063
|
+
if matched:
|
|
3064
|
+
continue
|
|
3065
|
+
if arg in self.commands:
|
|
3066
|
+
result['command'] = arg
|
|
3067
|
+
if arg in self.commands:
|
|
3068
|
+
self.commands[arg](result)
|
|
3069
|
+
i += 1
|
|
3070
|
+
else:
|
|
3071
|
+
result['positional'].append(arg)
|
|
3072
|
+
i += 1
|
|
3073
|
+
return result
|
|
3074
|
+
|
|
3075
|
+
|
|
3076
|
+
# ============================================================
|
|
3077
|
+
# 22. ЛОГГЕР
|
|
3078
|
+
# ============================================================
|
|
3079
|
+
|
|
3080
|
+
class Log:
|
|
3081
|
+
"""Логгер с ротацией."""
|
|
3082
|
+
_LEVELS = {'DEBUG': 10, 'INFO': 20, 'WARN': 30, 'ERROR': 40}
|
|
3083
|
+
|
|
3084
|
+
def __init__(self, name='app', log_dir='logs', console=True,
|
|
3085
|
+
level='INFO', colorize=True,
|
|
3086
|
+
max_size: str | int | None = None,
|
|
3087
|
+
backups: int = 5):
|
|
3088
|
+
self.name, self.console = name, console
|
|
3089
|
+
self.level = level.upper()
|
|
3090
|
+
self.colorize = colorize
|
|
3091
|
+
self.dir = Path(log_dir)
|
|
3092
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
3093
|
+
self.max_size = self._parse_size(max_size) if max_size else None
|
|
3094
|
+
self.backups = backups
|
|
3095
|
+
|
|
3096
|
+
@staticmethod
|
|
3097
|
+
def _parse_size(size) -> int:
|
|
3098
|
+
if isinstance(size, int):
|
|
3099
|
+
return size
|
|
3100
|
+
s = str(size).strip().upper()
|
|
3101
|
+
for unit, mult in [('GB', 1024**3), ('MB', 1024**2),
|
|
3102
|
+
('KB', 1024), ('B', 1)]:
|
|
3103
|
+
if s.endswith(unit):
|
|
3104
|
+
return int(float(s[:-len(unit)]) * mult)
|
|
3105
|
+
return int(s)
|
|
3106
|
+
|
|
3107
|
+
def _file(self) -> Path:
|
|
3108
|
+
return self.dir / f'{self.name}_{datetime.now():%Y-%m-%d}.log'
|
|
3109
|
+
|
|
3110
|
+
def _rotate_if_needed(self) -> None:
|
|
3111
|
+
if not self.max_size:
|
|
3112
|
+
return
|
|
3113
|
+
f = self._file()
|
|
3114
|
+
if not f.exists() or f.stat().st_size < self.max_size:
|
|
3115
|
+
return
|
|
3116
|
+
# Сдвигаем backup'ы
|
|
3117
|
+
for i in range(self.backups - 1, 0, -1):
|
|
3118
|
+
old = f.with_suffix(f'.{i}.log')
|
|
3119
|
+
new = f.with_suffix(f'.{i + 1}.log')
|
|
3120
|
+
if old.exists():
|
|
3121
|
+
if new.exists():
|
|
3122
|
+
new.unlink()
|
|
3123
|
+
old.rename(new)
|
|
3124
|
+
first = f.with_suffix('.1.log')
|
|
3125
|
+
if first.exists():
|
|
3126
|
+
first.unlink()
|
|
3127
|
+
f.rename(first)
|
|
3128
|
+
|
|
3129
|
+
def _write(self, level: str, msg: Any) -> None:
|
|
3130
|
+
if self._LEVELS[level] < self._LEVELS.get(self.level, 20):
|
|
3131
|
+
return
|
|
3132
|
+
self._rotate_if_needed()
|
|
3133
|
+
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
3134
|
+
line = f'{ts} [{level:<5}] {msg}'
|
|
3135
|
+
with open(self._file(), 'a', encoding='utf-8') as f:
|
|
3136
|
+
f.write(line + '\n')
|
|
3137
|
+
if self.console:
|
|
3138
|
+
if self.colorize:
|
|
3139
|
+
colors = {'DEBUG': 'gray', 'INFO': 'cyan',
|
|
3140
|
+
'WARN': 'yellow', 'ERROR': 'red'}
|
|
3141
|
+
print(c(line, colors.get(level, 'white')))
|
|
3142
|
+
else:
|
|
3143
|
+
print(line)
|
|
3144
|
+
|
|
3145
|
+
def debug(self, msg): self._write('DEBUG', msg)
|
|
3146
|
+
def info(self, msg): self._write('INFO', msg)
|
|
3147
|
+
def warn(self, msg): self._write('WARN', msg)
|
|
3148
|
+
def error(self, msg): self._write('ERROR', msg)
|
|
3149
|
+
|
|
3150
|
+
|
|
3151
|
+
# ============================================================
|
|
3152
|
+
# 23. ФОРМАТЫ
|
|
3153
|
+
# ============================================================
|
|
3154
|
+
|
|
3155
|
+
def human_size(n: float) -> str:
|
|
3156
|
+
n = float(n)
|
|
3157
|
+
for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
|
|
3158
|
+
if abs(n) < 1024:
|
|
3159
|
+
return f'{n:.1f} {unit}'
|
|
3160
|
+
n /= 1024
|
|
3161
|
+
return f'{n:.1f} PB'
|
|
3162
|
+
|
|
3163
|
+
|
|
3164
|
+
_PLURALS_RU = {
|
|
3165
|
+
'day': ('день', 'дня', 'дней'), 'hour': ('час', 'часа', 'часов'),
|
|
3166
|
+
'minute': ('минута', 'минуты', 'минут'),
|
|
3167
|
+
'second': ('секунда', 'секунды', 'секунд'), 'ms': ('мс', 'мс', 'мс'),
|
|
3168
|
+
}
|
|
3169
|
+
_PLURALS_EN = {
|
|
3170
|
+
'day': ('day', 'days'), 'hour': ('hour', 'hours'),
|
|
3171
|
+
'minute': ('minute', 'minutes'), 'second': ('second', 'seconds'),
|
|
3172
|
+
'ms': ('ms', 'ms'),
|
|
3173
|
+
}
|
|
3174
|
+
_UNITS_SHORT = {
|
|
3175
|
+
'ru': {'day': 'д', 'hour': 'ч', 'minute': 'м', 'second': 'с', 'ms': 'мс'},
|
|
3176
|
+
'en': {'day': 'd', 'hour': 'h', 'minute': 'm', 'second': 's', 'ms': 'ms'},
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
|
|
3180
|
+
def _plural(n: int, forms: tuple, lang: str) -> str:
|
|
3181
|
+
if lang == 'en':
|
|
3182
|
+
return forms[1] if n != 1 else forms[0]
|
|
3183
|
+
n = abs(n) % 100
|
|
3184
|
+
if 11 <= n <= 19:
|
|
3185
|
+
return forms[2]
|
|
3186
|
+
n %= 10
|
|
3187
|
+
if n == 1: return forms[0]
|
|
3188
|
+
if 2 <= n <= 4: return forms[1]
|
|
3189
|
+
return forms[2]
|
|
3190
|
+
|
|
3191
|
+
|
|
3192
|
+
def plural(n: int, one: str, few: str, many: str) -> str:
|
|
3193
|
+
return _plural(n, (one, few, many), 'ru')
|
|
3194
|
+
|
|
3195
|
+
|
|
3196
|
+
def human_time(seconds: float, *, lang: str = 'ru',
|
|
3197
|
+
short: bool = False, parts: int | None = None) -> str:
|
|
3198
|
+
neg = seconds < 0
|
|
3199
|
+
seconds = abs(float(seconds))
|
|
3200
|
+
if seconds < 1:
|
|
3201
|
+
ms = int(round(seconds * 1000))
|
|
3202
|
+
if short:
|
|
3203
|
+
text = f'{ms}{_UNITS_SHORT[lang]["ms"]}'
|
|
3204
|
+
else:
|
|
3205
|
+
word = _plural(ms, (_PLURALS_RU if lang == 'ru' else _PLURALS_EN)['ms'], lang)
|
|
3206
|
+
text = f'{ms} {word}'
|
|
3207
|
+
return f'{text} назад' if neg else text
|
|
3208
|
+
total = int(seconds)
|
|
3209
|
+
days, total = divmod(total, 86400)
|
|
3210
|
+
hours, total = divmod(total, 3600)
|
|
3211
|
+
minutes, secs = divmod(total, 60)
|
|
3212
|
+
units = [('day', days), ('hour', hours), ('minute', minutes), ('second', secs)]
|
|
3213
|
+
result = []
|
|
3214
|
+
for name, val in units:
|
|
3215
|
+
if val == 0 and not result and name != 'second':
|
|
3216
|
+
continue
|
|
3217
|
+
if val or (name == 'second' and not result):
|
|
3218
|
+
if short:
|
|
3219
|
+
result.append(f'{val}{_UNITS_SHORT[lang][name]}')
|
|
3220
|
+
else:
|
|
3221
|
+
word = _plural(val, (_PLURALS_RU if lang == 'ru' else _PLURALS_EN)[name], lang)
|
|
3222
|
+
result.append(f'{val} {word}')
|
|
3223
|
+
if parts and len(result) >= parts:
|
|
3224
|
+
break
|
|
3225
|
+
if not result:
|
|
3226
|
+
result = [f'0{_UNITS_SHORT[lang]["second"]}'] if short else \
|
|
3227
|
+
[f'0 {_plural(0, (_PLURALS_RU if lang == "ru" else _PLURALS_EN)["second"], lang)}']
|
|
3228
|
+
text = ' '.join(result)
|
|
3229
|
+
return f'{text} назад' if neg else text
|
|
3230
|
+
|
|
3231
|
+
|
|
3232
|
+
def human_delta(a: datetime, b: datetime, **kw) -> str:
|
|
3233
|
+
return human_time((b - a).total_seconds(), **kw)
|
|
3234
|
+
|
|
3235
|
+
|
|
3236
|
+
def human_eta(elapsed: float, done: int, total: int, **kw) -> str:
|
|
3237
|
+
if done == 0:
|
|
3238
|
+
return '...'
|
|
3239
|
+
speed = done / elapsed
|
|
3240
|
+
return '~' + human_time((total - done) / speed if speed else 0, parts=2, **kw)
|
|
3241
|
+
|
|
3242
|
+
|
|
3243
|
+
def human_ago(dt: datetime | str) -> str:
|
|
3244
|
+
if isinstance(dt, str):
|
|
3245
|
+
dt = datetime.fromisoformat(dt)
|
|
3246
|
+
s = int((datetime.now() - dt).total_seconds())
|
|
3247
|
+
if s < 60: return 'только что'
|
|
3248
|
+
if s < 3600: return f'{s // 60} мин назад'
|
|
3249
|
+
if s < 86400: return f'{s // 3600} ч назад'
|
|
3250
|
+
if s < 604800: return f'{s // 86400} дн назад'
|
|
3251
|
+
return dt.strftime('%d.%m.%Y')
|
|
3252
|
+
|
|
3253
|
+
|
|
3254
|
+
def money(n: float, currency: str = '₽') -> str:
|
|
3255
|
+
return f'{n:,.0f} {currency}'.replace(',', ' ')
|
|
3256
|
+
|
|
3257
|
+
|
|
3258
|
+
# ============================================================
|
|
3259
|
+
# 24. ТАЙМЕР / РЕТРАИ
|
|
3260
|
+
# ============================================================
|
|
3261
|
+
|
|
3262
|
+
@contextmanager
|
|
3263
|
+
def timer(label: str = '', show: bool = True):
|
|
3264
|
+
start = time.perf_counter()
|
|
3265
|
+
box_ = {'elapsed': 0.0}
|
|
3266
|
+
try:
|
|
3267
|
+
yield box_
|
|
3268
|
+
finally:
|
|
3269
|
+
box_['elapsed'] = time.perf_counter() - start
|
|
3270
|
+
if show:
|
|
3271
|
+
info(f'{label}: {box_["elapsed"]:.3f}s')
|
|
3272
|
+
|
|
3273
|
+
|
|
3274
|
+
def retry(times: int = 3, delay: float = 1.0, backoff: float = 2.0,
|
|
3275
|
+
exceptions: tuple = (Exception,),
|
|
3276
|
+
on_retry: Callable[[int, Exception], None] | None = None,
|
|
3277
|
+
raise_last: bool = True, default: Any = None):
|
|
3278
|
+
def deco(func):
|
|
3279
|
+
@functools.wraps(func)
|
|
3280
|
+
def wrapper(*args, **kw):
|
|
3281
|
+
wait_ = delay
|
|
3282
|
+
last = None
|
|
3283
|
+
for attempt in range(1, times + 1):
|
|
3284
|
+
try:
|
|
3285
|
+
return func(*args, **kw)
|
|
3286
|
+
except exceptions as e:
|
|
3287
|
+
last = e
|
|
3288
|
+
if attempt == times:
|
|
3289
|
+
break
|
|
3290
|
+
if on_retry:
|
|
3291
|
+
on_retry(attempt, e)
|
|
3292
|
+
time.sleep(wait_)
|
|
3293
|
+
wait_ *= backoff
|
|
3294
|
+
if raise_last and last is not None:
|
|
3295
|
+
raise last
|
|
3296
|
+
return default
|
|
3297
|
+
return wrapper
|
|
3298
|
+
return deco
|
|
3299
|
+
|
|
3300
|
+
|
|
3301
|
+
def debug_time(func):
|
|
3302
|
+
@functools.wraps(func)
|
|
3303
|
+
def wrapper(*args, **kwargs):
|
|
3304
|
+
t0 = time.perf_counter()
|
|
3305
|
+
try:
|
|
3306
|
+
return func(*args, **kwargs)
|
|
3307
|
+
finally:
|
|
3308
|
+
dt = time.perf_counter() - t0
|
|
3309
|
+
print(c(f'[t] {func.__name__}: {dt:.3f}s', 'gray'))
|
|
3310
|
+
return wrapper
|
|
3311
|
+
|
|
3312
|
+
|
|
3313
|
+
# ============================================================
|
|
3314
|
+
# 25. JSON / STORE
|
|
3315
|
+
# ============================================================
|
|
3316
|
+
|
|
3317
|
+
def load_json(path, default=None):
|
|
3318
|
+
p_ = Path(path)
|
|
3319
|
+
if not p_.exists():
|
|
3320
|
+
return default
|
|
3321
|
+
try:
|
|
3322
|
+
return json.loads(p_.read_text(encoding='utf-8'))
|
|
3323
|
+
except (json.JSONDecodeError, OSError):
|
|
3324
|
+
return default
|
|
3325
|
+
|
|
3326
|
+
|
|
3327
|
+
def save_json(path, data, indent=2, ensure_ascii=False) -> Path:
|
|
3328
|
+
p_ = Path(path)
|
|
3329
|
+
p_.parent.mkdir(parents=True, exist_ok=True)
|
|
3330
|
+
p_.write_text(
|
|
3331
|
+
json.dumps(data, ensure_ascii=ensure_ascii, indent=indent, default=str),
|
|
3332
|
+
encoding='utf-8',
|
|
3333
|
+
)
|
|
3334
|
+
return p_
|
|
3335
|
+
|
|
3336
|
+
|
|
3337
|
+
class Store:
|
|
3338
|
+
def __init__(self, path='store.json'):
|
|
3339
|
+
self.path = Path(path)
|
|
3340
|
+
self.data = load_json(self.path, default={}) or {}
|
|
3341
|
+
self._history: dict[str, list] = {}
|
|
3342
|
+
|
|
3343
|
+
def save(self): save_json(self.path, self.data)
|
|
3344
|
+
def get(self, key, default=None): return self.data.get(key, default)
|
|
3345
|
+
|
|
3346
|
+
def set(self, key, value):
|
|
3347
|
+
self.data[key] = value
|
|
3348
|
+
self._history.setdefault(key, []).append(value)
|
|
3349
|
+
self.save()
|
|
3350
|
+
return value
|
|
3351
|
+
|
|
3352
|
+
def delete(self, key):
|
|
3353
|
+
self.data.pop(key, None)
|
|
3354
|
+
self.save()
|
|
3355
|
+
|
|
3356
|
+
def update(self, **kw):
|
|
3357
|
+
self.data.update(kw)
|
|
3358
|
+
self.save()
|
|
3359
|
+
return self.data
|
|
3360
|
+
|
|
3361
|
+
def keys(self): return list(self.data.keys())
|
|
3362
|
+
|
|
3363
|
+
def clear(self):
|
|
3364
|
+
self.data.clear()
|
|
3365
|
+
self.save()
|
|
3366
|
+
|
|
3367
|
+
def history(self, key: str) -> list:
|
|
3368
|
+
return list(self._history.get(key, []))
|
|
3369
|
+
|
|
3370
|
+
def __contains__(self, key): return key in self.data
|
|
3371
|
+
def __getitem__(self, key): return self.data[key]
|
|
3372
|
+
def __setitem__(self, key, value): self.set(key, value)
|
|
3373
|
+
def __repr__(self): return f'Store({self.path!s}, {len(self.data)} keys)'
|
|
3374
|
+
|
|
3375
|
+
|
|
3376
|
+
# ============================================================
|
|
3377
|
+
# 26. ДЕБАГ
|
|
3378
|
+
# ============================================================
|
|
3379
|
+
|
|
3380
|
+
def _fmt_val(v: Any, max_len: int = 120) -> str:
|
|
3381
|
+
if isinstance(v, str):
|
|
3382
|
+
s = repr(v)
|
|
3383
|
+
return c(s if len(s) <= max_len else s[:max_len - 1] + '…', 'green')
|
|
3384
|
+
if isinstance(v, bool):
|
|
3385
|
+
return c(str(v), 'bright_yellow')
|
|
3386
|
+
if isinstance(v, (int, float)):
|
|
3387
|
+
return c(str(v), 'bright_cyan')
|
|
3388
|
+
if v is None:
|
|
3389
|
+
return c('None', 'gray')
|
|
3390
|
+
if isinstance(v, (list, tuple, set, dict)):
|
|
3391
|
+
s = repr(v)
|
|
3392
|
+
return c(s if len(s) <= max_len else s[:max_len - 1] + '…', 'bright_magenta')
|
|
3393
|
+
return c(repr(v)[:max_len], 'bright_white')
|
|
3394
|
+
|
|
3395
|
+
|
|
3396
|
+
class _Dbg:
|
|
3397
|
+
def __init__(self, color='magenta'):
|
|
3398
|
+
self.color = color
|
|
3399
|
+
|
|
3400
|
+
def __call__(self, *args, **kwargs):
|
|
3401
|
+
frame = inspect.currentframe().f_back
|
|
3402
|
+
info = inspect.getframeinfo(frame)
|
|
3403
|
+
filename = os.path.basename(info.filename)
|
|
3404
|
+
line = info.lineno
|
|
3405
|
+
src = linecache.getline(info.filename, line).strip()
|
|
3406
|
+
exprs = self._extract_exprs(src)
|
|
3407
|
+
parts = []
|
|
3408
|
+
for i, val in enumerate(args):
|
|
3409
|
+
name = exprs[i] if i < len(exprs) else f'arg{i}'
|
|
3410
|
+
parts.append(f'{c(name, self.color)}={_fmt_val(val)}')
|
|
3411
|
+
for k, v in kwargs.items():
|
|
3412
|
+
parts.append(f'{c(k, self.color)}={_fmt_val(v)}')
|
|
3413
|
+
loc = c(f'{filename}:{line}', 'gray')
|
|
3414
|
+
print(f'{c("🐛", self.color)} {loc} ' + ', '.join(parts))
|
|
3415
|
+
|
|
3416
|
+
@staticmethod
|
|
3417
|
+
def _extract_exprs(src: str) -> list[str]:
|
|
3418
|
+
m = _re.search(r'\bdbg\s*\((.*)\)', src)
|
|
3419
|
+
if not m:
|
|
3420
|
+
return []
|
|
3421
|
+
inner = m.group(1)
|
|
3422
|
+
parts, depth, cur = [], 0, ''
|
|
3423
|
+
for ch in inner:
|
|
3424
|
+
if ch in '([{':
|
|
3425
|
+
depth += 1
|
|
3426
|
+
elif ch in ')]}':
|
|
3427
|
+
depth -= 1
|
|
3428
|
+
if ch == ',' and depth == 0:
|
|
3429
|
+
parts.append(cur.strip())
|
|
3430
|
+
cur = ''
|
|
3431
|
+
else:
|
|
3432
|
+
cur += ch
|
|
3433
|
+
if cur.strip():
|
|
3434
|
+
parts.append(cur.strip())
|
|
3435
|
+
return parts
|
|
3436
|
+
|
|
3437
|
+
def trace(self):
|
|
3438
|
+
stack = inspect.stack()
|
|
3439
|
+
if len(stack) > 1:
|
|
3440
|
+
caller = stack[1]
|
|
3441
|
+
print(c(f'↳ {caller.function}() в '
|
|
3442
|
+
f'{os.path.basename(caller.filename)}:{caller.lineno}',
|
|
3443
|
+
self.color))
|
|
3444
|
+
|
|
3445
|
+
def stack(self, limit=10):
|
|
3446
|
+
stack = inspect.stack()[1:limit + 1]
|
|
3447
|
+
print(c(f'📚 стек ({len(stack)}):', self.color, bold=True))
|
|
3448
|
+
for i, fr in enumerate(stack):
|
|
3449
|
+
prefix = ' └─' if i else ' ┌─'
|
|
3450
|
+
print(f'{c(prefix, "gray")} {fr.function}() '
|
|
3451
|
+
f'{c(f"{os.path.basename(fr.filename)}:{fr.lineno}", "gray")}')
|
|
3452
|
+
|
|
3453
|
+
def watch(self, func):
|
|
3454
|
+
@functools.wraps(func)
|
|
3455
|
+
def wrapper(*args, **kwargs):
|
|
3456
|
+
name = c(func.__name__, self.color, bold=True)
|
|
3457
|
+
sig = ', '.join([_fmt_val(a) for a in args] +
|
|
3458
|
+
[f'{k}={_fmt_val(v)}' for k, v in kwargs.items()])
|
|
3459
|
+
print(f'→ {name}({sig})')
|
|
3460
|
+
t0 = time.perf_counter()
|
|
3461
|
+
try:
|
|
3462
|
+
result = func(*args, **kwargs)
|
|
3463
|
+
except Exception as e:
|
|
3464
|
+
dt = (time.perf_counter() - t0) * 1000
|
|
3465
|
+
print(f'← {name} ✗ {c(type(e).__name__, "red")}: {e} '
|
|
3466
|
+
f'{c(f"({dt:.1f}ms)", "gray")}')
|
|
3467
|
+
raise
|
|
3468
|
+
dt = (time.perf_counter() - t0) * 1000
|
|
3469
|
+
print(f'← {name} = {_fmt_val(result)} {c(f"({dt:.1f}ms)", "gray")}')
|
|
3470
|
+
return result
|
|
3471
|
+
return wrapper
|
|
3472
|
+
|
|
3473
|
+
def breakpoint(self):
|
|
3474
|
+
print(c('⏸ breakpoint', 'yellow', bold=True))
|
|
3475
|
+
import pdb
|
|
3476
|
+
pdb.set_trace()
|
|
3477
|
+
|
|
3478
|
+
|
|
3479
|
+
dbg = _Dbg()
|
|
3480
|
+
|
|
3481
|
+
|
|
3482
|
+
def color_traceback(exc: BaseException | None = None):
|
|
3483
|
+
if exc is None:
|
|
3484
|
+
exc = sys.exc_info()[1]
|
|
3485
|
+
if exc is None:
|
|
3486
|
+
print(c('Нет активного исключения', 'yellow'))
|
|
3487
|
+
return
|
|
3488
|
+
print(c(f'\n✗ {type(exc).__name__}: {exc}', 'red', bold=True))
|
|
3489
|
+
for frame in _tb.extract_tb(exc.__traceback__):
|
|
3490
|
+
loc = f'{os.path.basename(frame.filename)}:{frame.lineno}'
|
|
3491
|
+
print(f' {c("в", "gray")} {c(frame.name, "cyan")}() {c(loc, "gray")}')
|
|
3492
|
+
if frame.line:
|
|
3493
|
+
print(f' {c(frame.line.strip(), "bright_white")}')
|
|
3494
|
+
print()
|
|
3495
|
+
|
|
3496
|
+
|
|
3497
|
+
# ============================================================
|
|
3498
|
+
# 27. SHELL
|
|
3499
|
+
# ============================================================
|
|
3500
|
+
|
|
3501
|
+
def run_shell(cmd: str, check: bool = True,
|
|
3502
|
+
spinner_text: str | None = None) -> int:
|
|
3503
|
+
text = spinner_text if spinner_text is not None else cmd
|
|
3504
|
+
start = time.time()
|
|
3505
|
+
_fspin_start(text, _SPINNER_DEFAULTS['color'])
|
|
3506
|
+
try:
|
|
3507
|
+
rc = subprocess.call(cmd, shell=True)
|
|
3508
|
+
finally:
|
|
3509
|
+
dt = time.time() - start
|
|
3510
|
+
_fspin_finish('✓' if rc == 0 else '✗',
|
|
3511
|
+
'green' if rc == 0 else 'red',
|
|
3512
|
+
text, dt)
|
|
3513
|
+
if check and rc != 0:
|
|
3514
|
+
err(f'Команда завершилась с кодом {rc}')
|
|
3515
|
+
return rc
|
|
3516
|
+
|
|
3517
|
+
|
|
3518
|
+
# ============================================================
|
|
3519
|
+
# 28. ИГРОВОЕ
|
|
3520
|
+
# ============================================================
|
|
3521
|
+
|
|
3522
|
+
def dice(n: int = 1, sides: int = 6) -> int:
|
|
3523
|
+
return sum(random.randint(1, sides) for _ in range(n))
|
|
3524
|
+
|
|
3525
|
+
|
|
3526
|
+
def chance(p: float) -> bool:
|
|
3527
|
+
return random.random() < p
|
|
3528
|
+
|
|
3529
|
+
|
|
3530
|
+
def pick_weighted(items: list) -> Any:
|
|
3531
|
+
if not items:
|
|
3532
|
+
return None
|
|
3533
|
+
values = [it[0] for it in items]
|
|
3534
|
+
weights = [it[1] for it in items]
|
|
3535
|
+
return random.choices(values, weights=weights, k=1)[0]
|
|
3536
|
+
|
|
3537
|
+
|
|
3538
|
+
# ============================================================
|
|
3539
|
+
# 29. МЕЛОЧИ
|
|
3540
|
+
# ============================================================
|
|
3541
|
+
|
|
3542
|
+
def env(name: str, default: Any = None, cast: Callable = str) -> Any:
|
|
3543
|
+
val = os.environ.get(name)
|
|
3544
|
+
if val is None:
|
|
3545
|
+
return default
|
|
3546
|
+
try:
|
|
3547
|
+
return cast(val)
|
|
3548
|
+
except (ValueError, TypeError):
|
|
3549
|
+
return default
|
|
3550
|
+
|
|
3551
|
+
|
|
3552
|
+
def chunk(seq: list, size: int) -> Iterator[list]:
|
|
3553
|
+
for i in range(0, len(seq), size):
|
|
3554
|
+
yield seq[i:i + size]
|
|
3555
|
+
|
|
3556
|
+
|
|
3557
|
+
def pick(data: dict, path, default: Any = None) -> Any:
|
|
3558
|
+
keys = path.split('.') if isinstance(path, str) else path
|
|
3559
|
+
cur = data
|
|
3560
|
+
for k in keys:
|
|
3561
|
+
if isinstance(cur, dict) and k in cur:
|
|
3562
|
+
cur = cur[k]
|
|
3563
|
+
else:
|
|
3564
|
+
return default
|
|
3565
|
+
return cur
|
|
3566
|
+
|
|
3567
|
+
|
|
3568
|
+
def uniq(seq: Iterable) -> list:
|
|
3569
|
+
return list(dict.fromkeys(seq))
|
|
3570
|
+
|
|
3571
|
+
|
|
3572
|
+
def first(seq: Iterable, default=None, pred=None):
|
|
3573
|
+
return next((x for x in seq if pred is None or pred(x)), default)
|
|
3574
|
+
|
|
3575
|
+
|
|
3576
|
+
def last(seq: Iterable, default=None, pred=None):
|
|
3577
|
+
return next((x for x in reversed(list(seq)) if pred is None or pred(x)), default)
|
|
3578
|
+
|
|
3579
|
+
|
|
3580
|
+
def clamp(value: float, lo: float, hi: float) -> float:
|
|
3581
|
+
return max(lo, min(value, hi))
|
|
3582
|
+
|
|
3583
|
+
|
|
3584
|
+
def slugify(text: str) -> str:
|
|
3585
|
+
import unicodedata
|
|
3586
|
+
text = unicodedata.normalize('NFKD', text)
|
|
3587
|
+
text = text.encode('ascii', 'ignore').decode('ascii').lower()
|
|
3588
|
+
return _re.sub(r'[^a-z0-9]+', '-', text).strip('-')
|
|
3589
|
+
|
|
3590
|
+
|
|
3591
|
+
# ============================================================
|
|
3592
|
+
# 30. ИНИЦИАЛИЗАЦИЯ
|
|
3593
|
+
# ============================================================
|
|
3594
|
+
|
|
3595
|
+
def init(colors: bool = True, wcwidth_hint: bool = True,
|
|
3596
|
+
spinner_color: str | None = None,
|
|
3597
|
+
spinner_show_time: bool | None = None,
|
|
3598
|
+
spinner_preset: str | None = None,
|
|
3599
|
+
spinner_kind: str | None = None,
|
|
3600
|
+
spinner_speed: float | None = None,
|
|
3601
|
+
level_styles: dict | None = None,
|
|
3602
|
+
sounds: bool = True) -> None:
|
|
3603
|
+
"""Инициализирует rypy и задаёт дефолты."""
|
|
3604
|
+
enable_colors(colors)
|
|
3605
|
+
if not sounds:
|
|
3606
|
+
sound_off()
|
|
3607
|
+
if spinner_color is not None:
|
|
3608
|
+
_SPINNER_DEFAULTS['color'] = spinner_color
|
|
3609
|
+
if spinner_show_time is not None:
|
|
3610
|
+
_SPINNER_DEFAULTS['show_time'] = spinner_show_time
|
|
3611
|
+
if spinner_preset is not None:
|
|
3612
|
+
_SPINNER_DEFAULTS['preset'] = spinner_preset
|
|
3613
|
+
if spinner_kind is not None:
|
|
3614
|
+
_SPINNER_DEFAULTS['kind'] = spinner_kind
|
|
3615
|
+
if spinner_speed is not None:
|
|
3616
|
+
_SPINNER_DEFAULTS['speed'] = spinner_speed
|
|
3617
|
+
if level_styles:
|
|
3618
|
+
_LEVEL_STYLES.update(level_styles)
|
|
3619
|
+
if wcwidth_hint and not _HAS_WCWIDTH and colors and _ENABLED:
|
|
3620
|
+
print(c('ℹ rypy: pip install wcwidth для таблиц с эмодзи', 'gray'))
|
|
3621
|
+
|
|
3622
|
+
|
|
3623
|
+
def bootstrap(app_name: str = '', **kwargs) -> None:
|
|
3624
|
+
init(**kwargs)
|
|
3625
|
+
if app_name:
|
|
3626
|
+
banner(app_name)
|
|
3627
|
+
|
|
3628
|
+
|
|
3629
|
+
def import_all(namespace: dict | None = None) -> None:
|
|
3630
|
+
if namespace is None:
|
|
3631
|
+
namespace = inspect.currentframe().f_back.f_globals
|
|
3632
|
+
for name in __all__:
|
|
3633
|
+
namespace[name] = globals()[name]
|
|
3634
|
+
|
|
3635
|
+
|
|
3636
|
+
# ============================================================
|
|
3637
|
+
# 31. CHEATSHEET — УМНАЯ ШПАРГАЛКА
|
|
3638
|
+
# ============================================================
|
|
3639
|
+
|
|
3640
|
+
_CHEATSHEET = {
|
|
3641
|
+
'colors': ('Цвета и градиенты', [
|
|
3642
|
+
("ok / info / warn / err / debug", "ok('готово'); err('упало')"),
|
|
3643
|
+
("c(text, color, bold=True)", "c('ошибка', 'red', bold=True)"),
|
|
3644
|
+
("gprint(text, start, end)", "gprint('GRADIENT', 'red', 'blue')"),
|
|
3645
|
+
("gprint3(text, s, m, e)", "gprint3('X', 'red', 'yellow', 'green')"),
|
|
3646
|
+
("glow_print(text, duration=1.5)", "glow_print('★ ПРИВЕТ ★')"),
|
|
3647
|
+
("glow_print3(text, s, m, e, duration=1.5)", "glow_print3('★', 'red', 'yellow', 'green')"),
|
|
3648
|
+
("gradient / gradient3", "print(gradient('X', 'red', 'blue'))"),
|
|
3649
|
+
("hex_color('#ff8800')", "c('X', hex_color('#ff8800'))"),
|
|
3650
|
+
]),
|
|
3651
|
+
'spinner': ('Спиннеры', [
|
|
3652
|
+
("with spinner(text, preset=..)", "with spinner('Загрузка', preset='circle'): ..."),
|
|
3653
|
+
("spin / spin_done / spin_fail", "print(f'{spin(\"X\")}', end=''); spin_done()"),
|
|
3654
|
+
("step_run(text, fn, ...)", "step_run('A', f1, 'B', f2)"),
|
|
3655
|
+
("do(text, fn) / pause(text, sec)", "do('Считаю', sum, [1,2,3]); pause('Ждём', 2)"),
|
|
3656
|
+
("spinner_pause через with", "with spinner('X') as s: s.pause(); s.resume()"),
|
|
3657
|
+
("SSPINNER_FRAMES.keys()", "list(SPINNER_FRAMES.keys()) # 55+ пресетов"),
|
|
3658
|
+
("pick_preset(kind)", "pick_preset('circle')"),
|
|
3659
|
+
]),
|
|
3660
|
+
'bars': ('Прогресс и бары', [
|
|
3661
|
+
("progress(iter, prefix=..)", "for x in progress(range(100), prefix='X'): ..."),
|
|
3662
|
+
("progress3(iter, colors=..)", "for x in progress3(range(100)): ..."),
|
|
3663
|
+
("progress_multi([labels])", "with progress_multi(['A', 'B']) as pm: pm.update('A', 50)"),
|
|
3664
|
+
("PBar(total).update(n)", "bar = PBar(100); bar.update(50); bar.close()"),
|
|
3665
|
+
("bar_wave(total, prefix=..)", "b = bar_wave(100); b.update(50); b.close()"),
|
|
3666
|
+
("bar(v, max, width, color)", "print(f'[{bar(75, 100, 20, \"green\")}]')"),
|
|
3667
|
+
("bar3(v, max, s, m, e)", "print(bar3(75, 100, 20, 'red', 'yellow', 'green'))"),
|
|
3668
|
+
("hp_bar / mp_bar / xp_bar", "print(hp_bar(75, 100)); print(mp_bar(30, 50))"),
|
|
3669
|
+
("hud({'HP': (75, 100)})", "hud({'HP': (75, 100), 'MP': (30, 50)})"),
|
|
3670
|
+
]),
|
|
3671
|
+
'tables': ('Таблицы', [
|
|
3672
|
+
("table(rows, style='rounded')", "table(users, style='double')"),
|
|
3673
|
+
("Table(rows).style().show()", "Table(users).style('double').show()"),
|
|
3674
|
+
("table_from_csv(path)", "table_from_csv('users.csv')"),
|
|
3675
|
+
("highlight / formatters", "table(users, highlight=lambda r: r['age']>30)"),
|
|
3676
|
+
("footer={'Итого': 100}", "table(rows, footer={'name': 'Итого', 'score': 5000})"),
|
|
3677
|
+
]),
|
|
3678
|
+
'sound': ('Звуки', [
|
|
3679
|
+
("sound(name)", "sound('ok'); sound('error'); sound('level_up')"),
|
|
3680
|
+
("sound(freq, duration)", "sound(freq=880, duration=200)"),
|
|
3681
|
+
("sound_list()", "sound_list() # 20 встроенных звуков"),
|
|
3682
|
+
("sound_on / sound_off", "sound_off() # отключить все звуки"),
|
|
3683
|
+
]),
|
|
3684
|
+
'input': ('Интерактив', [
|
|
3685
|
+
("ask(label, default)", "name = ask('Имя', 'Аня')"),
|
|
3686
|
+
("confirm(label, default)", "if confirm('Точно?', default=False): ..."),
|
|
3687
|
+
("prompt(label, default)", "x = prompt('Значение')"),
|
|
3688
|
+
("password()", "pwd = password('Пароль')"),
|
|
3689
|
+
("spinner_selection(prompt, items)", "idx = spinner_selection('Выбор?', ['A', 'B'])"),
|
|
3690
|
+
("Ask().text().choice().run()", "Ask().text('Имя').confirm('OK?').run()"),
|
|
3691
|
+
("wizard([(label, kind, opts)])", "wizard([('Имя', 'text', {}), ('Возраст', 'number', {'min':18})])"),
|
|
3692
|
+
]),
|
|
3693
|
+
'frames': ('Рамки и декор', [
|
|
3694
|
+
("box(text, style, color, title)", "box('Готово!', color='green', title='OK')"),
|
|
3695
|
+
("banner(text)", "banner('MY APP', style='double')"),
|
|
3696
|
+
("kv({'key': 'value'})", "kv({'Хост': 'localhost', 'Порт': 5432})"),
|
|
3697
|
+
("center(text) / box_center([...])", "center('Привет', color='cyan')"),
|
|
3698
|
+
("notify_center(text, level)", "notify_center('Сохранено', 'ok')"),
|
|
3699
|
+
("section(title)", "section('Раздел 1')"),
|
|
3700
|
+
("rule / double_rule / rainbow_rule", "double_rule(); rainbow_rule()"),
|
|
3701
|
+
]),
|
|
3702
|
+
'animations': ('Анимации', [
|
|
3703
|
+
("typewriter(text, delay)", "typewriter('Привет', 0.03)"),
|
|
3704
|
+
("animate(text, duration)", "animate('Загрузка', 2)"),
|
|
3705
|
+
("glow(text, colors, cycles)", "glow('ЗАГРУЗКА')"),
|
|
3706
|
+
("rain / rain_line / rain_multi", "rain('★', 20, 2); rain_multi('★', 8, 5, 2)"),
|
|
3707
|
+
("stars_rain / hearts_rain / fireworks", "stars_rain(10); fireworks(3)"),
|
|
3708
|
+
("divider_animated / line_reveal / fade_in", "divider_animated(0.5)"),
|
|
3709
|
+
]),
|
|
3710
|
+
'data': ('Данные', [
|
|
3711
|
+
("tree({nested: dict})", "tree({'src': {'main.py': None}})"),
|
|
3712
|
+
("diff(dict_a, dict_b)", "diff({'a':1}, {'a':2, 'b':3})"),
|
|
3713
|
+
("chart(values, label)", "chart([1,3,5,8,6], 'CPU')"),
|
|
3714
|
+
("columns(items)", "columns(['a','b','c','d'])"),
|
|
3715
|
+
("flatten / uniq / chunk", "uniq([1,2,1,3]); list(chunk(list(range(10)), 3))"),
|
|
3716
|
+
("pick(data, 'a.b.c')", "pick({'a': {'b': 42}}, 'a.b')"),
|
|
3717
|
+
]),
|
|
3718
|
+
'advanced': ('Продвинутое', [
|
|
3719
|
+
("Keyboard()", "kb = Keyboard(); kb.on('q', quit); kb.start()"),
|
|
3720
|
+
("Stream(label)", "with Stream('Читаю') as s: s.update('строка 1')"),
|
|
3721
|
+
("Dashboard(refresh=0.5)", "with Dashboard() as db: db.panel('CPU', fn)"),
|
|
3722
|
+
("Parser('name')", "cli = Parser(); cli.flag('--v'); cli.parse()"),
|
|
3723
|
+
("Log(name, max_size='10MB')", "log = Log('app', max_size='10MB', backups=5)"),
|
|
3724
|
+
]),
|
|
3725
|
+
'misc': ('Мелочи', [
|
|
3726
|
+
("human_time / human_size / money", "human_time(3725); money(1234567)"),
|
|
3727
|
+
("timer('label')", "with timer('блок'): ..."),
|
|
3728
|
+
("retry(times, delay)", "@retry(3, 1.0); def fetch(): ..."),
|
|
3729
|
+
("dbg(x, y)", "dbg(a, b['c']) # с именами"),
|
|
3730
|
+
("env('NAME', default, cast)", "port = env('PORT', 8080, int)"),
|
|
3731
|
+
("dice(n, sides) / chance(p)", "dmg = dice(2, 6); if chance(0.3): ..."),
|
|
3732
|
+
("slugify(text) / clamp(v, lo, hi)", "slugify('Привет!'); clamp(150, 0, 100)"),
|
|
3733
|
+
]),
|
|
3734
|
+
}
|
|
3735
|
+
|
|
3736
|
+
|
|
3737
|
+
def cheatsheet(section_name: str | None = None,
|
|
3738
|
+
search: str | None = None) -> None:
|
|
3739
|
+
"""Печатает шпаргалку по всей библиотеке.
|
|
3740
|
+
|
|
3741
|
+
Примеры:
|
|
3742
|
+
cheatsheet() # все разделы
|
|
3743
|
+
cheatsheet('spinner') # только спиннеры
|
|
3744
|
+
cheatsheet(search='table') # поиск по всем разделам
|
|
3745
|
+
"""
|
|
3746
|
+
if section_name and section_name in _CHEATSHEET:
|
|
3747
|
+
title, items = _CHEATSHEET[section_name]
|
|
3748
|
+
_print_cheat_section(title, items)
|
|
3749
|
+
return
|
|
3750
|
+
|
|
3751
|
+
if search:
|
|
3752
|
+
q = search.lower()
|
|
3753
|
+
for key, (title, items) in _CHEATSHEET.items():
|
|
3754
|
+
hits = [(k, v) for k, v in items if q in k.lower() or q in v.lower()]
|
|
3755
|
+
if hits:
|
|
3756
|
+
_print_cheat_section(title, hits)
|
|
3757
|
+
return
|
|
3758
|
+
|
|
3759
|
+
# Всё
|
|
3760
|
+
print()
|
|
3761
|
+
banner(f'RYPY v{__version__} — шпаргалка',
|
|
3762
|
+
style='double', color='bright_magenta')
|
|
3763
|
+
print()
|
|
3764
|
+
cprint('Разделы:', color='gray')
|
|
3765
|
+
for key, (title, _) in _CHEATSHEET.items():
|
|
3766
|
+
print(f' {c(key, "yellow"):<15} — {title}')
|
|
3767
|
+
print()
|
|
3768
|
+
cprint('Используй: cheatsheet("spinner") или cheatsheet(search="table")',
|
|
3769
|
+
color='gray')
|
|
3770
|
+
print()
|
|
3771
|
+
|
|
3772
|
+
|
|
3773
|
+
def _print_cheat_section(title: str, items: list) -> None:
|
|
3774
|
+
print()
|
|
3775
|
+
cprint(f'▶ {title}', color='bright_cyan', bold=True)
|
|
3776
|
+
print(c('─' * min(term_width() - 2, 60), 'gray'))
|
|
3777
|
+
for name, example in items:
|
|
3778
|
+
print(f' {c(name, "yellow")}')
|
|
3779
|
+
print(f' {c(example, "gray")}')
|
|
3780
|
+
print()
|
|
3781
|
+
|
|
3782
|
+
|
|
3783
|
+
def docs(path: str | None = None, fmt: str = 'md') -> str | None:
|
|
3784
|
+
"""Генерирует документацию по всей библиотеке.
|
|
3785
|
+
|
|
3786
|
+
Примеры:
|
|
3787
|
+
docs() # печатает markdown в stdout
|
|
3788
|
+
docs('rypy.md') # пишет в файл
|
|
3789
|
+
"""
|
|
3790
|
+
lines = [f'# rypy v{__version__}', '',
|
|
3791
|
+
'Библиотека шорткатов для CLI.', '']
|
|
3792
|
+
for key, (title, items) in _CHEATSHEET.items():
|
|
3793
|
+
lines.append(f'## {title}')
|
|
3794
|
+
lines.append('')
|
|
3795
|
+
for name, example in items:
|
|
3796
|
+
lines.append(f'### `{name}`')
|
|
3797
|
+
lines.append('')
|
|
3798
|
+
lines.append('```python')
|
|
3799
|
+
lines.append(example)
|
|
3800
|
+
lines.append('```')
|
|
3801
|
+
lines.append('')
|
|
3802
|
+
text = '\n'.join(lines)
|
|
3803
|
+
if path:
|
|
3804
|
+
Path(path).write_text(text, encoding='utf-8')
|
|
3805
|
+
ok(f'Документация записана в {path}')
|
|
3806
|
+
return None
|
|
3807
|
+
print(text)
|
|
3808
|
+
return text
|
|
3809
|
+
|
|
3810
|
+
|
|
3811
|
+
def test_all() -> None:
|
|
3812
|
+
"""Smoke-тест всех ключевых функций. Печатает ✓/✗ на каждую."""
|
|
3813
|
+
tests = [
|
|
3814
|
+
('c', lambda: c('X', 'red') and True),
|
|
3815
|
+
('ok', lambda: ok('test') or True),
|
|
3816
|
+
('gradient', lambda: gradient('X', 'red', 'blue') and True),
|
|
3817
|
+
('gradient3', lambda: gradient3('X', 'red', 'yellow', 'green') and True),
|
|
3818
|
+
('bar', lambda: bar(5, 10, 10, 'green') and True),
|
|
3819
|
+
('bar3', lambda: bar3(5, 10, 10) and True),
|
|
3820
|
+
('hp_bar', lambda: hp_bar(50, 100) and True),
|
|
3821
|
+
('human_time', lambda: human_time(3725) and True),
|
|
3822
|
+
('human_size', lambda: human_size(1536) and True),
|
|
3823
|
+
('money', lambda: money(1234567) and True),
|
|
3824
|
+
('plural', lambda: plural(5, 'a', 'b', 'c') and True),
|
|
3825
|
+
('chunk', lambda: list(chunk([1, 2, 3], 2)) == [[1, 2], [3]]),
|
|
3826
|
+
('pick', lambda: pick({'a': {'b': 1}}, 'a.b') == 1),
|
|
3827
|
+
('uniq', lambda: uniq([1, 1, 2]) == [1, 2]),
|
|
3828
|
+
('slugify', lambda: slugify('Привет!') == 'privet'),
|
|
3829
|
+
('clamp', lambda: clamp(150, 0, 100) == 100),
|
|
3830
|
+
('flatten', lambda: list(flatten([1, [2, 3]])) == [1, 2, 3]),
|
|
3831
|
+
('dice', lambda: 1 <= dice(1, 6) <= 6),
|
|
3832
|
+
('chance', lambda: chance(1.0) is True),
|
|
3833
|
+
('sound', lambda: True), # только проверка, что не падает
|
|
3834
|
+
]
|
|
3835
|
+
section('Smoke-тест rypy')
|
|
3836
|
+
passed = 0
|
|
3837
|
+
for name, fn in tests:
|
|
3838
|
+
try:
|
|
3839
|
+
result = fn()
|
|
3840
|
+
if result:
|
|
3841
|
+
print(f' {c("✓", "green")} {name}')
|
|
3842
|
+
passed += 1
|
|
3843
|
+
else:
|
|
3844
|
+
print(f' {c("✗", "red")} {name} — вернул False')
|
|
3845
|
+
except Exception as e:
|
|
3846
|
+
print(f' {c("✗", "red")} {name} — {type(e).__name__}: {e}')
|
|
3847
|
+
space()
|
|
3848
|
+
if passed == len(tests):
|
|
3849
|
+
ok(f'Все {passed} тестов прошли')
|
|
3850
|
+
else:
|
|
3851
|
+
warn(f'Пройдено {passed}/{len(tests)}')
|
|
3852
|
+
|
|
3853
|
+
|
|
3854
|
+
def new(project_name: str = 'myproject',
|
|
3855
|
+
path: str | None = None) -> Path:
|
|
3856
|
+
"""Создаёт скелет нового проекта.
|
|
3857
|
+
|
|
3858
|
+
Пример:
|
|
3859
|
+
new('myapp') # создаст папку myapp/ с main.py и rypy.py
|
|
3860
|
+
"""
|
|
3861
|
+
base = Path(path or '.').resolve() / project_name
|
|
3862
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
3863
|
+
|
|
3864
|
+
# main.py
|
|
3865
|
+
main_py = base / 'main.py'
|
|
3866
|
+
main_py.write_text(
|
|
3867
|
+
f'"""\n{project_name} — CLI на rypy.\n"""\n'
|
|
3868
|
+
f'from rypy import *\n\n'
|
|
3869
|
+
f'bootstrap("{project_name.upper()}")\n\n\n'
|
|
3870
|
+
f'def main():\n'
|
|
3871
|
+
f' ok("Готово")\n\n\n'
|
|
3872
|
+
f'if __name__ == "__main__":\n'
|
|
3873
|
+
f' main()\n',
|
|
3874
|
+
encoding='utf-8',
|
|
3875
|
+
)
|
|
3876
|
+
|
|
3877
|
+
# requirements.txt
|
|
3878
|
+
(base / 'requirements.txt').write_text('wcwidth\n', encoding='utf-8')
|
|
3879
|
+
|
|
3880
|
+
# .gitignore
|
|
3881
|
+
(base / '.gitignore').write_text(
|
|
3882
|
+
'__pycache__/\n*.pyc\n.venv/\nlogs/\n*.log\nstore.json\n',
|
|
3883
|
+
encoding='utf-8',
|
|
3884
|
+
)
|
|
3885
|
+
|
|
3886
|
+
# README.md
|
|
3887
|
+
(base / 'README.md').write_text(
|
|
3888
|
+
f'# {project_name}\n\nCLI на [rypy](https://github.com/).\n\n'
|
|
3889
|
+
f'## Запуск\n\n```bash\npython main.py\n```\n',
|
|
3890
|
+
encoding='utf-8',
|
|
3891
|
+
)
|
|
3892
|
+
|
|
3893
|
+
ok(f'Проект создан: {base}')
|
|
3894
|
+
info(f'Скопируй rypy.py в {base}/')
|
|
3895
|
+
info(f'Затем: cd {base} && python main.py')
|
|
3896
|
+
return base
|
|
3897
|
+
|
|
3898
|
+
|
|
3899
|
+
# ============================================================
|
|
3900
|
+
# 32. ТОЧКА ВХОДА
|
|
3901
|
+
# ============================================================
|
|
3902
|
+
|
|
3903
|
+
if __name__ == '__main__':
|
|
3904
|
+
init()
|
|
3905
|
+
banner(f'RYPY v{__version__}')
|
|
3906
|
+
cprint(f' {_CHEATSHEET["colors"][0]}', color='gray')
|
|
3907
|
+
print()
|
|
3908
|
+
cprint('Всё работает. Используй:', color='gray')
|
|
3909
|
+
print(f' {c("cheatsheet()", "yellow")} — шпаргалка')
|
|
3910
|
+
print(f' {c("test_all()", "yellow")} — smoke-тест')
|
|
3911
|
+
print(f' {c("sound_list()", "yellow")} — все звуки')
|
|
3912
|
+
print(f' {c("docs()", "yellow")} — markdown-документация')
|
|
3913
|
+
print(f' {c("new(\"myapp\")", "yellow")} — создать проект')
|
|
3914
|
+
print()
|
|
3915
|
+
ok(f'rypy v{__version__} загружен')
|