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__} загружен')