tracetui 2.0.0__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.
tracetui/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """TraceTUI — Interactive TUI debugger for logs."""
2
+
3
+ __version__ = "0.2.0"
tracetui/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Точка входа в приложение."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
tracetui/app.py ADDED
@@ -0,0 +1,403 @@
1
+ """Основной TUI экран TraceTUI."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ from textual.app import App, ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.widgets import Header, Footer
8
+ from textual.css.query import NoMatches
9
+ from textual.theme import Theme
10
+
11
+ from .widgets.log_list import LogList
12
+ from .widgets.source_panel import SourcePanel
13
+ from .widgets.detail_panel import DetailPanel
14
+ from .widgets.live_monitor import LiveMonitor
15
+ from .models import LogSource
16
+ from .parsers.log_parser import load_log_source
17
+
18
+
19
+ class TraceTUIApp(App):
20
+ """Главное приложение TraceTUI."""
21
+
22
+ CSS = """
23
+ Screen {
24
+ layout: grid;
25
+ grid-size: 3;
26
+ grid-columns: 3fr 6fr 3fr;
27
+ grid-gutter: 1 2;
28
+ }
29
+
30
+ #source-panel {
31
+ width: 100%;
32
+ height: 95%;
33
+ background: $surface;
34
+ border: solid $accent;
35
+ padding: 1;
36
+ overflow: auto;
37
+ }
38
+
39
+ #log-list {
40
+ width: 100%;
41
+ height: 95%;
42
+ background: $boost;
43
+ border: solid $primary;
44
+ padding: 1;
45
+ overflow: auto;
46
+ }
47
+
48
+ /* Стили DataTable — виртуализированный рендеринг логов */
49
+ LogList {
50
+ background: $boost;
51
+ }
52
+
53
+ LogList .row-selected {
54
+ background: $primary;
55
+ color: white;
56
+ text-style: bold;
57
+ }
58
+
59
+ #detail-panel {
60
+ width: 100%;
61
+ height: 95%;
62
+ background: $surface;
63
+ border: solid $accent;
64
+ padding: 1;
65
+ overflow: auto;
66
+ }
67
+
68
+ /* Стили для чекбоксов источников в merge mode */
69
+ SourcePanel.merge-mode {
70
+ background: $surface;
71
+ }
72
+ """
73
+
74
+ BINDINGS = [
75
+ Binding("q", "quit", "Quit"),
76
+ Binding("f", "toggle_filter_mode", "Filter"),
77
+ Binding("e", "next_error", "Next error"),
78
+ Binding("t", "toggle_theme", "Theme"),
79
+ Binding("m", "toggle_merge_mode", "Timeline"),
80
+ Binding("left", "switch_source_left", "Prev source"),
81
+ Binding("right", "switch_source_right", "Next source"),
82
+ ]
83
+
84
+ def __init__(self, file_paths: list[Path] | None = None, format_type: str = "auto", log_level: str | None = None, **kwargs):
85
+ super().__init__(**kwargs)
86
+ self._is_dark = True
87
+ self._pending_sources: list[LogSource] = []
88
+ # Пути к файлам для асинхронной загрузки
89
+ self._file_paths: list[Path] = file_paths or []
90
+ self._format_type: str = format_type
91
+ # Минимальный уровень логирования для фильтрации
92
+ self._log_level: str | None = log_level
93
+ # Live-мониторинг
94
+ self._live_monitor: LiveMonitor | None = None
95
+ # Timeline merge mode
96
+ self.merge_mode: bool = False
97
+ # Индекс выбранного чекбокса в merge mode
98
+ self._selected_source_index: int = 0
99
+
100
+ def on_mount(self) -> None:
101
+ """Инициализация приложения."""
102
+ self._sources: list[LogSource] = []
103
+ self.title = "TraceTUI — Log Debugger"
104
+ self.sub_title = "↑↓ Navigate | ←→ Sources | F Filter | d Tag | E Next Error | T Theme | m Timeline | q Quit"
105
+
106
+ # Регистрируем светлую тему
107
+ self.register_theme(
108
+ Theme(
109
+ name="trace-light",
110
+ primary="#1976d2",
111
+ warning="#f57f17",
112
+ error="#c62828",
113
+ success="#2e7d32",
114
+ accent="#7b1fa2",
115
+ foreground="#1a1a1a",
116
+ background="#f0f0f0",
117
+ surface="#e0e0e0",
118
+ panel="#eeeeee",
119
+ boost="#ffffff",
120
+ dark=False,
121
+ )
122
+ )
123
+ # Принудительно регистрируем тему "textual" из built-in
124
+ self.register_theme(
125
+ Theme(
126
+ name="textual",
127
+ primary="#0072ff",
128
+ warning="#ffaf00",
129
+ error="#e6193C",
130
+ success="#13a10e",
131
+ accent="#8888fc",
132
+ foreground="#f8f8f2",
133
+ background="#272822",
134
+ surface="#1f2229",
135
+ panel="#272822",
136
+ boost="#353b45",
137
+ dark=True,
138
+ )
139
+ )
140
+
141
+ # Откладываем загрузку до полной инициализации экрана
142
+ self.call_later(self._load_pending_sources)
143
+ # Запускаем асинхронную загрузку файлов
144
+ self.call_later(self._load_sources_from_paths)
145
+
146
+ def on_stop(self) -> None:
147
+ """Остановка при закрытии приложения."""
148
+ if self._live_monitor:
149
+ self._live_monitor.stop()
150
+
151
+ def compose(self):
152
+ """Создание интерфейса с тремя панелями."""
153
+ yield Header()
154
+ yield SourcePanel(id="source-panel")
155
+ yield LogList(id="log-list")
156
+ yield DetailPanel(id="detail-panel")
157
+ yield Footer()
158
+
159
+ def add_log_source(self, source: LogSource) -> None:
160
+ """Добавляет источник логов (откладывается до монтирования)."""
161
+ self._sources.append(source)
162
+ self._pending_sources.append(source)
163
+
164
+ async def _load_sources_from_paths(self) -> None:
165
+ """Асинхронная загрузка файлов в фоне."""
166
+ if not self._file_paths:
167
+ return
168
+
169
+ async def _parse_and_register(path: Path) -> None:
170
+ """Парсит файл в фоне и регистрирует результат."""
171
+ # to_thread запускает синхронную функцию в пуле потоков
172
+ source = await asyncio.to_thread(load_log_source, path, self._format_type)
173
+ self.call_after_refresh(self._register_source, source)
174
+
175
+ tasks = []
176
+ for path in self._file_paths:
177
+ if path.exists():
178
+ tasks.append(_parse_and_register(path))
179
+ else:
180
+ self.call_after_refresh(lambda p=path: self.notify(f"Файл не найден: {p}", severity="error"))
181
+
182
+ await asyncio.gather(*tasks)
183
+
184
+ def _load_pending_sources(self) -> None:
185
+ """Загружаем все отложенные источники после полной инициализации."""
186
+ for source in self._pending_sources:
187
+ self._register_source(source)
188
+ self._pending_sources.clear()
189
+
190
+ def _register_source(self, source: LogSource) -> None:
191
+ """Регистрирует источник в панелях."""
192
+ # Добавляем в список, если ещё нет
193
+ if source not in self._sources:
194
+ self._sources.append(source)
195
+
196
+ # Фильтруем записи по минимальному уровню логирования
197
+ source.entries = self._filter_entries_by_level(source.entries)
198
+
199
+ source_panel = self.query_one("#source-panel", SourcePanel)
200
+ source_panel.add_source(source)
201
+
202
+ log_list = self.query_one("#log-list", LogList)
203
+ log_list.add_entries(source.entries)
204
+ log_list.set_sources(self._sources)
205
+
206
+ # Активируем первый источник
207
+ if self._sources.index(source) == 0:
208
+ source.is_active = True
209
+ source_panel.refresh()
210
+
211
+ # Запускаем мониторинг, если это первый файл
212
+ if len(self._sources) == 1 and self._file_paths and not self._live_monitor:
213
+ self._start_live_monitor()
214
+
215
+ def action_toggle_filter_mode(self) -> None:
216
+ """Передать действие фильтрации в LogList."""
217
+ log_list = self.query_one("#log-list", LogList)
218
+ log_list.action_toggle_filter_mode()
219
+
220
+ def _filter_entries_by_level(self, entries: list) -> list:
221
+ """Фильтрует записи по минимальному уровню логирования.
222
+
223
+ Args:
224
+ entries: Список LogEntry для фильтрации
225
+
226
+ Returns:
227
+ Отфильтрованный список записей
228
+ """
229
+ if not self._log_level:
230
+ return entries
231
+
232
+ # Определяем порядковый номер минимального уровня
233
+ level_order = {
234
+ "DEBUG": 0,
235
+ "INFO": 1,
236
+ "WARNING": 2,
237
+ "ERROR": 3,
238
+ "CRITICAL": 4,
239
+ }
240
+ min_order = level_order.get(self._log_level.upper(), 0)
241
+
242
+ return [
243
+ entry for entry in entries
244
+ if level_order.get(entry.level.value, 0) >= min_order
245
+ ]
246
+
247
+ def _start_live_monitor(self) -> None:
248
+ """Запускает фоновый мониторинг файлов."""
249
+ if not self._file_paths:
250
+ return
251
+
252
+ self._live_monitor = LiveMonitor(
253
+ file_paths=self._file_paths,
254
+ format_type=self._format_type,
255
+ on_new_entries=self._handle_new_entries,
256
+ poll_interval=1.0,
257
+ )
258
+ self._live_monitor.start()
259
+ self.notify("Live-мониторинг запущен", severity="information")
260
+
261
+ def _handle_new_entries(self, entries: list, file_path: Path) -> None:
262
+ """Обработчик новых записей из файлов.
263
+
264
+ Вызывается из фонового воркера для обновления UI.
265
+
266
+ Args:
267
+ entries: Список новых LogEntry
268
+ file_path: Путь к файлу, в котором появились записи
269
+ """
270
+ self.call_after_refresh(self._process_new_entries, entries, file_path)
271
+
272
+ def _process_new_entries(self, entries: list, file_path: Path) -> None:
273
+ """Добавляет новые записи в UI.
274
+
275
+ Args:
276
+ entries: Список новых LogEntry
277
+ file_path: Путь к файлу
278
+ """
279
+ if not entries:
280
+ return
281
+
282
+ # Фильтруем по минимальному уровню логирования
283
+ entries = self._filter_entries_by_level(entries)
284
+ if not entries:
285
+ return
286
+
287
+ # Находим источник по пути
288
+ source = None
289
+ for s in self._sources:
290
+ if s.path == file_path:
291
+ source = s
292
+ break
293
+
294
+ if not source:
295
+ return
296
+
297
+ # Добавляем новые записи с ограничением по памяти
298
+ source.add_entries(entries)
299
+ source.has_new = True
300
+
301
+ # Обновляем LogList
302
+ log_list = self.query_one("#log-list", LogList)
303
+ log_list.add_entries(entries)
304
+
305
+ # Обновляем счётчик в SourcePanel
306
+ source_panel = self.query_one("#source-panel", SourcePanel)
307
+ source_panel._refresh_sources()
308
+
309
+ # Показываем уведомление
310
+ self.notify(f"+{len(entries)} новых записей из {source.name}")
311
+
312
+ def action_next_error(self) -> None:
313
+ """Переход к следующей ошибке."""
314
+ log_list = self.query_one("#log-list", LogList)
315
+ log_list.action_next_error()
316
+
317
+ def action_toggle_merge_mode(self) -> None:
318
+ """Переключает режим таймлайна (merge mode)."""
319
+ self.merge_mode = not self.merge_mode
320
+ log_list = self.query_one("#log-list", LogList)
321
+
322
+ if self.merge_mode:
323
+ # Снимаем active flag у всех источников
324
+ for source in self._sources:
325
+ source.is_active = False
326
+ source.is_included = True # Все включены по умолчанию
327
+ self._selected_source_index = 0
328
+ self.sub_title = "↑↓ Navigate | Space Toggle Source | ←→ Select | F Filter | E Next Error | T Theme | m Timeline | q Quit"
329
+ self.notify("Timeline mode ON — merge all sources")
330
+ # Синхронизируем merge mode с LogList
331
+ log_list.set_merge_mode(True)
332
+ # Перезапускаем таймлайн асинхронно
333
+ self.call_later(self._refresh_timeline_async)
334
+ else:
335
+ # Восстанавливаем активный источник — первый источник
336
+ if self._sources:
337
+ self._sources[0].is_active = True
338
+ self.sub_title = "↑↓ Navigate | ←→ Sources | F Filter | d Tag | E Next Error | T Theme | m Timeline | q Quit"
339
+ self.notify("Timeline mode OFF — single source view")
340
+ # Синхронизируем merge mode с LogList
341
+ log_list.set_merge_mode(False)
342
+
343
+ async def _refresh_timeline_async(self) -> None:
344
+ """Асинхронный запуск таймлайн-мержа в фоновом потоке."""
345
+ if not self.merge_mode:
346
+ return
347
+
348
+ entries = await asyncio.to_thread(self._build_timeline_sync)
349
+ self.call_after_refresh(self._apply_timeline_entries, entries)
350
+
351
+ def _build_timeline_sync(self) -> list:
352
+ """Синхронная функция для запуска в asyncio.to_thread.
353
+
354
+ Возвращает отфильтрованный список LogEntry из всех включённых источников.
355
+ """
356
+ from .parsers.log_parser import timeline_merge
357
+
358
+ # Собираем пути только включённых источников
359
+ included_sources = [s for s in self._sources if s.is_included]
360
+ if not included_sources:
361
+ return []
362
+
363
+ all_paths = [source.path for source in included_sources]
364
+
365
+ entries = []
366
+ for entry, path in timeline_merge(all_paths):
367
+ # Подменяем file_path на имя файла для маркировки
368
+ entry.file_path = path.name
369
+ entries.append(entry)
370
+
371
+ # Фильтруем по уровню
372
+ entries = self._filter_entries_by_level(entries)
373
+ return entries
374
+
375
+ def _clear_timeline(self) -> None:
376
+ """Очищает LogList в merge mode."""
377
+ log_list = self.query_one("#log-list", LogList)
378
+ log_list._entries = []
379
+ log_list._selectedIndex = -1
380
+ log_list._update_display()
381
+
382
+ def _apply_timeline_entries(self, entries: list) -> None:
383
+ """Применяет объединённые записи в LogList."""
384
+ log_list = self.query_one("#log-list", LogList)
385
+ log_list._entries = entries
386
+ log_list._selectedIndex = -1
387
+ log_list._update_display()
388
+
389
+ def action_toggle_theme(self) -> None:
390
+ """Переключает тему (светлая/тёмная)."""
391
+ self._is_dark = not self._is_dark
392
+ self.theme = "trace-light" if not self._is_dark else "textual"
393
+ self.notify("Theme: Dark" if self._is_dark else "Theme: Light")
394
+
395
+ def action_switch_source_left(self) -> None:
396
+ """Переключить на предыдущий источник."""
397
+ log_list = self.query_one("#log-list", LogList)
398
+ log_list.switch_source(-1)
399
+
400
+ def action_switch_source_right(self) -> None:
401
+ """Переключить на следующий источник."""
402
+ log_list = self.query_one("#log-list", LogList)
403
+ log_list.switch_source(1)
tracetui/cli.py ADDED
@@ -0,0 +1,56 @@
1
+ """Клиентский интерфейс для запуска TraceTUI."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .app import TraceTUIApp
8
+
9
+
10
+ def main():
11
+ """Точка входа из CLI."""
12
+ parser = argparse.ArgumentParser(
13
+ description="TraceTUI — Interactive TUI debugger for logs"
14
+ )
15
+ parser.add_argument(
16
+ "files",
17
+ nargs="*",
18
+ type=Path,
19
+ help="Log files to open",
20
+ )
21
+ parser.add_argument(
22
+ "-f",
23
+ "--format",
24
+ choices=["auto", "loguru", "logging", "simple"],
25
+ default="auto",
26
+ help="Log format (default: auto-detect)",
27
+ )
28
+ parser.add_argument(
29
+ "-l",
30
+ "--log-level",
31
+ choices=["DEBUG", "INFO", "WARNING", "ERROR"],
32
+ default=None,
33
+ help="Filter by minimum log level",
34
+ )
35
+
36
+ args = parser.parse_args()
37
+
38
+ # Фильтруем несуществующие файлы
39
+ valid_paths = []
40
+ for file_path in args.files:
41
+ if file_path.exists():
42
+ valid_paths.append(file_path)
43
+ else:
44
+ print(f"File not found: {file_path}", file=sys.stderr)
45
+
46
+ app = TraceTUIApp(
47
+ file_paths=valid_paths if valid_paths else None,
48
+ format_type=args.format,
49
+ log_level=args.log_level,
50
+ )
51
+
52
+ app.run()
53
+
54
+
55
+ if __name__ == "__main__":
56
+ main()
tracetui/models.py ADDED
@@ -0,0 +1,103 @@
1
+ """Модели данных для логов."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ from datetime import datetime
8
+
9
+
10
+ class LogLevel(Enum):
11
+ """Уровни логирования."""
12
+
13
+ DEBUG = "DEBUG"
14
+ INFO = "INFO"
15
+ WARNING = "WARNING"
16
+ ERROR = "ERROR"
17
+ CRITICAL = "CRITICAL"
18
+
19
+ @property
20
+ def color(self) -> str:
21
+ """Возвращает цвет для уровня лога."""
22
+ return {
23
+ self.DEBUG: "grey50",
24
+ self.INFO: "white",
25
+ self.WARNING: "orange1",
26
+ self.ERROR: "red1",
27
+ self.CRITICAL: "bright_red",
28
+ }[self]
29
+
30
+ @property
31
+ def icon(self) -> str:
32
+ """Возвращает иконку для уровня лога."""
33
+ return {
34
+ self.DEBUG: "⚬",
35
+ self.INFO: "ℹ",
36
+ self.WARNING: "⚠",
37
+ self.ERROR: "✕",
38
+ self.CRITICAL: "✕",
39
+ }[self]
40
+
41
+
42
+ @dataclass
43
+ class LogEntry:
44
+ """Одна запись лога."""
45
+
46
+ level: LogLevel
47
+ message: str
48
+ timestamp: str = ""
49
+ logger_name: str = ""
50
+ file_path: str = ""
51
+ line_number: int = 0
52
+ traceback: str = ""
53
+ tags: list[str] = field(default_factory=list)
54
+ selected: bool = False
55
+ timestamp_dt: "datetime | None" = None
56
+
57
+ @property
58
+ def formatted_line(self) -> str:
59
+ """Форматированная строка для отображения."""
60
+ parts = []
61
+ if self.timestamp:
62
+ parts.append(self.timestamp)
63
+ if self.logger_name:
64
+ parts.append(f"[{self.logger_name}]")
65
+ parts.append(f"{self.level.value}:")
66
+ parts.append(self.message)
67
+ return " ".join(parts)
68
+
69
+
70
+ # Максимальное количество записей в одном источнике (для предотвращения утечки памяти)
71
+ MAX_ENTRIES = 10_000
72
+
73
+
74
+ @dataclass
75
+ class LogSource:
76
+ """Источник логов (файл или поток)."""
77
+
78
+ name: str
79
+ path: Path
80
+ format_type: str = "auto" # auto, loguru, logging, custom
81
+ entries: list[LogEntry] = field(default_factory=list)
82
+ is_active: bool = False
83
+ has_new: bool = False
84
+ is_included: bool = True # Флаг для merge mode — включён ли в таймлайн
85
+
86
+ @property
87
+ def entry_count(self) -> int:
88
+ return len(self.entries)
89
+
90
+ def add_entries(self, entries: list[LogEntry]) -> None:
91
+ """Добавляет новые записи с ограничением по максимальному количеству.
92
+
93
+ При превышении MAX_ENTRIES удаляет старые элементы с начала списка,
94
+ чтобы предотвратить утечку памяти.
95
+
96
+ Args:
97
+ entries: Список новых LogEntry для добавления
98
+ """
99
+ self.entries.extend(entries)
100
+ # Если превышен лимит — обрезаем с начала
101
+ if len(self.entries) > MAX_ENTRIES:
102
+ excess = len(self.entries) - MAX_ENTRIES
103
+ self.entries = self.entries[excess:]
@@ -0,0 +1 @@
1
+ """Пакет парсеров логов."""