backup worked
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass,field
|
||||||
|
|
||||||
# [IMPORTS] Third-party
|
# [IMPORTS] Third-party
|
||||||
from requests.exceptions import RequestException
|
from requests.exceptions import RequestException
|
||||||
@@ -37,17 +37,18 @@ class BackupConfig:
|
|||||||
consolidate: bool = True
|
consolidate: bool = True
|
||||||
rotate_archive: bool = True
|
rotate_archive: bool = True
|
||||||
clean_folders: bool = True
|
clean_folders: bool = True
|
||||||
retention_policy: RetentionPolicy = RetentionPolicy()
|
retention_policy: RetentionPolicy = field(default_factory=RetentionPolicy)
|
||||||
|
|
||||||
# [ENTITY: Function('backup_dashboards')]
|
# [ENTITY: Function('backup_dashboards')]
|
||||||
# CONTRACT:
|
# CONTRACT:
|
||||||
# PURPOSE: Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения.
|
# PURPOSE: Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения, пропуская ошибки экспорта.
|
||||||
# PRECONDITIONS:
|
# PRECONDITIONS:
|
||||||
# - `client` должен быть инициализированным экземпляром `SupersetClient`.
|
# - `client` должен быть инициализированным экземпляром `SupersetClient`.
|
||||||
# - `env_name` должен быть строкой, обозначающей окружение.
|
# - `env_name` должен быть строкой, обозначающей окружение.
|
||||||
# - `backup_root` должен быть валидным путем к корневой директории бэкапа.
|
# - `backup_root` должен быть валидным путем к корневой директории бэкапа.
|
||||||
# POSTCONDITIONS:
|
# POSTCONDITIONS:
|
||||||
# - Дашборды экспортируются и сохраняются.
|
# - Дашборды экспортируются и сохраняются.
|
||||||
|
# - Ошибки экспорта логируются и не приводят к остановке скрипта.
|
||||||
# - Возвращает `True` если все дашборды были экспортированы без критических ошибок, `False` иначе.
|
# - Возвращает `True` если все дашборды были экспортированы без критических ошибок, `False` иначе.
|
||||||
def backup_dashboards(
|
def backup_dashboards(
|
||||||
client: SupersetClient,
|
client: SupersetClient,
|
||||||
@@ -90,7 +91,9 @@ def backup_dashboards(
|
|||||||
|
|
||||||
success_count += 1
|
success_count += 1
|
||||||
except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
|
except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
|
||||||
logger.error(f"[STATE][backup_dashboards][FAILURE] Failed to export dashboard {dashboard_title}: {db_error}", exc_info=True)
|
logger.error(f"[STATE][backup_dashboards][FAILURE] Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}", exc_info=True)
|
||||||
|
# Продолжаем обработку других дашбордов
|
||||||
|
continue
|
||||||
|
|
||||||
if config.consolidate:
|
if config.consolidate:
|
||||||
consolidate_archive_folders(backup_root / env_name , logger=logger)
|
consolidate_archive_folders(backup_root / env_name , logger=logger)
|
||||||
@@ -125,13 +128,18 @@ def main() -> int:
|
|||||||
backup_config = BackupConfig(rotate_archive=True)
|
backup_config = BackupConfig(rotate_archive=True)
|
||||||
|
|
||||||
for env in environments:
|
for env in environments:
|
||||||
results[env] = backup_dashboards(
|
try:
|
||||||
clients[env],
|
results[env] = backup_dashboards(
|
||||||
env.upper(),
|
clients[env],
|
||||||
superset_backup_repo,
|
env.upper(),
|
||||||
logger=logger,
|
superset_backup_repo,
|
||||||
config=backup_config
|
logger=logger,
|
||||||
)
|
config=backup_config
|
||||||
|
)
|
||||||
|
except Exception as env_error:
|
||||||
|
logger.critical(f"[STATE][main][FAILURE] Critical error for environment {env}: {env_error}", exc_info=True)
|
||||||
|
# Продолжаем обработку других окружений
|
||||||
|
results[env] = False
|
||||||
|
|
||||||
if not all(results.values()):
|
if not all(results.values()):
|
||||||
exit_code = 1
|
exit_code = 1
|
||||||
|
|||||||
@@ -1,237 +1,442 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# [MODULE_PATH] superset_tool.migration_script
|
||||||
# CONTRACT:
|
# [FILE] migration_script.py
|
||||||
# PURPOSE: Интерактивный скрипт для миграции ассетов Superset между различными окружениями.
|
# [SEMANTICS] migration, cli, superset, ui, logging, fallback, error-recovery, non-interactive, temp-files, batch-delete
|
||||||
# SPECIFICATION_LINK: mod_migration_script
|
|
||||||
# PRECONDITIONS: Наличие корректных конфигурационных файлов для подключения к Superset.
|
|
||||||
# POSTCONDITIONS: Выбранные ассеты успешно перенесены из исходного в целевое окружение.
|
|
||||||
# IMPORTS: [argparse, superset_tool.client, superset_tool.utils.init_clients, superset_tool.utils.logger, superset_tool.utils.fileio]
|
|
||||||
"""
|
|
||||||
[MODULE] Superset Migration Tool
|
|
||||||
@description: Интерактивный скрипт для миграции ассетов Superset между различными окружениями.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from whiptail import Whiptail
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
# [IMPORTS]
|
# [IMPORTS]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Tuple, Dict
|
||||||
|
|
||||||
from superset_tool.client import SupersetClient
|
from superset_tool.client import SupersetClient
|
||||||
from superset_tool.utils.init_clients import setup_clients
|
from superset_tool.utils.init_clients import setup_clients
|
||||||
from superset_tool.utils.logger import SupersetLogger
|
|
||||||
from superset_tool.utils.fileio import (
|
from superset_tool.utils.fileio import (
|
||||||
save_and_unpack_dashboard,
|
create_temp_file, # новый контекстный менеджер
|
||||||
read_dashboard_from_disk,
|
|
||||||
update_yamls,
|
update_yamls,
|
||||||
create_dashboard_export
|
create_dashboard_export,
|
||||||
|
)
|
||||||
|
from superset_tool.utils.whiptail_fallback import (
|
||||||
|
menu,
|
||||||
|
checklist,
|
||||||
|
yesno,
|
||||||
|
msgbox,
|
||||||
|
inputbox,
|
||||||
|
gauge,
|
||||||
)
|
)
|
||||||
|
|
||||||
# [ENTITY: Class('Migration')]
|
from superset_tool.utils.logger import SupersetLogger # type: ignore
|
||||||
# CONTRACT:
|
# [END_IMPORTS]
|
||||||
# PURPOSE: Инкапсулирует логику и состояние процесса миграции.
|
|
||||||
# SPECIFICATION_LINK: class_migration
|
# --------------------------------------------------------------
|
||||||
# ATTRIBUTES:
|
# [ENTITY: Service('Migration')]
|
||||||
# - name: logger, type: SupersetLogger, description: Экземпляр логгера.
|
# [RELATION: Service('Migration')] -> [DEPENDS_ON] -> [PythonModule('superset_tool.client')]
|
||||||
# - name: from_c, type: SupersetClient, description: Клиент для исходного окружения.
|
# --------------------------------------------------------------
|
||||||
# - name: to_c, type: SupersetClient, description: Клиент для целевого окружения.
|
"""
|
||||||
# - name: dashboards_to_migrate, type: list, description: Список дашбордов для миграции.
|
:purpose: Интерактивный процесс миграции дашбордов с возможностью
|
||||||
# - name: db_config_replacement, type: dict, description: Конфигурация для замены данных БД.
|
«удалить‑и‑перезаписать» при ошибке импорта.
|
||||||
|
:preconditions:
|
||||||
|
- Конфигурация Superset‑клиентов доступна,
|
||||||
|
- Пользователь может взаимодействовать через консольный UI.
|
||||||
|
:postconditions:
|
||||||
|
- Выбранные дашборды импортированы в целевое окружение.
|
||||||
|
:sideeffect: Записывает журнал в каталог ``logs/`` текущего рабочего каталога.
|
||||||
|
"""
|
||||||
|
|
||||||
class Migration:
|
class Migration:
|
||||||
"""
|
"""
|
||||||
Класс для управления процессом миграции дашбордов Superset.
|
:ivar SupersetLogger logger: Логгер.
|
||||||
|
:ivar bool enable_delete_on_failure: Флаг «удалять‑при‑ошибке».
|
||||||
|
:ivar SupersetClient from_c: Клиент‑источник.
|
||||||
|
:ivar SupersetClient to_c: Клиент‑назначение.
|
||||||
|
:ivar List[dict] dashboards_to_migrate: Список выбранных дашбордов.
|
||||||
|
:ivar Optional[dict] db_config_replacement: Параметры замены имён БД.
|
||||||
|
:ivar List[dict] _failed_imports: Внутренний буфер неудавшихся импортов
|
||||||
|
(ключи: slug, zip_content, dash_id).
|
||||||
"""
|
"""
|
||||||
def __init__(self):
|
|
||||||
self.logger = SupersetLogger(name="migration_script")
|
|
||||||
self.from_c: SupersetClient = None
|
|
||||||
self.to_c: SupersetClient = None
|
|
||||||
self.dashboards_to_migrate = []
|
|
||||||
self.db_config_replacement = None
|
|
||||||
# END_FUNCTION___init__
|
|
||||||
|
|
||||||
# [ENTITY: Function('run')]
|
# --------------------------------------------------------------
|
||||||
# CONTRACT:
|
# [ENTITY: Method('__init__')]
|
||||||
# PURPOSE: Запускает основной воркфлоу миграции, координируя все шаги.
|
# --------------------------------------------------------------
|
||||||
# SPECIFICATION_LINK: func_run_migration
|
"""
|
||||||
# PRECONDITIONS: None
|
:purpose: Создать сервис миграции и настроить логгер.
|
||||||
# POSTCONDITIONS: Процесс миграции завершен.
|
:preconditions: None.
|
||||||
def run(self):
|
:postconditions: ``self.logger`` готов к использованию; ``enable_delete_on_failure`` = ``False``.
|
||||||
"""Запускает основной воркфлоу миграции."""
|
"""
|
||||||
|
def __init__(self) -> None:
|
||||||
|
default_log_dir = Path.cwd() / "logs"
|
||||||
|
self.logger = SupersetLogger(
|
||||||
|
name="migration_script",
|
||||||
|
log_dir=default_log_dir,
|
||||||
|
level=logging.INFO,
|
||||||
|
console=True,
|
||||||
|
)
|
||||||
|
self.enable_delete_on_failure = False
|
||||||
|
self.from_c: Optional[SupersetClient] = None
|
||||||
|
self.to_c: Optional[SupersetClient] = None
|
||||||
|
self.dashboards_to_migrate: List[dict] = []
|
||||||
|
self.db_config_replacement: Optional[dict] = None
|
||||||
|
self._failed_imports: List[dict] = [] # <-- буфер ошибок
|
||||||
|
assert self.logger is not None, "Logger must be instantiated."
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [ENTITY: Method('run')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Точка входа – последовательный запуск всех шагов миграции.
|
||||||
|
:preconditions: Логгер готов.
|
||||||
|
:postconditions: Скрипт завершён, пользователю выведено сообщение.
|
||||||
|
"""
|
||||||
|
def run(self) -> None:
|
||||||
self.logger.info("[INFO][run][ENTER] Запуск скрипта миграции.")
|
self.logger.info("[INFO][run][ENTER] Запуск скрипта миграции.")
|
||||||
|
self.ask_delete_on_failure()
|
||||||
self.select_environments()
|
self.select_environments()
|
||||||
self.select_dashboards()
|
self.select_dashboards()
|
||||||
self.confirm_db_config_replacement()
|
self.confirm_db_config_replacement()
|
||||||
self.execute_migration()
|
self.execute_migration()
|
||||||
self.logger.info("[INFO][run][EXIT] Скрипт миграции завершен.")
|
self.logger.info("[INFO][run][EXIT] Скрипт миграции завершён.")
|
||||||
# END_FUNCTION_run
|
# [END_ENTITY]
|
||||||
|
|
||||||
# [ENTITY: Function('select_environments')]
|
# --------------------------------------------------------------
|
||||||
# CONTRACT:
|
# [ENTITY: Method('ask_delete_on_failure')]
|
||||||
# PURPOSE: Шаг 1. Обеспечивает интерактивный выбор исходного и целевого окружений.
|
# --------------------------------------------------------------
|
||||||
# SPECIFICATION_LINK: func_select_environments
|
"""
|
||||||
# PRECONDITIONS: None
|
:purpose: Запросить у пользователя, следует ли удалять дашборд при ошибке импорта.
|
||||||
# POSTCONDITIONS: Атрибуты `self.from_c` и `self.to_c` инициализированы валидными клиентами Superset.
|
:preconditions: None.
|
||||||
def select_environments(self):
|
:postconditions: ``self.enable_delete_on_failure`` установлен.
|
||||||
"""Шаг 1: Выбор окружений (источник и назначение)."""
|
"""
|
||||||
self.logger.info("[INFO][select_environments][ENTER] Шаг 1/4: Выбор окружений.")
|
def ask_delete_on_failure(self) -> None:
|
||||||
|
self.enable_delete_on_failure = yesno(
|
||||||
|
"Поведение при ошибке импорта",
|
||||||
|
"Если импорт завершится ошибкой, удалить существующий дашборд и попытаться импортировать заново?",
|
||||||
|
)
|
||||||
|
self.logger.info(
|
||||||
|
"[INFO][ask_delete_on_failure] Delete‑on‑failure = %s",
|
||||||
|
self.enable_delete_on_failure,
|
||||||
|
)
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [ENTITY: Method('select_environments')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Выбрать исходное и целевое окружения Superset.
|
||||||
|
:preconditions: ``setup_clients`` успешно инициализирует все клиенты.
|
||||||
|
:postconditions: ``self.from_c`` и ``self.to_c`` установлены.
|
||||||
|
"""
|
||||||
|
def select_environments(self) -> None:
|
||||||
|
self.logger.info("[INFO][select_environments][ENTER] Шаг 1/5: Выбор окружений.")
|
||||||
try:
|
try:
|
||||||
all_clients = setup_clients(self.logger)
|
all_clients = setup_clients(self.logger)
|
||||||
available_envs = list(all_clients.keys())
|
available_envs = list(all_clients.keys())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[ERROR][select_environments][FAILURE] Ошибка при инициализации клиентов: {e}", exc_info=True)
|
self.logger.error("[ERROR][select_environments] %s", e, exc_info=True)
|
||||||
w = Whiptail(title="Ошибка", backtitle="Superset Migration Tool")
|
msgbox("Ошибка", "Не удалось инициализировать клиенты.")
|
||||||
w.msgbox("Не удалось инициализировать клиенты. Проверьте конфигурацию.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
w = Whiptail(title="Выбор окружения", backtitle="Superset Migration Tool")
|
rc, from_env_name = menu(
|
||||||
|
title="Выбор окружения",
|
||||||
# Select source environment
|
prompt="Исходное окружение:",
|
||||||
(return_code, from_env_name) = w.menu("Выберите исходное окружение:", available_envs)
|
choices=available_envs,
|
||||||
if return_code == 0:
|
)
|
||||||
self.from_c = all_clients[from_env_name]
|
if rc != 0:
|
||||||
self.logger.info(f"[INFO][select_environments][STATE] Исходное окружение: {from_env_name}")
|
|
||||||
else:
|
|
||||||
return
|
return
|
||||||
|
self.from_c = all_clients[from_env_name]
|
||||||
|
self.logger.info("[INFO][select_environments] from = %s", from_env_name)
|
||||||
|
|
||||||
# Select target environment
|
|
||||||
available_envs.remove(from_env_name)
|
available_envs.remove(from_env_name)
|
||||||
(return_code, to_env_name) = w.menu("Выберите целевое окружение:", available_envs)
|
rc, to_env_name = menu(
|
||||||
if return_code == 0:
|
title="Выбор окружения",
|
||||||
self.to_c = all_clients[to_env_name]
|
prompt="Целевое окружение:",
|
||||||
self.logger.info(f"[INFO][select_environments][STATE] Целевое окружение: {to_env_name}")
|
choices=available_envs,
|
||||||
else:
|
)
|
||||||
|
if rc != 0:
|
||||||
return
|
return
|
||||||
|
self.to_c = all_clients[to_env_name]
|
||||||
|
self.logger.info("[INFO][select_environments] to = %s", to_env_name)
|
||||||
|
self.logger.info("[INFO][select_environments][EXIT] Шаг 1 завершён.")
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
self.logger.info("[INFO][select_environments][EXIT] Шаг 1 завершен.")
|
# --------------------------------------------------------------
|
||||||
# END_FUNCTION_select_environments
|
# [ENTITY: Method('select_dashboards')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
# [ENTITY: Function('select_dashboards')]
|
"""
|
||||||
# CONTRACT:
|
:purpose: Позволить пользователю выбрать набор дашбордов для миграции.
|
||||||
# PURPOSE: Шаг 2. Обеспечивает интерактивный выбор дашбордов для миграции.
|
:preconditions: ``self.from_c`` инициализирован.
|
||||||
# SPECIFICATION_LINK: func_select_dashboards
|
:postconditions: ``self.dashboards_to_migrate`` заполнен.
|
||||||
# PRECONDITIONS: `self.from_c` должен быть инициализирован.
|
"""
|
||||||
# POSTCONDITIONS: `self.dashboards_to_migrate` содержит список выбранных дашбордов.
|
def select_dashboards(self) -> None:
|
||||||
def select_dashboards(self):
|
self.logger.info("[INFO][select_dashboards][ENTER] Шаг 2/5: Выбор дашбордов.")
|
||||||
"""Шаг 2: Выбор дашбордов для миграции."""
|
|
||||||
self.logger.info("[INFO][select_dashboards][ENTER] Шаг 2/4: Выбор дашбордов.")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_, all_dashboards = self.from_c.get_dashboards()
|
_, all_dashboards = self.from_c.get_dashboards() # type: ignore[attr-defined]
|
||||||
if not all_dashboards:
|
if not all_dashboards:
|
||||||
self.logger.warning("[WARN][select_dashboards][STATE] В исходном окружении не найдено дашбордов.")
|
self.logger.warning("[WARN][select_dashboards] No dashboards.")
|
||||||
w = Whiptail(title="Информация", backtitle="Superset Migration Tool")
|
msgbox("Информация", "В исходном окружении нет дашбордов.")
|
||||||
w.msgbox("В исходном окружении не найдено дашбордов.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
w = Whiptail(title="Выбор дашбордов", backtitle="Superset Migration Tool")
|
options = [("ALL", "Все дашборды")] + [
|
||||||
|
(str(d["id"]), d["dashboard_title"]) for d in all_dashboards
|
||||||
|
]
|
||||||
|
|
||||||
dashboard_options = [(str(d['id']), d['dashboard_title']) for d in all_dashboards]
|
rc, selected = checklist(
|
||||||
|
title="Выбор дашбордов",
|
||||||
|
prompt="Отметьте нужные дашборды (введите номера):",
|
||||||
|
options=options,
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
return
|
||||||
|
|
||||||
(return_code, selected_ids) = w.checklist("Выберите дашборды для миграции:", dashboard_options)
|
if "ALL" in selected:
|
||||||
|
self.dashboards_to_migrate = list(all_dashboards)
|
||||||
if return_code == 0:
|
self.logger.info(
|
||||||
self.dashboards_to_migrate = [d for d in all_dashboards if str(d['id']) in selected_ids]
|
"[INFO][select_dashboards] Выбраны все дашборды (%d).",
|
||||||
self.logger.info(f"[INFO][select_dashboards][STATE] Выбрано дашбордов: {len(self.dashboards_to_migrate)}")
|
len(self.dashboards_to_migrate),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.dashboards_to_migrate = [
|
||||||
|
d for d in all_dashboards if str(d["id"]) in selected
|
||||||
|
]
|
||||||
|
self.logger.info(
|
||||||
|
"[INFO][select_dashboards] Выбрано %d дашбордов.",
|
||||||
|
len(self.dashboards_to_migrate),
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"[ERROR][select_dashboards][FAILURE] Ошибка при получении или выборе дашбордов: {e}", exc_info=True)
|
self.logger.error("[ERROR][select_dashboards] %s", e, exc_info=True)
|
||||||
w = Whiptail(title="Ошибка", backtitle="Superset Migration Tool")
|
msgbox("Ошибка", "Не удалось получить список дашбордов.")
|
||||||
w.msgbox("Произошла ошибка при работе с дашбордами.")
|
self.logger.info("[INFO][select_dashboards][EXIT] Шаг 2 завершён.")
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
self.logger.info("[INFO][select_dashboards][EXIT] Шаг 2 завершен.")
|
# --------------------------------------------------------------
|
||||||
# END_FUNCTION_select_dashboards
|
# [ENTITY: Method('confirm_db_config_replacement')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
# [ENTITY: Function('confirm_db_config_replacement')]
|
"""
|
||||||
# CONTRACT:
|
:purpose: Запросить у пользователя, требуется ли заменить имена БД в YAML‑файлах.
|
||||||
# PURPOSE: Шаг 3. Управляет процессом подтверждения и настройки замены конфигураций БД.
|
:preconditions: None.
|
||||||
# SPECIFICATION_LINK: func_confirm_db_config_replacement
|
:postconditions: ``self.db_config_replacement`` либо ``None``, либо заполнен.
|
||||||
# PRECONDITIONS: `self.from_c` и `self.to_c` инициализированы.
|
"""
|
||||||
# POSTCONDITIONS: `self.db_config_replacement` содержит конфигурацию для замены или `None`.
|
def confirm_db_config_replacement(self) -> None:
|
||||||
def confirm_db_config_replacement(self):
|
if yesno("Замена БД", "Заменить конфигурацию БД в YAML‑файлах?"):
|
||||||
"""Шаг 3: Подтверждение и настройка замены конфигурации БД."""
|
rc, old_name = inputbox("Замена БД", "Старое имя БД (например, db_dev):")
|
||||||
self.logger.info("[INFO][confirm_db_config_replacement][ENTER] Шаг 3/4: Замена конфигурации БД.")
|
if rc != 0:
|
||||||
|
|
||||||
w = Whiptail(title="Замена конфигурации БД", backtitle="Superset Migration Tool")
|
|
||||||
if w.yesno("Хотите ли вы заменить конфигурации баз данных в YAML-файлах?"):
|
|
||||||
(return_code, old_db_name) = w.inputbox("Введите имя заменяемой базы данных (например, db_dev):")
|
|
||||||
if return_code != 0:
|
|
||||||
return
|
return
|
||||||
|
rc, new_name = inputbox("Замена БД", "Новое имя БД (например, db_prod):")
|
||||||
(return_code, new_db_name) = w.inputbox("Введите новое имя базы данных (например, db_prod):")
|
if rc != 0:
|
||||||
if return_code != 0:
|
|
||||||
return
|
return
|
||||||
|
self.db_config_replacement = {
|
||||||
self.db_config_replacement = {"old": {"database_name": old_db_name}, "new": {"database_name": new_db_name}}
|
"old": {"database_name": old_name},
|
||||||
self.logger.info(f"[INFO][confirm_db_config_replacement][STATE] Установлена ручная замена: {self.db_config_replacement}")
|
"new": {"database_name": new_name},
|
||||||
|
}
|
||||||
|
self.logger.info(
|
||||||
|
"[INFO][confirm_db_config_replacement] Replacement set: %s",
|
||||||
|
self.db_config_replacement,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.logger.info("[INFO][confirm_db_config_replacement][STATE] Замена конфигурации БД пропущена.")
|
self.logger.info("[INFO][confirm_db_config_replacement] Skipped.")
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
self.logger.info("[INFO][confirm_db_config_replacement][EXIT] Шаг 3 завершен.")
|
# --------------------------------------------------------------
|
||||||
# END_FUNCTION_confirm_db_config_replacement
|
# [ENTITY: Method('_batch_delete_by_ids')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
# [ENTITY: Function('execute_migration')]
|
"""
|
||||||
# CONTRACT:
|
:purpose: Удалить набор дашбордов по их ID единым запросом.
|
||||||
# PURPOSE: Шаг 4. Выполняет фактическую миграцию выбранных дашбордов.
|
:preconditions:
|
||||||
# SPECIFICATION_LINK: func_execute_migration
|
- ``ids`` – непустой список целых чисел.
|
||||||
# PRECONDITIONS: Все предыдущие шаги (`select_environments`, `select_dashboards`) успешно выполнены.
|
:postconditions: Все указанные дашборды удалены (если они существовали).
|
||||||
# POSTCONDITIONS: Выбранные дашборды перенесены в целевое окружение.
|
:sideeffect: Делает HTTP‑запрос ``DELETE /dashboard/?q=[ids]``.
|
||||||
def execute_migration(self):
|
"""
|
||||||
"""Шаг 4: Выполнение миграции и обновления конфигураций."""
|
def _batch_delete_by_ids(self, ids: List[int]) -> None:
|
||||||
self.logger.info("[INFO][execute_migration][ENTER] Шаг 4/4: Выполнение миграции.")
|
if not ids:
|
||||||
w = Whiptail(title="Выполнение миграции", backtitle="Superset Migration Tool")
|
self.logger.debug("[DEBUG][_batch_delete_by_ids] Empty ID list – nothing to delete.")
|
||||||
|
|
||||||
if not self.dashboards_to_migrate:
|
|
||||||
self.logger.warning("[WARN][execute_migration][STATE] Нет дашбордов для миграции.")
|
|
||||||
w.msgbox("Нет дашбордов для миграции. Завершение.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
total_dashboards = len(self.dashboards_to_migrate)
|
self.logger.info("[INFO][_batch_delete_by_ids] Deleting dashboards IDs: %s", ids)
|
||||||
self.logger.info(f"[INFO][execute_migration][STATE] Начало миграции {total_dashboards} дашбордов.")
|
# Формируем параметр q в виде JSON‑массива, как требует Superset.
|
||||||
with w.gauge("Выполняется миграция...", width=60, height=10) as gauge:
|
q_param = json.dumps(ids)
|
||||||
for i, dashboard in enumerate(self.dashboards_to_migrate):
|
response = self.to_c.network.request(
|
||||||
|
method="DELETE",
|
||||||
|
endpoint="/dashboard/",
|
||||||
|
params={"q": q_param},
|
||||||
|
)
|
||||||
|
# Superset обычно отвечает 200/204; проверяем поле ``result`` при наличии.
|
||||||
|
if isinstance(response, dict) and response.get("result", True) is False:
|
||||||
|
self.logger.warning("[WARN][_batch_delete_by_ids] Unexpected delete response: %s", response)
|
||||||
|
else:
|
||||||
|
self.logger.info("[INFO][_batch_delete_by_ids] Delete request completed.")
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [ENTITY: Method('execute_migration')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Выполнить экспорт‑импорт выбранных дашбордов, при необходимости
|
||||||
|
обновив YAML‑файлы. При ошибке импортов сохраняем slug, а потом
|
||||||
|
удаляем проблемные дашборды **по ID**, получив их через slug.
|
||||||
|
:preconditions:
|
||||||
|
- ``self.dashboards_to_migrate`` не пуст,
|
||||||
|
- ``self.from_c`` и ``self.to_c`` инициализированы.
|
||||||
|
:postconditions:
|
||||||
|
- Все успешные дашборды импортированы,
|
||||||
|
- Неудачные дашборды, если пользователь выбрал «удалять‑при‑ошибке»,
|
||||||
|
удалены и повторно импортированы.
|
||||||
|
:sideeffect: При включённом флаге ``enable_delete_on_failure`` производится
|
||||||
|
батч‑удаление и повторный импорт.
|
||||||
|
"""
|
||||||
|
def execute_migration(self) -> None:
|
||||||
|
if not self.dashboards_to_migrate:
|
||||||
|
self.logger.warning("[WARN][execute_migration] No dashboards to migrate.")
|
||||||
|
msgbox("Информация", "Нет дашбордов для миграции.")
|
||||||
|
return
|
||||||
|
|
||||||
|
total = len(self.dashboards_to_migrate)
|
||||||
|
self.logger.info("[INFO][execute_migration] Starting migration of %d dashboards.", total)
|
||||||
|
|
||||||
|
# Передаём режим клиенту‑назначению
|
||||||
|
self.to_c.delete_before_reimport = self.enable_delete_on_failure # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# 1️⃣ Основной проход – экспорт → импорт → сбор ошибок
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
with gauge("Миграция...", width=60, height=10) as g:
|
||||||
|
for i, dash in enumerate(self.dashboards_to_migrate):
|
||||||
|
dash_id = dash["id"]
|
||||||
|
dash_slug = dash.get("slug") # slug нужен для дальнейшего поиска
|
||||||
|
title = dash["dashboard_title"]
|
||||||
|
|
||||||
|
progress = int((i / total) * 100)
|
||||||
|
g.set_text(f"Миграция: {title} ({i + 1}/{total})")
|
||||||
|
g.set_percent(progress)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dashboard_id = dashboard['id']
|
# ------------------- Экспорт -------------------
|
||||||
dashboard_title = dashboard['dashboard_title']
|
exported_content, _ = self.from_c.export_dashboard(dash_id) # type: ignore[attr-defined]
|
||||||
|
|
||||||
progress = int((i / total_dashboards) * 100)
|
# ------------------- Временный ZIP -------------------
|
||||||
self.logger.debug(f"[DEBUG][execute_migration][PROGRESS] {progress}% - Миграция: {dashboard_title}")
|
with create_temp_file(
|
||||||
gauge.set_text(f"Миграция: {dashboard_title} ({i+1}/{total_dashboards})")
|
content=exported_content,
|
||||||
gauge.set_percent(progress)
|
suffix=".zip",
|
||||||
|
logger=self.logger,
|
||||||
|
) as tmp_zip_path:
|
||||||
|
self.logger.debug("[DEBUG][temp_zip] Temporary ZIP at %s", tmp_zip_path)
|
||||||
|
|
||||||
self.logger.info(f"[INFO][execute_migration][PROGRESS] Миграция дашборда: {dashboard_title} (ID: {dashboard_id})")
|
# ------------------- Распаковка во временный каталог -------------------
|
||||||
|
with create_temp_file(suffix=".dir", logger=self.logger) as tmp_unpack_dir:
|
||||||
|
self.logger.debug("[DEBUG][temp_dir] Temporary unpack dir: %s", tmp_unpack_dir)
|
||||||
|
|
||||||
# 1. Экспорт
|
with zipfile.ZipFile(tmp_zip_path, "r") as zip_ref:
|
||||||
exported_content, _ = self.from_c.export_dashboard(dashboard_id)
|
zip_ref.extractall(tmp_unpack_dir)
|
||||||
zip_path, unpacked_path = save_and_unpack_dashboard(exported_content, f"temp_export_{dashboard_id}", unpack=True)
|
self.logger.info("[INFO][execute_migration] Export unpacked to %s", tmp_unpack_dir)
|
||||||
self.logger.info(f"[INFO][execute_migration][STATE] Дашборд экспортирован и распакован в {unpacked_path}")
|
|
||||||
|
|
||||||
# 2. Обновление YAML, если нужно
|
# ------------------- YAML‑обновление (если нужно) -------------------
|
||||||
if self.db_config_replacement:
|
if self.db_config_replacement:
|
||||||
update_yamls(db_configs=[self.db_config_replacement], path=str(unpacked_path))
|
update_yamls(
|
||||||
self.logger.info(f"[INFO][execute_migration][STATE] YAML-файлы обновлены.")
|
db_configs=[self.db_config_replacement],
|
||||||
|
path=str(tmp_unpack_dir),
|
||||||
|
)
|
||||||
|
self.logger.info("[INFO][execute_migration] YAML‑files updated.")
|
||||||
|
|
||||||
# 3. Упаковка и импорт
|
# ------------------- Сборка нового ZIP -------------------
|
||||||
new_zip_path = f"migrated_dashboard_{dashboard_id}.zip"
|
with create_temp_file(suffix=".zip", logger=self.logger) as tmp_new_zip:
|
||||||
create_dashboard_export(new_zip_path, [str(unpacked_path)])
|
create_dashboard_export(
|
||||||
|
zip_path=tmp_new_zip,
|
||||||
|
source_paths=[str(tmp_unpack_dir)],
|
||||||
|
)
|
||||||
|
self.logger.info("[INFO][execute_migration] Re‑packed to %s", tmp_new_zip)
|
||||||
|
|
||||||
self.to_c.import_dashboard(new_zip_path)
|
# ------------------- Импорт -------------------
|
||||||
self.logger.info(f"[INFO][execute_migration][SUCCESS] Дашборд {dashboard_title} успешно импортирован.")
|
self.to_c.import_dashboard(
|
||||||
|
file_name=tmp_new_zip,
|
||||||
|
dash_id=dash_id,
|
||||||
|
dash_slug=dash_slug,
|
||||||
|
) # type: ignore[attr-defined]
|
||||||
|
|
||||||
except Exception as e:
|
# Если импорт прошёл без исключений – фиксируем успех
|
||||||
self.logger.error(f"[ERROR][execute_migration][FAILURE] Ошибка при миграции дашборда {dashboard_title}: {e}", exc_info=True)
|
self.logger.info("[INFO][execute_migration][SUCCESS] Dashboard %s imported.", title)
|
||||||
error_msg = f"Не удалось смигрировать дашборд: {dashboard_title}.\n\nОшибка: {e}"
|
|
||||||
w.msgbox(error_msg, width=60, height=15)
|
|
||||||
|
|
||||||
gauge.set_percent(100)
|
except Exception as exc:
|
||||||
|
# Сохраняем данные для повторного импорта после batch‑удаления
|
||||||
|
self.logger.error("[ERROR][execute_migration] %s", exc, exc_info=True)
|
||||||
|
self._failed_imports.append(
|
||||||
|
{
|
||||||
|
"slug": dash_slug,
|
||||||
|
"dash_id": dash_id,
|
||||||
|
"zip_content": exported_content,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
msgbox("Ошибка", f"Не удалось мигрировать дашборд {title}.\n\n{exc}")
|
||||||
|
|
||||||
self.logger.info("[INFO][execute_migration][STATE] Миграция завершена.")
|
g.set_percent(100)
|
||||||
w.msgbox("Миграция завершена!", width=40, height=8)
|
|
||||||
self.logger.info("[INFO][execute_migration][EXIT] Шаг 4 завершен.")
|
|
||||||
# END_FUNCTION_execute_migration
|
|
||||||
|
|
||||||
# END_CLASS_Migration
|
# -----------------------------------------------------------------
|
||||||
|
# 2️⃣ Если возникли ошибки и пользователь согласился удалять – удаляем и повторяем
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
if self.enable_delete_on_failure and self._failed_imports:
|
||||||
|
self.logger.info(
|
||||||
|
"[INFO][execute_migration] %d dashboards failed. Starting recovery procedure.",
|
||||||
|
len(self._failed_imports),
|
||||||
|
)
|
||||||
|
|
||||||
# [MAIN_EXECUTION_BLOCK]
|
# ------------------- Получаем список дашбордов в целевом окружении -------------------
|
||||||
|
_, target_dashboards = self.to_c.get_dashboards() # type: ignore[attr-defined]
|
||||||
|
slug_to_id: Dict[str, int] = {
|
||||||
|
d["slug"]: d["id"] for d in target_dashboards if "slug" in d and "id" in d
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------- Формируем список ID‑ов для удаления -------------------
|
||||||
|
ids_to_delete: List[int] = []
|
||||||
|
for fail in self._failed_imports:
|
||||||
|
slug = fail["slug"]
|
||||||
|
if slug and slug in slug_to_id:
|
||||||
|
ids_to_delete.append(slug_to_id[slug])
|
||||||
|
else:
|
||||||
|
self.logger.warning(
|
||||||
|
"[WARN][execute_migration] Unable to map slug '%s' to ID on target.",
|
||||||
|
slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------- Batch‑удаление -------------------
|
||||||
|
self._batch_delete_by_ids(ids_to_delete)
|
||||||
|
|
||||||
|
# ------------------- Повторный импорт только для проблемных дашбордов -------------------
|
||||||
|
for fail in self._failed_imports:
|
||||||
|
dash_slug = fail["slug"]
|
||||||
|
dash_id = fail["dash_id"]
|
||||||
|
zip_content = fail["zip_content"]
|
||||||
|
|
||||||
|
# Один раз создаём временный ZIP‑файл из сохранённого содержимого
|
||||||
|
with create_temp_file(
|
||||||
|
content=zip_content,
|
||||||
|
suffix=".zip",
|
||||||
|
logger=self.logger,
|
||||||
|
) as retry_zip_path:
|
||||||
|
self.logger.debug("[DEBUG][retry_zip] Retry ZIP for slug %s at %s", dash_slug, retry_zip_path)
|
||||||
|
|
||||||
|
# Пере‑импортируем – **slug** передаётся, но клиент будет использовать ID
|
||||||
|
self.to_c.import_dashboard(
|
||||||
|
file_name=retry_zip_path,
|
||||||
|
dash_id=dash_id,
|
||||||
|
dash_slug=dash_slug,
|
||||||
|
) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
self.logger.info("[INFO][execute_migration][RECOVERED] Dashboard slug '%s' re‑imported.", dash_slug)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# 3️⃣ Финальная отчётность
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
self.logger.info("[INFO][execute_migration] Migration finished.")
|
||||||
|
msgbox("Информация", "Миграция завершена!")
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
|
# [END_ENTITY: Service('Migration')]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# Точка входа
|
||||||
|
# --------------------------------------------------------------
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
migration = Migration()
|
Migration().run()
|
||||||
migration.run()
|
# [END_FILE migration_script.py]
|
||||||
# END_MAIN_EXECUTION_BLOCK
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
# END_MODULE_migration_script
|
|
||||||
@@ -1,82 +1,106 @@
|
|||||||
# pylint: disable=too-many-arguments,too-many-locals,too-many-statements,too-many-branches,unused-argument
|
# [MODULE_PATH] superset_tool.client
|
||||||
"""
|
# [FILE] client.py
|
||||||
[MODULE] Superset API Client
|
# [SEMANTICS] superset, api, client, logging, error-handling, slug-support
|
||||||
@contract: Реализует полное взаимодействие с Superset API
|
|
||||||
"""
|
|
||||||
|
|
||||||
# [IMPORTS] Стандартная библиотека
|
# --------------------------------------------------------------
|
||||||
|
# [IMPORTS]
|
||||||
|
# --------------------------------------------------------------
|
||||||
import json
|
import json
|
||||||
from typing import Optional, Dict, Tuple, List, Any, Union
|
|
||||||
import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
from requests import Response
|
from requests import Response
|
||||||
|
|
||||||
# [IMPORTS] Локальные модули
|
|
||||||
from superset_tool.models import SupersetConfig
|
from superset_tool.models import SupersetConfig
|
||||||
from superset_tool.exceptions import (
|
from superset_tool.exceptions import ExportError, InvalidZipFormatError
|
||||||
ExportError,
|
|
||||||
InvalidZipFormatError
|
|
||||||
)
|
|
||||||
from superset_tool.utils.fileio import get_filename_from_headers
|
from superset_tool.utils.fileio import get_filename_from_headers
|
||||||
from superset_tool.utils.logger import SupersetLogger
|
from superset_tool.utils.logger import SupersetLogger
|
||||||
from superset_tool.utils.network import APIClient
|
from superset_tool.utils.network import APIClient
|
||||||
|
# [END_IMPORTS]
|
||||||
|
|
||||||
# [CONSTANTS]
|
# --------------------------------------------------------------
|
||||||
DEFAULT_TIMEOUT = 30
|
# [ENTITY: Service('SupersetClient')]
|
||||||
|
# [RELATION: Service('SupersetClient')] -> [DEPENDS_ON] -> [PythonModule('superset_tool.utils.network')]
|
||||||
# [TYPE-ALIASES]
|
# --------------------------------------------------------------
|
||||||
JsonType = Union[Dict[str, Any], List[Dict[str, Any]]]
|
"""
|
||||||
ResponseType = Tuple[bytes, str]
|
:purpose: Класс‑обёртка над Superset REST‑API.
|
||||||
|
:preconditions:
|
||||||
|
- ``config`` – валидный объект :class:`SupersetConfig`.
|
||||||
|
- Доступен рабочий HTTP‑клиент :class:`APIClient`.
|
||||||
|
:postconditions:
|
||||||
|
- Объект готов к выполнению запросов (GET, POST, DELETE и т.д.).
|
||||||
|
:raises:
|
||||||
|
- :class:`TypeError` при передаче неверного типа конфигурации.
|
||||||
|
"""
|
||||||
class SupersetClient:
|
class SupersetClient:
|
||||||
"""[MAIN-CONTRACT] Клиент для работы с Superset API"""
|
"""
|
||||||
# [ENTITY: Function('__init__')]
|
:ivar SupersetLogger logger: Логгер, используемый в клиенте.
|
||||||
# CONTRACT:
|
:ivar SupersetConfig config: Текущая конфигурация подключения.
|
||||||
# PURPOSE: Инициализация клиента Superset.
|
:ivar APIClient network: Объект‑обёртка над ``requests``.
|
||||||
# PRECONDITIONS: `config` должен быть валидным `SupersetConfig`.
|
:ivar bool delete_before_reimport: Флаг, указывающий,
|
||||||
# POSTCONDITIONS: Клиент успешно инициализирован.
|
что при ошибке импорта дашборд следует удалить и повторить импорт.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [ENTITY: Method('__init__')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Инициализировать клиент и передать ему логгер.
|
||||||
|
:preconditions: ``config`` – экземпляр :class:`SupersetConfig`.
|
||||||
|
:postconditions: Атрибуты ``logger``, ``config`` и ``network`` созданы,
|
||||||
|
``delete_before_reimport`` установлен в ``False``.
|
||||||
|
"""
|
||||||
def __init__(self, config: SupersetConfig, logger: Optional[SupersetLogger] = None):
|
def __init__(self, config: SupersetConfig, logger: Optional[SupersetLogger] = None):
|
||||||
self.logger = logger or SupersetLogger(name="SupersetClient")
|
self.logger = logger or SupersetLogger(name="SupersetClient")
|
||||||
self.logger.info("[INFO][SupersetClient.__init__][ENTER] Initializing SupersetClient.")
|
self.logger.info("[INFO][SupersetClient.__init__] Initializing SupersetClient.")
|
||||||
self._validate_config(config)
|
self._validate_config(config)
|
||||||
self.config = config
|
self.config = config
|
||||||
self.env = config.env
|
|
||||||
self.network = APIClient(
|
self.network = APIClient(
|
||||||
config=config.dict(),
|
config=config.dict(),
|
||||||
verify_ssl=config.verify_ssl,
|
verify_ssl=config.verify_ssl,
|
||||||
timeout=config.timeout,
|
timeout=config.timeout,
|
||||||
logger=self.logger
|
logger=self.logger,
|
||||||
)
|
)
|
||||||
self.logger.info("[INFO][SupersetClient.__init__][SUCCESS] SupersetClient initialized successfully.")
|
self.delete_before_reimport: bool = False
|
||||||
# END_FUNCTION___init__
|
self.logger.info("[INFO][SupersetClient.__init__] SupersetClient initialized.")
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
# [ENTITY: Function('_validate_config')]
|
# --------------------------------------------------------------
|
||||||
# CONTRACT:
|
# [ENTITY: Method('_validate_config')]
|
||||||
# PURPOSE: Валидация конфигурации клиента.
|
# --------------------------------------------------------------
|
||||||
# PRECONDITIONS: `config` должен быть экземпляром `SupersetConfig`.
|
"""
|
||||||
# POSTCONDITIONS: Конфигурация валидна.
|
:purpose: Проверить, что передан объект :class:`SupersetConfig`.
|
||||||
|
:preconditions: ``config`` – произвольный объект.
|
||||||
|
:postconditions: При несовпадении типов возбуждается :class:`TypeError`.
|
||||||
|
"""
|
||||||
def _validate_config(self, config: SupersetConfig) -> None:
|
def _validate_config(self, config: SupersetConfig) -> None:
|
||||||
self.logger.debug("[DEBUG][SupersetClient._validate_config][ENTER] Validating config.")
|
self.logger.debug("[DEBUG][_validate_config][ENTER] Validating SupersetConfig.")
|
||||||
if not isinstance(config, SupersetConfig):
|
if not isinstance(config, SupersetConfig):
|
||||||
self.logger.error("[ERROR][SupersetClient._validate_config][FAILURE] Invalid config type.")
|
self.logger.error("[ERROR][_validate_config][FAILURE] Invalid config type.")
|
||||||
raise TypeError("Конфигурация должна быть экземпляром SupersetConfig")
|
raise TypeError("Конфигурация должна быть экземпляром SupersetConfig")
|
||||||
self.logger.debug("[DEBUG][SupersetClient._validate_config][SUCCESS] Config validated.")
|
self.logger.debug("[DEBUG][_validate_config][SUCCESS] Config is valid.")
|
||||||
# END_FUNCTION__validate_config
|
# [END_ENTITY]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [ENTITY: Property('headers')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
@property
|
@property
|
||||||
def headers(self) -> dict:
|
def headers(self) -> dict:
|
||||||
"""[INTERFACE] Базовые заголовки для API-вызовов."""
|
"""Базовые HTTP‑заголовки, используемые клиентом."""
|
||||||
return self.network.headers
|
return self.network.headers
|
||||||
# END_FUNCTION_headers
|
# [END_ENTITY]
|
||||||
|
|
||||||
# [ENTITY: Function('get_dashboards')]
|
# --------------------------------------------------------------
|
||||||
# CONTRACT:
|
# [ENTITY: Method('get_dashboards')]
|
||||||
# PURPOSE: Получение списка дашбордов с пагинацией.
|
# --------------------------------------------------------------
|
||||||
# PRECONDITIONS: None
|
"""
|
||||||
# POSTCONDITIONS: Возвращает кортеж с общим количеством и списком дашбордов.
|
:purpose: Получить список дашбордов с поддержкой пагинации.
|
||||||
|
:preconditions: None.
|
||||||
|
:postconditions: Возвращается кортеж ``(total_count, list_of_dashboards)``.
|
||||||
|
"""
|
||||||
def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
|
def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
|
||||||
self.logger.info("[INFO][SupersetClient.get_dashboards][ENTER] Getting dashboards.")
|
self.logger.info("[INFO][get_dashboards][ENTER] Fetching dashboards.")
|
||||||
validated_query = self._validate_query_params(query)
|
validated_query = self._validate_query_params(query)
|
||||||
total_count = self._fetch_total_object_count(endpoint="/dashboard/")
|
total_count = self._fetch_total_object_count(endpoint="/dashboard/")
|
||||||
paginated_data = self._fetch_all_pages(
|
paginated_data = self._fetch_all_pages(
|
||||||
@@ -85,236 +109,368 @@ class SupersetClient:
|
|||||||
"base_query": validated_query,
|
"base_query": validated_query,
|
||||||
"total_count": total_count,
|
"total_count": total_count,
|
||||||
"results_field": "result",
|
"results_field": "result",
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
self.logger.info("[INFO][SupersetClient.get_dashboards][SUCCESS] Got dashboards.")
|
self.logger.info("[INFO][get_dashboards][SUCCESS] Got dashboards.")
|
||||||
return total_count, paginated_data
|
return total_count, paginated_data
|
||||||
# END_FUNCTION_get_dashboards
|
# [END_ENTITY]
|
||||||
|
|
||||||
# [ENTITY: Function('get_dashboard')]
|
# --------------------------------------------------------------
|
||||||
# CONTRACT:
|
# [ENTITY: Method('export_dashboard')]
|
||||||
# PURPOSE: Получение метаданных дашборда по ID или SLUG.
|
# --------------------------------------------------------------
|
||||||
# PRECONDITIONS: `dashboard_id_or_slug` должен существовать.
|
"""
|
||||||
# POSTCONDITIONS: Возвращает метаданные дашборда.
|
:purpose: Скачать дашборд в виде ZIP‑архива.
|
||||||
def get_dashboard(self, dashboard_id_or_slug: str) -> dict:
|
:preconditions: ``dashboard_id`` – существующий идентификатор.
|
||||||
self.logger.info(f"[INFO][SupersetClient.get_dashboard][ENTER] Getting dashboard: {dashboard_id_or_slug}")
|
:postconditions: Возвращается бинарное содержимое и имя файла.
|
||||||
response_data = self.network.request(
|
"""
|
||||||
method="GET",
|
|
||||||
endpoint=f"/dashboard/{dashboard_id_or_slug}",
|
|
||||||
)
|
|
||||||
self.logger.info(f"[INFO][SupersetClient.get_dashboard][SUCCESS] Got dashboard: {dashboard_id_or_slug}")
|
|
||||||
return response_data.get("result", {})
|
|
||||||
# END_FUNCTION_get_dashboard
|
|
||||||
|
|
||||||
# [ENTITY: Function('get_datasets')]
|
|
||||||
# CONTRACT:
|
|
||||||
# PURPOSE: Получение списка датасетов с пагинацией.
|
|
||||||
# PRECONDITIONS: None
|
|
||||||
# POSTCONDITIONS: Возвращает кортеж с общим количеством и списком датасетов.
|
|
||||||
def get_datasets(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
|
|
||||||
self.logger.info("[INFO][SupersetClient.get_datasets][ENTER] Getting datasets.")
|
|
||||||
total_count = self._fetch_total_object_count(endpoint="/dataset/")
|
|
||||||
base_query = {
|
|
||||||
"columns": ["id", "table_name", "sql", "database", "schema"],
|
|
||||||
"page": 0,
|
|
||||||
"page_size": 100
|
|
||||||
}
|
|
||||||
validated_query = {**base_query, **(query or {})}
|
|
||||||
datasets = self._fetch_all_pages(
|
|
||||||
endpoint="/dataset/",
|
|
||||||
pagination_options={
|
|
||||||
"base_query": validated_query,
|
|
||||||
"total_count": total_count,
|
|
||||||
"results_field": "result",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.logger.info("[INFO][SupersetClient.get_datasets][SUCCESS] Got datasets.")
|
|
||||||
return total_count, datasets
|
|
||||||
# END_FUNCTION_get_datasets
|
|
||||||
|
|
||||||
# [ENTITY: Function('get_dataset')]
|
|
||||||
# CONTRACT:
|
|
||||||
# PURPOSE: Получение метаданных датасета по ID.
|
|
||||||
# PRECONDITIONS: `dataset_id` должен существовать.
|
|
||||||
# POSTCONDITIONS: Возвращает метаданные датасета.
|
|
||||||
def get_dataset(self, dataset_id: str) -> dict:
|
|
||||||
self.logger.info(f"[INFO][SupersetClient.get_dataset][ENTER] Getting dataset: {dataset_id}")
|
|
||||||
response_data = self.network.request(
|
|
||||||
method="GET",
|
|
||||||
endpoint=f"/dataset/{dataset_id}",
|
|
||||||
)
|
|
||||||
self.logger.info(f"[INFO][SupersetClient.get_dataset][SUCCESS] Got dataset: {dataset_id}")
|
|
||||||
return response_data.get("result", {})
|
|
||||||
# END_FUNCTION_get_dataset
|
|
||||||
|
|
||||||
def get_databases(self) -> List[Dict]:
|
|
||||||
self.logger.info("[INFO][SupersetClient.get_databases][ENTER] Getting databases.")
|
|
||||||
response = self.network.request("GET", "/database/")
|
|
||||||
self.logger.info("[INFO][SupersetClient.get_databases][SUCCESS] Got databases.")
|
|
||||||
return response.get('result', [])
|
|
||||||
|
|
||||||
# [ENTITY: Function('export_dashboard')]
|
|
||||||
# CONTRACT:
|
|
||||||
# PURPOSE: Экспорт дашборда в ZIP-архив.
|
|
||||||
# PRECONDITIONS: `dashboard_id` должен существовать.
|
|
||||||
# POSTCONDITIONS: Возвращает содержимое ZIP-архива и имя файла.
|
|
||||||
def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]:
|
def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]:
|
||||||
self.logger.info(f"[INFO][SupersetClient.export_dashboard][ENTER] Exporting dashboard: {dashboard_id}")
|
self.logger.info("[INFO][export_dashboard][ENTER] Exporting dashboard %s.", dashboard_id)
|
||||||
response = self.network.request(
|
response = self.network.request(
|
||||||
method="GET",
|
method="GET",
|
||||||
endpoint="/dashboard/export/",
|
endpoint="/dashboard/export/",
|
||||||
params={"q": json.dumps([dashboard_id])},
|
params={"q": json.dumps([dashboard_id])},
|
||||||
stream=True,
|
stream=True,
|
||||||
raw_response=True
|
raw_response=True,
|
||||||
)
|
)
|
||||||
self._validate_export_response(response, dashboard_id)
|
self._validate_export_response(response, dashboard_id)
|
||||||
filename = self._resolve_export_filename(response, dashboard_id)
|
filename = self._resolve_export_filename(response, dashboard_id)
|
||||||
content = response.content
|
self.logger.info("[INFO][export_dashboard][SUCCESS] Exported dashboard %s.", dashboard_id)
|
||||||
self.logger.info(f"[INFO][SupersetClient.export_dashboard][SUCCESS] Exported dashboard: {dashboard_id}")
|
return response.content, filename
|
||||||
return content, filename
|
# [END_ENTITY]
|
||||||
# END_FUNCTION_export_dashboard
|
|
||||||
|
|
||||||
# [ENTITY: Function('_validate_export_response')]
|
# --------------------------------------------------------------
|
||||||
# CONTRACT:
|
# [ENTITY: Method('import_dashboard')]
|
||||||
# PURPOSE: Валидация ответа экспорта.
|
# --------------------------------------------------------------
|
||||||
# PRECONDITIONS: `response` должен быть валидным HTTP-ответом.
|
"""
|
||||||
# POSTCONDITIONS: Ответ валиден.
|
:purpose: Импортировать дашборд из ZIP‑файла. При неуспешном импорте,
|
||||||
def _validate_export_response(self, response: Response, dashboard_id: int) -> None:
|
если ``delete_before_reimport`` = True, сначала удаляется
|
||||||
self.logger.debug(f"[DEBUG][SupersetClient._validate_export_response][ENTER] Validating export response for dashboard: {dashboard_id}")
|
дашборд по ID, затем импорт повторяется.
|
||||||
content_type = response.headers.get('Content-Type', '')
|
:preconditions:
|
||||||
if 'application/zip' not in content_type:
|
- ``file_name`` – путь к существующему ZIP‑архиву (str|Path).
|
||||||
self.logger.error(f"[ERROR][SupersetClient._validate_export_response][FAILURE] Invalid content type: {content_type}")
|
- ``dash_id`` – (опционально) ID дашборда, который следует удалить.
|
||||||
raise ExportError(f"Получен не ZIP-архив (Content-Type: {content_type})")
|
:postconditions: Возвращается словарь‑ответ API при успехе.
|
||||||
if not response.content:
|
"""
|
||||||
self.logger.error("[ERROR][SupersetClient._validate_export_response][FAILURE] Empty response content.")
|
def import_dashboard(
|
||||||
raise ExportError("Получены пустые данные при экспорте")
|
self,
|
||||||
self.logger.debug(f"[DEBUG][SupersetClient._validate_export_response][SUCCESS] Export response validated for dashboard: {dashboard_id}")
|
file_name: Union[str, Path],
|
||||||
# END_FUNCTION__validate_export_response
|
dash_id: Optional[int] = None,
|
||||||
|
dash_slug: Optional[str] = None, # сохраняем для возможного логирования
|
||||||
|
) -> Dict:
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# 1️⃣ Приводим путь к строке (API‑клиент ожидает str)
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
file_path: str = str(file_name) # <--- гарантируем тип str
|
||||||
|
self._validate_import_file(file_path)
|
||||||
|
|
||||||
# [ENTITY: Function('_resolve_export_filename')]
|
try:
|
||||||
# CONTRACT:
|
import_response = self._do_import(file_path)
|
||||||
# PURPOSE: Определение имени экспортируемого файла.
|
self.logger.info("[INFO][import_dashboard] Imported %s.", file_path)
|
||||||
# PRECONDITIONS: `response` должен быть валидным HTTP-ответом.
|
return import_response
|
||||||
# POSTCONDITIONS: Возвращает имя файла.
|
|
||||||
def _resolve_export_filename(self, response: Response, dashboard_id: int) -> str:
|
|
||||||
self.logger.debug(f"[DEBUG][SupersetClient._resolve_export_filename][ENTER] Resolving export filename for dashboard: {dashboard_id}")
|
|
||||||
filename = get_filename_from_headers(response.headers)
|
|
||||||
if not filename:
|
|
||||||
timestamp = datetime.datetime.now().strftime('%Y%m%dT%H%M%S')
|
|
||||||
filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip"
|
|
||||||
self.logger.warning(f"[WARNING][SupersetClient._resolve_export_filename][STATE_CHANGE] Could not resolve filename from headers, generated: {filename}")
|
|
||||||
self.logger.debug(f"[DEBUG][SupersetClient._resolve_export_filename][SUCCESS] Resolved export filename: {filename}")
|
|
||||||
return filename
|
|
||||||
# END_FUNCTION__resolve_export_filename
|
|
||||||
|
|
||||||
# [ENTITY: Function('export_to_file')]
|
except Exception as exc:
|
||||||
# CONTRACT:
|
# -----------------------------------------------------------------
|
||||||
# PURPOSE: Экспорт дашборда напрямую в файл.
|
# 2️⃣ Логируем первую неудачу, пытаемся удалить и повторить,
|
||||||
# PRECONDITIONS: `output_dir` должен существовать.
|
# только если включён флаг ``delete_before_reimport``.
|
||||||
# POSTCONDITIONS: Дашборд сохранен в файл.
|
# -----------------------------------------------------------------
|
||||||
def export_to_file(self, dashboard_id: int, output_dir: Union[str, Path]) -> Path:
|
self.logger.error(
|
||||||
self.logger.info(f"[INFO][SupersetClient.export_to_file][ENTER] Exporting dashboard {dashboard_id} to file in {output_dir}")
|
"[ERROR][import_dashboard] First import attempt failed: %s",
|
||||||
output_dir = Path(output_dir)
|
exc,
|
||||||
if not output_dir.exists():
|
exc_info=True,
|
||||||
self.logger.error(f"[ERROR][SupersetClient.export_to_file][FAILURE] Output directory does not exist: {output_dir}")
|
)
|
||||||
raise FileNotFoundError(f"Директория {output_dir} не найдена")
|
if not self.delete_before_reimport:
|
||||||
content, filename = self.export_dashboard(dashboard_id)
|
raise
|
||||||
target_path = output_dir / filename
|
|
||||||
with open(target_path, 'wb') as f:
|
|
||||||
f.write(content)
|
|
||||||
self.logger.info(f"[INFO][SupersetClient.export_to_file][SUCCESS] Exported dashboard {dashboard_id} to {target_path}")
|
|
||||||
return target_path
|
|
||||||
# END_FUNCTION_export_to_file
|
|
||||||
|
|
||||||
# [ENTITY: Function('import_dashboard')]
|
# -----------------------------------------------------------------
|
||||||
# CONTRACT:
|
# 3️⃣ Выбираем, как искать дашборд для удаления.
|
||||||
# PURPOSE: Импорт дашборда из ZIP-архива.
|
# При наличии ``dash_id`` – удаляем его.
|
||||||
# PRECONDITIONS: `file_name` должен быть валидным ZIP-файлом.
|
# Иначе, если известен ``dash_slug`` – переводим его в ID ниже.
|
||||||
# POSTCONDITIONS: Возвращает ответ API.
|
# -----------------------------------------------------------------
|
||||||
def import_dashboard(self, file_name: Union[str, Path]) -> Dict:
|
target_id: Optional[int] = dash_id
|
||||||
self.logger.info(f"[INFO][SupersetClient.import_dashboard][ENTER] Importing dashboard from: {file_name}")
|
if target_id is None and dash_slug is not None:
|
||||||
self._validate_import_file(file_name)
|
# Попытка динамического определения ID через slug.
|
||||||
import_response = self.network.upload_file(
|
# Мы делаем отдельный запрос к /dashboard/ (поисковый фильтр).
|
||||||
|
self.logger.debug("[DEBUG][import_dashboard] Resolving ID by slug '%s'.", dash_slug)
|
||||||
|
try:
|
||||||
|
_, candidates = self.get_dashboards(
|
||||||
|
query={"filters": [{"col": "slug", "op": "eq", "value": dash_slug}]}
|
||||||
|
)
|
||||||
|
if candidates:
|
||||||
|
target_id = candidates[0]["id"]
|
||||||
|
self.logger.debug("[DEBUG][import_dashboard] Resolved slug → ID %s.", target_id)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(
|
||||||
|
"[WARN][import_dashboard] Could not resolve slug '%s' to ID: %s",
|
||||||
|
dash_slug,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Если всё‑равно нет ID – считаем невозможным корректно удалить.
|
||||||
|
if target_id is None:
|
||||||
|
self.logger.error("[ERROR][import_dashboard] No ID available for delete‑retry.")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# 4️⃣ Удаляем найденный дашборд (по ID)
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
try:
|
||||||
|
self.delete_dashboard(target_id)
|
||||||
|
self.logger.info("[INFO][import_dashboard] Deleted dashboard ID %s, retrying import.", target_id)
|
||||||
|
except Exception as del_exc:
|
||||||
|
self.logger.error("[ERROR][import_dashboard] Delete failed: %s", del_exc, exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# 5️⃣ Повторный импорт (тот же файл)
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
try:
|
||||||
|
import_response = self._do_import(file_path)
|
||||||
|
self.logger.info("[INFO][import_dashboard] Re‑import succeeded.")
|
||||||
|
return import_response
|
||||||
|
except Exception as rec_exc:
|
||||||
|
self.logger.error(
|
||||||
|
"[ERROR][import_dashboard] Re‑import after delete failed: %s",
|
||||||
|
rec_exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [ENTITY: Method('_do_import')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Выполнить один запрос на импорт без обработки исключений.
|
||||||
|
:preconditions: ``file_name`` уже проверен и существует.
|
||||||
|
:postconditions: Возвращается словарь‑ответ API.
|
||||||
|
"""
|
||||||
|
def _do_import(self, file_name: Union[str, Path]) -> Dict:
|
||||||
|
return self.network.upload_file(
|
||||||
endpoint="/dashboard/import/",
|
endpoint="/dashboard/import/",
|
||||||
file_info={
|
file_info={
|
||||||
"file_obj": Path(file_name),
|
"file_obj": Path(file_name),
|
||||||
"file_name": Path(file_name).name,
|
"file_name": Path(file_name).name,
|
||||||
"form_field": "formData",
|
"form_field": "formData",
|
||||||
},
|
},
|
||||||
extra_data={'overwrite': 'true'},
|
extra_data={"overwrite": "true"},
|
||||||
timeout=self.config.timeout * 2
|
timeout=self.config.timeout * 2,
|
||||||
)
|
)
|
||||||
self.logger.info(f"[INFO][SupersetClient.import_dashboard][SUCCESS] Imported dashboard from: {file_name}")
|
# [END_ENTITY]
|
||||||
return import_response
|
|
||||||
# END_FUNCTION_import_dashboard
|
|
||||||
|
|
||||||
# [ENTITY: Function('_validate_query_params')]
|
# --------------------------------------------------------------
|
||||||
# CONTRACT:
|
# [ENTITY: Method('delete_dashboard')]
|
||||||
# PURPOSE: Нормализация и валидация параметров запроса.
|
# --------------------------------------------------------------
|
||||||
# PRECONDITIONS: None
|
"""
|
||||||
# POSTCONDITIONS: Возвращает валидный словарь параметров.
|
:purpose: Удалить дашборд **по ID или slug**.
|
||||||
|
:preconditions:
|
||||||
|
- ``dashboard_id`` – int ID **или** str slug дашборда.
|
||||||
|
:postconditions: На уровне API считается, что ресурс удалён
|
||||||
|
(HTTP 200/204). Логируется результат операции.
|
||||||
|
"""
|
||||||
|
def delete_dashboard(self, dashboard_id: Union[int, str]) -> None:
|
||||||
|
# ``dashboard_id`` может быть целым числом или строковым slug.
|
||||||
|
self.logger.info("[INFO][delete_dashboard][ENTER] Deleting dashboard %s.", dashboard_id)
|
||||||
|
response = self.network.request(
|
||||||
|
method="DELETE",
|
||||||
|
endpoint=f"/dashboard/{dashboard_id}",
|
||||||
|
)
|
||||||
|
# Superset обычно возвращает 200/204. Если есть поле ``result`` – проверяем.
|
||||||
|
if response.get("result", True) is not False:
|
||||||
|
self.logger.info("[INFO][delete_dashboard] Dashboard %s deleted.", dashboard_id)
|
||||||
|
else:
|
||||||
|
self.logger.warning("[WARN][delete_dashboard] Unexpected response while deleting %s.", dashboard_id)
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [ENTITY: Method('_extract_dashboard_id_from_zip')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Попытаться извлечь **ID** дашборда из ``metadata.yaml`` внутри ZIP‑архива.
|
||||||
|
:preconditions: ``file_name`` – путь к корректному ZIP‑файлу.
|
||||||
|
:postconditions: Возвращается ``int`` ID или ``None``.
|
||||||
|
"""
|
||||||
|
def _extract_dashboard_id_from_zip(self, file_name: Union[str, Path]) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
with zipfile.ZipFile(file_name, "r") as zf:
|
||||||
|
for name in zf.namelist():
|
||||||
|
if name.endswith("metadata.yaml"):
|
||||||
|
with zf.open(name) as meta_file:
|
||||||
|
meta = yaml.safe_load(meta_file.read())
|
||||||
|
dash_id = meta.get("dashboard_uuid") or meta.get("dashboard_id")
|
||||||
|
if dash_id is not None:
|
||||||
|
return int(dash_id)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.error("[ERROR][_extract_dashboard_id_from_zip] %s", exc, exc_info=True)
|
||||||
|
return None
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [ENTITY: Method('_extract_dashboard_slug_from_zip')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Попытаться извлечь **slug** дашборда из ``metadata.yaml`` внутри ZIP‑архива.
|
||||||
|
:preconditions: ``file_name`` – путь к корректному ZIP‑файлу.
|
||||||
|
:postconditions: Возвращается строка‑slug или ``None``.
|
||||||
|
"""
|
||||||
|
def _extract_dashboard_slug_from_zip(self, file_name: Union[str, Path]) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
with zipfile.ZipFile(file_name, "r") as zf:
|
||||||
|
for name in zf.namelist():
|
||||||
|
if name.endswith("metadata.yaml"):
|
||||||
|
with zf.open(name) as meta_file:
|
||||||
|
meta = yaml.safe_load(meta_file.read())
|
||||||
|
slug = meta.get("slug")
|
||||||
|
if slug:
|
||||||
|
return str(slug)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.error("[ERROR][_extract_dashboard_slug_from_zip] %s", exc, exc_info=True)
|
||||||
|
return None
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [ENTITY: Method('_validate_export_response')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Проверить, что ответ от ``/dashboard/export/`` – ZIP‑архив с данными.
|
||||||
|
:preconditions: ``response`` – объект :class:`requests.Response`.
|
||||||
|
:postconditions: При несоответствии возбуждается :class:`ExportError`.
|
||||||
|
"""
|
||||||
|
def _validate_export_response(self, response: Response, dashboard_id: int) -> None:
|
||||||
|
self.logger.debug("[DEBUG][_validate_export_response][ENTER] Validating response for %s.", dashboard_id)
|
||||||
|
content_type = response.headers.get("Content-Type", "")
|
||||||
|
if "application/zip" not in content_type:
|
||||||
|
self.logger.error("[ERROR][_validate_export_response][FAILURE] Invalid content type: %s", content_type)
|
||||||
|
raise ExportError(f"Получен не ZIP‑архив (Content-Type: {content_type})")
|
||||||
|
if not response.content:
|
||||||
|
self.logger.error("[ERROR][_validate_export_response][FAILURE] Empty response content.")
|
||||||
|
raise ExportError("Получены пустые данные при экспорте")
|
||||||
|
self.logger.debug("[DEBUG][_validate_export_response][SUCCESS] Response validated.")
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [ENTITY: Method('_resolve_export_filename')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Определить имя файла, полученного из заголовков ответа.
|
||||||
|
:preconditions: ``response.headers`` содержит (возможно) ``Content‑Disposition``.
|
||||||
|
:postconditions: Возвращается строка‑имя файла.
|
||||||
|
"""
|
||||||
|
def _resolve_export_filename(self, response: Response, dashboard_id: int) -> str:
|
||||||
|
self.logger.debug("[DEBUG][_resolve_export_filename][ENTER] Resolving filename.")
|
||||||
|
filename = get_filename_from_headers(response.headers)
|
||||||
|
if not filename:
|
||||||
|
from datetime import datetime
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||||
|
filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip"
|
||||||
|
self.logger.warning("[WARN][_resolve_export_filename] Generated filename: %s", filename)
|
||||||
|
self.logger.debug("[DEBUG][_resolve_export_filename][SUCCESS] Filename: %s", filename)
|
||||||
|
return filename
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [ENTITY: Method('_validate_query_params')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Сформировать корректный набор параметров запроса.
|
||||||
|
:preconditions: ``query`` – любой словарь или ``None``.
|
||||||
|
:postconditions: Возвращается словарь с обязательными полями.
|
||||||
|
"""
|
||||||
def _validate_query_params(self, query: Optional[Dict]) -> Dict:
|
def _validate_query_params(self, query: Optional[Dict]) -> Dict:
|
||||||
self.logger.debug("[DEBUG][SupersetClient._validate_query_params][ENTER] Validating query params.")
|
|
||||||
base_query = {
|
base_query = {
|
||||||
"columns": ["slug", "id", "changed_on_utc", "dashboard_title", "published"],
|
"columns": ["slug", "id", "changed_on_utc", "dashboard_title", "published"],
|
||||||
"page": 0,
|
"page": 0,
|
||||||
"page_size": 1000
|
"page_size": 1000,
|
||||||
}
|
}
|
||||||
validated_query = {**base_query, **(query or {})}
|
validated = {**base_query, **(query or {})}
|
||||||
self.logger.debug(f"[DEBUG][SupersetClient._validate_query_params][SUCCESS] Validated query params: {validated_query}")
|
self.logger.debug("[DEBUG][_validate_query_params] %s", validated)
|
||||||
return validated_query
|
return validated
|
||||||
# END_FUNCTION__validate_query_params
|
# [END_ENTITY]
|
||||||
|
|
||||||
# [ENTITY: Function('_fetch_total_object_count')]
|
# --------------------------------------------------------------
|
||||||
# CONTRACT:
|
# [ENTITY: Method('_fetch_total_object_count')]
|
||||||
# PURPOSE: Получение общего количества объектов.
|
# --------------------------------------------------------------
|
||||||
# PRECONDITIONS: `endpoint` должен быть валидным.
|
"""
|
||||||
# POSTCONDITIONS: Возвращает общее количество объектов.
|
:purpose: Получить общее количество объектов по указанному endpoint.
|
||||||
def _fetch_total_object_count(self, endpoint:str) -> int:
|
:preconditions: ``endpoint`` – строка, начинающаяся с «/».
|
||||||
self.logger.debug(f"[DEBUG][SupersetClient._fetch_total_object_count][ENTER] Fetching total object count for endpoint: {endpoint}")
|
:postconditions: Возвращается целое число.
|
||||||
query_params_for_count = {'page': 0, 'page_size': 1}
|
"""
|
||||||
|
def _fetch_total_object_count(self, endpoint: str) -> int:
|
||||||
|
query_params_for_count = {"page": 0, "page_size": 1}
|
||||||
count = self.network.fetch_paginated_count(
|
count = self.network.fetch_paginated_count(
|
||||||
endpoint=endpoint,
|
endpoint=endpoint,
|
||||||
query_params=query_params_for_count,
|
query_params=query_params_for_count,
|
||||||
count_field="count"
|
count_field="count",
|
||||||
)
|
)
|
||||||
self.logger.debug(f"[DEBUG][SupersetClient._fetch_total_object_count][SUCCESS] Fetched total object count: {count}")
|
self.logger.debug("[DEBUG][_fetch_total_object_count] %s → %s", endpoint, count)
|
||||||
return count
|
return count
|
||||||
# END_FUNCTION__fetch_total_object_count
|
# [END_ENTITY]
|
||||||
|
|
||||||
# [ENTITY: Function('_fetch_all_pages')]
|
# --------------------------------------------------------------
|
||||||
# CONTRACT:
|
# [ENTITY: Method('_fetch_all_pages')]
|
||||||
# PURPOSE: Обход всех страниц пагинированного API.
|
# --------------------------------------------------------------
|
||||||
# PRECONDITIONS: `pagination_options` должен содержать необходимые параметры.
|
"""
|
||||||
# POSTCONDITIONS: Возвращает список всех объектов.
|
:purpose: Обойти все страницы пагинированного API.
|
||||||
def _fetch_all_pages(self, endpoint:str, pagination_options: Dict) -> List[Dict]:
|
:preconditions: ``pagination_options`` – словарь, сформированный
|
||||||
self.logger.debug(f"[DEBUG][SupersetClient._fetch_all_pages][ENTER] Fetching all pages for endpoint: {endpoint}")
|
в ``_validate_query_params`` и ``_fetch_total_object_count``.
|
||||||
|
:postconditions: Возвращается список всех объектов.
|
||||||
|
"""
|
||||||
|
def _fetch_all_pages(self, endpoint: str, pagination_options: Dict) -> List[Dict]:
|
||||||
all_data = self.network.fetch_paginated_data(
|
all_data = self.network.fetch_paginated_data(
|
||||||
endpoint=endpoint,
|
endpoint=endpoint,
|
||||||
pagination_options=pagination_options
|
pagination_options=pagination_options,
|
||||||
)
|
)
|
||||||
self.logger.debug(f"[DEBUG][SupersetClient._fetch_all_pages][SUCCESS] Fetched all pages for endpoint: {endpoint}")
|
self.logger.debug("[DEBUG][_fetch_all_pages] Fetched %s items from %s.", len(all_data), endpoint)
|
||||||
return all_data
|
return all_data
|
||||||
# END_FUNCTION__fetch_all_pages
|
# [END_ENTITY]
|
||||||
|
|
||||||
# [ENTITY: Function('_validate_import_file')]
|
# --------------------------------------------------------------
|
||||||
# CONTRACT:
|
# [ENTITY: Method('_validate_import_file')]
|
||||||
# PURPOSE: Проверка файла перед импортом.
|
# --------------------------------------------------------------
|
||||||
# PRECONDITIONS: `zip_path` должен быть путем к файлу.
|
"""
|
||||||
# POSTCONDITIONS: Файл валиден.
|
:purpose: Проверить, что файл существует, является ZIP‑архивом и
|
||||||
|
содержит ``metadata.yaml``.
|
||||||
|
:preconditions: ``zip_path`` – путь к файлу.
|
||||||
|
:postconditions: При невалидном файле возбуждается :class:`InvalidZipFormatError`.
|
||||||
|
"""
|
||||||
def _validate_import_file(self, zip_path: Union[str, Path]) -> None:
|
def _validate_import_file(self, zip_path: Union[str, Path]) -> None:
|
||||||
self.logger.debug(f"[DEBUG][SupersetClient._validate_import_file][ENTER] Validating import file: {zip_path}")
|
|
||||||
path = Path(zip_path)
|
path = Path(zip_path)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
self.logger.error(f"[ERROR][SupersetClient._validate_import_file][FAILURE] Import file does not exist: {zip_path}")
|
self.logger.error("[ERROR][_validate_import_file] File not found: %s", zip_path)
|
||||||
raise FileNotFoundError(f"Файл {zip_path} не существует")
|
raise FileNotFoundError(f"Файл {zip_path} не существует")
|
||||||
if not zipfile.is_zipfile(path):
|
if not zipfile.is_zipfile(path):
|
||||||
self.logger.error(f"[ERROR][SupersetClient._validate_import_file][FAILURE] Import file is not a zip file: {zip_path}")
|
self.logger.error("[ERROR][_validate_import_file] Not a zip file: %s", zip_path)
|
||||||
raise InvalidZipFormatError(f"Файл {zip_path} не является ZIP-архивом")
|
raise InvalidZipFormatError(f"Файл {zip_path} не является ZIP‑архивом")
|
||||||
with zipfile.ZipFile(path, 'r') as zf:
|
with zipfile.ZipFile(path, "r") as zf:
|
||||||
if not any(n.endswith('metadata.yaml') for n in zf.namelist()):
|
if not any(n.endswith("metadata.yaml") for n in zf.namelist()):
|
||||||
self.logger.error(f"[ERROR][SupersetClient._validate_import_file][FAILURE] Import file does not contain metadata.yaml: {zip_path}")
|
self.logger.error("[ERROR][_validate_import_file] No metadata.yaml in %s", zip_path)
|
||||||
raise InvalidZipFormatError(f"Архив {zip_path} не содержит 'metadata.yaml'")
|
raise InvalidZipFormatError(f"Архив {zip_path} не содержит 'metadata.yaml'")
|
||||||
self.logger.debug(f"[DEBUG][SupersetClient._validate_import_file][SUCCESS] Validated import file: {zip_path}")
|
self.logger.debug("[DEBUG][_validate_import_file] File %s validated.", zip_path)
|
||||||
# END_FUNCTION__validate_import_file
|
# [END_ENTITY]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [ENTITY: Method('get_datasets')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Получить список датасетов с поддержкой пагинации.
|
||||||
|
:preconditions: None.
|
||||||
|
:postconditions: Возвращается кортеж ``(total_count, list_of_datasets)``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_datasets(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
|
||||||
|
self.logger.info("[INFO][get_datasets][ENTER] Fetching datasets.")
|
||||||
|
validated_query = self._validate_query_params(query)
|
||||||
|
total_count = self._fetch_total_object_count(endpoint="/dataset/")
|
||||||
|
paginated_data = self._fetch_all_pages(
|
||||||
|
endpoint="/dataset/",
|
||||||
|
pagination_options={
|
||||||
|
"base_query": validated_query,
|
||||||
|
"total_count": total_count,
|
||||||
|
"results_field": "result",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.logger.info("[INFO][get_datasets][SUCCESS] Got datasets.")
|
||||||
|
return total_count, paginated_data
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
|
|
||||||
|
# [END_FILE client.py]
|
||||||
@@ -1,88 +1,205 @@
|
|||||||
# [MODULE] Superset Tool Logger Utility
|
# [MODULE_PATH] superset_tool.utils.logger
|
||||||
# PURPOSE: Предоставляет стандартизированный класс-обертку `SupersetLogger` для настройки и использования логирования в проекте.
|
# [FILE] logger.py
|
||||||
# COHERENCE: Модуль согласован со стандартной библиотекой `logging`, расширяя ее для нужд проекта.
|
# [SEMANTICS] logging, utils, ai‑friendly, infrastructure
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [IMPORTS]
|
||||||
|
# --------------------------------------------------------------
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, Any, Mapping
|
||||||
|
# [END_IMPORTS]
|
||||||
|
|
||||||
# CONTRACT:
|
# --------------------------------------------------------------
|
||||||
# PURPOSE: Обеспечивает унифицированную настройку логгера с выводом в консоль и/или файл.
|
# [ENTITY: Service('SupersetLogger')]
|
||||||
# PRECONDITIONS:
|
# --------------------------------------------------------------
|
||||||
# - `name` должен быть строкой.
|
"""
|
||||||
# - `level` должен быть валидным уровнем логирования (например, `logging.INFO`).
|
:purpose: Универсальная обёртка над ``logging.Logger``. Позволяет:
|
||||||
# POSTCONDITIONS:
|
• задавать уровень и вывод в консоль/файл,
|
||||||
# - Создает и настраивает логгер с указанным именем и уровнем.
|
• передавать произвольные ``extra``‑поля,
|
||||||
# - Добавляет обработчики для вывода в файл (если указан `log_dir`) и в консоль (если `console=True`).
|
• использовать привычный API (info, debug, warning, error,
|
||||||
# - Очищает все предыдущие обработчики для данного логгера, чтобы избежать дублирования.
|
critical, exception) без «падения» при неверных аргументах.
|
||||||
# PARAMETERS:
|
:preconditions:
|
||||||
# - name: str - Имя логгера.
|
- ``name`` – строка‑идентификатор логгера,
|
||||||
# - log_dir: Optional[Path] - Директория для сохранения лог-файлов.
|
- ``level`` – валидный уровень из ``logging``,
|
||||||
# - level: int - Уровень логирования.
|
- ``log_dir`` – при указании директория, куда будет писаться файл‑лог.
|
||||||
# - console: bool - Флаг для включения вывода в консоль.
|
:postconditions:
|
||||||
|
- Создан полностью сконфигурированный ``logging.Logger`` без
|
||||||
|
дублирующих обработчиков.
|
||||||
|
"""
|
||||||
class SupersetLogger:
|
class SupersetLogger:
|
||||||
|
"""
|
||||||
|
:ivar logging.Logger logger: Внутренний стандартный логгер.
|
||||||
|
:ivar bool propagate: Отключаем наследование записей, чтобы
|
||||||
|
сообщения не «проваливались» выше.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [ENTITY: Method('__init__')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Конфигурировать базовый логгер, добавить обработчики
|
||||||
|
консоли и/или файла, очистить прежние обработчики.
|
||||||
|
:preconditions: Параметры валидны.
|
||||||
|
:postconditions: ``self.logger`` готов к использованию.
|
||||||
|
"""
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str = "superset_tool",
|
name: str = "superset_tool",
|
||||||
log_dir: Optional[Path] = None,
|
log_dir: Optional[Path] = None,
|
||||||
level: int = logging.INFO,
|
level: int = logging.INFO,
|
||||||
console: bool = True
|
console: bool = True,
|
||||||
):
|
) -> None:
|
||||||
self.logger = logging.getLogger(name)
|
self.logger = logging.getLogger(name)
|
||||||
self.logger.setLevel(level)
|
self.logger.setLevel(level)
|
||||||
|
self.logger.propagate = False # ← не «прокидываем» записи выше
|
||||||
|
|
||||||
formatter = logging.Formatter(
|
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
|
||||||
'%(asctime)s - %(levelname)s - %(message)s'
|
|
||||||
)
|
|
||||||
|
|
||||||
# [ANCHOR] HANDLER_RESET
|
# ---- Очистка предыдущих обработчиков (важно при повторных инициализациях) ----
|
||||||
# Очищаем существующие обработчики, чтобы избежать дублирования вывода при повторной инициализации.
|
|
||||||
if self.logger.hasHandlers():
|
if self.logger.hasHandlers():
|
||||||
self.logger.handlers.clear()
|
self.logger.handlers.clear()
|
||||||
|
|
||||||
# [ANCHOR] FILE_HANDLER
|
# ---- Файловый обработчик (если указана директория) ----
|
||||||
if log_dir:
|
if log_dir:
|
||||||
log_dir.mkdir(parents=True, exist_ok=True)
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
timestamp = datetime.now().strftime("%Y%m%d")
|
timestamp = datetime.now().strftime("%Y%m%d")
|
||||||
file_handler = logging.FileHandler(
|
file_handler = logging.FileHandler(
|
||||||
log_dir / f"{name}_{timestamp}.log", encoding='utf-8'
|
log_dir / f"{name}_{timestamp}.log", encoding="utf-8"
|
||||||
)
|
)
|
||||||
file_handler.setFormatter(formatter)
|
file_handler.setFormatter(formatter)
|
||||||
self.logger.addHandler(file_handler)
|
self.logger.addHandler(file_handler)
|
||||||
|
|
||||||
# [ANCHOR] CONSOLE_HANDLER
|
# ---- Консольный обработчик ----
|
||||||
if console:
|
if console:
|
||||||
console_handler = logging.StreamHandler(sys.stdout)
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
console_handler.setFormatter(formatter)
|
console_handler.setFormatter(formatter)
|
||||||
self.logger.addHandler(console_handler)
|
self.logger.addHandler(console_handler)
|
||||||
|
|
||||||
# CONTRACT:
|
# [END_ENTITY]
|
||||||
# PURPOSE: (HELPER) Генерирует строку с текущей датой для имени лог-файла.
|
|
||||||
# RETURN: str - Отформатированная дата (YYYYMMDD).
|
|
||||||
def _get_timestamp(self) -> str:
|
|
||||||
return datetime.now().strftime("%Y%m%d")
|
|
||||||
# END_FUNCTION__get_timestamp
|
|
||||||
|
|
||||||
# [INTERFACE] Методы логирования
|
# --------------------------------------------------------------
|
||||||
def info(self, message: str, extra: Optional[dict] = None, exc_info: bool = False):
|
# [ENTITY: Method('_log')]
|
||||||
self.logger.info(message, extra=extra, exc_info=exc_info)
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Универсальная вспомогательная обёртка над
|
||||||
|
``logging.Logger.<level>``. Принимает любые ``*args``
|
||||||
|
(подстановочные параметры) и ``extra``‑словарь.
|
||||||
|
:preconditions:
|
||||||
|
- ``level_method`` – один из методов ``logger``,
|
||||||
|
- ``msg`` – строка‑шаблон,
|
||||||
|
- ``*args`` – значения для ``%``‑подстановок,
|
||||||
|
- ``extra`` – пользовательские атрибуты (может быть ``None``).
|
||||||
|
:postconditions: Запись в журнал выполнена.
|
||||||
|
"""
|
||||||
|
def _log(
|
||||||
|
self,
|
||||||
|
level_method: Any,
|
||||||
|
msg: str,
|
||||||
|
*args: Any,
|
||||||
|
extra: Optional[Mapping[str, Any]] = None,
|
||||||
|
exc_info: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if extra is not None:
|
||||||
|
level_method(msg, *args, extra=extra, exc_info=exc_info)
|
||||||
|
else:
|
||||||
|
level_method(msg, *args, exc_info=exc_info)
|
||||||
|
|
||||||
def error(self, message: str, extra: Optional[dict] = None, exc_info: bool = False):
|
# [END_ENTITY]
|
||||||
self.logger.error(message, extra=extra, exc_info=exc_info)
|
|
||||||
|
|
||||||
def warning(self, message: str, extra: Optional[dict] = None, exc_info: bool = False):
|
# --------------------------------------------------------------
|
||||||
self.logger.warning(message, extra=extra, exc_info=exc_info)
|
# [ENTITY: Method('info')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Записать сообщение уровня INFO.
|
||||||
|
"""
|
||||||
|
def info(
|
||||||
|
self,
|
||||||
|
msg: str,
|
||||||
|
*args: Any,
|
||||||
|
extra: Optional[Mapping[str, Any]] = None,
|
||||||
|
exc_info: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self._log(self.logger.info, msg, *args, extra=extra, exc_info=exc_info)
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
def critical(self, message: str, extra: Optional[dict] = None, exc_info: bool = False):
|
# --------------------------------------------------------------
|
||||||
self.logger.critical(message, extra=extra, exc_info=exc_info)
|
# [ENTITY: Method('debug')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Записать сообщение уровня DEBUG.
|
||||||
|
"""
|
||||||
|
def debug(
|
||||||
|
self,
|
||||||
|
msg: str,
|
||||||
|
*args: Any,
|
||||||
|
extra: Optional[Mapping[str, Any]] = None,
|
||||||
|
exc_info: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self._log(self.logger.debug, msg, *args, extra=extra, exc_info=exc_info)
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
def debug(self, message: str, extra: Optional[dict] = None, exc_info: bool = False):
|
# --------------------------------------------------------------
|
||||||
self.logger.debug(message, extra=extra, exc_info=exc_info)
|
# [ENTITY: Method('warning')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Записать сообщение уровня WARNING.
|
||||||
|
"""
|
||||||
|
def warning(
|
||||||
|
self,
|
||||||
|
msg: str,
|
||||||
|
*args: Any,
|
||||||
|
extra: Optional[Mapping[str, Any]] = None,
|
||||||
|
exc_info: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self._log(self.logger.warning, msg, *args, extra=extra, exc_info=exc_info)
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
def exception(self, message: str, *args, **kwargs):
|
# --------------------------------------------------------------
|
||||||
self.logger.exception(message, *args, **kwargs)
|
# [ENTITY: Method('error')]
|
||||||
# END_CLASS_SupersetLogger
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Записать сообщение уровня ERROR.
|
||||||
|
"""
|
||||||
|
def error(
|
||||||
|
self,
|
||||||
|
msg: str,
|
||||||
|
*args: Any,
|
||||||
|
extra: Optional[Mapping[str, Any]] = None,
|
||||||
|
exc_info: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self._log(self.logger.error, msg, *args, extra=extra, exc_info=exc_info)
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
# END_MODULE_logger
|
# --------------------------------------------------------------
|
||||||
|
# [ENTITY: Method('critical')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Записать сообщение уровня CRITICAL.
|
||||||
|
"""
|
||||||
|
def critical(
|
||||||
|
self,
|
||||||
|
msg: str,
|
||||||
|
*args: Any,
|
||||||
|
extra: Optional[Mapping[str, Any]] = None,
|
||||||
|
exc_info: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self._log(self.logger.critical, msg, *args, extra=extra, exc_info=exc_info)
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [ENTITY: Method('exception')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Записать сообщение уровня ERROR вместе с трассировкой
|
||||||
|
текущего исключения (аналог ``logger.exception``).
|
||||||
|
"""
|
||||||
|
def exception(self, msg: str, *args: Any, **kwargs: Any) -> None:
|
||||||
|
self.logger.exception(msg, *args, **kwargs)
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [END_FILE logger.py]
|
||||||
|
# --------------------------------------------------------------
|
||||||
@@ -99,7 +99,7 @@ class APIClient:
|
|||||||
"csrf_token": csrf_token
|
"csrf_token": csrf_token
|
||||||
}
|
}
|
||||||
self._authenticated = True
|
self._authenticated = True
|
||||||
self.logger.info("[INFO][APIClient.authenticate][SUCCESS] Authenticated successfully.")
|
self.logger.info(f"[INFO][APIClient.authenticate][SUCCESS] Authenticated successfully. Tokens {self._tokens}")
|
||||||
return self._tokens
|
return self._tokens
|
||||||
except requests.exceptions.HTTPError as e:
|
except requests.exceptions.HTTPError as e:
|
||||||
self.logger.error(f"[ERROR][APIClient.authenticate][FAILURE] Authentication failed: {e}")
|
self.logger.error(f"[ERROR][APIClient.authenticate][FAILURE] Authentication failed: {e}")
|
||||||
@@ -132,12 +132,13 @@ class APIClient:
|
|||||||
_headers = self.headers.copy()
|
_headers = self.headers.copy()
|
||||||
if headers:
|
if headers:
|
||||||
_headers.update(headers)
|
_headers.update(headers)
|
||||||
|
timeout = kwargs.pop('timeout', self.request_settings.get("timeout", DEFAULT_TIMEOUT))
|
||||||
try:
|
try:
|
||||||
response = self.session.request(
|
response = self.session.request(
|
||||||
method,
|
method,
|
||||||
full_url,
|
full_url,
|
||||||
headers=_headers,
|
headers=_headers,
|
||||||
timeout=self.request_settings.get("timeout", DEFAULT_TIMEOUT),
|
timeout=timeout,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
148
superset_tool/utils/whiptail_fallback.py
Normal file
148
superset_tool/utils/whiptail_fallback.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# [MODULE_PATH] superset_tool.utils.whiptail_fallback
|
||||||
|
# [FILE] whiptail_fallback.py
|
||||||
|
# [SEMANTICS] ui, fallback, console, utils, non‑interactive
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [IMPORTS]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
import sys
|
||||||
|
from typing import List, Tuple, Optional, Any
|
||||||
|
# [END_IMPORTS]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [ENTITY: Service('ConsoleUI')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Плотный консольный UI‑fallback для всех функций,
|
||||||
|
которые в оригинальном проекте использовали ``whiptail``.
|
||||||
|
Всё взаимодействие теперь **не‑интерактивно**: функции,
|
||||||
|
выводящие сообщение, просто печатают его без ожидания
|
||||||
|
``Enter``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def menu(
|
||||||
|
title: str,
|
||||||
|
prompt: str,
|
||||||
|
choices: List[str],
|
||||||
|
backtitle: str = "Superset Migration Tool",
|
||||||
|
) -> Tuple[int, Optional[str]]:
|
||||||
|
"""Return (rc, selected item). rc == 0 → OK."""
|
||||||
|
print(f"\n=== {title} ===")
|
||||||
|
print(prompt)
|
||||||
|
for idx, item in enumerate(choices, 1):
|
||||||
|
print(f"{idx}) {item}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = input("\nВведите номер (0 – отмена): ").strip()
|
||||||
|
sel = int(raw)
|
||||||
|
if sel == 0:
|
||||||
|
return 1, None
|
||||||
|
return 0, choices[sel - 1]
|
||||||
|
except Exception:
|
||||||
|
return 1, None
|
||||||
|
|
||||||
|
|
||||||
|
def checklist(
|
||||||
|
title: str,
|
||||||
|
prompt: str,
|
||||||
|
options: List[Tuple[str, str]],
|
||||||
|
backtitle: str = "Superset Migration Tool",
|
||||||
|
) -> Tuple[int, List[str]]:
|
||||||
|
"""Return (rc, list of selected **values**)."""
|
||||||
|
print(f"\n=== {title} ===")
|
||||||
|
print(prompt)
|
||||||
|
for idx, (val, label) in enumerate(options, 1):
|
||||||
|
print(f"{idx}) [{val}] {label}")
|
||||||
|
|
||||||
|
raw = input("\nВведите номера через запятую (пустой ввод → отказ): ").strip()
|
||||||
|
if not raw:
|
||||||
|
return 1, []
|
||||||
|
|
||||||
|
try:
|
||||||
|
indices = {int(x) for x in raw.split(",") if x.strip()}
|
||||||
|
selected = [options[i - 1][0] for i in indices if 0 < i <= len(options)]
|
||||||
|
return 0, selected
|
||||||
|
except Exception:
|
||||||
|
return 1, []
|
||||||
|
|
||||||
|
|
||||||
|
def yesno(
|
||||||
|
title: str,
|
||||||
|
question: str,
|
||||||
|
backtitle: str = "Superset Migration Tool",
|
||||||
|
) -> bool:
|
||||||
|
"""True → пользователь ответил «да». """
|
||||||
|
ans = input(f"\n=== {title} ===\n{question} (y/n): ").strip().lower()
|
||||||
|
return ans in ("y", "yes", "да", "д")
|
||||||
|
|
||||||
|
|
||||||
|
def msgbox(
|
||||||
|
title: str,
|
||||||
|
msg: str,
|
||||||
|
width: int = 60,
|
||||||
|
height: int = 15,
|
||||||
|
backtitle: str = "Superset Migration Tool",
|
||||||
|
) -> None:
|
||||||
|
"""Простой вывод сообщения – без ожидания Enter."""
|
||||||
|
print(f"\n=== {title} ===\n{msg}\n")
|
||||||
|
# **Убрано:** input("Нажмите <Enter> для продолжения...")
|
||||||
|
|
||||||
|
|
||||||
|
def inputbox(
|
||||||
|
title: str,
|
||||||
|
prompt: str,
|
||||||
|
backtitle: str = "Superset Migration Tool",
|
||||||
|
) -> Tuple[int, Optional[str]]:
|
||||||
|
"""Return (rc, введённая строка). rc == 0 → успешно."""
|
||||||
|
print(f"\n=== {title} ===")
|
||||||
|
val = input(f"{prompt}\n")
|
||||||
|
if val == "":
|
||||||
|
return 1, None
|
||||||
|
return 0, val
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [ENTITY: Service('ConsoleGauge')]
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
"""
|
||||||
|
:purpose: Минимальная имитация ``whiptail``‑gauge в консоли.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class _ConsoleGauge:
|
||||||
|
"""Контекст‑менеджер для простого прогресс‑бара."""
|
||||||
|
def __init__(self, title: str, width: int = 60, height: int = 10):
|
||||||
|
self.title = title
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self._percent = 0
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
print(f"\n=== {self.title} ===")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
sys.stdout.write("\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
def set_text(self, txt: str) -> None:
|
||||||
|
sys.stdout.write(f"\r{txt} ")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
def set_percent(self, percent: int) -> None:
|
||||||
|
self._percent = percent
|
||||||
|
sys.stdout.write(f"{percent}%")
|
||||||
|
sys.stdout.flush()
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
|
def gauge(
|
||||||
|
title: str,
|
||||||
|
width: int = 60,
|
||||||
|
height: int = 10,
|
||||||
|
) -> Any:
|
||||||
|
"""Always returns the console fallback gauge."""
|
||||||
|
return _ConsoleGauge(title, width, height)
|
||||||
|
# [END_ENTITY]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# [END_FILE whiptail_fallback.py]
|
||||||
|
# --------------------------------------------------------------
|
||||||
29
whiptailtest.py
Normal file
29
whiptailtest.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# test_whiptail.py
|
||||||
|
from superset_tool.utils.whiptail_fallback import (
|
||||||
|
menu, checklist, yesno, msgbox, inputbox, gauge,
|
||||||
|
)
|
||||||
|
|
||||||
|
rc, env = menu('Тестовое меню', 'Выберите среду:', ['dev', 'prod'])
|
||||||
|
print('menu →', rc, env)
|
||||||
|
|
||||||
|
rc, ids = checklist(
|
||||||
|
'Тестовый чек‑лист',
|
||||||
|
'Выберите пункты:',
|
||||||
|
[('1', 'Первый'), ('2', 'Второй'), ('3', 'Третий')],
|
||||||
|
)
|
||||||
|
print('checklist →', rc, ids)
|
||||||
|
|
||||||
|
if yesno('Вопрос', 'Продолжить?'):
|
||||||
|
print('Ответ – ДА')
|
||||||
|
else:
|
||||||
|
print('Ответ – НЕТ')
|
||||||
|
|
||||||
|
rc, txt = inputbox('Ввод', 'Введите произвольный текст:')
|
||||||
|
print('inputbox →', rc, txt)
|
||||||
|
|
||||||
|
msgbox('Сообщение', 'Это просто тестовое сообщение.')
|
||||||
|
|
||||||
|
with gauge('Прогресс‑бар') as g:
|
||||||
|
for i in range(0, 101, 20):
|
||||||
|
g.set_text(f'Шаг {i // 20 + 1}')
|
||||||
|
g.set_percent(i)
|
||||||
Reference in New Issue
Block a user