add: логи внутри TUI

This commit is contained in:
Sergei Erjemin 2025-06-08 22:28:00 +03:00
parent e6dd940d8a
commit 22bd7ee303
3 changed files with 194 additions and 64 deletions

View File

@ -4,8 +4,10 @@ import logging
import yaml
from typing import Optional, Dict # Для Python < 3.9 используйте Dict, для Python 3.9+ можно просто dict
# --- Настройки и инициирование логирования модуля ---
logger = logging.getLogger(__name__)
class ConfigError(Exception):
"""Базовый класс для ошибок конфигурации."""
pass
@ -38,7 +40,7 @@ def load_config(path: str) -> Optional[Dict]:
logger.error(msg)
raise InvalidConfigFormatError(msg)
logger.info(msg=f"Конфигурация успешно загружена из `{path}`")
logger.info(msg=f"Конфигурация успешно загружена из: `{path}`")
return config_data
except FileNotFoundError:

View File

@ -3,69 +3,105 @@
import argparse
import logging
import sys
from config import load_config, ConfigError, ConfigNotFoundError, InvalidConfigFormatError
from tui import PGanecApp
from logging.handlers import MemoryHandler
from pganec.config import load_config, ConfigError, ConfigNotFoundError, InvalidConfigFormatError
from pganec.tui import PGanecApp
# --- Настройки и инициирование логирования ---
logger = logging.getLogger(__name__)
def print_hi(name):
# Use a breakpoint in the code line below to debug your script.
print(f'Hi, {name}') # Press ⌘F8 to toggle the breakpoint.
# Press the green button in the gutter to run the script.
# --- Главная функция приложения ---
if __name__ == '__main__':
parser = argparse.ArgumentParser(description="PGanec — TUI резервного копирования и восстановления баз PostgreSQL")
parser.add_argument("-c", "--config", required=True,
help="Путь к файлу конфигурации (YAML)")
parser.add_argument("-d", "--debug-level", default="QUIET",
parser.add_argument("-d", "--debug-level", default="INFO",
help="Уровень отладки (QUIET, NOTSET, DEBUG, INFO, WARNING, ERROR, CRITICAL)")
parser.add_argument("-l", "--loging", default="",
help="Путь к файлу логирования (если не указан, логирование не будет вестись)")
args = parser.parse_args()
# Настройка отладочных логов
# --- Определение числового уровня логирования для передачи в TUI ---
level_input_str = args.debug_level.upper()
chosen_log_level = None
if level_input_str == "QUIET": # Опция "QUIET"
chosen_log_level = logging.CRITICAL + 1
else:
chosen_log_level = getattr(logging, level_input_str, None)
if chosen_log_level is None:
logger.warning(f"Неизвестный уровень отладки '{args.debug_level}'. Используется DEBUG.")
chosen_log_level = logging.DEBUG
log_level_to_pass = logging.INFO # Значение по умолчанию, если что-то пойдет не так
logging.basicConfig(
level=chosen_log_level,
format="%(asctime)s - %(name)s - %(levelname)s - %(module)s.%(funcName)s:%(lineno)d - %(message)s"
)
logger.debug(f"Параметрами: {args}")
logger.debug(f"Уровень: {chosen_log_level}")
if level_input_str == "QUIET":
log_level_to_pass = logging.CRITICAL + 1
else:
# Пытаемся получить числовое значение для стандартных уровней
parsed_level = getattr(logging, level_input_str, None)
if parsed_level is not None and isinstance(parsed_level, int):
log_level_to_pass = parsed_level
else:
# Если уровень не распознан, выводим предупреждение в stderr,
# так как logger.warning может быть еще не настроен для вывода.
print(f"Предупреждение: Неизвестный уровень логирования '{args.debug_level}'. "
f"Используется INFO по умолчанию.", file=sys.stderr)
log_level_to_pass = logging.INFO # Возвращаемся к INFO по умолчанию
# --- Настройка раннего логирования с MemoryHandler ---
# 1. Устанавливаем уровень корневого логгера, чтобы он пропускал нужные сообщения
root_logger = logging.getLogger()
root_logger.setLevel(log_level_to_pass) # Важно для MemoryHandler
# 2. Создаем MemoryHandler
# Он будет копить логи до момента, когда TUI сможет их отобразить.
# `capacity` - сколько записей хранить.
# `flushLevel` - уровень, при котором MemoryHandler попытается сбросить логи в target.
# Ставим очень высокий, чтобы он не сбрасывал сам по себе, т.к. target еще нет.
# `target` - пока None, установим его в TUI.
memory_handler = MemoryHandler(capacity=200, flushLevel=logging.CRITICAL + 10, target=None)
memory_handler.setLevel(logging.NOTSET) # MemoryHandler должен принимать все, что пропускает root_logger
# 3. Добавляем MemoryHandler к корневому логгеру
root_logger.addHandler(memory_handler)
logger.debug(
f"main.py: MemoryHandler добавлен. Log Level: '{logging.getLevelName(root_logger.getEffectiveLevel())}'")
logging.getLogger().setLevel(log_level_to_pass)
logger.debug(f"Запущено с параметрами: {args}")
logger.info(f"Задан уровень логирования: '{logging.getLevelName(log_level_to_pass)}'"
f" ({log_level_to_pass})")
# logging.basicConfig(
# level=chosen_log_level_int,
# format="%(asctime)s - %(name)s - %(levelname)s - %(module)s.%(funcName)s:%(lineno)d - %(message)s"
# )
# Загрузка конфигурации
config = None # Инициализируем config
try:
config = load_config(args.config)
logger.info(f"Конфигурация успешно загружена: {config}")
if config: # Добавим проверку, что config не None, если load_config может вернуть None при успехе (хотя по типу не должен)
logger.debug(
f"Конфигурация успешно загружена: {list(config.keys())}") # Логируем ключи, а не всю конфигурацию
else:
# Эта ситуация не должна возникать, если load_config выбрасывает исключения при ошибках
# или возвращает dict. Но для полноты.
print(f"Ошибка: Конфигурация не была загружена из '{args.config}', но исключение не было выброшено.",
file=sys.stderr)
sys.exit(1)
except ConfigNotFoundError:
print(f"Ошибка: Файл конфигурации '{args.config}' не найден.", file=sys.stderr)
# Возможно сюда стоит добавить TUI интерфейс для ввода (выбора) файла конфигурации...
# Но так как ошибка может быть связана с правами доступа (например, TUI запущен от неправильного
# пользователя), оставим идею "на-подумать", а пока просто завершим выполнение программы.
sys.exit(1)
except InvalidConfigFormatError as e:
print(f"Ошибка: Файл конфигурации '{args.config}' имеет неверный формат. {e}", file=sys.stderr)
sys.exit(1)
except ConfigError as e: # Общая ошибка конфигурации (должна быть последней в цепочке)
except ConfigError as e:
print(f"Ошибка при загрузке конфигурации: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e: # Для совсем непредвиденных, не ConfigError
except Exception as e:
logger.critical(f"Необработанная ошибка во время инициализации: {e}", exc_info=True)
print(f"Произошла критическая и непредвиденная ошибка: {e}", file=sys.stderr)
sys.exit(2)
# Запуск главного меню TUI
PGanecApp().run()
# Запуск главного меню TUI с передачей числового уровня логирования
# app = PGanecApp(log_level_int=log_level_to_pass) # Передаем и конфигурацию
app = PGanecApp(log_level_int=log_level_to_pass, app_config=config, early_log_handler=memory_handler)
app.run()
# PGanecApp().run()

View File

@ -2,73 +2,123 @@
# src/pganec/tui.py
import logging
from logging.handlers import MemoryHandler
from typing import Dict, Any, Optional
from textual.app import App, ComposeResult
from textual.containers import Vertical, Horizontal
from textual.widgets import Button, Header, Footer, Static
from textual.widgets import Button, Header, Footer, Static, Log
# --- Настройки и инициирование логирования модуля ---
logger = logging.getLogger(__name__)
# Кастомный обработчик логирования для Textual
class TextualLogHandler(logging.Handler):
"""
Обработчик логирования, который направляет записи в виджет Textual Log.
"""
def __init__(self, textual_log_widget: Log):
super().__init__()
self.textual_log_widget = textual_log_widget
def emit(self, record: logging.LogRecord):
"""
Отправляет отформатированную запись лога в виджет.
"""
try:
msg = self.format(record)
# Метод Log.write() является потоко-безопасным, и его можно вызывать напрямую.
self.textual_log_widget.write(msg)
except Exception:
# Стандартная обработка ошибок для Handler, если что-то пошло не так (например, виджет был удален
# или произошла ошибка форматирования)
self.handleError(record)
class MainMenu(Static):
def compose(self) -> ComposeResult:
yield Horizontal(
Button(label="Backup (DB -> Dump)", id="backup", variant="primary"),
Button(label="Restore (Dump -> DB)", id="restore", variant="primary"),
Button(label="Backup (DB -> Dump)", id="backup"),
Button(label="Restore (Dump -> DB)", id="restore"),
Button(label="Copy (DB -> DB)", id="copy", variant="error"),
Button(label="Quit", id="quit", variant="error"),
id="menu",
)
yield Footer()
class PGanecApp(App):
CSS = """
#menu {
width: 100%;
align: left top;
padding: 0;
Screen {
layout: vertical; /* Используем вертикальный layout для всего экрана */
}
#main_content_area { /* Новый контейнер для всего, что НЕ прибито к краям */
/* Этот контейнер будет занимать все доступное пространство
между Header и Log/Footer */
height: 1fr; /* 1fr означает "одна доля доступного пространства" */
overflow-y: auto; /* Если контент не влезет, появится прокрутка */
}
#title { width: 100%; text-align: left; background: purple; padding: 0 1; }
#menu { width: 100%; height: 3; align: left top; padding: 0; }
Button {
align: left middle;
text-align: left;
align: center middle;
width: 25%;
color: orange;
background: transparent;
margin: 0;
padding: 0;
margin: 0; padding: 0;
}
Button.-primary {
color: orange;
border: round orange;
text-align: left;
}
Button#backup, Button#restore { color: orange; border: round orange; }
Button.-error {
color: grey;
border: round orange;
text-align: left;
}
Button#copy, Button#quit { color: grey; border: round orange; }
Button:focus {
border: heavy green;
Button:focus { border: heavy green; }
Log#app_log_viewer {
dock: bottom;
height: 10;
margin-top: 1;
border-top: double grey;
background: 15%;
}
Log#app_log_viewer:focus { height: 50%; border-top: solid green; background: 45%; }
Footer { dock: bottom; height: 1; }
Header { dock: top; height: 1; }
"""
BINDINGS = [
("b", "backup", "Backup"),
("r", "restore", "Restore"),
("с", "copy", "Copy"),
("c", "copy", "Copy"),
("q", "quit", "Quit"),
("right", "focus_next"), ("down", "focus_next"),
("left", "focus_previous"), ("up", "focus_previous"),
("enter", "activate"),
("right", "focus_next"), ("down", "focus_next"), # Стрелочки для навигации (вниз/вправо -- вперед)
("left", "focus_previous"), ("up", "focus_previous"), # Стрелочки для навигации (вверх/влево -- назад)
("enter", "activate", "Activate"),
]
def __init__(self,
log_level_int: int = logging.INFO,
app_config: Dict[str, Any] = None,
early_log_handler: Optional[MemoryHandler] = None,
**kwargs):
"""
Конструктор приложения.
:param cli_log_level_str: Уровень логирования из CLI (задаётся через параметры при запуске приложения).
"""
super().__init__(**kwargs)
self.log_level_int = log_level_int
self.app_config = app_config if app_config is not None else {} # Сохраняем конфигурацию
self.early_log_handler = early_log_handler # <--- Сохраняем
logger.debug(
f"TUI инициализирован. Log Level: `{logging.getLevelName(self.log_level_int)}`; Config Keys: {list(self.app_config.keys())}"
)
def compose(self) -> ComposeResult:
yield Header()
yield Static("PGanec", id="title")
yield MainMenu()
with Vertical(id="main_content_area"):
yield Static("PGanec TUI", id="title")
yield MainMenu()
# Другие элементы основного интерфейса могут быть здесь
yield Log(id="app_log_viewer", highlight=True) # Виджет лога
yield Footer()
async def on_button_pressed(self, event: Button.Pressed) -> None:
@ -82,6 +132,48 @@ class PGanecApp(App):
elif button_id == "quit":
await self.action_quit()
def on_mount(self) -> None:
"""Вызывается после монтирования DOM приложения."""
# --- Обжужукиваем логирование в #app_log_viewer ---
log_viewer_widget = self.query_one("#app_log_viewer", Log)
textual_handler = TextualLogHandler(log_viewer_widget)
if self.log_level_int >= logging.INFO:
formatter = logging.Formatter(
fmt="%(asctime)s [%(levelname)-8s]\t %(message)s\n",
datefmt="%H:%M:%S" # Формат времени: часы:минуты:секунды
)
else:
formatter = logging.Formatter(
fmt="%(asctime)s [%(levelname)-8s] %(module)s.%(funcName)s:%(lineno)04d\t- %(message)s\n",
datefmt="%H:%M:%S.%s" # Формат времени: часы:минуты:секунды.милисекунды
)
textual_handler.setFormatter(formatter)
textual_handler.setLevel(self.log_level_int)
# Получаем корневой логгер
target_logger = logging.getLogger()
target_logger.addHandler(textual_handler) # Добавляем наш обработчик
# --- Перенос и сброс ранних логов из MemoryHandler ---
if self.early_log_handler:
# Устанавливаем наш textual_handler как цель для MemoryHandler
self.early_log_handler.setTarget(textual_handler)
# Сбрасываем все накопленные записи
self.early_log_handler.flush()
# Закрываем и удаляем MemoryHandler из корневого логгера, т.к. он больше не нужен
self.early_log_handler.close()
logger.debug("TUI: Перенесли и закрыли ранние логи…")
target_logger.removeHandler(self.early_log_handler)
logger.debug("TUI: Удалили ранний MemoryHandler из корневого логгера…")
self.early_log_handler = None # Очищаем ссылку
# --- Конец переноса и сброса ---
logger.debug("TUI: DEBUG сообщение для проверки")
logger.info("TUI-логгер инициализирован")
logger.warning("TUI: WARNING для проверки")
logger.error("TUI: ERROR тоже для проверки")
async def action_activate(self) -> None:
focused = self.focused
if isinstance(focused, Button):