001-fix-ui-ws-validation #2

Merged
busya merged 26 commits from 001-fix-ui-ws-validation into migration 2025-12-21 00:29:20 +03:00
26 changed files with 2475 additions and 2252 deletions
Showing only changes of commit d3395d55c3 - Show all commits

View File

@@ -1,10 +1,13 @@
# <GRACE_MODULE id="backup_script" name="backup_script.py"> # [DEF:backup_script:Module]
# @SEMANTICS: backup, superset, automation, dashboard #
# @PURPOSE: Этот модуль отвечает за автоматизированное резервное копирование дашбордов Superset. # @SEMANTICS: backup, superset, automation, dashboard
# @DEPENDS_ON: superset_tool.client -> Использует SupersetClient для взаимодействия с API. # @PURPOSE: Этот модуль отвечает за автоматизированное резервное копирование дашбордов Superset.
# @DEPENDS_ON: superset_tool.utils -> Использует утилиты для логирования, работы с файлами и инициализации клиентов. # @LAYER: App
# @RELATION: DEPENDS_ON -> superset_tool.client
# @RELATION: DEPENDS_ON -> superset_tool.utils
# @PUBLIC_API: BackupConfig, backup_dashboards, main
# <IMPORTS> # [SECTION: IMPORTS]
import logging import logging
import sys import sys
from pathlib import Path from pathlib import Path
@@ -22,12 +25,10 @@ from superset_tool.utils.fileio import (
RetentionPolicy RetentionPolicy
) )
from superset_tool.utils.init_clients import setup_clients from superset_tool.utils.init_clients import setup_clients
# </IMPORTS> # [/SECTION]
# --- Начало кода модуля --- # [DEF:BackupConfig:DataClass]
# @PURPOSE: Хранит конфигурацию для процесса бэкапа.
# <ANCHOR id="BackupConfig" type="DataClass">
# @PURPOSE: Хранит конфигурацию для процесса бэкапа.
@dataclass @dataclass
class BackupConfig: class BackupConfig:
"""Конфигурация для процесса бэкапа.""" """Конфигурация для процесса бэкапа."""
@@ -35,26 +36,26 @@ class BackupConfig:
rotate_archive: bool = True rotate_archive: bool = True
clean_folders: bool = True clean_folders: bool = True
retention_policy: RetentionPolicy = field(default_factory=RetentionPolicy) retention_policy: RetentionPolicy = field(default_factory=RetentionPolicy)
# </ANCHOR id="BackupConfig"> # [/DEF:BackupConfig]
# <ANCHOR id="backup_dashboards" type="Function"> # [DEF:backup_dashboards:Function]
# @PURPOSE: Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения, пропуская ошибки экспорта. # @PURPOSE: Выполняет бэкап всех доступных дашбордов для заданного клиента и окружения, пропуская ошибки экспорта.
# @PRE: `client` должен быть инициализированным экземпляром `SupersetClient`. # @PRE: `client` должен быть инициализированным экземпляром `SupersetClient`.
# @PRE: `env_name` должен быть строкой, обозначающей окружение. # @PRE: `env_name` должен быть строкой, обозначающей окружение.
# @PRE: `backup_root` должен быть валидным путем к корневой директории бэкапа. # @PRE: `backup_root` должен быть валидным путем к корневой директории бэкапа.
# @POST: Дашборды экспортируются и сохраняются. Ошибки экспорта логируются и не приводят к остановке скрипта. # @POST: Дашборды экспортируются и сохраняются. Ошибки экспорта логируются и не приводят к остановке скрипта.
# @PARAM: client: SupersetClient - Клиент для доступа к API Superset. # @RELATION: CALLS -> client.get_dashboards
# @PARAM: env_name: str - Имя окружения (e.g., 'PROD'). # @RELATION: CALLS -> client.export_dashboard
# @PARAM: backup_root: Path - Корневая директория для сохранения бэкапов. # @RELATION: CALLS -> save_and_unpack_dashboard
# @PARAM: logger: SupersetLogger - Инстанс логгера. # @RELATION: CALLS -> archive_exports
# @PARAM: config: BackupConfig - Конфигурация процесса бэкапа. # @RELATION: CALLS -> consolidate_archive_folders
# @RETURN: bool - `True` если все дашборды были экспортированы без критических ошибок, `False` иначе. # @RELATION: CALLS -> remove_empty_directories
# @RELATION: CALLS -> client.get_dashboards # @PARAM: client (SupersetClient) - Клиент для доступа к API Superset.
# @RELATION: CALLS -> client.export_dashboard # @PARAM: env_name (str) - Имя окружения (e.g., 'PROD').
# @RELATION: CALLS -> save_and_unpack_dashboard # @PARAM: backup_root (Path) - Корневая директория для сохранения бэкапов.
# @RELATION: CALLS -> archive_exports # @PARAM: logger (SupersetLogger) - Инстанс логгера.
# @RELATION: CALLS -> consolidate_archive_folders # @PARAM: config (BackupConfig) - Конфигурация процесса бэкапа.
# @RELATION: CALLS -> remove_empty_directories # @RETURN: bool - `True` если все дашборды были экспортированы без критических ошибок, `False` иначе.
def backup_dashboards( def backup_dashboards(
client: SupersetClient, client: SupersetClient,
env_name: str, env_name: str,
@@ -110,13 +111,13 @@ def backup_dashboards(
except (RequestException, IOError) as e: except (RequestException, IOError) as e:
logger.critical(f"[backup_dashboards][Failure] Fatal error during backup for {env_name}: {e}", exc_info=True) logger.critical(f"[backup_dashboards][Failure] Fatal error during backup for {env_name}: {e}", exc_info=True)
return False return False
# </ANCHOR id="backup_dashboards"> # [/DEF:backup_dashboards]
# <ANCHOR id="main" type="Function"> # [DEF:main:Function]
# @PURPOSE: Основная точка входа для запуска процесса резервного копирования. # @PURPOSE: Основная точка входа для запуска процесса резервного копирования.
# @RETURN: int - Код выхода (0 - успех, 1 - ошибка). # @RELATION: CALLS -> setup_clients
# @RELATION: CALLS -> setup_clients # @RELATION: CALLS -> backup_dashboards
# @RELATION: CALLS -> backup_dashboards # @RETURN: int - Код выхода (0 - успех, 1 - ошибка).
def main() -> int: def main() -> int:
log_dir = Path("P:\\Superset\\010 Бекапы\\Logs") log_dir = Path("P:\\Superset\\010 Бекапы\\Logs")
logger = SupersetLogger(log_dir=log_dir, level=logging.INFO, console=True) logger = SupersetLogger(log_dir=log_dir, level=logging.INFO, console=True)
@@ -154,11 +155,9 @@ def main() -> int:
logger.info("[main][Exit] Superset backup process finished.") logger.info("[main][Exit] Superset backup process finished.")
return exit_code return exit_code
# </ANCHOR id="main"> # [/DEF:main]
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())
# --- Конец кода модуля --- # [/DEF:backup_script]
# </GRACE_MODULE id="backup_script">

Binary file not shown.

70
debug_db_api.py Normal file
View File

@@ -0,0 +1,70 @@
# [DEF:debug_db_api:Module]
#
# @SEMANTICS: debug, api, database, script
# @PURPOSE: Скрипт для отладки структуры ответа API баз данных.
# @LAYER: App
# @RELATION: DEPENDS_ON -> superset_tool.client
# @RELATION: DEPENDS_ON -> superset_tool.utils
# @PUBLIC_API: debug_database_api
# [SECTION: IMPORTS]
import json
import logging
from superset_tool.client import SupersetClient
from superset_tool.utils.init_clients import setup_clients
from superset_tool.utils.logger import SupersetLogger
# [/SECTION]
# [DEF:debug_database_api:Function]
# @PURPOSE: Отладка структуры ответа API баз данных.
# @RELATION: CALLS -> setup_clients
# @RELATION: CALLS -> client.get_databases
def debug_database_api():
logger = SupersetLogger(name="debug_db_api", level=logging.DEBUG)
# Инициализируем клиенты
clients = setup_clients(logger)
# Проверяем доступные окружения
print("Доступные окружения:")
for env_name, client in clients.items():
print(f" {env_name}: {client.config.base_url}")
# Выбираем два окружения для тестирования
if len(clients) < 2:
print("Недостаточно окружений для тестирования")
return
env_names = list(clients.keys())[:2]
from_env, to_env = env_names[0], env_names[1]
from_client = clients[from_env]
to_client = clients[to_env]
print(f"\nТестируем API для окружений: {from_env} -> {to_env}")
try:
# Получаем список баз данных из первого окружения
print(f"\nПолучаем список БД из {from_env}:")
count, dbs = from_client.get_databases()
print(f"Найдено {count} баз данных")
print("Полный ответ API:")
print(json.dumps({"count": count, "result": dbs}, indent=2, ensure_ascii=False))
# Получаем список баз данных из второго окружения
print(f"\nПолучаем список БД из {to_env}:")
count, dbs = to_client.get_databases()
print(f"Найдено {count} баз данных")
print("Полный ответ API:")
print(json.dumps({"count": count, "result": dbs}, indent=2, ensure_ascii=False))
except Exception as e:
print(f"Ошибка при тестировании API: {e}")
import traceback
traceback.print_exc()
# [/DEF:debug_database_api]
if __name__ == "__main__":
debug_database_api()
# [/DEF:debug_db_api]

View File

@@ -1,26 +1,27 @@
# <GRACE_MODULE id="get_dataset_structure" name="get_dataset_structure.py"> # [DEF:get_dataset_structure:Module]
# @SEMANTICS: superset, dataset, structure, debug, json #
# @PURPOSE: Этот модуль предназначен для получения и сохранения структуры данных датасета из Superset. Он используется для отладки и анализа данных, возвращаемых API. # @SEMANTICS: superset, dataset, structure, debug, json
# @DEPENDS_ON: superset_tool.client -> Использует SupersetClient для взаимодействия с API. # @PURPOSE: Этот модуль предназначен для получения и сохранения структуры данных датасета из Superset. Он используется для отладки и анализа данных, возвращаемых API.
# @DEPENDS_ON: superset_tool.utils.init_clients -> Для инициализации клиентов Superset. # @LAYER: App
# @DEPENDS_ON: superset_tool.utils.logger -> Для логирования. # @RELATION: DEPENDS_ON -> superset_tool.client
# @RELATION: DEPENDS_ON -> superset_tool.utils.init_clients
# @RELATION: DEPENDS_ON -> superset_tool.utils.logger
# @PUBLIC_API: get_and_save_dataset
# <IMPORTS> # [SECTION: IMPORTS]
import argparse import argparse
import json import json
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.logger import SupersetLogger
# </IMPORTS> # [/SECTION]
# --- Начало кода модуля --- # [DEF:get_and_save_dataset:Function]
# @PURPOSE: Получает структуру датасета из Superset и сохраняет ее в JSON-файл.
# <ANCHOR id="get_and_save_dataset" type="Function"> # @RELATION: CALLS -> setup_clients
# @PURPOSE: Получает структуру датасета из Superset и сохраняет ее в JSON-файл. # @RELATION: CALLS -> superset_client.get_dataset
# @PARAM: env: str - Среда (dev, prod, и т.д.) для подключения. # @PARAM: env (str) - Среда (dev, prod, и т.д.) для подключения.
# @PARAM: dataset_id: int - ID датасета для получения. # @PARAM: dataset_id (int) - ID датасета для получения.
# @PARAM: output_path: str - Путь для сохранения JSON-файла. # @PARAM: output_path (str) - Путь для сохранения JSON-файла.
# @RELATION: CALLS -> setup_clients
# @RELATION: CALLS -> superset_client.get_dataset
def get_and_save_dataset(env: str, dataset_id: int, output_path: str): def get_and_save_dataset(env: str, dataset_id: int, output_path: str):
""" """
Получает структуру датасета и сохраняет в файл. Получает структуру датасета и сохраняет в файл.
@@ -49,11 +50,8 @@ def get_and_save_dataset(env: str, dataset_id: int, output_path: str):
except Exception as e: except Exception as e:
logger.error("[get_and_save_dataset][Failure] An error occurred: %s", e, exc_info=True) logger.error("[get_and_save_dataset][Failure] An error occurred: %s", e, exc_info=True)
# [/DEF:get_and_save_dataset]
# </ANCHOR>
# <ANCHOR id="__main__" type="Object">
# @PURPOSE: Точка входа для CLI. Парсит аргументы и запускает получение структуры датасета.
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Получение структуры датасета из Superset.") parser = argparse.ArgumentParser(description="Получение структуры датасета из Superset.")
parser.add_argument("--dataset-id", required=True, type=int, help="ID датасета.") parser.add_argument("--dataset-id", required=True, type=int, help="ID датасета.")
@@ -62,8 +60,5 @@ if __name__ == "__main__":
args = parser.parse_args() args = parser.parse_args()
get_and_save_dataset(args.env, args.dataset_id, args.output_path) get_and_save_dataset(args.env, args.dataset_id, args.output_path)
# </ANCHOR>
# --- Конец кода модуля --- # [/DEF:get_dataset_structure]
# </GRACE_MODULE>

View File

@@ -1,14 +1,18 @@
# <GRACE_MODULE id="migration_script" name="migration_script.py"> # [DEF:migration_script:Module]
# @SEMANTICS: migration, cli, superset, ui, logging, error-recovery, batch-delete #
# @PURPOSE: Предоставляет интерактивный CLI для миграции дашбордов Superset между окружениями с возможностью восстановления после ошибок. # @SEMANTICS: migration, cli, superset, ui, logging, error-recovery, batch-delete
# @DEPENDS_ON: superset_tool.client -> Для взаимодействия с API Superset. # @PURPOSE: Предоставляет интерактивный CLI для миграции дашбордов Superset между окружениями с возможностью восстановления после ошибок.
# @DEPENDS_ON: superset_tool.utils -> Для инициализации клиентов, работы с файлами, UI и логирования. # @LAYER: App
# @RELATION: DEPENDS_ON -> superset_tool.client
# @RELATION: DEPENDS_ON -> superset_tool.utils
# @PUBLIC_API: Migration
# <IMPORTS> # [SECTION: IMPORTS]
import json import json
import logging import logging
import sys import sys
import zipfile import zipfile
import re
from pathlib import Path from pathlib import Path
from typing import List, Optional, Tuple, Dict from typing import List, Optional, Tuple, Dict
from superset_tool.client import SupersetClient from superset_tool.client import SupersetClient
@@ -16,22 +20,20 @@ from superset_tool.utils.init_clients import setup_clients
from superset_tool.utils.fileio import create_temp_file, update_yamls, create_dashboard_export from superset_tool.utils.fileio import create_temp_file, update_yamls, create_dashboard_export
from superset_tool.utils.whiptail_fallback import menu, checklist, yesno, msgbox, inputbox, gauge from superset_tool.utils.whiptail_fallback import menu, checklist, yesno, msgbox, inputbox, gauge
from superset_tool.utils.logger import SupersetLogger from superset_tool.utils.logger import SupersetLogger
# </IMPORTS> # [/SECTION]
# --- Начало кода модуля --- # [DEF:Migration:Class]
# @PURPOSE: Инкапсулирует логику интерактивной миграции дашбордов с возможностью «удалить‑и‑перезаписать» при ошибке импорта.
# <ANCHOR id="Migration" type="Class"> # @RELATION: CREATES_INSTANCE_OF -> SupersetLogger
# @PURPOSE: Инкапсулирует логику интерактивной миграции дашбордов с возможностью «удалить‑и‑перезаписать» при ошибке импорта. # @RELATION: USES -> SupersetClient
# @RELATION: CREATES_INSTANCE_OF -> SupersetLogger
# @RELATION: USES -> SupersetClient
class Migration: class Migration:
""" """
Интерактивный процесс миграции дашбордов. Интерактивный процесс миграции дашбордов.
""" """
# [DEF:Migration.__init__:Function]
# @PURPOSE: Инициализирует сервис миграции, настраивает логгер и начальные состояния.
# @POST: `self.logger` готов к использованию; `enable_delete_on_failure` = `False`.
def __init__(self) -> None: def __init__(self) -> None:
# <ANCHOR id="Migration.__init__" type="Function">
# @PURPOSE: Инициализирует сервис миграции, настраивает логгер и начальные состояния.
# @POST: `self.logger` готов к использованию; `enable_delete_on_failure` = `False`.
default_log_dir = Path.cwd() / "logs" default_log_dir = Path.cwd() / "logs"
self.logger = SupersetLogger( self.logger = SupersetLogger(
name="migration_script", name="migration_script",
@@ -46,17 +48,17 @@ class Migration:
self.db_config_replacement: Optional[dict] = None self.db_config_replacement: Optional[dict] = None
self._failed_imports: List[dict] = [] self._failed_imports: List[dict] = []
assert self.logger is not None, "Logger must be instantiated." assert self.logger is not None, "Logger must be instantiated."
# </ANCHOR id="Migration.__init__"> # [/DEF:Migration.__init__]
# <ANCHOR id="Migration.run" type="Function"> # [DEF:Migration.run:Function]
# @PURPOSE: Точка входа последовательный запуск всех шагов миграции. # @PURPOSE: Точка входа последовательный запуск всех шагов миграции.
# @PRE: Логгер готов. # @PRE: Логгер готов.
# @POST: Скрипт завершён, пользователю выведено сообщение. # @POST: Скрипт завершён, пользователю выведено сообщение.
# @RELATION: CALLS -> self.ask_delete_on_failure # @RELATION: CALLS -> self.ask_delete_on_failure
# @RELATION: CALLS -> self.select_environments # @RELATION: CALLS -> self.select_environments
# @RELATION: CALLS -> self.select_dashboards # @RELATION: CALLS -> self.select_dashboards
# @RELATION: CALLS -> self.confirm_db_config_replacement # @RELATION: CALLS -> self.confirm_db_config_replacement
# @RELATION: CALLS -> self.execute_migration # @RELATION: CALLS -> self.execute_migration
def run(self) -> None: def run(self) -> None:
self.logger.info("[run][Entry] Запуск скрипта миграции.") self.logger.info("[run][Entry] Запуск скрипта миграции.")
self.ask_delete_on_failure() self.ask_delete_on_failure()
@@ -65,12 +67,12 @@ class Migration:
self.confirm_db_config_replacement() self.confirm_db_config_replacement()
self.execute_migration() self.execute_migration()
self.logger.info("[run][Exit] Скрипт миграции завершён.") self.logger.info("[run][Exit] Скрипт миграции завершён.")
# </ANCHOR id="Migration.run"> # [/DEF:Migration.run]
# <ANCHOR id="Migration.ask_delete_on_failure" type="Function"> # [DEF:Migration.ask_delete_on_failure:Function]
# @PURPOSE: Запрашивает у пользователя, следует ли удалять дашборд при ошибке импорта. # @PURPOSE: Запрашивает у пользователя, следует ли удалять дашборд при ошибке импорта.
# @POST: `self.enable_delete_on_failure` установлен. # @POST: `self.enable_delete_on_failure` установлен.
# @RELATION: CALLS -> yesno # @RELATION: CALLS -> yesno
def ask_delete_on_failure(self) -> None: def ask_delete_on_failure(self) -> None:
self.enable_delete_on_failure = yesno( self.enable_delete_on_failure = yesno(
"Поведение при ошибке импорта", "Поведение при ошибке импорта",
@@ -80,14 +82,14 @@ class Migration:
"[ask_delete_on_failure][State] Delete-on-failure = %s", "[ask_delete_on_failure][State] Delete-on-failure = %s",
self.enable_delete_on_failure, self.enable_delete_on_failure,
) )
# </ANCHOR id="Migration.ask_delete_on_failure"> # [/DEF:Migration.ask_delete_on_failure]
# <ANCHOR id="Migration.select_environments" type="Function"> # [DEF:Migration.select_environments:Function]
# @PURPOSE: Позволяет пользователю выбрать исходное и целевое окружения Superset. # @PURPOSE: Позволяет пользователю выбрать исходное и целевое окружения Superset.
# @PRE: `setup_clients` успешно инициализирует все клиенты. # @PRE: `setup_clients` успешно инициализирует все клиенты.
# @POST: `self.from_c` и `self.to_c` установлены. # @POST: `self.from_c` и `self.to_c` установлены.
# @RELATION: CALLS -> setup_clients # @RELATION: CALLS -> setup_clients
# @RELATION: CALLS -> menu # @RELATION: CALLS -> menu
def select_environments(self) -> None: def select_environments(self) -> None:
self.logger.info("[select_environments][Entry] Шаг 1/5: Выбор окружений.") self.logger.info("[select_environments][Entry] Шаг 1/5: Выбор окружений.")
try: try:
@@ -119,14 +121,14 @@ class Migration:
self.to_c = all_clients[to_env_name] self.to_c = all_clients[to_env_name]
self.logger.info("[select_environments][State] to = %s", to_env_name) self.logger.info("[select_environments][State] to = %s", to_env_name)
self.logger.info("[select_environments][Exit] Шаг 1 завершён.") self.logger.info("[select_environments][Exit] Шаг 1 завершён.")
# </ANCHOR id="Migration.select_environments"> # [/DEF:Migration.select_environments]
# <ANCHOR id="Migration.select_dashboards" type="Function"> # [DEF:Migration.select_dashboards:Function]
# @PURPOSE: Позволяет пользователю выбрать набор дашбордов для миграции. # @PURPOSE: Позволяет пользователю выбрать набор дашбордов для миграции.
# @PRE: `self.from_c` инициализирован. # @PRE: `self.from_c` инициализирован.
# @POST: `self.dashboards_to_migrate` заполнен. # @POST: `self.dashboards_to_migrate` заполнен.
# @RELATION: CALLS -> self.from_c.get_dashboards # @RELATION: CALLS -> self.from_c.get_dashboards
# @RELATION: CALLS -> checklist # @RELATION: CALLS -> checklist
def select_dashboards(self) -> None: def select_dashboards(self) -> None:
self.logger.info("[select_dashboards][Entry] Шаг 2/5: Выбор дашбордов.") self.logger.info("[select_dashboards][Entry] Шаг 2/5: Выбор дашбордов.")
try: try:
@@ -135,11 +137,20 @@ class Migration:
self.logger.warning("[select_dashboards][State] No dashboards.") self.logger.warning("[select_dashboards][State] No dashboards.")
msgbox("Информация", "В исходном окружении нет дашбордов.") msgbox("Информация", "В исходном окружении нет дашбордов.")
return return
options = [("ALL", "Все дашборды")] + [ rc, regex = inputbox("Поиск", "Введите регулярное выражение для поиска дашбордов:")
(str(d["id"]), d["dashboard_title"]) for d in all_dashboards if rc != 0:
return
# Ensure regex is a string and perform caseinsensitive search
regex_str = str(regex)
filtered_dashboards = [
d for d in all_dashboards if re.search(regex_str, d["dashboard_title"], re.IGNORECASE)
] ]
options = [("ALL", "Все дашборды")] + [
(str(d["id"]), d["dashboard_title"]) for d in filtered_dashboards
]
rc, selected = checklist( rc, selected = checklist(
title="Выбор дашбордов", title="Выбор дашбордов",
prompt="Отметьте нужные дашборды (введите номера):", prompt="Отметьте нужные дашборды (введите номера):",
@@ -147,14 +158,14 @@ class Migration:
) )
if rc != 0: if rc != 0:
return return
if "ALL" in selected: if "ALL" in selected:
self.dashboards_to_migrate = list(all_dashboards) self.dashboards_to_migrate = filtered_dashboards
else: else:
self.dashboards_to_migrate = [ self.dashboards_to_migrate = [
d for d in all_dashboards if str(d["id"]) in selected d for d in filtered_dashboards if str(d["id"]) in selected
] ]
self.logger.info( self.logger.info(
"[select_dashboards][State] Выбрано %d дашбордов.", "[select_dashboards][State] Выбрано %d дашбордов.",
len(self.dashboards_to_migrate), len(self.dashboards_to_migrate),
@@ -163,32 +174,106 @@ class Migration:
self.logger.error("[select_dashboards][Failure] %s", e, exc_info=True) self.logger.error("[select_dashboards][Failure] %s", e, exc_info=True)
msgbox("Ошибка", "Не удалось получить список дашбордов.") msgbox("Ошибка", "Не удалось получить список дашбордов.")
self.logger.info("[select_dashboards][Exit] Шаг 2 завершён.") self.logger.info("[select_dashboards][Exit] Шаг 2 завершён.")
# </ANCHOR id="Migration.select_dashboards"> # [/DEF:Migration.select_dashboards]
# <ANCHOR id="Migration.confirm_db_config_replacement" type="Function"> # [DEF:Migration.confirm_db_config_replacement:Function]
# @PURPOSE: Запрашивает у пользователя, требуется ли заменить имена БД в YAML-файлах. # @PURPOSE: Запрашивает у пользователя, требуется ли заменить имена БД в YAML-файлах.
# @POST: `self.db_config_replacement` либо `None`, либо заполнен. # @POST: `self.db_config_replacement` либо `None`, либо заполнен.
# @RELATION: CALLS -> yesno # @RELATION: CALLS -> yesno
# @RELATION: CALLS -> inputbox # @RELATION: CALLS -> self._select_databases
def confirm_db_config_replacement(self) -> None: def confirm_db_config_replacement(self) -> None:
if yesno("Замена БД", "Заменить конфигурацию БД в YAMLфайлах?"): if yesno("Замена БД", "Заменить конфигурацию БД в YAMLфайлах?"):
rc, old_name = inputbox("Замена БД", "Старое имя БД (например, db_dev):") old_db, new_db = self._select_databases()
if rc != 0: return if not old_db or not new_db:
rc, new_name = inputbox("Замена БД", "Новое имя БД (например, db_prod):") self.logger.info("[confirm_db_config_replacement][State] Selection cancelled.")
if rc != 0: return return
self.db_config_replacement = { "old": {"database_name": old_name}, "new": {"database_name": new_name} } self.db_config_replacement = { "old": {"database_name": old_db["database_name"]}, "new": {"database_name": new_db["database_name"]} }
self.logger.info("[confirm_db_config_replacement][State] Replacement set: %s", self.db_config_replacement) self.logger.info("[confirm_db_config_replacement][State] Replacement set: %s", self.db_config_replacement)
else: else:
self.logger.info("[confirm_db_config_replacement][State] Skipped.") self.logger.info("[confirm_db_config_replacement][State] Skipped.")
# </ANCHOR id="Migration.confirm_db_config_replacement"> # [/DEF:Migration.confirm_db_config_replacement]
# <ANCHOR id="Migration._batch_delete_by_ids" type="Function"> # [DEF:Migration._select_databases:Function]
# @PURPOSE: Удаляет набор дашбордов по их ID единым запросом. # @PURPOSE: Позволяет пользователю выбрать исходную и целевую БД через API.
# @PRE: `ids` непустой список целых чисел. # @POST: Возвращает кортеж (старая БД, новая БД) или (None, None) при отмене.
# @POST: Все указанные дашборды удалены (если они существовали). # @RELATION: CALLS -> self.from_c.get_databases
# @PARAM: ids: List[int] - Список ID дашбордов для удаления. # @RELATION: CALLS -> self.to_c.get_databases
# @RELATION: CALLS -> self.to_c.network.request # @RELATION: CALLS -> self.from_c.get_database
# @RELATION: CALLS -> self.to_c.get_database
# @RELATION: CALLS -> menu
def _select_databases(self) -> tuple:
self.logger.info("[_select_databases][Entry] Selecting databases from both environments.")
# Получаем список БД из обоих окружений
try:
_, from_dbs = self.from_c.get_databases()
_, to_dbs = self.to_c.get_databases()
except Exception as e:
self.logger.error("[_select_databases][Failure] Failed to fetch databases: %s", e)
msgbox("Ошибка", "Не удалось получить список баз данных.")
return None, None
# Формируем список для выбора
# По Swagger документации, в ответе API поле называется "database_name"
from_choices = []
for db in from_dbs:
db_name = db.get("database_name", "Без имени")
from_choices.append((str(db["id"]), db_name))
to_choices = []
for db in to_dbs:
db_name = db.get("database_name", "Без имени")
to_choices.append((str(db["id"]), db_name))
# Показываем список БД для исходного окружения
rc, from_sel = menu(
title="Выбор исходной БД",
prompt="Выберите исходную БД:",
choices=[f"{name} (ID: {id})" for id, name in from_choices]
)
if rc != 0:
return None, None
# Определяем выбранную БД
from_db_id = from_choices[[choice[1] for choice in from_choices].index(from_sel.split(" (ID: ")[0])]
# Получаем полную информацию о выбранной БД из исходного окружения
try:
from_db = self.from_c.get_database(int(from_db_id))
except Exception as e:
self.logger.error("[_select_databases][Failure] Failed to fetch database details: %s", e)
msgbox("Ошибка", "Не удалось получить информацию о выбранной базе данных.")
return None, None
# Показываем список БД для целевого окружения
rc, to_sel = menu(
title="Выбор целевой БД",
prompt="Выберите целевую БД:",
choices=[f"{name} (ID: {id})" for id, name in to_choices]
)
if rc != 0:
return None, None
# Определяем выбранную БД
to_db_id = to_choices[[choice[1] for choice in to_choices].index(to_sel.split(" (ID: ")[0])]
# Получаем полную информацию о выбранной БД из целевого окружения
try:
to_db = self.to_c.get_database(int(to_db_id))
except Exception as e:
self.logger.error("[_select_databases][Failure] Failed to fetch database details: %s", e)
msgbox("Ошибка", "Не удалось получить информацию о выбранной базе данных.")
return None, None
self.logger.info("[_select_databases][Exit] Selected databases: %s -> %s", from_db.get("database_name", "Без имени"), to_db.get("database_name", "Без имени"))
return from_db, to_db
# [/DEF:Migration._select_databases]
# [DEF:Migration._batch_delete_by_ids:Function]
# @PURPOSE: Удаляет набор дашбордов по их ID единым запросом.
# @PRE: `ids` непустой список целых чисел.
# @POST: Все указанные дашборды удалены (если они существовали).
# @RELATION: CALLS -> self.to_c.network.request
# @PARAM: ids (List[int]) - Список ID дашбордов для удаления.
def _batch_delete_by_ids(self, ids: List[int]) -> None: def _batch_delete_by_ids(self, ids: List[int]) -> None:
if not ids: if not ids:
self.logger.debug("[_batch_delete_by_ids][Skip] Empty ID list nothing to delete.") self.logger.debug("[_batch_delete_by_ids][Skip] Empty ID list nothing to delete.")
@@ -202,18 +287,18 @@ class Migration:
self.logger.warning("[_batch_delete_by_ids][Warning] Unexpected delete response: %s", response) self.logger.warning("[_batch_delete_by_ids][Warning] Unexpected delete response: %s", response)
else: else:
self.logger.info("[_batch_delete_by_ids][Success] Delete request completed.") self.logger.info("[_batch_delete_by_ids][Success] Delete request completed.")
# </ANCHOR id="Migration._batch_delete_by_ids"> # [/DEF:Migration._batch_delete_by_ids]
# <ANCHOR id="Migration.execute_migration" type="Function"> # [DEF:Migration.execute_migration:Function]
# @PURPOSE: Выполняет экспорт-импорт дашбордов, обрабатывает ошибки и, при необходимости, выполняет процедуру восстановления. # @PURPOSE: Выполняет экспорт-импорт дашбордов, обрабатывает ошибки и, при необходимости, выполняет процедуру восстановления.
# @PRE: `self.dashboards_to_migrate` не пуст; `self.from_c` и `self.to_c` инициализированы. # @PRE: `self.dashboards_to_migrate` не пуст; `self.from_c` и `self.to_c` инициализированы.
# @POST: Успешные дашборды импортированы; неудачные - восстановлены или залогированы. # @POST: Успешные дашборды импортированы; неудачные - восстановлены или залогированы.
# @RELATION: CALLS -> self.from_c.export_dashboard # @RELATION: CALLS -> self.from_c.export_dashboard
# @RELATION: CALLS -> create_temp_file # @RELATION: CALLS -> create_temp_file
# @RELATION: CALLS -> update_yamls # @RELATION: CALLS -> update_yamls
# @RELATION: CALLS -> create_dashboard_export # @RELATION: CALLS -> create_dashboard_export
# @RELATION: CALLS -> self.to_c.import_dashboard # @RELATION: CALLS -> self.to_c.import_dashboard
# @RELATION: CALLS -> self._batch_delete_by_ids # @RELATION: CALLS -> self._batch_delete_by_ids
def execute_migration(self) -> None: def execute_migration(self) -> None:
if not self.dashboards_to_migrate: if not self.dashboards_to_migrate:
self.logger.warning("[execute_migration][Skip] No dashboards to migrate.") self.logger.warning("[execute_migration][Skip] No dashboards to migrate.")
@@ -269,13 +354,11 @@ class Migration:
self.logger.info("[execute_migration][Exit] Migration finished.") self.logger.info("[execute_migration][Exit] Migration finished.")
msgbox("Информация", "Миграция завершена!") msgbox("Информация", "Миграция завершена!")
# </ANCHOR id="Migration.execute_migration"> # [/DEF:Migration.execute_migration]
# </ANCHOR id="Migration"> # [/DEF:Migration]
# --- Конец кода модуля ---
if __name__ == "__main__": if __name__ == "__main__":
Migration().run() Migration().run()
# </GRACE_MODULE id="migration_script"> # [/DEF:migration_script]

View File

@@ -1,24 +1,25 @@
# <GRACE_MODULE id="run_mapper" name="run_mapper.py"> # [DEF:run_mapper:Module]
# @SEMANTICS: runner, configuration, cli, main #
# @PURPOSE: Этот модуль является CLI-точкой входа для запуска процесса меппинга метаданных датасетов. # @SEMANTICS: runner, configuration, cli, main
# @DEPENDS_ON: dataset_mapper -> Использует DatasetMapper для выполнения основной логики. # @PURPOSE: Этот модуль является CLI-точкой входа для запуска процесса меппинга метаданных датасетов.
# @DEPENDS_ON: superset_tool.utils -> Для инициализации клиентов и логирования. # @LAYER: App
# @RELATION: DEPENDS_ON -> superset_tool.utils.dataset_mapper
# @RELATION: DEPENDS_ON -> superset_tool.utils
# @PUBLIC_API: main
# <IMPORTS> # [SECTION: IMPORTS]
import argparse import argparse
import keyring import keyring
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.logger import SupersetLogger
from superset_tool.utils.dataset_mapper import DatasetMapper from superset_tool.utils.dataset_mapper import DatasetMapper
# </IMPORTS> # [/SECTION]
# --- Начало кода модуля --- # [DEF:main:Function]
# @PURPOSE: Парсит аргументы командной строки и запускает процесс меппинга.
# <ANCHOR id="main" type="Function"> # @RELATION: CREATES_INSTANCE_OF -> DatasetMapper
# @PURPOSE: Парсит аргументы командной строки и запускает процесс меппинга. # @RELATION: CALLS -> setup_clients
# @RELATION: CREATES_INSTANCE_OF -> DatasetMapper # @RELATION: CALLS -> DatasetMapper.run_mapping
# @RELATION: CALLS -> setup_clients
# @RELATION: CALLS -> DatasetMapper.run_mapping
def main(): def main():
parser = argparse.ArgumentParser(description="Map dataset verbose names in Superset.") parser = argparse.ArgumentParser(description="Map dataset verbose names in Superset.")
parser.add_argument('--source', type=str, required=True, choices=['postgres', 'excel', 'both'], help='The source for the mapping.') parser.add_argument('--source', type=str, required=True, choices=['postgres', 'excel', 'both'], help='The source for the mapping.')
@@ -63,11 +64,9 @@ def main():
except Exception as main_exc: except Exception as main_exc:
logger.error("[main][Failure] An unexpected error occurred: %s", main_exc, exc_info=True) logger.error("[main][Failure] An unexpected error occurred: %s", main_exc, exc_info=True)
# </ANCHOR id="main"> # [/DEF:main]
if __name__ == '__main__': if __name__ == '__main__':
main() main()
# --- Конец кода модуля --- # [/DEF:run_mapper]
# </GRACE_MODULE id="run_mapper">

View File

@@ -1,118 +1,119 @@
# <GRACE_MODULE id="search_script" name="search_script.py"> # [DEF:search_script:Module]
# @SEMANTICS: search, superset, dataset, regex, file_output #
# @PURPOSE: Предоставляет утилиты для поиска по текстовым паттернам в метаданных датасетов Superset. # @SEMANTICS: search, superset, dataset, regex, file_output
# @DEPENDS_ON: superset_tool.client -> Для взаимодействия с API Superset. # @PURPOSE: Предоставляет утилиты для поиска по текстовым паттернам в метаданных датасетов Superset.
# @DEPENDS_ON: superset_tool.utils -> Для логирования и инициализации клиентов. # @LAYER: App
# @RELATION: DEPENDS_ON -> superset_tool.client
# <IMPORTS> # @RELATION: DEPENDS_ON -> superset_tool.utils
import logging # @PUBLIC_API: search_datasets, save_results_to_file, print_search_results, main
# [SECTION: IMPORTS]
import logging
import re import re
import os import os
from typing import Dict, Optional from typing import Dict, Optional
from requests.exceptions import RequestException from requests.exceptions import RequestException
from superset_tool.client import SupersetClient from superset_tool.client import SupersetClient
from superset_tool.exceptions import SupersetAPIError from superset_tool.exceptions import SupersetAPIError
from superset_tool.utils.logger import SupersetLogger from superset_tool.utils.logger import SupersetLogger
from superset_tool.utils.init_clients import setup_clients from superset_tool.utils.init_clients import setup_clients
# </IMPORTS> # [/SECTION]
# --- Начало кода модуля --- # [DEF:search_datasets:Function]
# @PURPOSE: Выполняет поиск по строковому паттерну в метаданных всех датасетов.
# <ANCHOR id="search_datasets" type="Function"> # @PRE: `client` должен быть инициализированным экземпляром `SupersetClient`.
# @PURPOSE: Выполняет поиск по строковому паттерну в метаданных всех датасетов. # @PRE: `search_pattern` должен быть валидной строкой регулярного выражения.
# @PRE: `client` должен быть инициализированным экземпляром `SupersetClient`. # @POST: Возвращает словарь с результатами поиска, где ключ - ID датасета, значение - список совпадений.
# @PRE: `search_pattern` должен быть валидной строкой регулярного выражения. # @RELATION: CALLS -> client.get_datasets
# @POST: Возвращает словарь с результатами поиска, где ключ - ID датасета, значение - список совпадений. # @THROW: re.error - Если паттерн регулярного выражения невалиден.
# @PARAM: client: SupersetClient - Клиент для доступа к API Superset. # @THROW: SupersetAPIError, RequestException - При критических ошибках API.
# @PARAM: search_pattern: str - Регулярное выражение для поиска. # @PARAM: client (SupersetClient) - Клиент для доступа к API Superset.
# @PARAM: logger: Optional[SupersetLogger] - Инстанс логгера. # @PARAM: search_pattern (str) - Регулярное выражение для поиска.
# @RETURN: Optional[Dict] - Словарь с результатами или None, если ничего не найдено. # @PARAM: logger (Optional[SupersetLogger]) - Инстанс логгера.
# @THROW: re.error - Если паттерн регулярного выражения невалиден. # @RETURN: Optional[Dict] - Словарь с результатами или None, если ничего не найдено.
# @THROW: SupersetAPIError, RequestException - При критических ошибках API. def search_datasets(
# @RELATION: CALLS -> client.get_datasets client: SupersetClient,
def search_datasets( search_pattern: str,
client: SupersetClient, logger: Optional[SupersetLogger] = None
search_pattern: str, ) -> Optional[Dict]:
logger: Optional[SupersetLogger] = None logger = logger or SupersetLogger(name="dataset_search")
) -> Optional[Dict]: logger.info(f"[search_datasets][Enter] Searching for pattern: '{search_pattern}'")
logger = logger or SupersetLogger(name="dataset_search") try:
logger.info(f"[search_datasets][Enter] Searching for pattern: '{search_pattern}'") _, datasets = client.get_datasets(query={"columns": ["id", "table_name", "sql", "database", "columns"]})
try:
_, datasets = client.get_datasets(query={"columns": ["id", "table_name", "sql", "database", "columns"]}) if not datasets:
logger.warning("[search_datasets][State] No datasets found.")
if not datasets: return None
logger.warning("[search_datasets][State] No datasets found.")
return None pattern = re.compile(search_pattern, re.IGNORECASE)
results = {}
pattern = re.compile(search_pattern, re.IGNORECASE)
results = {} for dataset in datasets:
dataset_id = dataset.get('id')
for dataset in datasets: if not dataset_id:
dataset_id = dataset.get('id') continue
if not dataset_id:
continue matches = []
for field, value in dataset.items():
matches = [] value_str = str(value)
for field, value in dataset.items(): if pattern.search(value_str):
value_str = str(value) match_obj = pattern.search(value_str)
if pattern.search(value_str): matches.append({
match_obj = pattern.search(value_str) "field": field,
matches.append({ "match": match_obj.group() if match_obj else "",
"field": field, "value": value_str
"match": match_obj.group() if match_obj else "", })
"value": value_str
}) if matches:
results[dataset_id] = matches
if matches:
results[dataset_id] = matches logger.info(f"[search_datasets][Success] Found matches in {len(results)} datasets.")
return results
logger.info(f"[search_datasets][Success] Found matches in {len(results)} datasets.")
return results except re.error as e:
logger.error(f"[search_datasets][Failure] Invalid regex pattern: {e}", exc_info=True)
except re.error as e: raise
logger.error(f"[search_datasets][Failure] Invalid regex pattern: {e}", exc_info=True) except (SupersetAPIError, RequestException) as e:
raise logger.critical(f"[search_datasets][Failure] Critical error during search: {e}", exc_info=True)
except (SupersetAPIError, RequestException) as e: raise
logger.critical(f"[search_datasets][Failure] Critical error during search: {e}", exc_info=True) # [/DEF:search_datasets]
raise
# </ANCHOR id="search_datasets"> # [DEF:save_results_to_file:Function]
# @PURPOSE: Сохраняет результаты поиска в текстовый файл.
# <ANCHOR id="save_results_to_file" type="Function"> # @PRE: `results` является словарем, возвращенным `search_datasets`, или `None`.
# @PURPOSE: Сохраняет результаты поиска в текстовый файл. # @PRE: `filename` должен быть допустимым путем к файлу.
# @PRE: `results` является словарем, возвращенным `search_datasets`, или `None`. # @POST: Записывает отформатированные результаты в указанный файл.
# @PRE: `filename` должен быть допустимым путем к файлу. # @PARAM: results (Optional[Dict]) - Словарь с результатами поиска.
# @POST: Записывает отформатированные результаты в указанный файл. # @PARAM: filename (str) - Имя файла для сохранения результатов.
# @PARAM: results: Optional[Dict] - Словарь с результатами поиска. # @PARAM: logger (Optional[SupersetLogger]) - Инстанс логгера.
# @PARAM: filename: str - Имя файла для сохранения результатов. # @RETURN: bool - Успешно ли выполнено сохранение.
# @PARAM: logger: Optional[SupersetLogger] - Инстанс логгера. def save_results_to_file(results: Optional[Dict], filename: str, logger: Optional[SupersetLogger] = None) -> bool:
# @RETURN: bool - Успешно ли выполнено сохранение. logger = logger or SupersetLogger(name="file_writer")
def save_results_to_file(results: Optional[Dict], filename: str, logger: Optional[SupersetLogger] = None) -> bool: logger.info(f"[save_results_to_file][Enter] Saving results to file: {filename}")
logger = logger or SupersetLogger(name="file_writer") try:
logger.info(f"[save_results_to_file][Enter] Saving results to file: {filename}") formatted_report = print_search_results(results)
try: with open(filename, 'w', encoding='utf-8') as f:
formatted_report = print_search_results(results) f.write(formatted_report)
with open(filename, 'w', encoding='utf-8') as f: logger.info(f"[save_results_to_file][Success] Results saved to {filename}")
f.write(formatted_report) return True
logger.info(f"[save_results_to_file][Success] Results saved to {filename}") except Exception as e:
return True logger.error(f"[save_results_to_file][Failure] Failed to save results to file: {e}", exc_info=True)
except Exception as e: return False
logger.error(f"[save_results_to_file][Failure] Failed to save results to file: {e}", exc_info=True) # [/DEF:save_results_to_file]
return False
# </ANCHOR id="save_results_to_file"> # [DEF:print_search_results:Function]
# @PURPOSE: Форматирует результаты поиска для читаемого вывода в консоль.
# <ANCHOR id="print_search_results" type="Function"> # @PRE: `results` является словарем, возвращенным `search_datasets`, или `None`.
# @PURPOSE: Форматирует результаты поиска для читаемого вывода в консоль. # @POST: Возвращает отформатированную строку с результатами.
# @PRE: `results` является словарем, возвращенным `search_datasets`, или `None`. # @PARAM: results (Optional[Dict]) - Словарь с результатами поиска.
# @POST: Возвращает отформатированную строку с результатами. # @PARAM: context_lines (int) - Количество строк контекста для вывода до и после совпадения.
# @PARAM: results: Optional[Dict] - Словарь с результатами поиска. # @RETURN: str - Отформатированный отчет.
# @PARAM: context_lines: int - Количество строк контекста для вывода до и после совпадения. def print_search_results(results: Optional[Dict], context_lines: int = 3) -> str:
# @RETURN: str - Отформатированный отчет. if not results:
def print_search_results(results: Optional[Dict], context_lines: int = 3) -> str: return "Ничего не найдено"
if not results:
return "Ничего не найдено" output = []
for dataset_id, matches in results.items():
output = []
for dataset_id, matches in results.items():
# Получаем информацию о базе данных для текущего датасета # Получаем информацию о базе данных для текущего датасета
database_info = "" database_info = ""
# Ищем поле database среди совпадений, чтобы вывести его # Ищем поле database среди совпадений, чтобы вывести его
@@ -131,63 +132,63 @@ def print_search_results(results: Optional[Dict], context_lines: int = 3) -> str
output.append(f" Database: {database_info}") output.append(f" Database: {database_info}")
output.append("") # Пустая строка для читабельности output.append("") # Пустая строка для читабельности
for match_info in matches: for match_info in matches:
field, match_text, full_value = match_info['field'], match_info['match'], match_info['value'] field, match_text, full_value = match_info['field'], match_info['match'], match_info['value']
output.append(f" - Поле: {field}") output.append(f" - Поле: {field}")
output.append(f" Совпадение: '{match_text}'") output.append(f" Совпадение: '{match_text}'")
lines = full_value.splitlines() lines = full_value.splitlines()
if not lines: continue if not lines: continue
match_line_index = -1 match_line_index = -1
for i, line in enumerate(lines): for i, line in enumerate(lines):
if match_text in line: if match_text in line:
match_line_index = i match_line_index = i
break break
if match_line_index != -1: if match_line_index != -1:
start = max(0, match_line_index - context_lines) start = max(0, match_line_index - context_lines)
end = min(len(lines), match_line_index + context_lines + 1) end = min(len(lines), match_line_index + context_lines + 1)
output.append(" Контекст:") output.append(" Контекст:")
for i in range(start, end): for i in range(start, end):
prefix = f"{i + 1:5d}: " prefix = f"{i + 1:5d}: "
line_content = lines[i] line_content = lines[i]
if i == match_line_index: if i == match_line_index:
highlighted = line_content.replace(match_text, f">>>{match_text}<<<") highlighted = line_content.replace(match_text, f">>>{match_text}<<<")
output.append(f" {prefix}{highlighted}") output.append(f" {prefix}{highlighted}")
else: else:
output.append(f" {prefix}{line_content}") output.append(f" {prefix}{line_content}")
output.append("-" * 25) output.append("-" * 25)
return "\n".join(output) return "\n".join(output)
# </ANCHOR id="print_search_results"> # [/DEF:print_search_results]
# <ANCHOR id="main" type="Function"> # [DEF:main:Function]
# @PURPOSE: Основная точка входа для запуска скрипта поиска. # @PURPOSE: Основная точка входа для запуска скрипта поиска.
# @RELATION: CALLS -> setup_clients # @RELATION: CALLS -> setup_clients
# @RELATION: CALLS -> search_datasets # @RELATION: CALLS -> search_datasets
# @RELATION: CALLS -> print_search_results # @RELATION: CALLS -> print_search_results
# @RELATION: CALLS -> save_results_to_file # @RELATION: CALLS -> save_results_to_file
def main(): def main():
logger = SupersetLogger(level=logging.INFO, console=True) logger = SupersetLogger(level=logging.INFO, console=True)
clients = setup_clients(logger) clients = setup_clients(logger)
target_client = clients['prod'] target_client = clients['dev5']
search_query = r"from dm_view.[a-z_]*" search_query = r"from dm(_view)*.account_debt"
# Генерируем имя файла на основе времени # Генерируем имя файла на основе времени
import datetime import datetime
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
output_filename = f"search_results_{timestamp}.txt" output_filename = f"search_results_{timestamp}.txt"
results = search_datasets( results = search_datasets(
client=target_client, client=target_client,
search_pattern=search_query, search_pattern=search_query,
logger=logger logger=logger
) )
report = print_search_results(results) report = print_search_results(results)
logger.info(f"[main][Success] Search finished. Report:\n{report}") logger.info(f"[main][Success] Search finished. Report:\n{report}")
# Сохраняем результаты в файл # Сохраняем результаты в файл
success = save_results_to_file(results, output_filename, logger) success = save_results_to_file(results, output_filename, logger)
@@ -195,12 +196,9 @@ def main():
logger.info(f"[main][Success] Results also saved to file: {output_filename}") logger.info(f"[main][Success] Results also saved to file: {output_filename}")
else: else:
logger.error(f"[main][Failure] Failed to save results to file: {output_filename}") logger.error(f"[main][Failure] Failed to save results to file: {output_filename}")
# [/DEF:main]
# </ANCHOR id="main"> if __name__ == "__main__":
main()
if __name__ == "__main__":
main() # [/DEF:search_script]
# --- Конец кода модуля ---
# </GRACE_MODULE id="search_script">

View File

@@ -1,120 +1,144 @@
### **Протокол GRACE-Py: Семантическая Разметка для AI-Агентов на Python** # 📁 BUNDLE: Engineering Prompting & GRACE Methodology
**Context Transfer Protocol for LLM Agents**
**Версия: 2.2 (Hybrid)** ## 1. Фундаментальная Парадигма (The "Physics" of LLMs)
Мы отказываемся от антропоморфного подхода ("диалог с помощником") в пользу инженерного подхода ("программирование семантического процессора").
#### **I. Философия и Основные Принципы** * **Трансформер = GNN (Graph Neural Network):** LLM обрабатывает токены как узлы в полносвязном графе. Чтобы модель работала эффективно, мы должны явно задавать топологию этого графа через семантические связи.
* **Мышление = Навигация по Состояниям (FSM):** Генерация — это переход между "состояниями веры" (Belief States). Мы управляем этими переходами через Якоря и Контракты.
* **Causal Attention & KV Cache:** Модель читает слева-направо. Смысл, обработанный в начале, "замораживается". **Правило:** Контекст и Контракты всегда строго *до* реализации.
* **Sparse Attention & Block Processing:** На больших контекстах (100k+) модель работает не с отдельными токенами, а с семантическими сжатиями блоков (чанков). Наша разметка создает идеальные границы для этих блоков, помогая механизму Top-K retrieval.
* **Проблема "Семантического Казино":** Без жесткой структуры модель играет в рулетку вероятностей. Мы устраняем это через детерминированные структуры (графы, схемы).
* **Проблема "Нейронного Воя" (Neural Howlround):** Самоусиливающиеся ошибки в длинных сессиях. **Решение:** Разделение сессий, жесткие инварианты и использование "суперпозиции" (анализ вариантов перед решением).
Этот протокол является **единственным источником истины** для правил семантического обогащения кода. Его цель — превратить процесс разработки с LLM-агентами из непредсказуемого "диалога" в управляемую **инженерную дисциплину**. ---
* **Аксиома 1: Код Вторичен.** Первична его семантическая модель (графы, контракты, якоря). ## 2. Методология GRACE (Framework)
* **Аксиома 2: Когерентность Абсолютна.** Все артефакты (ТЗ, граф, контракты, код) должны быть на 100% семантически согласованы. Целостная система управления жизненным циклом генерации.
* **Аксиома 3: Архитектура GPT — Закон.** Протокол построен на фундаментальных принципах работы трансформеров (Causal Attention, KV Cache, Sparse Attention).
#### **II. Структура Файла (`.py`)** * **G (Graph):** Глобальная карта проекта. Определяет связи (`DEPENDS_ON`, `CALLS`) между модулями. Служит картой для навигации внимания.
* **R (Rules):** Инварианты и ограничения (Безопасность, Стек, Паттерны).
* **A (Anchors):** Система навигации внутри кода.
* *Открывающий якорь:* Задает контекст.
* *Замыкающий якорь:* **Аккумулятор семантики**. Критически важен для RAG-систем (Cursor, GraphRAG), так как "вбирает" в себя смысл всего блока.
* **C (Contracts):** Принцип **Design by Contract (DbC)**. Спецификация (`@PRE`, `@POST`) всегда пишется *до* кода. Реализация обязана содержать проверки (`assert`/`raise`) этих условий.
* **E (Evaluation):** Логирование как декларация состояния (`[STATE:Validation]`) и проверка когерентности (`[Coherence:OK]`).
Каждый Python-файл ДОЛЖЕН иметь четкую, машиночитаемую структуру, обрамленную якорями. ---
## 3. Рабочий Протокол: GRACE-Py v3.1 (Strict Edition)
Это стандарт синтаксиса, к которому мы пришли. Он минимизирует "шум" (интерференцию с XML), использует нативные для Python токены (`def`) и убирает ролевую шелуху.
**Скопируйте этот блок в System Prompt новой LLM:**
```markdown
# SYSTEM STANDARD: GRACE-Py CODE GENERATION PROTOCOL
**OBJECTIVE:** Generate Python code that strictly adheres to the Semantic Coherence standards defined below. All output must be machine-readable, fractal-structured, and optimized for Sparse Attention navigation.
## I. CORE REQUIREMENTS
1. **Causal Validity:** Semantic definitions (Contracts) must ALWAYS precede implementation code.
2. **Immutability:** Once defined, architectural decisions in the Module Header are treated as immutable constraints.
3. **Format Compliance:** Output must strictly follow the `[DEF]` / `[/DEF]` anchor syntax.
---
## II. SYNTAX SPECIFICATION
Code must be wrapped in semantic anchors using square brackets to minimize token interference.
### 1. Entity Anchors (The "Container")
* **Start:** `# [DEF:identifier:Type]`
* **End:** `# [/DEF:identifier]` (MANDATORY for semantic accumulation)
* **Types:** `Module`, `Class`, `Function`, `DataClass`, `Enum`.
### 2. Metadata Tags (The "Content")
* **Syntax:** `# @KEY: Value`
* **Location:** Inside the `[DEF]` block, before any code.
### 3. Graph Relations (The "Map")
* **Syntax:** `# @RELATION: TYPE -> TARGET_ID`
* **Types:** `DEPENDS_ON`, `CALLS`, `INHERITS_FROM`, `IMPLEMENTS`, `WRITES_TO`, `READS_FROM`.
---
## III. FILE STRUCTURE STANDARD (Module Header)
Every `.py` file starts with a Module definition.
```python ```python
# <GRACE_MODULE id="my_module" name="my_module.py"> # [DEF:module_name:Module]
# @SEMANTICS: domain, usecase, data_processing #
# @PURPOSE: Этот модуль отвечает за обработку пользовательских данных. # @SEMANTICS: [keywords for vector search]
# @DEPENDS_ON: utils_module -> Использует утилиты для валидации. # @PURPOSE: [Primary responsibility of the module]
# @LAYER: [Architecture layer: Domain/Infra/UI]
# @RELATION: [Dependencies]
#
# @INVARIANT: [Global immutable rule for this file]
# @CONSTRAINT: [Hard restriction, e.g., "No SQL here"]
# @PUBLIC_API: [Exported symbols]
# <IMPORTS> # [SECTION: IMPORTS]
import os ...
from typing import List # [/SECTION]
# </IMPORTS>
# --- Начало кода модуля --- # ... IMPLEMENTATION ...
# ... (классы, функции, константы) ... # [/DEF:module_name]
# --- Конец кода модуля ---
# </GRACE_MODULE id="my_module">
``` ```
#### **III. Компоненты Разметки (Детализация GRACE-Py)** ---
##### **A. Anchors (Якоря): Навигация и Консолидация** ## IV. FUNCTION & CLASS CONTRACTS (DbC)
1. **Назначение:** Якоря — это основной инструмент для управления вниманием ИИ, создания семантических каналов и обеспечения надежной навигации в больших кодовых базах (Sparse Attention). Contracts are the **Source of Truth**.
2. **Синтаксис:** Используются парные комментарии в псевдо-XML формате.
* **Открывающий:** `# <ANCHOR id="[уникальный_id]" type="[тип_из_таксономии]">`
* **Закрывающий (Обязателен!):** `# </ANCHOR id="[уникальный_id]">`
3. **"Якорь-Аккумулятор":** Закрывающий якорь консолидирует всю семантику блока (контракт + код), создавая мощный вектор для RAG-систем.
4. **Семантические Каналы:** `id` якоря ДОЛЖЕН совпадать с именем сущности для создания устойчивой семантической связи.
5. **Таксономия Типов (`type`):** `Module`, `Class`, `Interface`, `Object`, `DataClass`, `SealedInterface`, `EnumClass`, `Function`, `UseCase`, `ViewModel`, `Repository`.
##### **C. Contracts (Контракты): Тактические Спецификации**
1. **Назначение:** Предоставление ИИ точных инструкций для генерации и валидации кода.
2. **Расположение:** Контракт всегда располагается **внутри открывающего якоря**, ДО декларации кода (`def` или `class`).
3. **Синтаксис:** JSDoc-подобный стиль с `@tag` для лаконичности и читаемости.
```python
# <ANCHOR id="process_data" type="Function">
# @PURPOSE: Валидирует и обрабатывает входящие данные пользователя.
# @SPEC_LINK: tz-req-005
# @PRE: `raw_data` не должен быть пустым.
# @POST: Возвращаемый словарь содержит ключ 'is_valid'.
# @PARAM: raw_data: Dict[str, any] - Сырые данные от пользователя.
# @RETURN: Dict[str, any] - Обработанные и валидированные данные.
# @TEST: input='{"user_id": 123}', expected_output='{"is_valid": True}'
# @THROW: ValueError - Если 'user_id' отсутствует.
# @RELATION: CALLS -> validate_user_id
# @CONSTRAINT: Не использовать внешние сетевые вызовы.
```
4. **Реализация в Коде:** Предусловия и постусловия, описанные в контракте, ДОЛЖНЫ быть реализованы в коде с использованием `assert`, `require()`/`check()` или явных `if...raise`.
##### **G. Graph (Граф Знаний)**
1. **Назначение:** Описание высокоуровневых зависимостей между сущностями.
2. **Реализация:** Граф определяется тегами `@RELATION` внутри GRACE блока (якоря). Это создает распределенный граф, который легко парсить.
* **Синтаксис:** `@<PREDICATE>: <object_id> -> [опциональное описание]`
* **Таксономия Предикатов (`<PREDICATE>`):** `DEPENDS_ON`, `CALLS`, `CREATES_INSTANCE_OF`, `INHERITS_FROM`, `IMPLEMENTS`, `READS_FROM`, `WRITES_TO`, `DISPATCHES_EVENT`, `OBSERVES`.
##### **E. Evaluation (Логирование)**
1. **Назначение:** Декларация `belief state` агента и обеспечение трассируемости для отладки.
2. **Формат:** `logger.level(f"[ANCHOR_ID][STATE] Сообщение")`
* **`ANCHOR_ID`:** `id` якоря, в котором находится лог.
* **`STATE`:** Текущее состояние логики (например, `Entry`, `Validation`, `Exit`, `CoherenceCheckFailed`).
3. **Пример:** `logger.debug(f"[process_data][Validation] Проверка `raw_data`...")`
#### **IV. Запреты и Ограничения**
1. **Запрет на Обычные Комментарии:** Комментарии в стиле `//` или `/* */` **ЗАПРЕЩЕНЫ**. Вся мета-информация должна быть в структурированных GRACE блоках.
* **Исключение:** `# [AI_NOTE]: ...` для прямых указаний агенту в конкретной точке кода.
#### **V. Полный Пример Разметки Функции (GRACE-Py 2.2)**
**Required Template:**
```python ```python
# <ANCHOR id="process_data" type="Function"> # [DEF:func_name:Function]
# @PURPOSE: Валидирует и обрабатывает входящие данные пользователя. # @PURPOSE: [Description]
# @SPEC_LINK: tz-req-005 # @SPEC_LINK: [Requirement ID]
# @PRE: `raw_data` не должен быть пустым. #
# @PARAM: raw_data: Dict[str, any] - Сырые данные от пользователя. # @PRE: [Condition required before execution]
# @RETURN: Dict[str, any] - Обработанные и валидированные данные. # @POST: [Condition guaranteed after execution]
# @TEST: input='{}', expected_exception='AssertionError' # @PARAM: [name] ([type]) - [desc]
# @RELATION: CALLS -> some_helper_function # @RETURN: [type] - [desc]
def process_data(raw_data: dict) -> dict: # @THROW: [Exception] - [Reason]
""" #
Docstring для стандартных инструментов Python. # @RELATION: [Graph connections]
Не является источником истины для ИИ-агентов. def func_name(...):
""" # 1. Runtime check of @PRE (Assertions)
logger.debug(f"[process_data][Entry] Начало обработки данных.") # 2. Logic implementation
# 3. Runtime check of @POST
# Реализация контракта pass
assert raw_data, "Precondition failed: raw_data must not be empty." # [/DEF:func_name]
# ... Основная логика ...
processed_data = {"is_valid": True}
processed_data.update(raw_data)
logger.info(f"[process_data][CoherenceCheck:Passed] Код соответствует контракту.")
logger.debug(f"[process_data][Exit] Завершение обработки.")
return processed_data
# </ANCHOR id="process_data">
``` ```
---
## V. LOGGING STANDARD (BELIEF STATE)
Logs define the agent's internal state for debugging and coherence checks.
**Format:** `logger.level(f"[{ANCHOR_ID}][{STATE}] {MESSAGE} context={...}")`
**States:** `Entry`, `Validation`, `Action`, `Coherence:OK`, `Coherence:Failed`, `Exit`.
---
## VI. GENERATION WORKFLOW
1. **Analyze Request:** Identify target module and graph position.
2. **Define Structure:** Generate `[DEF]` anchors and Contracts FIRST.
3. **Implement Logic:** Write code satisfying Contracts.
4. **Validate:** If logic conflicts with Contract -> Stop -> Report Error.
```
---
## 4. Интеграция с RAG (GraphRAG)
Как этот код используется инструментами (например, Cursor):
1. **Индексация:** RAG-система парсит теги `[DEF]`, `[/DEF]` и `@RELATION`.
2. **Построение Графа:** На основе `@RELATION` и `@DEPENDS_ON` строится граф знаний проекта.
3. **Вектор-Аккумулятор:** Замыкающий тег `[/DEF:func_name]` используется как точка для создания эмбеддинга всего блока. Это позволяет находить функцию не только по имени, но и по её внутренней логике.
4. **Поиск:** При запросе "Где логика авторизации?" система находит модуль по тегу `@SEMANTICS: auth` и переходит к конкретным функциям по графу.

View File

@@ -0,0 +1,14 @@
# [DEF:superset_tool:Module]
# @SEMANTICS: package, root
# @PURPOSE: Root package for superset_tool.
# @LAYER: Domain
# @PUBLIC_API: SupersetClient, SupersetConfig
# [SECTION: IMPORTS]
from .client import SupersetClient
from .models import SupersetConfig
# [/SECTION]
__all__ = ["SupersetClient", "SupersetConfig"]
# [/DEF:superset_tool]

View File

@@ -1,36 +1,41 @@
# <GRACE_MODULE id="superset_tool.client" name="client.py"> # [DEF:superset_tool.client:Module]
# @SEMANTICS: superset, api, client, rest, http, dashboard, dataset, import, export #
# @PURPOSE: Предоставляет высокоуровневый клиент для взаимодействия с Superset REST API, инкапсулируя логику запросов, обработку ошибок и пагинацию. # @SEMANTICS: superset, api, client, rest, http, dashboard, dataset, import, export
# @DEPENDS_ON: superset_tool.models -> Использует SupersetConfig для конфигурации. # @PURPOSE: Предоставляет высокоуровневый клиент для взаимодействия с Superset REST API, инкапсулируя логику запросов, обработку ошибок и пагинацию.
# @DEPENDS_ON: superset_tool.exceptions -> Выбрасывает специализированные исключения. # @LAYER: Domain
# @DEPENDS_ON: superset_tool.utils -> Использует утилиты для сети, логгирования и работы с файлами. # @RELATION: DEPENDS_ON -> superset_tool.models
# @RELATION: DEPENDS_ON -> superset_tool.exceptions
# @RELATION: DEPENDS_ON -> superset_tool.utils
#
# @INVARIANT: All network operations must use the internal APIClient instance.
# @CONSTRAINT: No direct use of 'requests' library outside of APIClient.
# @PUBLIC_API: SupersetClient
# <IMPORTS> # [SECTION: IMPORTS]
import json import json
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Dict, List, Optional, Tuple, Union, cast
from requests import Response from requests import Response
from superset_tool.models import SupersetConfig from superset_tool.models import SupersetConfig
from superset_tool.exceptions import ExportError, InvalidZipFormatError from superset_tool.exceptions import 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
# </IMPORTS> # [/SECTION]
# --- Начало кода модуля --- # [DEF:SupersetClient:Class]
# @PURPOSE: Класс-обёртка над Superset REST API, предоставляющий методы для работы с дашбордами и датасетами.
# <ANCHOR id="SupersetClient" type="Class"> # @RELATION: CREATES_INSTANCE_OF -> APIClient
# @PURPOSE: Класс-обёртка над Superset REST API, предоставляющий методы для работы с дашбордами и датасетами. # @RELATION: USES -> SupersetConfig
# @RELATION: CREATES_INSTANCE_OF -> APIClient
# @RELATION: USES -> SupersetConfig
class SupersetClient: class SupersetClient:
# [DEF:SupersetClient.__init__:Function]
# @PURPOSE: Инициализирует клиент, проверяет конфигурацию и создает сетевой клиент.
# @PRE: `config` должен быть валидным объектом SupersetConfig.
# @POST: Атрибуты `logger`, `config`, и `network` созданы и готовы к работе.
# @PARAM: config (SupersetConfig) - Конфигурация подключения.
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
def __init__(self, config: SupersetConfig, logger: Optional[SupersetLogger] = None): def __init__(self, config: SupersetConfig, logger: Optional[SupersetLogger] = None):
# <ANCHOR id="SupersetClient.__init__" type="Function">
# @PURPOSE: Инициализирует клиент, проверяет конфигурацию и создает сетевой клиент.
# @PARAM: config: SupersetConfig - Конфигурация подключения.
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
# @POST: Атрибуты `logger`, `config`, и `network` созданы.
self.logger = logger or SupersetLogger(name="SupersetClient") self.logger = logger or SupersetLogger(name="SupersetClient")
self.logger.info("[SupersetClient.__init__][Enter] Initializing SupersetClient.") self.logger.info("[SupersetClient.__init__][Enter] Initializing SupersetClient.")
self._validate_config(config) self._validate_config(config)
@@ -43,32 +48,40 @@ class SupersetClient:
) )
self.delete_before_reimport: bool = False self.delete_before_reimport: bool = False
self.logger.info("[SupersetClient.__init__][Exit] SupersetClient initialized.") self.logger.info("[SupersetClient.__init__][Exit] SupersetClient initialized.")
# </ANCHOR id="SupersetClient.__init__"> # [/DEF:SupersetClient.__init__]
# <ANCHOR id="SupersetClient._validate_config" type="Function"> # [DEF:SupersetClient._validate_config:Function]
# @PURPOSE: Проверяет, что переданный объект конфигурации имеет корректный тип. # @PURPOSE: Проверяет, что переданный объект конфигурации имеет корректный тип.
# @PARAM: config: SupersetConfig - Объект для проверки. # @PRE: `config` должен быть передан.
# @THROW: TypeError - Если `config` не является экземпляром `SupersetConfig`. # @POST: Если проверка пройдена, выполнение продолжается.
# @THROW: TypeError - Если `config` не является экземпляром `SupersetConfig`.
# @PARAM: config (SupersetConfig) - Объект для проверки.
def _validate_config(self, config: SupersetConfig) -> None: def _validate_config(self, config: SupersetConfig) -> None:
self.logger.debug("[_validate_config][Enter] Validating SupersetConfig.") self.logger.debug("[_validate_config][Enter] Validating SupersetConfig.")
assert isinstance(config, SupersetConfig), "Конфигурация должна быть экземпляром SupersetConfig" assert isinstance(config, SupersetConfig), "Конфигурация должна быть экземпляром SupersetConfig"
self.logger.debug("[_validate_config][Exit] Config is valid.") self.logger.debug("[_validate_config][Exit] Config is valid.")
# </ANCHOR id="SupersetClient._validate_config"> # [/DEF:SupersetClient._validate_config]
@property @property
def headers(self) -> dict: def headers(self) -> dict:
# <ANCHOR id="SupersetClient.headers" type="Property"> # [DEF:SupersetClient.headers:Function]
# @PURPOSE: Возвращает базовые HTTP-заголовки, используемые сетевым клиентом. # @PURPOSE: Возвращает базовые HTTP-заголовки, используемые сетевым клиентом.
# @PRE: self.network должен быть инициализирован.
# @POST: Возвращаемый словарь содержит актуальные заголовки, включая токен авторизации.
return self.network.headers return self.network.headers
# </ANCHOR id="SupersetClient.headers"> # [/DEF:SupersetClient.headers]
# <ANCHOR id="SupersetClient.get_dashboards" type="Function"> # [DEF:SupersetClient.get_dashboards:Function]
# @PURPOSE: Получает полный список дашбордов, автоматически обрабатывая пагинацию. # @PURPOSE: Получает полный список дашбордов, автоматически обрабатывая пагинацию.
# @PARAM: query: Optional[Dict] - Дополнительные параметры запроса для API. # @RELATION: CALLS -> self._fetch_total_object_count
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список дашбордов). # @RELATION: CALLS -> self._fetch_all_pages
# @RELATION: CALLS -> self._fetch_total_object_count # @PRE: self.network должен быть инициализирован.
# @RELATION: CALLS -> self._fetch_all_pages # @POST: Возвращаемый список содержит все дашборды, доступные по API.
# @THROW: APIError - В случае ошибки сетевого запроса.
# @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса для API.
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список дашбордов).
def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]: def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
assert self.network, "[get_dashboards][PRE] Network client must be initialized."
self.logger.info("[get_dashboards][Enter] Fetching dashboards.") self.logger.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/")
@@ -78,15 +91,18 @@ class SupersetClient:
) )
self.logger.info("[get_dashboards][Exit] Found %d dashboards.", total_count) self.logger.info("[get_dashboards][Exit] Found %d dashboards.", total_count)
return total_count, paginated_data return total_count, paginated_data
# </ANCHOR id="SupersetClient.get_dashboards"> # [/DEF:SupersetClient.get_dashboards]
# <ANCHOR id="SupersetClient.export_dashboard" type="Function"> # [DEF:SupersetClient.export_dashboard:Function]
# @PURPOSE: Экспортирует дашборд в виде ZIP-архива. # @PURPOSE: Экспортирует дашборд в виде ZIP-архива.
# @PARAM: dashboard_id: int - ID дашборда для экспорта. # @RELATION: CALLS -> self.network.request
# @RETURN: Tuple[bytes, str] - Бинарное содержимое ZIP-архива и имя файла. # @PRE: dashboard_id должен быть положительным целым числом.
# @THROW: ExportError - Если экспорт завершился неудачей. # @POST: Возвращает бинарное содержимое ZIP-архива и имя файла.
# @RELATION: CALLS -> self.network.request # @THROW: ExportError - Если экспорт завершился неудачей.
# @PARAM: dashboard_id (int) - ID дашборда для экспорта.
# @RETURN: Tuple[bytes, str] - Бинарное содержимое ZIP-архива и имя файла.
def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]: def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]:
assert isinstance(dashboard_id, int) and dashboard_id > 0, "[export_dashboard][PRE] dashboard_id must be a positive integer."
self.logger.info("[export_dashboard][Enter] Exporting dashboard %s.", dashboard_id) self.logger.info("[export_dashboard][Enter] Exporting dashboard %s.", dashboard_id)
response = self.network.request( response = self.network.request(
method="GET", method="GET",
@@ -95,22 +111,28 @@ class SupersetClient:
stream=True, stream=True,
raw_response=True, raw_response=True,
) )
response = cast(Response, response)
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)
self.logger.info("[export_dashboard][Exit] Exported dashboard %s to %s.", dashboard_id, filename) self.logger.info("[export_dashboard][Exit] Exported dashboard %s to %s.", dashboard_id, filename)
return response.content, filename return response.content, filename
# </ANCHOR id="SupersetClient.export_dashboard"> # [/DEF:SupersetClient.export_dashboard]
# <ANCHOR id="SupersetClient.import_dashboard" type="Function"> # [DEF:SupersetClient.import_dashboard:Function]
# @PURPOSE: Импортирует дашборд из ZIP-файла с возможностью автоматического удаления и повторной попытки при ошибке. # @PURPOSE: Импортирует дашборд из ZIP-файла с возможностью автоматического удаления и повторной попытки при ошибке.
# @PARAM: file_name: Union[str, Path] - Путь к ZIP-архиву. # @RELATION: CALLS -> self._do_import
# @PARAM: dash_id: Optional[int] - ID дашборда для удаления при сбое. # @RELATION: CALLS -> self.delete_dashboard
# @PARAM: dash_slug: Optional[str] - Slug дашборда для поиска ID, если ID не предоставлен. # @RELATION: CALLS -> self.get_dashboards
# @RETURN: Dict - Ответ API в случае успеха. # @PRE: Файл, указанный в `file_name`, должен существовать и быть валидным ZIP-архивом Superset.
# @RELATION: CALLS -> self._do_import # @POST: Дашборд успешно импортирован, возвращен ответ API.
# @RELATION: CALLS -> self.delete_dashboard # @THROW: FileNotFoundError - Если файл не найден.
# @RELATION: CALLS -> self.get_dashboards # @THROW: InvalidZipFormatError - Если файл не является валидным ZIP-архивом Superset.
# @PARAM: file_name (Union[str, Path]) - Путь к ZIP-архиву.
# @PARAM: dash_id (Optional[int]) - ID дашборда для удаления при сбое.
# @PARAM: dash_slug (Optional[str]) - Slug дашборда для поиска ID, если ID не предоставлен.
# @RETURN: Dict - Ответ API в случае успеха.
def import_dashboard(self, file_name: Union[str, Path], dash_id: Optional[int] = None, dash_slug: Optional[str] = None) -> Dict: def import_dashboard(self, file_name: Union[str, Path], dash_id: Optional[int] = None, dash_slug: Optional[str] = None) -> Dict:
assert file_name, "[import_dashboard][PRE] file_name must be provided."
file_path = str(file_name) file_path = str(file_name)
self._validate_import_file(file_path) self._validate_import_file(file_path)
try: try:
@@ -128,12 +150,18 @@ class SupersetClient:
self.delete_dashboard(target_id) self.delete_dashboard(target_id)
self.logger.info("[import_dashboard][State] Deleted dashboard ID %s, retrying import.", target_id) self.logger.info("[import_dashboard][State] Deleted dashboard ID %s, retrying import.", target_id)
return self._do_import(file_path) return self._do_import(file_path)
# </ANCHOR id="SupersetClient.import_dashboard"> # [/DEF:SupersetClient.import_dashboard]
# <ANCHOR id="SupersetClient._resolve_target_id_for_delete" type="Function"> # [DEF:SupersetClient._resolve_target_id_for_delete:Function]
# @PURPOSE: Определяет ID дашборда для удаления, используя ID или slug. # @PURPOSE: Определяет ID дашборда для удаления, используя ID или slug.
# @INTERNAL # @PARAM: dash_id (Optional[int]) - ID дашборда.
# @PARAM: dash_slug (Optional[str]) - Slug дашборда.
# @PRE: По крайней мере один из параметров (dash_id или dash_slug) должен быть предоставлен.
# @POST: Возвращает ID дашборда, если найден, иначе None.
# @THROW: APIError - В случае ошибки сетевого запроса при поиске по slug.
# @RETURN: Optional[int] - Найденный ID или None.
def _resolve_target_id_for_delete(self, dash_id: Optional[int], dash_slug: Optional[str]) -> Optional[int]: def _resolve_target_id_for_delete(self, dash_id: Optional[int], dash_slug: Optional[str]) -> Optional[int]:
assert dash_id is not None or dash_slug is not None, "[_resolve_target_id_for_delete][PRE] At least one of ID or slug must be provided."
if dash_id is not None: if dash_id is not None:
return dash_id return dash_id
if dash_slug is not None: if dash_slug is not None:
@@ -147,37 +175,58 @@ class SupersetClient:
except Exception as e: except Exception as e:
self.logger.warning("[_resolve_target_id_for_delete][Warning] Could not resolve slug '%s' to ID: %s", dash_slug, e) self.logger.warning("[_resolve_target_id_for_delete][Warning] Could not resolve slug '%s' to ID: %s", dash_slug, e)
return None return None
# </ANCHOR id="SupersetClient._resolve_target_id_for_delete"> # [/DEF:SupersetClient._resolve_target_id_for_delete]
# <ANCHOR id="SupersetClient._do_import" type="Function"> # [DEF:SupersetClient._do_import:Function]
# @PURPOSE: Выполняет один запрос на импорт без обработки исключений. # @PURPOSE: Выполняет один запрос на импорт без обработки исключений.
# @INTERNAL # @PRE: Файл должен существовать.
# @POST: Файл успешно загружен, возвращен ответ API.
# @THROW: FileNotFoundError - Если файл не существует.
# @PARAM: file_name (Union[str, Path]) - Путь к файлу.
# @RETURN: Dict - Ответ API.
def _do_import(self, file_name: Union[str, Path]) -> Dict: def _do_import(self, file_name: Union[str, Path]) -> Dict:
self.logger.debug(f"[_do_import][State] Uploading file: {file_name}")
file_path = Path(file_name)
if file_path.exists():
self.logger.debug(f"[_do_import][State] File size: {file_path.stat().st_size} bytes")
else:
self.logger.error(f"[_do_import][Failure] File does not exist: {file_name}")
raise FileNotFoundError(f"File does not exist: {file_name}")
return self.network.upload_file( return self.network.upload_file(
endpoint="/dashboard/import/", endpoint="/dashboard/import/",
file_info={"file_obj": Path(file_name), "file_name": Path(file_name).name, "form_field": "formData"}, file_info={"file_obj": file_path, "file_name": file_path.name, "form_field": "formData"},
extra_data={"overwrite": "true"}, extra_data={"overwrite": "true"},
timeout=self.config.timeout * 2, timeout=self.config.timeout * 2,
) )
# </ANCHOR id="SupersetClient._do_import"> # [/DEF:SupersetClient._do_import]
# <ANCHOR id="SupersetClient.delete_dashboard" type="Function"> # [DEF:SupersetClient.delete_dashboard:Function]
# @PURPOSE: Удаляет дашборд по его ID или slug. # @PURPOSE: Удаляет дашборд по его ID или slug.
# @PARAM: dashboard_id: Union[int, str] - ID или slug дашборда. # @RELATION: CALLS -> self.network.request
# @RELATION: CALLS -> self.network.request # @PRE: dashboard_id должен быть предоставлен.
# @POST: Дашборд удален или залогировано предупреждение.
# @THROW: APIError - В случае ошибки сетевого запроса.
# @PARAM: dashboard_id (Union[int, str]) - ID или slug дашборда.
def delete_dashboard(self, dashboard_id: Union[int, str]) -> None: def delete_dashboard(self, dashboard_id: Union[int, str]) -> None:
assert dashboard_id, "[delete_dashboard][PRE] dashboard_id must be provided."
self.logger.info("[delete_dashboard][Enter] Deleting dashboard %s.", dashboard_id) self.logger.info("[delete_dashboard][Enter] Deleting dashboard %s.", dashboard_id)
response = self.network.request(method="DELETE", endpoint=f"/dashboard/{dashboard_id}") response = self.network.request(method="DELETE", endpoint=f"/dashboard/{dashboard_id}")
response = cast(Dict, response)
if response.get("result", True) is not False: if response.get("result", True) is not False:
self.logger.info("[delete_dashboard][Success] Dashboard %s deleted.", dashboard_id) self.logger.info("[delete_dashboard][Success] Dashboard %s deleted.", dashboard_id)
else: else:
self.logger.warning("[delete_dashboard][Warning] Unexpected response while deleting %s: %s", dashboard_id, response) self.logger.warning("[delete_dashboard][Warning] Unexpected response while deleting %s: %s", dashboard_id, response)
# </ANCHOR id="SupersetClient.delete_dashboard"> # [/DEF:SupersetClient.delete_dashboard]
# <ANCHOR id="SupersetClient._extract_dashboard_id_from_zip" type="Function"> # [DEF:SupersetClient._extract_dashboard_id_from_zip:Function]
# @PURPOSE: Извлекает ID дашборда из `metadata.yaml` внутри ZIP-архива. # @PURPOSE: Извлекает ID дашборда из `metadata.yaml` внутри ZIP-архива.
# @INTERNAL # @PARAM: file_name (Union[str, Path]) - Путь к ZIP-файлу.
# @PRE: Файл, указанный в `file_name`, должен быть валидным ZIP-архивом.
# @POST: Возвращает ID дашборда, если найден в metadata.yaml, иначе None.
# @THROW: ImportError - Если не установлен `yaml`.
# @RETURN: Optional[int] - ID дашборда или None.
def _extract_dashboard_id_from_zip(self, file_name: Union[str, Path]) -> Optional[int]: def _extract_dashboard_id_from_zip(self, file_name: Union[str, Path]) -> Optional[int]:
assert zipfile.is_zipfile(file_name), "[_extract_dashboard_id_from_zip][PRE] file_name must be a valid zip file."
try: try:
import yaml import yaml
with zipfile.ZipFile(file_name, "r") as zf: with zipfile.ZipFile(file_name, "r") as zf:
@@ -190,12 +239,17 @@ class SupersetClient:
except Exception as exc: except Exception as exc:
self.logger.error("[_extract_dashboard_id_from_zip][Failure] %s", exc, exc_info=True) self.logger.error("[_extract_dashboard_id_from_zip][Failure] %s", exc, exc_info=True)
return None return None
# </ANCHOR id="SupersetClient._extract_dashboard_id_from_zip"> # [/DEF:SupersetClient._extract_dashboard_id_from_zip]
# <ANCHOR id="SupersetClient._extract_dashboard_slug_from_zip" type="Function"> # [DEF:SupersetClient._extract_dashboard_slug_from_zip:Function]
# @PURPOSE: Извлекает slug дашборда из `metadata.yaml` внутри ZIP-архива. # @PURPOSE: Извлекает slug дашборда из `metadata.yaml` внутри ZIP-архива.
# @INTERNAL # @PARAM: file_name (Union[str, Path]) - Путь к ZIP-файлу.
# @PRE: Файл, указанный в `file_name`, должен быть валидным ZIP-архивом.
# @POST: Возвращает slug дашборда, если найден в metadata.yaml, иначе None.
# @THROW: ImportError - Если не установлен `yaml`.
# @RETURN: Optional[str] - Slug дашборда или None.
def _extract_dashboard_slug_from_zip(self, file_name: Union[str, Path]) -> Optional[str]: def _extract_dashboard_slug_from_zip(self, file_name: Union[str, Path]) -> Optional[str]:
assert zipfile.is_zipfile(file_name), "[_extract_dashboard_slug_from_zip][PRE] file_name must be a valid zip file."
try: try:
import yaml import yaml
with zipfile.ZipFile(file_name, "r") as zf: with zipfile.ZipFile(file_name, "r") as zf:
@@ -208,79 +262,111 @@ class SupersetClient:
except Exception as exc: except Exception as exc:
self.logger.error("[_extract_dashboard_slug_from_zip][Failure] %s", exc, exc_info=True) self.logger.error("[_extract_dashboard_slug_from_zip][Failure] %s", exc, exc_info=True)
return None return None
# </ANCHOR id="SupersetClient._extract_dashboard_slug_from_zip"> # [/DEF:SupersetClient._extract_dashboard_slug_from_zip]
# <ANCHOR id="SupersetClient._validate_export_response" type="Function"> # [DEF:SupersetClient._validate_export_response:Function]
# @PURPOSE: Проверяет, что HTTP-ответ на экспорт является валидным ZIP-архивом. # @PURPOSE: Проверяет, что HTTP-ответ на экспорт является валидным ZIP-архивом.
# @INTERNAL # @PRE: response должен быть объектом requests.Response.
# @THROW: ExportError - Если ответ не является ZIP-архивом или пуст. # @POST: Проверка пройдена, если ответ является непустым ZIP-архивом.
# @THROW: ExportError - Если ответ не является ZIP-архивом или пуст.
# @PARAM: response (Response) - HTTP ответ.
# @PARAM: dashboard_id (int) - ID дашборда.
def _validate_export_response(self, response: Response, dashboard_id: int) -> None: def _validate_export_response(self, response: Response, dashboard_id: int) -> None:
assert isinstance(response, Response), "[_validate_export_response][PRE] response must be a requests.Response object."
content_type = response.headers.get("Content-Type", "") content_type = response.headers.get("Content-Type", "")
if "application/zip" not in content_type: if "application/zip" not in content_type:
raise ExportError(f"Получен не ZIP-архив (Content-Type: {content_type})") raise ExportError(f"Получен не ZIP-архив (Content-Type: {content_type})")
if not response.content: if not response.content:
raise ExportError("Получены пустые данные при экспорте") raise ExportError("Получены пустые данные при экспорте")
# </ANCHOR id="SupersetClient._validate_export_response"> # [/DEF:SupersetClient._validate_export_response]
# <ANCHOR id="SupersetClient._resolve_export_filename" type="Function"> # [DEF:SupersetClient._resolve_export_filename:Function]
# @PURPOSE: Определяет имя файла для экспорта из заголовков или генерирует его. # @PURPOSE: Определяет имя файла для экспорта из заголовков или генерирует его.
# @INTERNAL # @PRE: response должен быть объектом requests.Response.
# @POST: Возвращает непустое имя файла.
# @PARAM: response (Response) - HTTP ответ.
# @PARAM: dashboard_id (int) - ID дашборда.
# @RETURN: str - Имя файла.
def _resolve_export_filename(self, response: Response, dashboard_id: int) -> str: def _resolve_export_filename(self, response: Response, dashboard_id: int) -> str:
filename = get_filename_from_headers(response.headers) assert isinstance(response, Response), "[_resolve_export_filename][PRE] response must be a requests.Response object."
filename = get_filename_from_headers(dict(response.headers))
if not filename: if not filename:
from datetime import datetime from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip" filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip"
self.logger.warning("[_resolve_export_filename][Warning] Generated filename: %s", filename) self.logger.warning("[_resolve_export_filename][Warning] Generated filename: %s", filename)
return filename return filename
# </ANCHOR id="SupersetClient._resolve_export_filename"> # [/DEF:SupersetClient._resolve_export_filename]
# <ANCHOR id="SupersetClient._validate_query_params" type="Function"> # [DEF:SupersetClient._validate_query_params:Function]
# @PURPOSE: Формирует корректный набор параметров запроса с пагинацией. # @PURPOSE: Формирует корректный набор параметров запроса с пагинацией.
# @INTERNAL # @PARAM: query (Optional[Dict]) - Исходные параметры.
# @PRE: query, если предоставлен, должен быть словарем.
# @POST: Возвращает словарь, содержащий базовые параметры пагинации, объединенные с `query`.
# @RETURN: Dict - Валидированные параметры.
def _validate_query_params(self, query: Optional[Dict]) -> Dict: def _validate_query_params(self, query: Optional[Dict]) -> Dict:
assert query is None or isinstance(query, dict), "[_validate_query_params][PRE] query must be a dictionary or None."
base_query = {"columns": ["slug", "id", "changed_on_utc", "dashboard_title", "published"], "page": 0, "page_size": 1000} base_query = {"columns": ["slug", "id", "changed_on_utc", "dashboard_title", "published"], "page": 0, "page_size": 1000}
return {**base_query, **(query or {})} return {**base_query, **(query or {})}
# </ANCHOR id="SupersetClient._validate_query_params"> # [/DEF:SupersetClient._validate_query_params]
# <ANCHOR id="SupersetClient._fetch_total_object_count" type="Function"> # [DEF:SupersetClient._fetch_total_object_count:Function]
# @PURPOSE: Получает общее количество объектов по указанному эндпоинту для пагинации. # @PURPOSE: Получает общее количество объектов по указанному эндпоинту для пагинации.
# @INTERNAL # @PARAM: endpoint (str) - API эндпоинт.
# @PRE: endpoint должен быть непустой строкой.
# @POST: Возвращает общее количество объектов (>= 0).
# @THROW: APIError - В случае ошибки сетевого запроса.
# @RETURN: int - Количество объектов.
def _fetch_total_object_count(self, endpoint: str) -> int: def _fetch_total_object_count(self, endpoint: str) -> int:
assert endpoint and isinstance(endpoint, str), "[_fetch_total_object_count][PRE] endpoint must be a non-empty string."
return self.network.fetch_paginated_count( return self.network.fetch_paginated_count(
endpoint=endpoint, endpoint=endpoint,
query_params={"page": 0, "page_size": 1}, query_params={"page": 0, "page_size": 1},
count_field="count", count_field="count",
) )
# </ANCHOR id="SupersetClient._fetch_total_object_count"> # [/DEF:SupersetClient._fetch_total_object_count]
# <ANCHOR id="SupersetClient._fetch_all_pages" type="Function"> # [DEF:SupersetClient._fetch_all_pages:Function]
# @PURPOSE: Итерируется по всем страницам пагинированного API и собирает все данные. # @PURPOSE: Итерируется по всем страницам пагинированного API и собирает все данные.
# @INTERNAL # @PARAM: endpoint (str) - API эндпоинт.
# @PARAM: pagination_options (Dict) - Опции пагинации.
# @PRE: endpoint должен быть непустой строкой, pagination_options - словарем.
# @POST: Возвращает полный список объектов.
# @THROW: APIError - В случае ошибки сетевого запроса.
# @RETURN: List[Dict] - Список всех объектов.
def _fetch_all_pages(self, endpoint: str, pagination_options: Dict) -> List[Dict]: def _fetch_all_pages(self, endpoint: str, pagination_options: Dict) -> List[Dict]:
assert endpoint and isinstance(endpoint, str), "[_fetch_all_pages][PRE] endpoint must be a non-empty string."
assert isinstance(pagination_options, dict), "[_fetch_all_pages][PRE] pagination_options must be a dictionary."
return self.network.fetch_paginated_data(endpoint=endpoint, pagination_options=pagination_options) return self.network.fetch_paginated_data(endpoint=endpoint, pagination_options=pagination_options)
# </ANCHOR id="SupersetClient._fetch_all_pages"> # [/DEF:SupersetClient._fetch_all_pages]
# <ANCHOR id="SupersetClient._validate_import_file" type="Function"> # [DEF:SupersetClient._validate_import_file:Function]
# @PURPOSE: Проверяет, что файл существует, является ZIP-архивом и содержит `metadata.yaml`. # @PURPOSE: Проверяет, что файл существует, является ZIP-архивом и содержит `metadata.yaml`.
# @INTERNAL # @PRE: zip_path должен быть предоставлен.
# @THROW: FileNotFoundError - Если файл не найден. # @POST: Проверка пройдена, если файл существует, является ZIP и содержит `metadata.yaml`.
# @THROW: InvalidZipFormatError - Если файл не является ZIP или не содержит `metadata.yaml`. # @THROW: FileNotFoundError - Если файл не найден.
# @THROW: InvalidZipFormatError - Если файл не является ZIP или не содержит `metadata.yaml`.
# @PARAM: zip_path (Union[str, Path]) - Путь к файлу.
def _validate_import_file(self, zip_path: Union[str, Path]) -> None: def _validate_import_file(self, zip_path: Union[str, Path]) -> None:
assert zip_path, "[_validate_import_file][PRE] zip_path must be provided."
path = Path(zip_path) path = Path(zip_path)
assert path.exists(), f"Файл {zip_path} не существует" assert path.exists(), f"Файл {zip_path} не существует"
assert zipfile.is_zipfile(path), f"Файл {zip_path} не является ZIP-архивом" assert zipfile.is_zipfile(path), f"Файл {zip_path} не является ZIP-архивом"
with zipfile.ZipFile(path, "r") as zf: with zipfile.ZipFile(path, "r") as zf:
assert any(n.endswith("metadata.yaml") for n in zf.namelist()), f"Архив {zip_path} не содержит 'metadata.yaml'" assert any(n.endswith("metadata.yaml") for n in zf.namelist()), f"Архив {zip_path} не содержит 'metadata.yaml'"
# </ANCHOR id="SupersetClient._validate_import_file"> # [/DEF:SupersetClient._validate_import_file]
# <ANCHOR id="SupersetClient.get_datasets" type="Function"> # [DEF:SupersetClient.get_datasets:Function]
# @PURPOSE: Получает полный список датасетов, автоматически обрабатывая пагинацию. # @PURPOSE: Получает полный список датасетов, автоматически обрабатывая пагинацию.
# @PARAM: query: Optional[Dict] - Дополнительные параметры запроса для API. # @RELATION: CALLS -> self._fetch_total_object_count
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список датасетов). # @RELATION: CALLS -> self._fetch_all_pages
# @RELATION: CALLS -> self._fetch_total_object_count # @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса.
# @RELATION: CALLS -> self._fetch_all_pages # @PRE: self.network должен быть инициализирован.
# @POST: Возвращаемый список содержит все датасеты, доступные по API.
# @THROW: APIError - В случае ошибки сетевого запроса.
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список датасетов).
def get_datasets(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]: def get_datasets(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
assert self.network, "[get_datasets][PRE] Network client must be initialized."
self.logger.info("[get_datasets][Enter] Fetching datasets.") self.logger.info("[get_datasets][Enter] Fetching datasets.")
validated_query = self._validate_query_params(query) validated_query = self._validate_query_params(query)
total_count = self._fetch_total_object_count(endpoint="/dataset/") total_count = self._fetch_total_object_count(endpoint="/dataset/")
@@ -290,27 +376,76 @@ class SupersetClient:
) )
self.logger.info("[get_datasets][Exit] Found %d datasets.", total_count) self.logger.info("[get_datasets][Exit] Found %d datasets.", total_count)
return total_count, paginated_data return total_count, paginated_data
# </ANCHOR id="SupersetClient.get_datasets"> # [/DEF:SupersetClient.get_datasets]
# <ANCHOR id="SupersetClient.get_dataset" type="Function"> # [DEF:SupersetClient.get_databases:Function]
# @PURPOSE: Получает информацию о конкретном датасете по его ID. # @PURPOSE: Получает полный список баз данных, автоматически обрабатывая пагинацию.
# @PARAM: dataset_id: int - ID датасета. # @RELATION: CALLS -> self._fetch_total_object_count
# @RETURN: Dict - Словарь с информацией о датасете. # @RELATION: CALLS -> self._fetch_all_pages
# @RELATION: CALLS -> self.network.request # @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса.
# @PRE: self.network должен быть инициализирован.
# @POST: Возвращаемый список содержит все базы данных, доступные по API.
# @THROW: APIError - В случае ошибки сетевого запроса.
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список баз данных).
def get_databases(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
assert self.network, "[get_databases][PRE] Network client must be initialized."
self.logger.info("[get_databases][Enter] Fetching databases.")
validated_query = self._validate_query_params(query)
total_count = self._fetch_total_object_count(endpoint="/database/")
paginated_data = self._fetch_all_pages(
endpoint="/database/",
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
)
self.logger.info("[get_databases][Exit] Found %d databases.", total_count)
return total_count, paginated_data
# [/DEF:SupersetClient.get_databases]
# [DEF:SupersetClient.get_dataset:Function]
# @PURPOSE: Получает информацию о конкретном датасете по его ID.
# @RELATION: CALLS -> self.network.request
# @PARAM: dataset_id (int) - ID датасета.
# @PRE: dataset_id должен быть положительным целым числом.
# @POST: Возвращает словарь с информацией о датасете.
# @THROW: APIError - В случае ошибки сетевого запроса или если датасет не найден.
# @RETURN: Dict - Информация о датасете.
def get_dataset(self, dataset_id: int) -> Dict: def get_dataset(self, dataset_id: int) -> Dict:
assert isinstance(dataset_id, int) and dataset_id > 0, "[get_dataset][PRE] dataset_id must be a positive integer."
self.logger.info("[get_dataset][Enter] Fetching dataset %s.", dataset_id) self.logger.info("[get_dataset][Enter] Fetching dataset %s.", dataset_id)
response = self.network.request(method="GET", endpoint=f"/dataset/{dataset_id}") response = self.network.request(method="GET", endpoint=f"/dataset/{dataset_id}")
response = cast(Dict, response)
self.logger.info("[get_dataset][Exit] Got dataset %s.", dataset_id) self.logger.info("[get_dataset][Exit] Got dataset %s.", dataset_id)
return response return response
# </ANCHOR id="SupersetClient.get_dataset"> # [/DEF:SupersetClient.get_dataset]
# <ANCHOR id="SupersetClient.update_dataset" type="Function"> # [DEF:SupersetClient.get_database:Function]
# @PURPOSE: Обновляет данные датасета по его ID. # @PURPOSE: Получает информацию о конкретной базе данных по её ID.
# @PARAM: dataset_id: int - ID датасета для обновления. # @RELATION: CALLS -> self.network.request
# @PARAM: data: Dict - Словарь с данными для обновления. # @PARAM: database_id (int) - ID базы данных.
# @RETURN: Dict - Ответ API. # @PRE: database_id должен быть положительным целым числом.
# @RELATION: CALLS -> self.network.request # @POST: Возвращает словарь с информацией о базе данных.
# @THROW: APIError - В случае ошибки сетевого запроса или если база данных не найдена.
# @RETURN: Dict - Информация о базе данных.
def get_database(self, database_id: int) -> Dict:
assert isinstance(database_id, int) and database_id > 0, "[get_database][PRE] database_id must be a positive integer."
self.logger.info("[get_database][Enter] Fetching database %s.", database_id)
response = self.network.request(method="GET", endpoint=f"/database/{database_id}")
response = cast(Dict, response)
self.logger.info("[get_database][Exit] Got database %s.", database_id)
return response
# [/DEF:SupersetClient.get_database]
# [DEF:SupersetClient.update_dataset:Function]
# @PURPOSE: Обновляет данные датасета по его ID.
# @RELATION: CALLS -> self.network.request
# @PARAM: dataset_id (int) - ID датасета.
# @PARAM: data (Dict) - Данные для обновления.
# @PRE: dataset_id должен быть положительным целым числом, data - непустым словарем.
# @POST: Датасет успешно обновлен, возвращен ответ API.
# @THROW: APIError - В случае ошибки сетевого запроса.
# @RETURN: Dict - Ответ API.
def update_dataset(self, dataset_id: int, data: Dict) -> Dict: def update_dataset(self, dataset_id: int, data: Dict) -> Dict:
assert isinstance(dataset_id, int) and dataset_id > 0, "[update_dataset][PRE] dataset_id must be a positive integer."
assert isinstance(data, dict) and data, "[update_dataset][PRE] data must be a non-empty dictionary."
self.logger.info("[update_dataset][Enter] Updating dataset %s.", dataset_id) self.logger.info("[update_dataset][Enter] Updating dataset %s.", dataset_id)
response = self.network.request( response = self.network.request(
method="PUT", method="PUT",
@@ -318,12 +453,11 @@ class SupersetClient:
data=json.dumps(data), data=json.dumps(data),
headers={'Content-Type': 'application/json'} headers={'Content-Type': 'application/json'}
) )
response = cast(Dict, response)
self.logger.info("[update_dataset][Exit] Updated dataset %s.", dataset_id) self.logger.info("[update_dataset][Exit] Updated dataset %s.", dataset_id)
return response return response
# </ANCHOR id="SupersetClient.update_dataset"> # [/DEF:SupersetClient.update_dataset]
# </ANCHOR id="SupersetClient"> # [/DEF:SupersetClient]
# --- Конец кода модуля --- # [/DEF:superset_tool.client]
# </GRACE_MODULE id="superset_tool.client">

View File

@@ -1,110 +1,128 @@
# <GRACE_MODULE id="superset_tool.exceptions" name="exceptions.py"> # [DEF:superset_tool.exceptions:Module]
# @SEMANTICS: exception, error, hierarchy # @PURPOSE: Определяет иерархию пользовательских исключений для всего инструмента, обеспечивая единую точку обработки ошибок.
# @PURPOSE: Определяет иерархию пользовательских исключений для всего инструмента, обеспечивая единую точку обработки ошибок. # @SEMANTICS: exception, error, hierarchy
# @RELATION: ALL_CLASSES -> INHERITS_FROM -> SupersetToolError (or other exception in this module) # @LAYER: Infra
# <IMPORTS> # [SECTION: IMPORTS]
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any, Union from typing import Optional, Dict, Any, Union
# </IMPORTS> # [/SECTION]
# --- Начало кода модуля --- # [DEF:SupersetToolError:Class]
# @PURPOSE: Базовый класс для всех ошибок, генерируемых инструментом.
# <ANCHOR id="SupersetToolError" type="Class"> # @RELATION: INHERITS_FROM -> Exception
# @PURPOSE: Базовый класс для всех ошибок, генерируемых инструментом. # @PARAM: message (str) - Сообщение об ошибке.
# @INHERITS_FROM: Exception # @PARAM: context (Optional[Dict[str, Any]]) - Дополнительный контекст ошибки.
class SupersetToolError(Exception): class SupersetToolError(Exception):
def __init__(self, message: str, context: Optional[Dict[str, Any]] = None): def __init__(self, message: str, context: Optional[Dict[str, Any]] = None):
self.context = context or {} self.context = context or {}
super().__init__(f"{message} | Context: {self.context}") super().__init__(f"{message} | Context: {self.context}")
# </ANCHOR id="SupersetToolError"> # [/DEF:SupersetToolError]
# <ANCHOR id="AuthenticationError" type="Class"> # [DEF:AuthenticationError:Class]
# @PURPOSE: Ошибки, связанные с аутентификацией или авторизацией. # @PURPOSE: Ошибки, связанные с аутентификацией или авторизацией.
# @INHERITS_FROM: SupersetToolError # @RELATION: INHERITS_FROM -> SupersetToolError
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class AuthenticationError(SupersetToolError): class AuthenticationError(SupersetToolError):
def __init__(self, message: str = "Authentication failed", **context: Any): def __init__(self, message: str = "Authentication failed", **context: Any):
super().__init__(f"[AUTH_FAILURE] {message}", context={"type": "authentication", **context}) super().__init__(f"[AUTH_FAILURE] {message}", context={"type": "authentication", **context})
# </ANCHOR id="AuthenticationError"> # [/DEF:AuthenticationError]
# <ANCHOR id="PermissionDeniedError" type="Class"> # [DEF:PermissionDeniedError:Class]
# @PURPOSE: Ошибка, возникающая при отказе в доступе к ресурсу. # @PURPOSE: Ошибка, возникающая при отказе в доступе к ресурсу.
# @INHERITS_FROM: AuthenticationError # @RELATION: INHERITS_FROM -> AuthenticationError
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: required_permission (Optional[str]) - Требуемое разрешение.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class PermissionDeniedError(AuthenticationError): class PermissionDeniedError(AuthenticationError):
def __init__(self, message: str = "Permission denied", required_permission: Optional[str] = None, **context: Any): def __init__(self, message: str = "Permission denied", required_permission: Optional[str] = None, **context: Any):
full_message = f"Permission denied: {required_permission}" if required_permission else message full_message = f"Permission denied: {required_permission}" if required_permission else message
super().__init__(full_message, context={"required_permission": required_permission, **context}) super().__init__(full_message, context={"required_permission": required_permission, **context})
# </ANCHOR id="PermissionDeniedError"> # [/DEF:PermissionDeniedError]
# <ANCHOR id="SupersetAPIError" type="Class"> # [DEF:SupersetAPIError:Class]
# @PURPOSE: Общие ошибки при взаимодействии с Superset API. # @PURPOSE: Общие ошибки при взаимодействии с Superset API.
# @INHERITS_FROM: SupersetToolError # @RELATION: INHERITS_FROM -> SupersetToolError
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class SupersetAPIError(SupersetToolError): class SupersetAPIError(SupersetToolError):
def __init__(self, message: str = "Superset API error", **context: Any): def __init__(self, message: str = "Superset API error", **context: Any):
super().__init__(f"[API_FAILURE] {message}", context={"type": "api_call", **context}) super().__init__(f"[API_FAILURE] {message}", context={"type": "api_call", **context})
# </ANCHOR id="SupersetAPIError"> # [/DEF:SupersetAPIError]
# <ANCHOR id="ExportError" type="Class"> # [DEF:ExportError:Class]
# @PURPOSE: Ошибки, специфичные для операций экспорта. # @PURPOSE: Ошибки, специфичные для операций экспорта.
# @INHERITS_FROM: SupersetAPIError # @RELATION: INHERITS_FROM -> SupersetAPIError
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class ExportError(SupersetAPIError): class ExportError(SupersetAPIError):
def __init__(self, message: str = "Dashboard export failed", **context: Any): def __init__(self, message: str = "Dashboard export failed", **context: Any):
super().__init__(f"[EXPORT_FAILURE] {message}", context={"subtype": "export", **context}) super().__init__(f"[EXPORT_FAILURE] {message}", context={"subtype": "export", **context})
# </ANCHOR id="ExportError"> # [/DEF:ExportError]
# <ANCHOR id="DashboardNotFoundError" type="Class"> # [DEF:DashboardNotFoundError:Class]
# @PURPOSE: Ошибка, когда запрошенный дашборд или ресурс не найден (404). # @PURPOSE: Ошибка, когда запрошенный дашборд или ресурс не найден (404).
# @INHERITS_FROM: SupersetAPIError # @RELATION: INHERITS_FROM -> SupersetAPIError
# @PARAM: dashboard_id_or_slug (Union[int, str]) - ID или slug дашборда.
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class DashboardNotFoundError(SupersetAPIError): class DashboardNotFoundError(SupersetAPIError):
def __init__(self, dashboard_id_or_slug: Union[int, str], message: str = "Dashboard not found", **context: Any): def __init__(self, dashboard_id_or_slug: Union[int, str], message: str = "Dashboard not found", **context: Any):
super().__init__(f"[NOT_FOUND] Dashboard '{dashboard_id_or_slug}' {message}", context={"subtype": "not_found", "resource_id": dashboard_id_or_slug, **context}) super().__init__(f"[NOT_FOUND] Dashboard '{dashboard_id_or_slug}' {message}", context={"subtype": "not_found", "resource_id": dashboard_id_or_slug, **context})
# </ANCHOR id="DashboardNotFoundError"> # [/DEF:DashboardNotFoundError]
# <ANCHOR id="DatasetNotFoundError" type="Class"> # [DEF:DatasetNotFoundError:Class]
# @PURPOSE: Ошибка, когда запрашиваемый набор данных не существует (404). # @PURPOSE: Ошибка, когда запрашиваемый набор данных не существует (404).
# @INHERITS_FROM: SupersetAPIError # @RELATION: INHERITS_FROM -> SupersetAPIError
# @PARAM: dataset_id_or_slug (Union[int, str]) - ID или slug набора данных.
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class DatasetNotFoundError(SupersetAPIError): class DatasetNotFoundError(SupersetAPIError):
def __init__(self, dataset_id_or_slug: Union[int, str], message: str = "Dataset not found", **context: Any): def __init__(self, dataset_id_or_slug: Union[int, str], message: str = "Dataset not found", **context: Any):
super().__init__(f"[NOT_FOUND] Dataset '{dataset_id_or_slug}' {message}", context={"subtype": "not_found", "resource_id": dataset_id_or_slug, **context}) super().__init__(f"[NOT_FOUND] Dataset '{dataset_id_or_slug}' {message}", context={"subtype": "not_found", "resource_id": dataset_id_or_slug, **context})
# </ANCHOR id="DatasetNotFoundError"> # [/DEF:DatasetNotFoundError]
# <ANCHOR id="InvalidZipFormatError" type="Class"> # [DEF:InvalidZipFormatError:Class]
# @PURPOSE: Ошибка, указывающая на некорректный формат или содержимое ZIP-архива. # @PURPOSE: Ошибка, указывающая на некорректный формат или содержимое ZIP-архива.
# @INHERITS_FROM: SupersetToolError # @RELATION: INHERITS_FROM -> SupersetToolError
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: file_path (Optional[Union[str, Path]]) - Путь к файлу.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class InvalidZipFormatError(SupersetToolError): class InvalidZipFormatError(SupersetToolError):
def __init__(self, message: str = "Invalid ZIP format or content", file_path: Optional[Union[str, Path]] = None, **context: Any): def __init__(self, message: str = "Invalid ZIP format or content", file_path: Optional[Union[str, Path]] = None, **context: Any):
super().__init__(f"[FILE_ERROR] {message}", context={"type": "file_validation", "file_path": str(file_path) if file_path else "N/A", **context}) super().__init__(f"[FILE_ERROR] {message}", context={"type": "file_validation", "file_path": str(file_path) if file_path else "N/A", **context})
# </ANCHOR id="InvalidZipFormatError"> # [/DEF:InvalidZipFormatError]
# <ANCHOR id="NetworkError" type="Class"> # [DEF:NetworkError:Class]
# @PURPOSE: Ошибки, связанные с сетевым соединением. # @PURPOSE: Ошибки, связанные с сетевым соединением.
# @INHERITS_FROM: SupersetToolError # @RELATION: INHERITS_FROM -> SupersetToolError
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class NetworkError(SupersetToolError): class NetworkError(SupersetToolError):
def __init__(self, message: str = "Network connection failed", **context: Any): def __init__(self, message: str = "Network connection failed", **context: Any):
super().__init__(f"[NETWORK_FAILURE] {message}", context={"type": "network", **context}) super().__init__(f"[NETWORK_FAILURE] {message}", context={"type": "network", **context})
# </ANCHOR id="NetworkError"> # [/DEF:NetworkError]
# <ANCHOR id="FileOperationError" type="Class"> # [DEF:FileOperationError:Class]
# @PURPOSE: Общие ошибки файловых операций (I/O). # @PURPOSE: Общие ошибки файловых операций (I/O).
# @INHERITS_FROM: SupersetToolError # @RELATION: INHERITS_FROM -> SupersetToolError
class FileOperationError(SupersetToolError): class FileOperationError(SupersetToolError):
pass pass
# </ANCHOR id="FileOperationError"> # [/DEF:FileOperationError]
# <ANCHOR id="InvalidFileStructureError" type="Class"> # [DEF:InvalidFileStructureError:Class]
# @PURPOSE: Ошибка, указывающая на некорректную структуру файлов или директорий. # @PURPOSE: Ошибка, указывающая на некорректную структуру файлов или директорий.
# @INHERITS_FROM: FileOperationError # @RELATION: INHERITS_FROM -> FileOperationError
class InvalidFileStructureError(FileOperationError): class InvalidFileStructureError(FileOperationError):
pass pass
# </ANCHOR id="InvalidFileStructureError"> # [/DEF:InvalidFileStructureError]
# <ANCHOR id="ConfigurationError" type="Class"> # [DEF:ConfigurationError:Class]
# @PURPOSE: Ошибки, связанные с неверной конфигурацией инструмента. # @PURPOSE: Ошибки, связанные с неверной конфигурацией инструмента.
# @INHERITS_FROM: SupersetToolError # @RELATION: INHERITS_FROM -> SupersetToolError
class ConfigurationError(SupersetToolError): class ConfigurationError(SupersetToolError):
pass pass
# </ANCHOR id="ConfigurationError"> # [/DEF:ConfigurationError]
# --- Конец кода модуля --- # [/DEF:superset_tool.exceptions]
# </GRACE_MODULE id="superset_tool.exceptions">

View File

@@ -1,21 +1,22 @@
# <GRACE_MODULE id="superset_tool.models" name="models.py"> # [DEF:superset_tool.models:Module]
# @SEMANTICS: pydantic, model, config, validation, data-structure #
# @PURPOSE: Определяет Pydantic-модели для конфигурации инструмента, обеспечивая валидацию данных. # @SEMANTICS: pydantic, model, config, validation, data-structure
# @DEPENDS_ON: pydantic -> Для создания моделей и валидации. # @PURPOSE: Определяет Pydantic-модели для конфигурации инструмента, обеспечивая валидацию данных.
# @DEPENDS_ON: superset_tool.utils.logger -> Для логирования в процессе валидации. # @LAYER: Infra
# @RELATION: DEPENDS_ON -> pydantic
# @RELATION: DEPENDS_ON -> superset_tool.utils.logger
# @PUBLIC_API: SupersetConfig, DatabaseConfig
# <IMPORTS> # [SECTION: IMPORTS]
import re import re
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from pydantic import BaseModel, validator, Field from pydantic import BaseModel, validator, Field
from .utils.logger import SupersetLogger from .utils.logger import SupersetLogger
# </IMPORTS> # [/SECTION]
# --- Начало кода модуля --- # [DEF:SupersetConfig:Class]
# @PURPOSE: Модель конфигурации для подключения к одному экземпляру Superset API.
# <ANCHOR id="SupersetConfig" type="DataClass"> # @RELATION: INHERITS_FROM -> pydantic.BaseModel
# @PURPOSE: Модель конфигурации для подключения к одному экземпляру Superset API.
# @INHERITS_FROM: pydantic.BaseModel
class SupersetConfig(BaseModel): class SupersetConfig(BaseModel):
env: str = Field(..., description="Название окружения (например, dev, prod).") env: str = Field(..., description="Название окружения (например, dev, prod).")
base_url: str = Field(..., description="Базовый URL Superset API, включая /api/v1.") base_url: str = Field(..., description="Базовый URL Superset API, включая /api/v1.")
@@ -24,59 +25,60 @@ class SupersetConfig(BaseModel):
timeout: int = Field(30, description="Таймаут в секундах для HTTP-запросов.") timeout: int = Field(30, description="Таймаут в секундах для HTTP-запросов.")
logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования.") logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования.")
# <ANCHOR id="SupersetConfig.validate_auth" type="Function"> # [DEF:SupersetConfig.validate_auth:Function]
# @PURPOSE: Проверяет, что словарь `auth` содержит все необходимые для аутентификации поля. # @PURPOSE: Проверяет, что словарь `auth` содержит все необходимые для аутентификации поля.
# @PRE: `v` должен быть словарем. # @PRE: `v` должен быть словарем.
# @POST: Возвращает `v`, если все обязательные поля (`provider`, `username`, `password`, `refresh`) присутствуют. # @POST: Возвращает `v`, если все обязательные поля (`provider`, `username`, `password`, `refresh`) присутствуют.
# @THROW: ValueError - Если отсутствуют обязательные поля. # @THROW: ValueError - Если отсутствуют обязательные поля.
# @PARAM: v (Dict[str, str]) - Значение поля auth.
@validator('auth') @validator('auth')
def validate_auth(cls, v: Dict[str, str]) -> Dict[str, str]: def validate_auth(cls, v: Dict[str, str]) -> Dict[str, str]:
required = {'provider', 'username', 'password', 'refresh'} required = {'provider', 'username', 'password', 'refresh'}
if not required.issubset(v.keys()): if not required.issubset(v.keys()):
raise ValueError(f"Словарь 'auth' должен содержать поля: {required}. Отсутствующие: {required - v.keys()}") raise ValueError(f"Словарь 'auth' должен содержать поля: {required}. Отсутствующие: {required - v.keys()}")
return v return v
# </ANCHOR> # [/DEF:SupersetConfig.validate_auth]
# <ANCHOR id="SupersetConfig.check_base_url_format" type="Function"> # [DEF:SupersetConfig.check_base_url_format:Function]
# @PURPOSE: Проверяет, что `base_url` соответствует формату URL и содержит `/api/v1`. # @PURPOSE: Проверяет, что `base_url` соответствует формату URL и содержит `/api/v1`.
# @PRE: `v` должна быть строкой. # @PRE: `v` должна быть строкой.
# @POST: Возвращает очищенный `v`, если формат корректен. # @POST: Возвращает очищенный `v`, если формат корректен.
# @THROW: ValueError - Если формат URL невалиден. # @THROW: ValueError - Если формат URL невалиден.
# @PARAM: v (str) - Значение поля base_url.
@validator('base_url') @validator('base_url')
def check_base_url_format(cls, v: str) -> str: def check_base_url_format(cls, v: str) -> str:
v = v.strip() v = v.strip()
if not re.fullmatch(r'https?://.+/api/v1/?(?:.*)?', v): if not re.fullmatch(r'https?://.+/api/v1/?(?:.*)?', v):
raise ValueError(f"Invalid URL format: {v}. Must include '/api/v1'.") raise ValueError(f"Invalid URL format: {v}. Must include '/api/v1'.")
return v return v
# </ANCHOR> # [/DEF:SupersetConfig.check_base_url_format]
class Config: class Config:
arbitrary_types_allowed = True arbitrary_types_allowed = True
# </ANCHOR id="SupersetConfig"> # [/DEF:SupersetConfig]
# <ANCHOR id="DatabaseConfig" type="DataClass"> # [DEF:DatabaseConfig:Class]
# @PURPOSE: Модель для параметров трансформации баз данных при миграции дашбордов. # @PURPOSE: Модель для параметров трансформации баз данных при миграции дашбордов.
# @INHERITS_FROM: pydantic.BaseModel # @RELATION: INHERITS_FROM -> pydantic.BaseModel
class DatabaseConfig(BaseModel): class DatabaseConfig(BaseModel):
database_config: Dict[str, Dict[str, Any]] = Field(..., description="Словарь, содержащий 'old' и 'new' конфигурации базы данных.") database_config: Dict[str, Dict[str, Any]] = Field(..., description="Словарь, содержащий 'old' и 'new' конфигурации базы данных.")
logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования.") logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования.")
# <ANCHOR id="DatabaseConfig.validate_config" type="Function"> # [DEF:DatabaseConfig.validate_config:Function]
# @PURPOSE: Проверяет, что словарь `database_config` содержит ключи 'old' и 'new'. # @PURPOSE: Проверяет, что словарь `database_config` содержит ключи 'old' и 'new'.
# @PRE: `v` должен быть словарем. # @PRE: `v` должен быть словарем.
# @POST: Возвращает `v`, если ключи 'old' и 'new' присутствуют. # @POST: Возвращает `v`, если ключи 'old' и 'new' присутствуют.
# @THROW: ValueError - Если отсутствуют обязательные ключи. # @THROW: ValueError - Если отсутствуют обязательные ключи.
# @PARAM: v (Dict[str, Dict[str, Any]]) - Значение поля database_config.
@validator('database_config') @validator('database_config')
def validate_config(cls, v: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: def validate_config(cls, v: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
if not {'old', 'new'}.issubset(v.keys()): if not {'old', 'new'}.issubset(v.keys()):
raise ValueError("'database_config' должен содержать ключи 'old' и 'new'.") raise ValueError("'database_config' должен содержать ключи 'old' и 'new'.")
return v return v
# </ANCHOR> # [/DEF:DatabaseConfig.validate_config]
class Config: class Config:
arbitrary_types_allowed = True arbitrary_types_allowed = True
# </ANCHOR id="DatabaseConfig"> # [/DEF:DatabaseConfig]
# --- Конец кода модуля --- # [/DEF:superset_tool.models]
# </GRACE_MODULE id="superset_tool.models">

View File

@@ -0,0 +1,5 @@
# [DEF:superset_tool.utils:Module]
# @SEMANTICS: package, utils
# @PURPOSE: Utility package for superset_tool.
# @LAYER: Infra
# [/DEF:superset_tool.utils]

View File

@@ -1,37 +1,38 @@
# <GRACE_MODULE id="dataset_mapper" name="dataset_mapper.py"> # [DEF:superset_tool.utils.dataset_mapper:Module]
# @SEMANTICS: dataset, mapping, postgresql, xlsx, superset #
# @PURPOSE: Этот модуль отвечает за обновление метаданных (verbose_map) в датасетах Superset, извлекая их из PostgreSQL или XLSX-файлов. # @SEMANTICS: dataset, mapping, postgresql, xlsx, superset
# @DEPENDS_ON: superset_tool.client -> Использует SupersetClient для взаимодействия с API. # @PURPOSE: Этот модуль отвечает за обновление метаданных (verbose_map) в датасетах Superset, извлекая их из PostgreSQL или XLSX-файлов.
# @DEPENDS_ON: pandas -> для чтения XLSX-файлов. # @LAYER: Domain
# @DEPENDS_ON: psycopg2 -> для подключения к PostgreSQL. # @RELATION: DEPENDS_ON -> superset_tool.client
# @RELATION: DEPENDS_ON -> pandas
# <IMPORTS> # @RELATION: DEPENDS_ON -> psycopg2
import pandas as pd # @PUBLIC_API: DatasetMapper
import psycopg2
# [SECTION: IMPORTS]
import pandas as pd # type: ignore
import psycopg2 # type: ignore
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.logger import SupersetLogger
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
# </IMPORTS> # [/SECTION]
# --- Начало кода модуля --- # [DEF:DatasetMapper:Class]
# @PURPOSE: Класс для меппинга и обновления verbose_map в датасетах Superset.
# <ANCHOR id="DatasetMapper" type="Class">
# @PURPOSE: Класс для меппинга и обновления verbose_map в датасетах Superset.
class DatasetMapper: class DatasetMapper:
def __init__(self, logger: SupersetLogger): def __init__(self, logger: SupersetLogger):
self.logger = logger self.logger = logger
# <ANCHOR id="DatasetMapper.get_postgres_comments" type="Function"> # [DEF:DatasetMapper.get_postgres_comments:Function]
# @PURPOSE: Извлекает комментарии к колонкам из системного каталога PostgreSQL. # @PURPOSE: Извлекает комментарии к колонкам из системного каталога PostgreSQL.
# @PRE: `db_config` должен содержать валидные креды для подключения к PostgreSQL. # @PRE: `db_config` должен содержать валидные креды для подключения к PostgreSQL.
# @PRE: `table_name` и `table_schema` должны быть строками. # @PRE: `table_name` и `table_schema` должны быть строками.
# @POST: Возвращается словарь с меппингом `column_name` -> `column_comment`. # @POST: Возвращается словарь с меппингом `column_name` -> `column_comment`.
# @PARAM: db_config: Dict - Конфигурация для подключения к БД. # @THROW: Exception - При ошибках подключения или выполнения запроса к БД.
# @PARAM: table_name: str - Имя таблицы. # @PARAM: db_config (Dict) - Конфигурация для подключения к БД.
# @PARAM: table_schema: str - Схема таблицы. # @PARAM: table_name (str) - Имя таблицы.
# @RETURN: Dict[str, str] - Словарь с комментариями к колонкам. # @PARAM: table_schema (str) - Схема таблицы.
# @THROW: Exception - При ошибках подключения или выполнения запроса к БД. # @RETURN: Dict[str, str] - Словарь с комментариями к колонкам.
def get_postgres_comments(self, db_config: Dict, table_name: str, table_schema: str) -> Dict[str, str]: def get_postgres_comments(self, db_config: Dict, table_name: str, table_schema: str) -> Dict[str, str]:
self.logger.info("[get_postgres_comments][Enter] Fetching comments from PostgreSQL for %s.%s.", table_schema, table_name) self.logger.info("[get_postgres_comments][Enter] Fetching comments from PostgreSQL for %s.%s.", table_schema, table_name)
query = f""" query = f"""
@@ -84,15 +85,15 @@ class DatasetMapper:
self.logger.error("[get_postgres_comments][Failure] %s", e, exc_info=True) self.logger.error("[get_postgres_comments][Failure] %s", e, exc_info=True)
raise raise
return comments return comments
# </ANCHOR id="DatasetMapper.get_postgres_comments"> # [/DEF:DatasetMapper.get_postgres_comments]
# <ANCHOR id="DatasetMapper.load_excel_mappings" type="Function"> # [DEF:DatasetMapper.load_excel_mappings:Function]
# @PURPOSE: Загружает меппинги 'column_name' -> 'column_comment' из XLSX файла. # @PURPOSE: Загружает меппинги 'column_name' -> 'column_comment' из XLSX файла.
# @PRE: `file_path` должен быть валидным путем к XLSX файлу с колонками 'column_name' и 'column_comment'. # @PRE: `file_path` должен быть валидным путем к XLSX файлу с колонками 'column_name' и 'column_comment'.
# @POST: Возвращается словарь с меппингами. # @POST: Возвращается словарь с меппингами.
# @PARAM: file_path: str - Путь к XLSX файлу. # @THROW: Exception - При ошибках чтения файла или парсинга.
# @RETURN: Dict[str, str] - Словарь с меппингами. # @PARAM: file_path (str) - Путь к XLSX файлу.
# @THROW: Exception - При ошибках чтения файла или парсинга. # @RETURN: Dict[str, str] - Словарь с меппингами.
def load_excel_mappings(self, file_path: str) -> Dict[str, str]: def load_excel_mappings(self, file_path: str) -> Dict[str, str]:
self.logger.info("[load_excel_mappings][Enter] Loading mappings from %s.", file_path) self.logger.info("[load_excel_mappings][Enter] Loading mappings from %s.", file_path)
try: try:
@@ -103,21 +104,21 @@ class DatasetMapper:
except Exception as e: except Exception as e:
self.logger.error("[load_excel_mappings][Failure] %s", e, exc_info=True) self.logger.error("[load_excel_mappings][Failure] %s", e, exc_info=True)
raise raise
# </ANCHOR id="DatasetMapper.load_excel_mappings"> # [/DEF:DatasetMapper.load_excel_mappings]
# <ANCHOR id="DatasetMapper.run_mapping" type="Function"> # [DEF:DatasetMapper.run_mapping:Function]
# @PURPOSE: Основная функция для выполнения меппинга и обновления verbose_map датасета в Superset. # @PURPOSE: Основная функция для выполнения меппинга и обновления verbose_map датасета в Superset.
# @PARAM: superset_client: SupersetClient - Клиент Superset. # @RELATION: CALLS -> self.get_postgres_comments
# @PARAM: dataset_id: int - ID датасета для обновления. # @RELATION: CALLS -> self.load_excel_mappings
# @PARAM: source: str - Источник данных ('postgres', 'excel', 'both'). # @RELATION: CALLS -> superset_client.get_dataset
# @PARAM: postgres_config: Optional[Dict] - Конфигурация для подключения к PostgreSQL. # @RELATION: CALLS -> superset_client.update_dataset
# @PARAM: excel_path: Optional[str] - Путь к XLSX файлу. # @PARAM: superset_client (SupersetClient) - Клиент Superset.
# @PARAM: table_name: Optional[str] - Имя таблицы в PostgreSQL. # @PARAM: dataset_id (int) - ID датасета для обновления.
# @PARAM: table_schema: Optional[str] - Схема таблицы в PostgreSQL. # @PARAM: source (str) - Источник данных ('postgres', 'excel', 'both').
# @RELATION: CALLS -> self.get_postgres_comments # @PARAM: postgres_config (Optional[Dict]) - Конфигурация для подключения к PostgreSQL.
# @RELATION: CALLS -> self.load_excel_mappings # @PARAM: excel_path (Optional[str]) - Путь к XLSX файлу.
# @RELATION: CALLS -> superset_client.get_dataset # @PARAM: table_name (Optional[str]) - Имя таблицы в PostgreSQL.
# @RELATION: CALLS -> superset_client.update_dataset # @PARAM: table_schema (Optional[str]) - Схема таблицы в PostgreSQL.
def run_mapping(self, superset_client: SupersetClient, dataset_id: int, source: str, postgres_config: Optional[Dict] = None, excel_path: Optional[str] = None, table_name: Optional[str] = None, table_schema: Optional[str] = None): def run_mapping(self, superset_client: SupersetClient, dataset_id: int, source: str, postgres_config: Optional[Dict] = None, excel_path: Optional[str] = None, table_name: Optional[str] = None, table_schema: Optional[str] = None):
self.logger.info("[run_mapping][Enter] Starting dataset mapping for ID %d from source '%s'.", dataset_id, source) self.logger.info("[run_mapping][Enter] Starting dataset mapping for ID %d from source '%s'.", dataset_id, source)
mappings: Dict[str, str] = {} mappings: Dict[str, str] = {}
@@ -132,14 +133,14 @@ class DatasetMapper:
if source not in ['postgres', 'excel', 'both']: if source not in ['postgres', 'excel', 'both']:
self.logger.error("[run_mapping][Failure] Invalid source: %s.", source) self.logger.error("[run_mapping][Failure] Invalid source: %s.", source)
return return
dataset_response = superset_client.get_dataset(dataset_id) dataset_response = superset_client.get_dataset(dataset_id)
dataset_data = dataset_response['result'] dataset_data = dataset_response['result']
original_columns = dataset_data.get('columns', []) original_columns = dataset_data.get('columns', [])
updated_columns = [] updated_columns = []
changes_made = False changes_made = False
for column in original_columns: for column in original_columns:
col_name = column.get('column_name') col_name = column.get('column_name')
@@ -161,7 +162,7 @@ class DatasetMapper:
} }
new_column = {k: v for k, v in new_column.items() if v is not None} new_column = {k: v for k, v in new_column.items() if v is not None}
if col_name in mappings: if col_name in mappings:
mapping_value = mappings[col_name] mapping_value = mappings[col_name]
if isinstance(mapping_value, str) and new_column.get('verbose_name') != mapping_value: if isinstance(mapping_value, str) and new_column.get('verbose_name') != mapping_value:
@@ -169,7 +170,7 @@ class DatasetMapper:
changes_made = True changes_made = True
updated_columns.append(new_column) updated_columns.append(new_column)
updated_metrics = [] updated_metrics = []
for metric in dataset_data.get("metrics", []): for metric in dataset_data.get("metrics", []):
new_metric = { new_metric = {
@@ -186,7 +187,7 @@ class DatasetMapper:
"uuid": metric.get("uuid"), "uuid": metric.get("uuid"),
} }
updated_metrics.append({k: v for k, v in new_metric.items() if v is not None}) updated_metrics.append({k: v for k, v in new_metric.items() if v is not None})
if changes_made: if changes_made:
payload_for_update = { payload_for_update = {
"database_id": dataset_data.get("database", {}).get("id"), "database_id": dataset_data.get("database", {}).get("id"),
@@ -213,18 +214,16 @@ class DatasetMapper:
} }
payload_for_update = {k: v for k, v in payload_for_update.items() if v is not None} payload_for_update = {k: v for k, v in payload_for_update.items() if v is not None}
superset_client.update_dataset(dataset_id, payload_for_update) superset_client.update_dataset(dataset_id, payload_for_update)
self.logger.info("[run_mapping][Success] Dataset %d columns' verbose_name updated.", dataset_id) self.logger.info("[run_mapping][Success] Dataset %d columns' verbose_name updated.", dataset_id)
else: else:
self.logger.info("[run_mapping][State] No changes in columns' verbose_name, skipping update.") self.logger.info("[run_mapping][State] No changes in columns' verbose_name, skipping update.")
except (AssertionError, FileNotFoundError, Exception) as e: except (AssertionError, FileNotFoundError, Exception) as e:
self.logger.error("[run_mapping][Failure] %s", e, exc_info=True) self.logger.error("[run_mapping][Failure] %s", e, exc_info=True)
return return
# </ANCHOR id="DatasetMapper.run_mapping"> # [/DEF:DatasetMapper.run_mapping]
# </ANCHOR id="DatasetMapper"> # [/DEF:DatasetMapper]
# --- Конец кода модуля --- # [/DEF:superset_tool.utils.dataset_mapper]
# </GRACE_MODULE id="dataset_mapper">

View File

@@ -1,16 +1,19 @@
# <GRACE_MODULE id="superset_tool.utils.fileio" name="fileio.py"> # [DEF:superset_tool.utils.fileio:Module]
# @SEMANTICS: file, io, zip, yaml, temp, archive, utility #
# @PURPOSE: Предоставляет набор утилит для управления файловыми операциями, включая работу с временными файлами, архивами ZIP, файлами YAML и очистку директорий. # @SEMANTICS: file, io, zip, yaml, temp, archive, utility
# @DEPENDS_ON: superset_tool.exceptions -> Для генерации специфичных ошибок. # @PURPOSE: Предоставляет набор утилит для управления файловыми операциями, включая работу с временными файлами, архивами ZIP, файлами YAML и очистку директорий.
# @DEPENDS_ON: superset_tool.utils.logger -> Для логирования операций. # @LAYER: Infra
# @DEPENDS_ON: pyyaml -> Для работы с YAML файлами. # @RELATION: DEPENDS_ON -> superset_tool.exceptions
# @RELATION: DEPENDS_ON -> superset_tool.utils.logger
# @RELATION: DEPENDS_ON -> pyyaml
# @PUBLIC_API: create_temp_file, remove_empty_directories, read_dashboard_from_disk, calculate_crc32, RetentionPolicy, archive_exports, save_and_unpack_dashboard, update_yamls, create_dashboard_export, sanitize_filename, get_filename_from_headers, consolidate_archive_folders
# <IMPORTS> # [SECTION: IMPORTS]
import os import os
import re import re
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Any, Optional, Tuple, Dict, List, Union, LiteralString from typing import Any, Optional, Tuple, Dict, List, Union, LiteralString, Generator
from contextlib import contextmanager from contextlib import contextmanager
import tempfile import tempfile
from datetime import date, datetime from datetime import date, datetime
@@ -21,20 +24,18 @@ from dataclasses import dataclass
import yaml import yaml
from superset_tool.exceptions import InvalidZipFormatError from superset_tool.exceptions import InvalidZipFormatError
from superset_tool.utils.logger import SupersetLogger from superset_tool.utils.logger import SupersetLogger
# </IMPORTS> # [/SECTION]
# --- Начало кода модуля --- # [DEF:create_temp_file:Function]
# @PURPOSE: Контекстный менеджер для создания временного файла или директории с гарантированным удалением.
# <ANCHOR id="create_temp_file" type="Function"> # @PARAM: content (Optional[bytes]) - Бинарное содержимое для записи во временный файл.
# @PURPOSE: Контекстный менеджер для создания временного файла или директории с гарантированным удалением. # @PARAM: suffix (str) - Суффикс ресурса. Если `.dir`, создается директория.
# @PARAM: content: Optional[bytes] - Бинарное содержимое для записи во временный файл. # @PARAM: mode (str) - Режим записи в файл (e.g., 'wb').
# @PARAM: suffix: str - Суффикс ресурса. Если `.dir`, создается директория. # @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
# @PARAM: mode: str - Режим записи в файл (e.g., 'wb'). # @YIELDS: Path - Путь к временному ресурсу.
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера. # @THROW: IOError - При ошибках создания ресурса.
# @YIELDS: Path - Путь к временному ресурсу.
# @THROW: IOError - При ошибках создания ресурса.
@contextmanager @contextmanager
def create_temp_file(content: Optional[bytes] = None, suffix: str = ".zip", mode: str = 'wb', logger: Optional[SupersetLogger] = None) -> Path: def create_temp_file(content: Optional[bytes] = None, suffix: str = ".zip", mode: str = 'wb', logger: Optional[SupersetLogger] = None) -> Generator[Path, None, None]:
logger = logger or SupersetLogger(name="fileio") logger = logger or SupersetLogger(name="fileio")
resource_path = None resource_path = None
is_dir = suffix.startswith('.dir') is_dir = suffix.startswith('.dir')
@@ -63,13 +64,13 @@ def create_temp_file(content: Optional[bytes] = None, suffix: str = ".zip", mode
logger.debug("[create_temp_file][Cleanup] Removed temporary file: %s", resource_path) logger.debug("[create_temp_file][Cleanup] Removed temporary file: %s", resource_path)
except OSError as e: except OSError as e:
logger.error("[create_temp_file][Failure] Error during cleanup of %s: %s", resource_path, e) logger.error("[create_temp_file][Failure] Error during cleanup of %s: %s", resource_path, e)
# </ANCHOR id="create_temp_file"> # [/DEF:create_temp_file]
# <ANCHOR id="remove_empty_directories" type="Function"> # [DEF:remove_empty_directories:Function]
# @PURPOSE: Рекурсивно удаляет все пустые поддиректории, начиная с указанного пути. # @PURPOSE: Рекурсивно удаляет все пустые поддиректории, начиная с указанного пути.
# @PARAM: root_dir: str - Путь к корневой директории для очистки. # @PARAM: root_dir (str) - Путь к корневой директории для очистки.
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера. # @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
# @RETURN: int - Количество удаленных директорий. # @RETURN: int - Количество удаленных директорий.
def remove_empty_directories(root_dir: str, logger: Optional[SupersetLogger] = None) -> int: def remove_empty_directories(root_dir: str, logger: Optional[SupersetLogger] = None) -> int:
logger = logger or SupersetLogger(name="fileio") logger = logger or SupersetLogger(name="fileio")
logger.info("[remove_empty_directories][Enter] Starting cleanup of empty directories in %s", root_dir) logger.info("[remove_empty_directories][Enter] Starting cleanup of empty directories in %s", root_dir)
@@ -87,14 +88,14 @@ def remove_empty_directories(root_dir: str, logger: Optional[SupersetLogger] = N
logger.error("[remove_empty_directories][Failure] Failed to remove %s: %s", current_dir, e) logger.error("[remove_empty_directories][Failure] Failed to remove %s: %s", current_dir, e)
logger.info("[remove_empty_directories][Exit] Removed %d empty directories.", removed_count) logger.info("[remove_empty_directories][Exit] Removed %d empty directories.", removed_count)
return removed_count return removed_count
# </ANCHOR id="remove_empty_directories"> # [/DEF:remove_empty_directories]
# <ANCHOR id="read_dashboard_from_disk" type="Function"> # [DEF:read_dashboard_from_disk:Function]
# @PURPOSE: Читает бинарное содержимое файла с диска. # @PURPOSE: Читает бинарное содержимое файла с диска.
# @PARAM: file_path: str - Путь к файлу. # @PARAM: file_path (str) - Путь к файлу.
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера. # @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
# @RETURN: Tuple[bytes, str] - Кортеж (содержимое, имя файла). # @RETURN: Tuple[bytes, str] - Кортеж (содержимое, имя файла).
# @THROW: FileNotFoundError - Если файл не найден. # @THROW: FileNotFoundError - Если файл не найден.
def read_dashboard_from_disk(file_path: str, logger: Optional[SupersetLogger] = None) -> Tuple[bytes, str]: def read_dashboard_from_disk(file_path: str, logger: Optional[SupersetLogger] = None) -> Tuple[bytes, str]:
logger = logger or SupersetLogger(name="fileio") logger = logger or SupersetLogger(name="fileio")
path = Path(file_path) path = Path(file_path)
@@ -104,36 +105,36 @@ def read_dashboard_from_disk(file_path: str, logger: Optional[SupersetLogger] =
if not content: if not content:
logger.warning("[read_dashboard_from_disk][Warning] File is empty: %s", file_path) logger.warning("[read_dashboard_from_disk][Warning] File is empty: %s", file_path)
return content, path.name return content, path.name
# </ANCHOR id="read_dashboard_from_disk"> # [/DEF:read_dashboard_from_disk]
# <ANCHOR id="calculate_crc32" type="Function"> # [DEF:calculate_crc32:Function]
# @PURPOSE: Вычисляет контрольную сумму CRC32 для файла. # @PURPOSE: Вычисляет контрольную сумму CRC32 для файла.
# @PARAM: file_path: Path - Путь к файлу. # @PARAM: file_path (Path) - Путь к файлу.
# @RETURN: str - 8-значное шестнадцатеричное представление CRC32. # @RETURN: str - 8-значное шестнадцатеричное представление CRC32.
# @THROW: IOError - При ошибках чтения файла. # @THROW: IOError - При ошибках чтения файла.
def calculate_crc32(file_path: Path) -> str: def calculate_crc32(file_path: Path) -> str:
with open(file_path, 'rb') as f: with open(file_path, 'rb') as f:
crc32_value = zlib.crc32(f.read()) crc32_value = zlib.crc32(f.read())
return f"{crc32_value:08x}" return f"{crc32_value:08x}"
# </ANCHOR id="calculate_crc32"> # [/DEF:calculate_crc32]
# <ANCHOR id="RetentionPolicy" type="DataClass"> # [DEF:RetentionPolicy:DataClass]
# @PURPOSE: Определяет политику хранения для архивов (ежедневные, еженедельные, ежемесячные). # @PURPOSE: Определяет политику хранения для архивов (ежедневные, еженедельные, ежемесячные).
@dataclass @dataclass
class RetentionPolicy: class RetentionPolicy:
daily: int = 7 daily: int = 7
weekly: int = 4 weekly: int = 4
monthly: int = 12 monthly: int = 12
# </ANCHOR id="RetentionPolicy"> # [/DEF:RetentionPolicy]
# <ANCHOR id="archive_exports" type="Function"> # [DEF:archive_exports:Function]
# @PURPOSE: Управляет архивом экспортированных файлов, применяя политику хранения и дедупликацию. # @PURPOSE: Управляет архивом экспортированных файлов, применяя политику хранения и дедупликацию.
# @PARAM: output_dir: str - Директория с архивами. # @RELATION: CALLS -> apply_retention_policy
# @PARAM: policy: RetentionPolicy - Политика хранения. # @RELATION: CALLS -> calculate_crc32
# @PARAM: deduplicate: bool - Флаг для включения удаления дубликатов по CRC32. # @PARAM: output_dir (str) - Директория с архивами.
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера. # @PARAM: policy (RetentionPolicy) - Политика хранения.
# @RELATION: CALLS -> apply_retention_policy # @PARAM: deduplicate (bool) - Флаг для включения удаления дубликатов по CRC32.
# @RELATION: CALLS -> calculate_crc32 # @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
def archive_exports(output_dir: str, policy: RetentionPolicy, deduplicate: bool = False, logger: Optional[SupersetLogger] = None) -> None: def archive_exports(output_dir: str, policy: RetentionPolicy, deduplicate: bool = False, logger: Optional[SupersetLogger] = None) -> None:
logger = logger or SupersetLogger(name="fileio") logger = logger or SupersetLogger(name="fileio")
output_path = Path(output_dir) output_path = Path(output_dir)
@@ -142,16 +143,78 @@ def archive_exports(output_dir: str, policy: RetentionPolicy, deduplicate: bool
return return
logger.info("[archive_exports][Enter] Managing archive in %s", output_dir) logger.info("[archive_exports][Enter] Managing archive in %s", output_dir)
# ... (логика дедупликации и политики хранения) ...
# </ANCHOR id="archive_exports"> # 1. Collect all zip files
zip_files = list(output_path.glob("*.zip"))
if not zip_files:
logger.info("[archive_exports][State] No zip files found in %s", output_dir)
return
# <ANCHOR id="apply_retention_policy" type="Function"> # 2. Deduplication
# @PURPOSE: (Helper) Применяет политику хранения к списку файлов, возвращая те, что нужно сохранить. if deduplicate:
# @INTERNAL logger.info("[archive_exports][State] Starting deduplication...")
# @PARAM: files_with_dates: List[Tuple[Path, date]] - Список файлов с датами. checksums = {}
# @PARAM: policy: RetentionPolicy - Политика хранения. files_to_remove = []
# @PARAM: logger: SupersetLogger - Логгер.
# @RETURN: set - Множество путей к файлам, которые должны быть сохранены. # Sort by modification time (newest first) to keep the latest version
zip_files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
for file_path in zip_files:
try:
crc = calculate_crc32(file_path)
if crc in checksums:
files_to_remove.append(file_path)
logger.debug("[archive_exports][State] Duplicate found: %s (same as %s)", file_path.name, checksums[crc].name)
else:
checksums[crc] = file_path
except Exception as e:
logger.error("[archive_exports][Failure] Failed to calculate CRC32 for %s: %s", file_path, e)
for f in files_to_remove:
try:
f.unlink()
zip_files.remove(f)
logger.info("[archive_exports][State] Removed duplicate: %s", f.name)
except OSError as e:
logger.error("[archive_exports][Failure] Failed to remove duplicate %s: %s", f, e)
# 3. Retention Policy
files_with_dates = []
for file_path in zip_files:
# Try to extract date from filename
# Pattern: ..._YYYYMMDD_HHMMSS.zip or ..._YYYYMMDD.zip
match = re.search(r'_(\d{8})_', file_path.name)
file_date = None
if match:
try:
date_str = match.group(1)
file_date = datetime.strptime(date_str, "%Y%m%d").date()
except ValueError:
pass
if not file_date:
# Fallback to modification time
file_date = datetime.fromtimestamp(file_path.stat().st_mtime).date()
files_with_dates.append((file_path, file_date))
files_to_keep = apply_retention_policy(files_with_dates, policy, logger)
for file_path, _ in files_with_dates:
if file_path not in files_to_keep:
try:
file_path.unlink()
logger.info("[archive_exports][State] Removed by retention policy: %s", file_path.name)
except OSError as e:
logger.error("[archive_exports][Failure] Failed to remove %s: %s", file_path, e)
# [/DEF:archive_exports]
# [DEF:apply_retention_policy:Function]
# @PURPOSE: (Helper) Применяет политику хранения к списку файлов, возвращая те, что нужно сохранить.
# @PARAM: files_with_dates (List[Tuple[Path, date]]) - Список файлов с датами.
# @PARAM: policy (RetentionPolicy) - Политика хранения.
# @PARAM: logger (SupersetLogger) - Логгер.
# @RETURN: set - Множество путей к файлам, которые должны быть сохранены.
def apply_retention_policy(files_with_dates: List[Tuple[Path, date]], policy: RetentionPolicy, logger: SupersetLogger) -> set: def apply_retention_policy(files_with_dates: List[Tuple[Path, date]], policy: RetentionPolicy, logger: SupersetLogger) -> set:
# Сортируем по дате (от новой к старой) # Сортируем по дате (от новой к старой)
sorted_files = sorted(files_with_dates, key=lambda x: x[1], reverse=True) sorted_files = sorted(files_with_dates, key=lambda x: x[1], reverse=True)
@@ -177,17 +240,17 @@ def apply_retention_policy(files_with_dates: List[Tuple[Path, date]], policy: Re
files_to_keep.update(monthly_files[:policy.monthly]) files_to_keep.update(monthly_files[:policy.monthly])
logger.debug("[apply_retention_policy][State] Keeping %d files according to retention policy", len(files_to_keep)) logger.debug("[apply_retention_policy][State] Keeping %d files according to retention policy", len(files_to_keep))
return files_to_keep return files_to_keep
# </ANCHOR id="apply_retention_policy"> # [/DEF:apply_retention_policy]
# <ANCHOR id="save_and_unpack_dashboard" type="Function"> # [DEF:save_and_unpack_dashboard:Function]
# @PURPOSE: Сохраняет бинарное содержимое ZIP-архива на диск и опционально распаковывает его. # @PURPOSE: Сохраняет бинарное содержимое ZIP-архива на диск и опционально распаковывает его.
# @PARAM: zip_content: bytes - Содержимое ZIP-архива. # @PARAM: zip_content (bytes) - Содержимое ZIP-архива.
# @PARAM: output_dir: Union[str, Path] - Директория для сохранения. # @PARAM: output_dir (Union[str, Path]) - Директория для сохранения.
# @PARAM: unpack: bool - Флаг, нужно ли распаковывать архив. # @PARAM: unpack (bool) - Флаг, нужно ли распаковывать архив.
# @PARAM: original_filename: Optional[str] - Исходное имя файла для сохранения. # @PARAM: original_filename (Optional[str]) - Исходное имя файла для сохранения.
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера. # @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
# @RETURN: Tuple[Path, Optional[Path]] - Путь к ZIP-файлу и, если применимо, путь к директории с распаковкой. # @RETURN: Tuple[Path, Optional[Path]] - Путь к ZIP-файлу и, если применимо, путь к директории с распаковкой.
# @THROW: InvalidZipFormatError - При ошибке формата ZIP. # @THROW: InvalidZipFormatError - При ошибке формата ZIP.
def save_and_unpack_dashboard(zip_content: bytes, output_dir: Union[str, Path], unpack: bool = False, original_filename: Optional[str] = None, logger: Optional[SupersetLogger] = None) -> Tuple[Path, Optional[Path]]: def save_and_unpack_dashboard(zip_content: bytes, output_dir: Union[str, Path], unpack: bool = False, original_filename: Optional[str] = None, logger: Optional[SupersetLogger] = None) -> Tuple[Path, Optional[Path]]:
logger = logger or SupersetLogger(name="fileio") logger = logger or SupersetLogger(name="fileio")
logger.info("[save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: %s", unpack) logger.info("[save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: %s", unpack)
@@ -207,17 +270,17 @@ def save_and_unpack_dashboard(zip_content: bytes, output_dir: Union[str, Path],
except zipfile.BadZipFile as e: except zipfile.BadZipFile as e:
logger.error("[save_and_unpack_dashboard][Failure] Invalid ZIP archive: %s", e) logger.error("[save_and_unpack_dashboard][Failure] Invalid ZIP archive: %s", e)
raise InvalidZipFormatError(f"Invalid ZIP file: {e}") from e raise InvalidZipFormatError(f"Invalid ZIP file: {e}") from e
# </ANCHOR id="save_and_unpack_dashboard"> # [/DEF:save_and_unpack_dashboard]
# <ANCHOR id="update_yamls" type="Function"> # [DEF:update_yamls:Function]
# @PURPOSE: Обновляет конфигурации в YAML-файлах, заменяя значения или применяя regex. # @PURPOSE: Обновляет конфигурации в YAML-файлах, заменяя значения или применяя regex.
# @PARAM: db_configs: Optional[List[Dict]] - Список конфигураций для замены. # @RELATION: CALLS -> _update_yaml_file
# @PARAM: path: str - Путь к директории с YAML файлами. # @THROW: FileNotFoundError - Если `path` не существует.
# @PARAM: regexp_pattern: Optional[LiteralString] - Паттерн для поиска. # @PARAM: db_configs (Optional[List[Dict]]) - Список конфигураций для замены.
# @PARAM: replace_string: Optional[LiteralString] - Строка для замены. # @PARAM: path (str) - Путь к директории с YAML файлами.
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера. # @PARAM: regexp_pattern (Optional[LiteralString]) - Паттерн для поиска.
# @THROW: FileNotFoundError - Если `path` не существует. # @PARAM: replace_string (Optional[LiteralString]) - Строка для замены.
# @RELATION: CALLS -> _update_yaml_file # @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
def update_yamls(db_configs: Optional[List[Dict]] = None, path: str = "dashboards", regexp_pattern: Optional[LiteralString] = None, replace_string: Optional[LiteralString] = None, logger: Optional[SupersetLogger] = None) -> None: def update_yamls(db_configs: Optional[List[Dict]] = None, path: str = "dashboards", regexp_pattern: Optional[LiteralString] = None, replace_string: Optional[LiteralString] = None, logger: Optional[SupersetLogger] = None) -> None:
logger = logger or SupersetLogger(name="fileio") logger = logger or SupersetLogger(name="fileio")
logger.info("[update_yamls][Enter] Starting YAML configuration update.") logger.info("[update_yamls][Enter] Starting YAML configuration update.")
@@ -228,57 +291,64 @@ def update_yamls(db_configs: Optional[List[Dict]] = None, path: str = "dashboard
for file_path in dir_path.rglob("*.yaml"): for file_path in dir_path.rglob("*.yaml"):
_update_yaml_file(file_path, configs, regexp_pattern, replace_string, logger) _update_yaml_file(file_path, configs, regexp_pattern, replace_string, logger)
# </ANCHOR id="update_yamls"> # [/DEF:update_yamls]
# <ANCHOR id="_update_yaml_file" type="Function"> # [DEF:_update_yaml_file:Function]
# @PURPOSE: (Helper) Обновляет один YAML файл. # @PURPOSE: (Helper) Обновляет один YAML файл.
# @INTERNAL # @PARAM: file_path (Path) - Путь к файлу.
def _update_yaml_file(file_path: Path, db_configs: List[Dict], regexp_pattern: Optional[str], replace_string: Optional[str], logger: SupersetLogger) -> None: # @PARAM: db_configs (List[Dict]) - Конфигурации.
# Читаем содержимое файла # @PARAM: regexp_pattern (Optional[str]) - Паттерн.
try: # @PARAM: replace_string (Optional[str]) - Замена.
with open(file_path, 'r', encoding='utf-8') as f: # @PARAM: logger (SupersetLogger) - Логгер.
content = f.read() def _update_yaml_file(file_path: Path, db_configs: List[Dict], regexp_pattern: Optional[str], replace_string: Optional[str], logger: SupersetLogger) -> None:
except Exception as e: # Читаем содержимое файла
logger.error("[_update_yaml_file][Failure] Failed to read %s: %s", file_path, e) try:
return with open(file_path, 'r', encoding='utf-8') as f:
# Если задан pattern и replace_string, применяем замену по регулярному выражению content = f.read()
if regexp_pattern and replace_string: except Exception as e:
try: logger.error("[_update_yaml_file][Failure] Failed to read %s: %s", file_path, e)
new_content = re.sub(regexp_pattern, replace_string, content) return
if new_content != content: # Если задан pattern и replace_string, применяем замену по регулярному выражению
with open(file_path, 'w', encoding='utf-8') as f: if regexp_pattern and replace_string:
f.write(new_content) try:
logger.info("[_update_yaml_file][State] Updated %s using regex pattern", file_path) new_content = re.sub(regexp_pattern, replace_string, content)
except Exception as e: if new_content != content:
logger.error("[_update_yaml_file][Failure] Error applying regex to %s: %s", file_path, e) with open(file_path, 'w', encoding='utf-8') as f:
# Если заданы конфигурации, заменяем значения f.write(new_content)
if db_configs: logger.info("[_update_yaml_file][State] Updated %s using regex pattern", file_path)
try: except Exception as e:
parsed_data = yaml.safe_load(content) logger.error("[_update_yaml_file][Failure] Error applying regex to %s: %s", file_path, e)
if not isinstance(parsed_data, dict): # Если заданы конфигурации, заменяем значения (поддержка old/new)
logger.warning("[_update_yaml_file][Warning] YAML content is not a dictionary in %s", file_path) if db_configs:
return try:
# Обновляем данные # Прямой текстовый заменитель для старых/новых значений, чтобы сохранить структуру файла
for config in db_configs: modified_content = content
for key, value in config.items(): for cfg in db_configs:
if key in parsed_data: # Ожидаем структуру: {'old': {...}, 'new': {...}}
old_value = parsed_data[key] old_cfg = cfg.get('old', {})
parsed_data[key] = value new_cfg = cfg.get('new', {})
logger.info("[_update_yaml_file][State] Changed %s.%s from %s to %s", file_path, key, old_value, value) for key, old_val in old_cfg.items():
# Записываем обратно if key in new_cfg:
with open(file_path, 'w', encoding='utf-8') as f: new_val = new_cfg[key]
yaml.dump(parsed_data, f, default_flow_style=False, allow_unicode=True) # Заменяем только точные совпадения старого значения в тексте YAML
except Exception as e: if isinstance(old_val, str):
logger.error("[_update_yaml_file][Failure] Error updating YAML in %s: %s", file_path, e) escaped_old = re.escape(old_val)
# </ANCHOR id="_update_yaml_file"> modified_content = re.sub(escaped_old, new_val, modified_content)
logger.info("[_update_yaml_file][State] Replaced '%s' with '%s' for key %s in %s", old_val, new_val, key, file_path)
# Записываем обратно изменённый контент без парсинга YAML, сохраняем оригинальное форматирование
with open(file_path, 'w', encoding='utf-8') as f:
f.write(modified_content)
except Exception as e:
logger.error("[_update_yaml_file][Failure] Error performing raw replacement in %s: %s", file_path, e)
# [/DEF:_update_yaml_file]
# <ANCHOR id="create_dashboard_export" type="Function"> # [DEF:create_dashboard_export:Function]
# @PURPOSE: Создает ZIP-архив из указанных исходных путей. # @PURPOSE: Создает ZIP-архив из указанных исходных путей.
# @PARAM: zip_path: Union[str, Path] - Путь для сохранения ZIP архива. # @PARAM: zip_path (Union[str, Path]) - Путь для сохранения ZIP архива.
# @PARAM: source_paths: List[Union[str, Path]] - Список исходных путей для архивации. # @PARAM: source_paths (List[Union[str, Path]]) - Список исходных путей для архивации.
# @PARAM: exclude_extensions: Optional[List[str]] - Список расширений для исключения. # @PARAM: exclude_extensions (Optional[List[str]]) - Список расширений для исключения.
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера. # @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
# @RETURN: bool - `True` при успехе, `False` при ошибке. # @RETURN: bool - `True` при успехе, `False` при ошибке.
def create_dashboard_export(zip_path: Union[str, Path], source_paths: List[Union[str, Path]], exclude_extensions: Optional[List[str]] = None, logger: Optional[SupersetLogger] = None) -> bool: def create_dashboard_export(zip_path: Union[str, Path], source_paths: List[Union[str, Path]], exclude_extensions: Optional[List[str]] = None, logger: Optional[SupersetLogger] = None) -> bool:
logger = logger or SupersetLogger(name="fileio") logger = logger or SupersetLogger(name="fileio")
logger.info("[create_dashboard_export][Enter] Packing dashboard: %s -> %s", source_paths, zip_path) logger.info("[create_dashboard_export][Enter] Packing dashboard: %s -> %s", source_paths, zip_path)
@@ -297,32 +367,32 @@ def create_dashboard_export(zip_path: Union[str, Path], source_paths: List[Union
except (IOError, zipfile.BadZipFile, AssertionError) as e: except (IOError, zipfile.BadZipFile, AssertionError) as e:
logger.error("[create_dashboard_export][Failure] Error: %s", e, exc_info=True) logger.error("[create_dashboard_export][Failure] Error: %s", e, exc_info=True)
return False return False
# </ANCHOR id="create_dashboard_export"> # [/DEF:create_dashboard_export]
# <ANCHOR id="sanitize_filename" type="Function"> # [DEF:sanitize_filename:Function]
# @PURPOSE: Очищает строку от символов, недопустимых в именах файлов. # @PURPOSE: Очищает строку от символов, недопустимых в именах файлов.
# @PARAM: filename: str - Исходное имя файла. # @PARAM: filename (str) - Исходное имя файла.
# @RETURN: str - Очищенная строка. # @RETURN: str - Очищенная строка.
def sanitize_filename(filename: str) -> str: def sanitize_filename(filename: str) -> str:
return re.sub(r'[\\/*?:"<>|]', "_", filename).strip() return re.sub(r'[\\/*?:"<>|]', "_", filename).strip()
# </ANCHOR id="sanitize_filename"> # [/DEF:sanitize_filename]
# <ANCHOR id="get_filename_from_headers" type="Function"> # [DEF:get_filename_from_headers:Function]
# @PURPOSE: Извлекает имя файла из HTTP заголовка 'Content-Disposition'. # @PURPOSE: Извлекает имя файла из HTTP заголовка 'Content-Disposition'.
# @PARAM: headers: dict - Словарь HTTP заголовков. # @PARAM: headers (dict) - Словарь HTTP заголовков.
# @RETURN: Optional[str] - Имя файла или `None`. # @RETURN: Optional[str] - Имя файла или `None`.
def get_filename_from_headers(headers: dict) -> Optional[str]: def get_filename_from_headers(headers: dict) -> Optional[str]:
content_disposition = headers.get("Content-Disposition", "") content_disposition = headers.get("Content-Disposition", "")
if match := re.search(r'filename="?([^"]+)"?', content_disposition): if match := re.search(r'filename="?([^"]+)"?', content_disposition):
return match.group(1).strip() return match.group(1).strip()
return None return None
# </ANCHOR id="get_filename_from_headers"> # [/DEF:get_filename_from_headers]
# <ANCHOR id="consolidate_archive_folders" type="Function"> # [DEF:consolidate_archive_folders:Function]
# @PURPOSE: Консолидирует директории архивов на основе общего слага в имени. # @PURPOSE: Консолидирует директории архивов на основе общего слага в имени.
# @PARAM: root_directory: Path - Корневая директория для консолидации. # @THROW: TypeError, ValueError - Если `root_directory` невалиден.
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера. # @PARAM: root_directory (Path) - Корневая директория для консолидации.
# @THROW: TypeError, ValueError - Если `root_directory` невалиден. # @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
def consolidate_archive_folders(root_directory: Path, logger: Optional[SupersetLogger] = None) -> None: def consolidate_archive_folders(root_directory: Path, logger: Optional[SupersetLogger] = None) -> None:
logger = logger or SupersetLogger(name="fileio") logger = logger or SupersetLogger(name="fileio")
assert isinstance(root_directory, Path), "root_directory must be a Path object." assert isinstance(root_directory, Path), "root_directory must be a Path object."
@@ -371,8 +441,6 @@ def consolidate_archive_folders(root_directory: Path, logger: Optional[SupersetL
logger.info("[consolidate_archive_folders][State] Removed source directory: %s", source_dir) logger.info("[consolidate_archive_folders][State] Removed source directory: %s", source_dir)
except Exception as e: except Exception as e:
logger.error("[consolidate_archive_folders][Failure] Failed to remove source directory %s: %s", source_dir, e) logger.error("[consolidate_archive_folders][Failure] Failed to remove source directory %s: %s", source_dir, e)
# </ANCHOR id="consolidate_archive_folders"> # [/DEF:consolidate_archive_folders]
# --- Конец кода модуля --- # [/DEF:superset_tool.utils.fileio]
# </GRACE_MODULE id="superset_tool.utils.fileio">

View File

@@ -1,40 +1,43 @@
# <GRACE_MODULE id="superset_tool.utils.init_clients" name="init_clients.py"> # [DEF:superset_tool.utils.init_clients:Module]
# @SEMANTICS: utility, factory, client, initialization, configuration #
# @PURPOSE: Централизованно инициализирует клиенты Superset для различных окружений (DEV, PROD, SBX, PREPROD), используя `keyring` для безопасного доступа к паролям. # @SEMANTICS: utility, factory, client, initialization, configuration
# @DEPENDS_ON: superset_tool.models -> Использует SupersetConfig для создания конфигураций. # @PURPOSE: Централизованно инициализирует клиенты Superset для различных окружений (DEV, PROD, SBX, PREPROD), используя `keyring` для безопасного доступа к паролям.
# @DEPENDS_ON: superset_tool.client -> Создает экземпляры SupersetClient. # @LAYER: Infra
# @DEPENDS_ON: keyring -> Для безопасного получения паролей. # @RELATION: DEPENDS_ON -> superset_tool.models
# @RELATION: DEPENDS_ON -> superset_tool.client
# @RELATION: DEPENDS_ON -> keyring
# @PUBLIC_API: setup_clients
# <IMPORTS> # [SECTION: IMPORTS]
import keyring import keyring
from typing import Dict from typing import Dict
from superset_tool.models import SupersetConfig from superset_tool.models import SupersetConfig
from superset_tool.client import SupersetClient from superset_tool.client import SupersetClient
from superset_tool.utils.logger import SupersetLogger from superset_tool.utils.logger import SupersetLogger
# </IMPORTS> # [/SECTION]
# --- Начало кода модуля --- # [DEF:setup_clients:Function]
# @PURPOSE: Инициализирует и возвращает словарь клиентов `SupersetClient` для всех предопределенных окружений.
# <ANCHOR id="setup_clients" type="Function"> # @PRE: `keyring` должен содержать пароли для систем "dev migrate", "prod migrate", "sbx migrate", "preprod migrate".
# @PURPOSE: Инициализирует и возвращает словарь клиентов `SupersetClient` для всех предопределенных окружений. # @PRE: `logger` должен быть валидным экземпляром `SupersetLogger`.
# @PRE: `keyring` должен содержать пароли для систем "dev migrate", "prod migrate", "sbx migrate", "preprod migrate". # @POST: Возвращает словарь с инициализированными клиентами.
# @PRE: `logger` должен быть валидным экземпляром `SupersetLogger`. # @THROW: ValueError - Если пароль для окружения не найден в `keyring`.
# @POST: Возвращает словарь с инициализированными клиентами. # @THROW: Exception - При любых других ошибках инициализации.
# @PARAM: logger: SupersetLogger - Экземпляр логгера для записи процесса. # @RELATION: CREATES_INSTANCE_OF -> SupersetConfig
# @RETURN: Dict[str, SupersetClient] - Словарь, где ключ - имя окружения, значение - `SupersetClient`. # @RELATION: CREATES_INSTANCE_OF -> SupersetClient
# @THROW: ValueError - Если пароль для окружения не найден в `keyring`. # @PARAM: logger (SupersetLogger) - Экземпляр логгера для записи процесса.
# @THROW: Exception - При любых других ошибках инициализации. # @RETURN: Dict[str, SupersetClient] - Словарь, где ключ - имя окружения, значение - `SupersetClient`.
# @RELATION: CREATES_INSTANCE_OF -> SupersetConfig
# @RELATION: CREATES_INSTANCE_OF -> SupersetClient
def setup_clients(logger: SupersetLogger) -> Dict[str, SupersetClient]: def setup_clients(logger: SupersetLogger) -> Dict[str, SupersetClient]:
logger.info("[setup_clients][Enter] Starting Superset clients initialization.") logger.info("[setup_clients][Enter] Starting Superset clients initialization.")
clients = {} clients = {}
environments = { environments = {
"dev": "https://devta.bi.dwh.rusal.com/api/v1/", "dev": "https://devta.bi.dwh.rusal.com/api/v1",
"prod": "https://prodta.bi.dwh.rusal.com/api/v1/", "prod": "https://prodta.bi.dwh.rusal.com/api/v1",
"sbx": "https://sandboxta.bi.dwh.rusal.com/api/v1/", "sbx": "https://sandboxta.bi.dwh.rusal.com/api/v1",
"preprod": "https://preprodta.bi.dwh.rusal.com/api/v1/" "preprod": "https://preprodta.bi.dwh.rusal.com/api/v1",
"uatta": "https://uatta.bi.dwh.rusal.com/api/v1",
"dev5":"https://dev.bi.dwh.rusal.com/api/v1"
} }
try: try:
@@ -60,8 +63,6 @@ def setup_clients(logger: SupersetLogger) -> Dict[str, SupersetClient]:
except Exception as e: except Exception as e:
logger.critical("[setup_clients][Failure] Critical error during client initialization: %s", e, exc_info=True) logger.critical("[setup_clients][Failure] Critical error during client initialization: %s", e, exc_info=True)
raise raise
# </ANCHOR id="setup_clients"> # [/DEF:setup_clients]
# --- Конец кода модуля --- # [/DEF:superset_tool.utils.init_clients]
# </GRACE_MODULE id="superset_tool.utils.init_clients">

View File

@@ -1,29 +1,34 @@
# <GRACE_MODULE id="superset_tool.utils.logger" name="logger.py"> # [DEF:superset_tool.utils.logger:Module]
# @SEMANTICS: logging, utility, infrastructure, wrapper #
# @PURPOSE: Предоставляет универсальную обёртку над стандартным `logging.Logger` для унифицированного создания и управления логгерами с выводом в консоль и/или файл. # @SEMANTICS: logging, utility, infrastructure, wrapper
# @PURPOSE: Предоставляет универсальную обёртку над стандартным `logging.Logger` для унифицированного создания и управления логгерами с выводом в консоль и/или файл.
# @LAYER: Infra
# @RELATION: WRAPS -> logging.Logger
#
# @INVARIANT: Логгер всегда должен иметь имя.
# @PUBLIC_API: SupersetLogger
# <IMPORTS> # [SECTION: 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, Any, Mapping from typing import Optional, Any, Mapping
# </IMPORTS> # [/SECTION]
# --- Начало кода модуля --- # [DEF:SupersetLogger:Class]
# @PURPOSE: Обёртка над `logging.Logger`, которая упрощает конфигурацию и использование логгеров.
# <ANCHOR id="SupersetLogger" type="Class"> # @RELATION: WRAPS -> logging.Logger
# @PURPOSE: Обёртка над `logging.Logger`, которая упрощает конфигурацию и использование логгеров.
# @RELATION: WRAPS -> logging.Logger
class SupersetLogger: class SupersetLogger:
# [DEF:SupersetLogger.__init__:Function]
# @PURPOSE: Конфигурирует и инициализирует логгер, добавляя обработчики для файла и/или консоли.
# @PRE: Если log_dir указан, путь должен быть валидным (или создаваемым).
# @POST: `self.logger` готов к использованию с настроенными обработчиками.
# @PARAM: name (str) - Идентификатор логгера.
# @PARAM: log_dir (Optional[Path]) - Директория для сохранения лог-файлов.
# @PARAM: level (int) - Уровень логирования (e.g., `logging.INFO`).
# @PARAM: console (bool) - Флаг для включения вывода в консоль.
def __init__(self, name: str = "superset_tool", log_dir: Optional[Path] = None, level: int = logging.INFO, console: bool = True) -> None: def __init__(self, name: str = "superset_tool", log_dir: Optional[Path] = None, level: int = logging.INFO, console: bool = True) -> None:
# <ANCHOR id="SupersetLogger.__init__" type="Function">
# @PURPOSE: Конфигурирует и инициализирует логгер, добавляя обработчики для файла и/или консоли.
# @PARAM: name: str - Идентификатор логгера.
# @PARAM: log_dir: Optional[Path] - Директория для сохранения лог-файлов.
# @PARAM: level: int - Уровень логирования (e.g., `logging.INFO`).
# @PARAM: console: bool - Флаг для включения вывода в консоль.
# @POST: `self.logger` готов к использованию с настроенными обработчиками.
self.logger = logging.getLogger(name) self.logger = logging.getLogger(name)
self.logger.setLevel(level) self.logger.setLevel(level)
self.logger.propagate = False self.logger.propagate = False
@@ -44,52 +49,55 @@ class SupersetLogger:
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)
# </ANCHOR id="SupersetLogger.__init__"> # [/DEF:SupersetLogger.__init__]
# <ANCHOR id="SupersetLogger._log" type="Function"> # [DEF:SupersetLogger._log:Function]
# @PURPOSE: (Helper) Универсальный метод для вызова соответствующего уровня логирования. # @PURPOSE: (Helper) Универсальный метод для вызова соответствующего уровня логирования.
# @INTERNAL # @PARAM: level_method (Any) - Метод логгера (info, debug, etc).
# @PARAM: msg (str) - Сообщение.
# @PARAM: args (Any) - Аргументы форматирования.
# @PARAM: extra (Optional[Mapping[str, Any]]) - Дополнительные данные.
# @PARAM: exc_info (bool) - Добавлять ли информацию об исключении.
def _log(self, level_method: Any, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None: def _log(self, level_method: Any, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None:
level_method(msg, *args, extra=extra, exc_info=exc_info) level_method(msg, *args, extra=extra, exc_info=exc_info)
# </ANCHOR id="SupersetLogger._log"> # [/DEF:SupersetLogger._log]
# <ANCHOR id="SupersetLogger.info" type="Function"> # [DEF:SupersetLogger.info:Function]
# @PURPOSE: Записывает сообщение уровня INFO. # @PURPOSE: Записывает сообщение уровня INFO.
def info(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None: 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) self._log(self.logger.info, msg, *args, extra=extra, exc_info=exc_info)
# </ANCHOR id="SupersetLogger.info"> # [/DEF:SupersetLogger.info]
# <ANCHOR id="SupersetLogger.debug" type="Function"> # [DEF:SupersetLogger.debug:Function]
# @PURPOSE: Записывает сообщение уровня DEBUG. # @PURPOSE: Записывает сообщение уровня DEBUG.
def debug(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None: 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) self._log(self.logger.debug, msg, *args, extra=extra, exc_info=exc_info)
# </ANCHOR id="SupersetLogger.debug"> # [/DEF:SupersetLogger.debug]
# <ANCHOR id="SupersetLogger.warning" type="Function"> # [DEF:SupersetLogger.warning:Function]
# @PURPOSE: Записывает сообщение уровня WARNING. # @PURPOSE: Записывает сообщение уровня WARNING.
def warning(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None: 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) self._log(self.logger.warning, msg, *args, extra=extra, exc_info=exc_info)
# </ANCHOR id="SupersetLogger.warning"> # [/DEF:SupersetLogger.warning]
# <ANCHOR id="SupersetLogger.error" type="Function"> # [DEF:SupersetLogger.error:Function]
# @PURPOSE: Записывает сообщение уровня ERROR. # @PURPOSE: Записывает сообщение уровня ERROR.
def error(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None: 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) self._log(self.logger.error, msg, *args, extra=extra, exc_info=exc_info)
# </ANCHOR id="SupersetLogger.error"> # [/DEF:SupersetLogger.error]
# <ANCHOR id="SupersetLogger.critical" type="Function"> # [DEF:SupersetLogger.critical:Function]
# @PURPOSE: Записывает сообщение уровня CRITICAL. # @PURPOSE: Записывает сообщение уровня CRITICAL.
def critical(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None: 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) self._log(self.logger.critical, msg, *args, extra=extra, exc_info=exc_info)
# </ANCHOR id="SupersetLogger.critical"> # [/DEF:SupersetLogger.critical]
# <ANCHOR id="SupersetLogger.exception" type="Function"> # [DEF:SupersetLogger.exception:Function]
# @PURPOSE: Записывает сообщение уровня ERROR вместе с трассировкой стека текущего исключения. # @PURPOSE: Записывает сообщение уровня ERROR вместе с трассировкой стека текущего исключения.
def exception(self, msg: str, *args: Any, **kwargs: Any) -> None: def exception(self, msg: str, *args: Any, **kwargs: Any) -> None:
self.logger.exception(msg, *args, **kwargs) self.logger.exception(msg, *args, **kwargs)
# </ANCHOR id="SupersetLogger.exception"> # [/DEF:SupersetLogger.exception]
# </ANCHOR id="SupersetLogger">
# --- Конец кода модуля --- # [/DEF:SupersetLogger]
# </GRACE_MODULE id="superset_tool.utils.logger"> # [/DEF:superset_tool.utils.logger]

View File

@@ -1,49 +1,56 @@
# <GRACE_MODULE id="superset_tool.utils.network" name="network.py"> # [DEF:superset_tool.utils.network:Module]
# @SEMANTICS: network, http, client, api, requests, session, authentication #
# @PURPOSE: Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API, включая аутентификацию, управление сессией, retry-логику и обработку ошибок. # @SEMANTICS: network, http, client, api, requests, session, authentication
# @DEPENDS_ON: superset_tool.exceptions -> Для генерации специфичных сетевых и API ошибок. # @PURPOSE: Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API, включая аутентификацию, управление сессией, retry-логику и обработку ошибок.
# @DEPENDS_ON: superset_tool.utils.logger -> Для детального логирования сетевых операций. # @LAYER: Infra
# @DEPENDS_ON: requests -> Основа для выполнения HTTP-запросов. # @RELATION: DEPENDS_ON -> superset_tool.exceptions
# @RELATION: DEPENDS_ON -> superset_tool.utils.logger
# @RELATION: DEPENDS_ON -> requests
# @PUBLIC_API: APIClient
# <IMPORTS> # [SECTION: IMPORTS]
from typing import Optional, Dict, Any, List, Union from typing import Optional, Dict, Any, List, Union, cast
import json import json
import io import io
from pathlib import Path from pathlib import Path
import requests import requests
from requests.adapters import HTTPAdapter
import urllib3 import urllib3
from urllib3.util.retry import Retry
from superset_tool.exceptions import AuthenticationError, NetworkError, DashboardNotFoundError, SupersetAPIError, PermissionDeniedError from superset_tool.exceptions import AuthenticationError, NetworkError, DashboardNotFoundError, SupersetAPIError, PermissionDeniedError
from superset_tool.utils.logger import SupersetLogger from superset_tool.utils.logger import SupersetLogger
# </IMPORTS> # [/SECTION]
# --- Начало кода модуля --- # [DEF:APIClient:Class]
# @PURPOSE: Инкапсулирует HTTP-логику для работы с API, включая сессии, аутентификацию, и обработку запросов.
# <ANCHOR id="APIClient" type="Class">
# @PURPOSE: Инкапсулирует HTTP-логику для работы с API, включая сессии, аутентификацию, и обработку запросов.
class APIClient: class APIClient:
DEFAULT_TIMEOUT = 30 DEFAULT_TIMEOUT = 30
# [DEF:APIClient.__init__:Function]
# @PURPOSE: Инициализирует API клиент с конфигурацией, сессией и логгером.
# @PARAM: config (Dict[str, Any]) - Конфигурация.
# @PARAM: verify_ssl (bool) - Проверять ли SSL.
# @PARAM: timeout (int) - Таймаут запросов.
# @PARAM: logger (Optional[SupersetLogger]) - Логгер.
def __init__(self, config: Dict[str, Any], verify_ssl: bool = True, timeout: int = DEFAULT_TIMEOUT, logger: Optional[SupersetLogger] = None): def __init__(self, config: Dict[str, Any], verify_ssl: bool = True, timeout: int = DEFAULT_TIMEOUT, logger: Optional[SupersetLogger] = None):
# <ANCHOR id="APIClient.__init__" type="Function">
# @PURPOSE: Инициализирует API клиент с конфигурацией, сессией и логгером.
self.logger = logger or SupersetLogger(name="APIClient") self.logger = logger or SupersetLogger(name="APIClient")
self.logger.info("[APIClient.__init__][Enter] Initializing APIClient.") self.logger.info("[APIClient.__init__][Entry] Initializing APIClient.")
self.base_url = config.get("base_url") self.base_url: str = config.get("base_url", "")
self.auth = config.get("auth") self.auth = config.get("auth")
self.request_settings = {"verify_ssl": verify_ssl, "timeout": timeout} self.request_settings = {"verify_ssl": verify_ssl, "timeout": timeout}
self.session = self._init_session() self.session = self._init_session()
self._tokens: Dict[str, str] = {} self._tokens: Dict[str, str] = {}
self._authenticated = False self._authenticated = False
self.logger.info("[APIClient.__init__][Exit] APIClient initialized.") self.logger.info("[APIClient.__init__][Exit] APIClient initialized.")
# </ANCHOR> # [/DEF:APIClient.__init__]
# [DEF:APIClient._init_session:Function]
# @PURPOSE: Создает и настраивает `requests.Session` с retry-логикой.
# @RETURN: requests.Session - Настроенная сессия.
def _init_session(self) -> requests.Session: def _init_session(self) -> requests.Session:
# <ANCHOR id="APIClient._init_session" type="Function">
# @PURPOSE: Создает и настраивает `requests.Session` с retry-логикой.
# @INTERNAL
session = requests.Session() session = requests.Session()
retries = requests.adapters.Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504]) retries = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504])
adapter = requests.adapters.HTTPAdapter(max_retries=retries) adapter = HTTPAdapter(max_retries=retries)
session.mount('http://', adapter) session.mount('http://', adapter)
session.mount('https://', adapter) session.mount('https://', adapter)
if not self.request_settings["verify_ssl"]: if not self.request_settings["verify_ssl"]:
@@ -51,14 +58,14 @@ class APIClient:
self.logger.warning("[_init_session][State] SSL verification disabled.") self.logger.warning("[_init_session][State] SSL verification disabled.")
session.verify = self.request_settings["verify_ssl"] session.verify = self.request_settings["verify_ssl"]
return session return session
# </ANCHOR> # [/DEF:APIClient._init_session]
# [DEF:APIClient.authenticate:Function]
# @PURPOSE: Выполняет аутентификацию в Superset API и получает access и CSRF токены.
# @POST: `self._tokens` заполнен, `self._authenticated` установлен в `True`.
# @RETURN: Dict[str, str] - Словарь с токенами.
# @THROW: AuthenticationError, NetworkError - при ошибках.
def authenticate(self) -> Dict[str, str]: def authenticate(self) -> Dict[str, str]:
# <ANCHOR id="APIClient.authenticate" type="Function">
# @PURPOSE: Выполняет аутентификацию в Superset API и получает access и CSRF токены.
# @POST: `self._tokens` заполнен, `self._authenticated` установлен в `True`.
# @RETURN: Словарь с токенами.
# @THROW: AuthenticationError, NetworkError - при ошибках.
self.logger.info("[authenticate][Enter] Authenticating to %s", self.base_url) self.logger.info("[authenticate][Enter] Authenticating to %s", self.base_url)
try: try:
login_url = f"{self.base_url}/security/login" login_url = f"{self.base_url}/security/login"
@@ -78,12 +85,12 @@ class APIClient:
raise AuthenticationError(f"Authentication failed: {e}") from e raise AuthenticationError(f"Authentication failed: {e}") from e
except (requests.exceptions.RequestException, KeyError) as e: except (requests.exceptions.RequestException, KeyError) as e:
raise NetworkError(f"Network or parsing error during authentication: {e}") from e raise NetworkError(f"Network or parsing error during authentication: {e}") from e
# </ANCHOR> # [/DEF:APIClient.authenticate]
@property @property
def headers(self) -> Dict[str, str]: def headers(self) -> Dict[str, str]:
# <ANCHOR id="APIClient.headers" type="Property"> # [DEF:APIClient.headers:Function]
# @PURPOSE: Возвращает HTTP-заголовки для аутентифицированных запросов. # @PURPOSE: Возвращает HTTP-заголовки для аутентифицированных запросов.
if not self._authenticated: self.authenticate() if not self._authenticated: self.authenticate()
return { return {
"Authorization": f"Bearer {self._tokens['access_token']}", "Authorization": f"Bearer {self._tokens['access_token']}",
@@ -91,13 +98,17 @@ class APIClient:
"Referer": self.base_url, "Referer": self.base_url,
"Content-Type": "application/json" "Content-Type": "application/json"
} }
# </ANCHOR> # [/DEF:APIClient.headers]
# [DEF:APIClient.request:Function]
# @PURPOSE: Выполняет универсальный HTTP-запрос к API.
# @RETURN: `requests.Response` если `raw_response=True`, иначе `dict`.
# @THROW: SupersetAPIError, NetworkError и их подклассы.
# @PARAM: method (str) - HTTP метод.
# @PARAM: endpoint (str) - API эндпоинт.
# @PARAM: headers (Optional[Dict]) - Дополнительные заголовки.
# @PARAM: raw_response (bool) - Возвращать ли сырой ответ.
def request(self, method: str, endpoint: str, headers: Optional[Dict] = None, raw_response: bool = False, **kwargs) -> Union[requests.Response, Dict[str, Any]]: def request(self, method: str, endpoint: str, headers: Optional[Dict] = None, raw_response: bool = False, **kwargs) -> Union[requests.Response, Dict[str, Any]]:
# <ANCHOR id="APIClient.request" type="Function">
# @PURPOSE: Выполняет универсальный HTTP-запрос к API.
# @RETURN: `requests.Response` если `raw_response=True`, иначе `dict`.
# @THROW: SupersetAPIError, NetworkError и их подклассы.
full_url = f"{self.base_url}{endpoint}" full_url = f"{self.base_url}{endpoint}"
_headers = self.headers.copy() _headers = self.headers.copy()
if headers: _headers.update(headers) if headers: _headers.update(headers)
@@ -110,34 +121,40 @@ class APIClient:
self._handle_http_error(e, endpoint) self._handle_http_error(e, endpoint)
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
self._handle_network_error(e, full_url) self._handle_network_error(e, full_url)
# </ANCHOR> # [/DEF:APIClient.request]
# [DEF:APIClient._handle_http_error:Function]
# @PURPOSE: (Helper) Преобразует HTTP ошибки в кастомные исключения.
# @PARAM: e (requests.exceptions.HTTPError) - Ошибка.
# @PARAM: endpoint (str) - Эндпоинт.
def _handle_http_error(self, e: requests.exceptions.HTTPError, endpoint: str): def _handle_http_error(self, e: requests.exceptions.HTTPError, endpoint: str):
# <ANCHOR id="APIClient._handle_http_error" type="Function">
# @PURPOSE: (Helper) Преобразует HTTP ошибки в кастомные исключения.
# @INTERNAL
status_code = e.response.status_code status_code = e.response.status_code
if status_code == 404: raise DashboardNotFoundError(endpoint) from e if status_code == 404: raise DashboardNotFoundError(endpoint) from e
if status_code == 403: raise PermissionDeniedError() from e if status_code == 403: raise PermissionDeniedError() from e
if status_code == 401: raise AuthenticationError() from e if status_code == 401: raise AuthenticationError() from e
raise SupersetAPIError(f"API Error {status_code}: {e.response.text}") from e raise SupersetAPIError(f"API Error {status_code}: {e.response.text}") from e
# </ANCHOR> # [/DEF:APIClient._handle_http_error]
# [DEF:APIClient._handle_network_error:Function]
# @PURPOSE: (Helper) Преобразует сетевые ошибки в `NetworkError`.
# @PARAM: e (requests.exceptions.RequestException) - Ошибка.
# @PARAM: url (str) - URL.
def _handle_network_error(self, e: requests.exceptions.RequestException, url: str): def _handle_network_error(self, e: requests.exceptions.RequestException, url: str):
# <ANCHOR id="APIClient._handle_network_error" type="Function">
# @PURPOSE: (Helper) Преобразует сетевые ошибки в `NetworkError`.
# @INTERNAL
if isinstance(e, requests.exceptions.Timeout): msg = "Request timeout" if isinstance(e, requests.exceptions.Timeout): msg = "Request timeout"
elif isinstance(e, requests.exceptions.ConnectionError): msg = "Connection error" elif isinstance(e, requests.exceptions.ConnectionError): msg = "Connection error"
else: msg = f"Unknown network error: {e}" else: msg = f"Unknown network error: {e}"
raise NetworkError(msg, url=url) from e raise NetworkError(msg, url=url) from e
# </ANCHOR> # [/DEF:APIClient._handle_network_error]
# [DEF:APIClient.upload_file:Function]
# @PURPOSE: Загружает файл на сервер через multipart/form-data.
# @RETURN: Ответ API в виде словаря.
# @THROW: SupersetAPIError, NetworkError, TypeError.
# @PARAM: endpoint (str) - Эндпоинт.
# @PARAM: file_info (Dict[str, Any]) - Информация о файле.
# @PARAM: extra_data (Optional[Dict]) - Дополнительные данные.
# @PARAM: timeout (Optional[int]) - Таймаут.
def upload_file(self, endpoint: str, file_info: Dict[str, Any], extra_data: Optional[Dict] = None, timeout: Optional[int] = None) -> Dict: def upload_file(self, endpoint: str, file_info: Dict[str, Any], extra_data: Optional[Dict] = None, timeout: Optional[int] = None) -> Dict:
# <ANCHOR id="APIClient.upload_file" type="Function">
# @PURPOSE: Загружает файл на сервер через multipart/form-data.
# @RETURN: Ответ API в виде словаря.
# @THROW: SupersetAPIError, NetworkError, TypeError.
full_url = f"{self.base_url}{endpoint}" full_url = f"{self.base_url}{endpoint}"
_headers = self.headers.copy(); _headers.pop('Content-Type', None) _headers = self.headers.copy(); _headers.pop('Content-Type', None)
@@ -153,32 +170,51 @@ class APIClient:
raise TypeError(f"Unsupported file_obj type: {type(file_obj)}") raise TypeError(f"Unsupported file_obj type: {type(file_obj)}")
return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout) return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout)
# </ANCHOR> # [/DEF:APIClient.upload_file]
# [DEF:APIClient._perform_upload:Function]
# @PURPOSE: (Helper) Выполняет POST запрос с файлом.
# @PARAM: url (str) - URL.
# @PARAM: files (Dict) - Файлы.
# @PARAM: data (Optional[Dict]) - Данные.
# @PARAM: headers (Dict) - Заголовки.
# @PARAM: timeout (Optional[int]) - Таймаут.
# @RETURN: Dict - Ответ.
def _perform_upload(self, url: str, files: Dict, data: Optional[Dict], headers: Dict, timeout: Optional[int]) -> Dict: def _perform_upload(self, url: str, files: Dict, data: Optional[Dict], headers: Dict, timeout: Optional[int]) -> Dict:
# <ANCHOR id="APIClient._perform_upload" type="Function">
# @PURPOSE: (Helper) Выполняет POST запрос с файлом.
# @INTERNAL
try: try:
response = self.session.post(url, files=files, data=data or {}, headers=headers, timeout=timeout or self.request_settings["timeout"]) response = self.session.post(url, files=files, data=data or {}, headers=headers, timeout=timeout or self.request_settings["timeout"])
response.raise_for_status() response.raise_for_status()
# Добавляем логирование для отладки
if response.status_code == 200:
try:
return response.json()
except Exception as json_e:
self.logger.debug(f"[_perform_upload][Debug] Response is not valid JSON: {response.text[:200]}...")
raise SupersetAPIError(f"API error during upload: Response is not valid JSON: {json_e}") from json_e
return response.json() return response.json()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
raise SupersetAPIError(f"API error during upload: {e.response.text}") from e raise SupersetAPIError(f"API error during upload: {e.response.text}") from e
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
raise NetworkError(f"Network error during upload: {e}", url=url) from e raise NetworkError(f"Network error during upload: {e}", url=url) from e
# </ANCHOR> # [/DEF:APIClient._perform_upload]
# [DEF:APIClient.fetch_paginated_count:Function]
# @PURPOSE: Получает общее количество элементов для пагинации.
# @PARAM: endpoint (str) - Эндпоинт.
# @PARAM: query_params (Dict) - Параметры запроса.
# @PARAM: count_field (str) - Поле с количеством.
# @RETURN: int - Количество.
def fetch_paginated_count(self, endpoint: str, query_params: Dict, count_field: str = "count") -> int: def fetch_paginated_count(self, endpoint: str, query_params: Dict, count_field: str = "count") -> int:
# <ANCHOR id="APIClient.fetch_paginated_count" type="Function"> response_json = cast(Dict[str, Any], self.request("GET", endpoint, params={"q": json.dumps(query_params)}))
# @PURPOSE: Получает общее количество элементов для пагинации.
response_json = self.request("GET", endpoint, params={"q": json.dumps(query_params)})
return response_json.get(count_field, 0) return response_json.get(count_field, 0)
# </ANCHOR> # [/DEF:APIClient.fetch_paginated_count]
# [DEF:APIClient.fetch_paginated_data:Function]
# @PURPOSE: Автоматически собирает данные со всех страниц пагинированного эндпоинта.
# @PARAM: endpoint (str) - Эндпоинт.
# @PARAM: pagination_options (Dict[str, Any]) - Опции пагинации.
# @RETURN: List[Any] - Список данных.
def fetch_paginated_data(self, endpoint: str, pagination_options: Dict[str, Any]) -> List[Any]: def fetch_paginated_data(self, endpoint: str, pagination_options: Dict[str, Any]) -> List[Any]:
# <ANCHOR id="APIClient.fetch_paginated_data" type="Function">
# @PURPOSE: Автоматически собирает данные со всех страниц пагинированного эндпоинта.
base_query, total_count = pagination_options["base_query"], pagination_options["total_count"] base_query, total_count = pagination_options["base_query"], pagination_options["total_count"]
results_field, page_size = pagination_options["results_field"], base_query.get('page_size') results_field, page_size = pagination_options["results_field"], base_query.get('page_size')
assert page_size and page_size > 0, "'page_size' must be a positive number." assert page_size and page_size > 0, "'page_size' must be a positive number."
@@ -186,13 +222,11 @@ class APIClient:
results = [] results = []
for page in range((total_count + page_size - 1) // page_size): for page in range((total_count + page_size - 1) // page_size):
query = {**base_query, 'page': page} query = {**base_query, 'page': page}
response_json = self.request("GET", endpoint, params={"q": json.dumps(query)}) response_json = cast(Dict[str, Any], self.request("GET", endpoint, params={"q": json.dumps(query)}))
results.extend(response_json.get(results_field, [])) results.extend(response_json.get(results_field, []))
return results return results
# </ANCHOR> # [/DEF:APIClient.fetch_paginated_data]
# </ANCHOR id="APIClient"> # [/DEF:APIClient]
# --- Конец кода модуля --- # [/DEF:superset_tool.utils.network]
# </GRACE_MODULE id="superset_tool.utils.network">

View File

@@ -1,20 +1,21 @@
# <GRACE_MODULE id="superset_tool.utils.whiptail_fallback" name="whiptail_fallback.py"> # [DEF:superset_tool.utils.whiptail_fallback:Module]
# @SEMANTICS: ui, fallback, console, utility, interactive #
# @PURPOSE: Предоставляет плотный консольный UI-fallback для интерактивных диалогов, имитируя `whiptail` для систем, где он недоступен. # @SEMANTICS: ui, fallback, console, utility, interactive
# @PURPOSE: Предоставляет плотный консольный UI-fallback для интерактивных диалогов, имитируя `whiptail` для систем, где он недоступен.
# @LAYER: UI
# @PUBLIC_API: menu, checklist, yesno, msgbox, inputbox, gauge
# <IMPORTS> # [SECTION: IMPORTS]
import sys import sys
from typing import List, Tuple, Optional, Any from typing import List, Tuple, Optional, Any
# </IMPORTS> # [/SECTION]
# --- Начало кода модуля --- # [DEF:menu:Function]
# @PURPOSE: Отображает меню выбора и возвращает выбранный элемент.
# <ANCHOR id="menu" type="Function"> # @PARAM: title (str) - Заголовок меню.
# @PURPOSE: Отображает меню выбора и возвращает выбранный элемент. # @PARAM: prompt (str) - Приглашение к вводу.
# @PARAM: title: str - Заголовок меню. # @PARAM: choices (List[str]) - Список вариантов для выбора.
# @PARAM: prompt: str - Приглашение к вводу. # @RETURN: Tuple[int, Optional[str]] - Кортеж (код возврата, выбранный элемент). rc=0 - успех.
# @PARAM: choices: List[str] - Список вариантов для выбора.
# @RETURN: Tuple[int, Optional[str]] - Кортеж (код возврата, выбранный элемент). rc=0 - успех.
def menu(title: str, prompt: str, choices: List[str], **kwargs) -> Tuple[int, Optional[str]]: def menu(title: str, prompt: str, choices: List[str], **kwargs) -> Tuple[int, Optional[str]]:
print(f"\n=== {title} ===\n{prompt}") print(f"\n=== {title} ===\n{prompt}")
for idx, item in enumerate(choices, 1): for idx, item in enumerate(choices, 1):
@@ -25,14 +26,14 @@ def menu(title: str, prompt: str, choices: List[str], **kwargs) -> Tuple[int, Op
return (0, choices[sel - 1]) if 0 < sel <= len(choices) else (1, None) return (0, choices[sel - 1]) if 0 < sel <= len(choices) else (1, None)
except (ValueError, IndexError): except (ValueError, IndexError):
return 1, None return 1, None
# </ANCHOR id="menu"> # [/DEF:menu]
# <ANCHOR id="checklist" type="Function"> # [DEF:checklist:Function]
# @PURPOSE: Отображает список с возможностью множественного выбора. # @PURPOSE: Отображает список с возможностью множественного выбора.
# @PARAM: title: str - Заголовок. # @PARAM: title (str) - Заголовок.
# @PARAM: prompt: str - Приглашение к вводу. # @PARAM: prompt (str) - Приглашение к вводу.
# @PARAM: options: List[Tuple[str, str]] - Список кортежей (значение, метка). # @PARAM: options (List[Tuple[str, str]]) - Список кортежей (значение, метка).
# @RETURN: Tuple[int, List[str]] - Кортеж (код возврата, список выбранных значений). # @RETURN: Tuple[int, List[str]] - Кортеж (код возврата, список выбранных значений).
def checklist(title: str, prompt: str, options: List[Tuple[str, str]], **kwargs) -> Tuple[int, List[str]]: def checklist(title: str, prompt: str, options: List[Tuple[str, str]], **kwargs) -> Tuple[int, List[str]]:
print(f"\n=== {title} ===\n{prompt}") print(f"\n=== {title} ===\n{prompt}")
for idx, (val, label) in enumerate(options, 1): for idx, (val, label) in enumerate(options, 1):
@@ -45,40 +46,39 @@ def checklist(title: str, prompt: str, options: List[Tuple[str, str]], **kwargs)
return 0, selected_values return 0, selected_values
except (ValueError, IndexError): except (ValueError, IndexError):
return 1, [] return 1, []
# </ANCHOR id="checklist"> # [/DEF:checklist]
# <ANCHOR id="yesno" type="Function"> # [DEF:yesno:Function]
# @PURPOSE: Задает вопрос с ответом да/нет. # @PURPOSE: Задает вопрос с ответом да/нет.
# @PARAM: title: str - Заголовок. # @PARAM: title (str) - Заголовок.
# @PARAM: question: str - Вопрос для пользователя. # @PARAM: question (str) - Вопрос для пользователя.
# @RETURN: bool - `True`, если пользователь ответил "да". # @RETURN: bool - `True`, если пользователь ответил "да".
def yesno(title: str, question: str, **kwargs) -> bool: def yesno(title: str, question: str, **kwargs) -> bool:
ans = input(f"\n=== {title} ===\n{question} (y/n): ").strip().lower() ans = input(f"\n=== {title} ===\n{question} (y/n): ").strip().lower()
return ans in ("y", "yes", "да", "д") return ans in ("y", "yes", "да", "д")
# </ANCHOR id="yesno"> # [/DEF:yesno]
# <ANCHOR id="msgbox" type="Function"> # [DEF:msgbox:Function]
# @PURPOSE: Отображает информационное сообщение. # @PURPOSE: Отображает информационное сообщение.
# @PARAM: title: str - Заголовок. # @PARAM: title (str) - Заголовок.
# @PARAM: msg: str - Текст сообщения. # @PARAM: msg (str) - Текст сообщения.
def msgbox(title: str, msg: str, **kwargs) -> None: def msgbox(title: str, msg: str, **kwargs) -> None:
print(f"\n=== {title} ===\n{msg}\n") print(f"\n=== {title} ===\n{msg}\n")
# </ANCHOR id="msgbox"> # [/DEF:msgbox]
# <ANCHOR id="inputbox" type="Function"> # [DEF:inputbox:Function]
# @PURPOSE: Запрашивает у пользователя текстовый ввод. # @PURPOSE: Запрашивает у пользователя текстовый ввод.
# @PARAM: title: str - Заголовок. # @PARAM: title (str) - Заголовок.
# @PARAM: prompt: str - Приглашение к вводу. # @PARAM: prompt (str) - Приглашение к вводу.
# @RETURN: Tuple[int, Optional[str]] - Кортеж (код возврата, введенная строка). # @RETURN: Tuple[int, Optional[str]] - Кортеж (код возврата, введенная строка).
def inputbox(title: str, prompt: str, **kwargs) -> Tuple[int, Optional[str]]: def inputbox(title: str, prompt: str, **kwargs) -> Tuple[int, Optional[str]]:
print(f"\n=== {title} ===") print(f"\n=== {title} ===")
val = input(f"{prompt}\n") val = input(f"{prompt}\n")
return (0, val) if val else (1, None) return (0, val) if val else (1, None)
# </ANCHOR id="inputbox"> # [/DEF:inputbox]
# <ANCHOR id="_ConsoleGauge" type="Class"> # [DEF:_ConsoleGauge:Class]
# @PURPOSE: Контекстный менеджер для имитации `whiptail gauge` в консоли. # @PURPOSE: Контекстный менеджер для имитации `whiptail gauge` в консоли.
# @INTERNAL
class _ConsoleGauge: class _ConsoleGauge:
def __init__(self, title: str, **kwargs): def __init__(self, title: str, **kwargs):
self.title = title self.title = title
@@ -91,16 +91,14 @@ class _ConsoleGauge:
sys.stdout.write(f"\r{txt} "); sys.stdout.flush() sys.stdout.write(f"\r{txt} "); sys.stdout.flush()
def set_percent(self, percent: int) -> None: def set_percent(self, percent: int) -> None:
sys.stdout.write(f"{percent}%"); sys.stdout.flush() sys.stdout.write(f"{percent}%"); sys.stdout.flush()
# </ANCHOR id="_ConsoleGauge"> # [/DEF:_ConsoleGauge]
# <ANCHOR id="gauge" type="Function"> # [DEF:gauge:Function]
# @PURPOSE: Создает и возвращает экземпляр `_ConsoleGauge`. # @PURPOSE: Создает и возвращает экземпляр `_ConsoleGauge`.
# @PARAM: title: str - Заголовок для индикатора прогресса. # @PARAM: title (str) - Заголовок для индикатора прогресса.
# @RETURN: _ConsoleGauge - Экземпляр контекстного менеджера. # @RETURN: _ConsoleGauge - Экземпляр контекстного менеджера.
def gauge(title: str, **kwargs) -> _ConsoleGauge: def gauge(title: str, **kwargs) -> _ConsoleGauge:
return _ConsoleGauge(title, **kwargs) return _ConsoleGauge(title, **kwargs)
# </ANCHOR id="gauge"> # [/DEF:gauge]
# --- Конец кода модуля --- # [/DEF:superset_tool.utils.whiptail_fallback]
# </GRACE_MODULE id="superset_tool.utils.whiptail_fallback">

View File

@@ -1,119 +0,0 @@
<PROJECT_SEMANTICS>
<METADATA>
<VERSION>1.0</VERSION>
<LAST_UPDATED>2025-08-16T10:00:00Z</LAST_UPDATED>
</METADATA>
<STRUCTURE_MAP>
<MODULE path="backup_script.py" id="mod_backup_script">
<PURPOSE>Скрипт для создания резервных копий дашбордов и чартов из Superset.</PURPOSE>
</MODULE>
<MODULE path="migration_script.py" id="mod_migration_script">
<PURPOSE>Интерактивный скрипт для миграции ассетов Superset между различными окружениями.</PURPOSE>
<ENTITY type="Class" name="Migration" id="class_migration"/>
<ENTITY type="Function" name="run" id="func_run_migration"/>
<ENTITY type="Function" name="select_environments" id="func_select_environments"/>
<ENTITY type="Function" name="select_dashboards" id="func_select_dashboards"/>
<ENTITY type="Function" name="confirm_db_config_replacement" id="func_confirm_db_config_replacement"/>
<ENTITY type="Function" name="execute_migration" id="func_execute_migration"/>
</MODULE>
<MODULE path="search_script.py" id="mod_search_script">
<PURPOSE>Скрипт для поиска ассетов в Superset.</PURPOSE>
</MODULE>
<MODULE path="temp_pylint_runner.py" id="mod_temp_pylint_runner">
<PURPOSE>Временный скрипт для запуска Pylint.</PURPOSE>
</MODULE>
<MODULE path="superset_tool/" id="mod_superset_tool">
<PURPOSE>Пакет для взаимодействия с Superset API.</PURPOSE>
<ENTITY type="Module" name="client.py" id="mod_client"/>
<ENTITY type="Module" name="exceptions.py" id="mod_exceptions"/>
<ENTITY type="Module" name="models.py" id="mod_models"/>
<ENTITY type="Module" name="utils" id="mod_utils"/>
</MODULE>
<MODULE path="superset_tool/client.py" id="mod_client">
<PURPOSE>Клиент для взаимодействия с Superset API.</PURPOSE>
<ENTITY type="Class" name="SupersetClient" id="class_superset_client"/>
<ENTITY type="Function" name="get_databases" id="func_get_databases"/>
</MODULE>
<MODULE path="superset_tool/exceptions.py" id="mod_exceptions">
<PURPOSE>Пользовательские исключения для Superset Tool.</PURPOSE>
</MODULE>
<MODULE path="superset_tool/models.py" id="mod_models">
<PURPOSE>Модели данных для Superset.</PURPOSE>
</MODULE>
<MODULE path="superset_tool/utils/" id="mod_utils">
<PURPOSE>Утилиты для Superset Tool.</PURPOSE>
<ENTITY type="Module" name="fileio.py" id="mod_fileio"/>
<ENTITY type="Module" name="init_clients.py" id="mod_init_clients"/>
<ENTITY type="Module" name="logger.py" id="mod_logger"/>
<ENTITY type="Module" name="network.py" id="mod_network"/>
</MODULE>
<MODULE path="superset_tool/utils/fileio.py" id="mod_fileio">
<PURPOSE>Утилиты для работы с файлами.</PURPOSE>
<ENTITY type="Function" name="_process_yaml_value" id="func_process_yaml_value"/>
<ENTITY type="Function" name="_update_yaml_file" id="func_update_yaml_file"/>
</MODULE>
<MODULE path="superset_tool/utils/init_clients.py" id="mod_init_clients">
<PURPOSE>Инициализация клиентов для взаимодействия с API.</PURPOSE>
</MODULE>
<MODULE path="superset_tool/utils/logger.py" id="mod_logger">
<PURPOSE>Конфигурация логгера.</PURPOSE>
</MODULE>
<MODULE path="superset_tool/utils/network.py" id="mod_network">
<PURPOSE>Сетевые утилиты.</PURPOSE>
</MODULE>
</STRUCTURE_MAP>
<SEMANTIC_GRAPH>
<NODE id="mod_backup_script" type="Module" label="Скрипт для создания резервных копий."/>
<NODE id="mod_migration_script" type="Module" label="Интерактивный скрипт для миграции ассетов Superset."/>
<NODE id="mod_search_script" type="Module" label="Скрипт для поиска."/>
<NODE id="mod_temp_pylint_runner" type="Module" label="Временный скрипт для запуска Pylint."/>
<NODE id="mod_superset_tool" type="Package" label="Пакет для взаимодействия с Superset API."/>
<NODE id="mod_client" type="Module" label="Клиент Superset API."/>
<NODE id="mod_exceptions" type="Module" label="Пользовательские исключения."/>
<NODE id="mod_models" type="Module" label="Модели данных."/>
<NODE id="mod_utils" type="Package" label="Утилиты."/>
<NODE id="mod_fileio" type="Module" label="Файловые утилиты."/>
<NODE id="mod_init_clients" type="Module" label="Инициализация клиентов."/>
<NODE id="mod_logger" type="Module" label="Конфигурация логгера."/>
<NODE id="mod_network" type="Module" label="Сетевые утилиты."/>
<NODE id="class_superset_client" type="Class" label="Клиент Superset."/>
<NODE id="func_get_databases" type="Function" label="Получение списка баз данных."/>
<NODE id="func_process_yaml_value" type="Function" label="(HELPER) Рекурсивно обрабатывает значения в YAML-структуре."/>
<NODE id="func_update_yaml_file" type="Function" label="(HELPER) Обновляет один YAML файл."/>
<NODE id="class_migration" type="Class" label="Инкапсулирует логику и состояние процесса миграции."/>
<NODE id="func_run_migration" type="Function" label="Запускает основной воркфлоу миграции."/>
<NODE id="func_select_environments" type="Function" label="Обеспечивает интерактивный выбор исходного и целевого окружений."/>
<NODE id="func_select_dashboards" type="Function" label="Обеспечивает интерактивный выбор дашбордов для миграции."/>
<NODE id="func_confirm_db_config_replacement" type="Function" label="Управляет процессом подтверждения и настройки замены конфигураций БД."/>
<NODE id="func_execute_migration" type="Function" label="Выполняет фактическую миграцию выбранных дашбордов."/>
<EDGE source_id="mod_superset_tool" target_id="mod_client" relation="CONTAINS"/>
<EDGE source_id="mod_superset_tool" target_id="mod_exceptions" relation="CONTAINS"/>
<EDGE source_id="mod_superset_tool" target_id="mod_models" relation="CONTAINS"/>
<EDGE source_id="mod_superset_tool" target_id="mod_utils" relation="CONTAINS"/>
<EDGE source_id="mod_client" target_id="class_superset_client" relation="CONTAINS"/>
<EDGE source_id="class_superset_client" target_id="func_get_databases" relation="CONTAINS"/>
<EDGE source_id="mod_utils" target_id="mod_fileio" relation="CONTAINS"/>
<EDGE source_id="mod_utils" target_id="mod_init_clients" relation="CONTAINS"/>
<EDGE source_id="mod_utils" target_id="mod_logger" relation="CONTAINS"/>
<EDGE source_id="mod_utils" target_id="mod_network" relation="CONTAINS"/>
<EDGE source_id="mod_backup_script" target_id="mod_superset_tool" relation="USES"/>
<EDGE source_id="mod_migration_script" target_id="mod_superset_tool" relation="USES"/>
<EDGE source_id="mod_search_script" target_id="mod_superset_tool" relation="USES"/>
<EDGE source_id="mod_fileio" target_id="func_process_yaml_value" relation="CONTAINS"/>
<EDGE source_id="mod_fileio" target_id="func_update_yaml_file" relation="CONTAINS"/>
<EDGE source_id="func_update_yamls" target_id="func_update_yaml_file" relation="CALLS"/>
<EDGE source_id="func_update_yaml_file" target_id="func_process_yaml_value" relation="CALLS"/>
<EDGE source_id="mod_migration_script" target_id="class_migration" relation="CONTAINS"/>
<EDGE source_id="class_migration" target_id="func_run_migration" relation="CONTAINS"/>
<EDGE source_id="class_migration" target_id="func_select_environments" relation="CONTAINS"/>
<EDGE source_id="class_migration" target_id="func_select_dashboards" relation="CONTAINS"/>
<EDGE source_id="class_migration" target_id="func_confirm_db_config_replacement" relation="CONTAINS"/>
<EDGE source_id="func_run_migration" target_id="func_select_environments" relation="CALLS"/>
<EDGE source_id="func_run_migration" target_id="func_select_dashboards" relation="CALLS"/>
<EDGE source_id="func_run_migration" target_id="func_confirm_db_config_replacement" relation="CALLS"/>
<EDGE source_id="class_migration" target_id="func_execute_migration" relation="CONTAINS"/>
<EDGE source_id="func_run_migration" target_id="func_execute_migration" relation="CALLS"/>
</SEMANTIC_GRAPH>
</PROJECT_SEMANTICS>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +0,0 @@
put /api/v1/dataset/{pk}
{
"cache_timeout": 0,
"columns": [
{
"advanced_data_type": "string",
"column_name": "string",
"description": "string",
"expression": "string",
"extra": "string",
"filterable": true,
"groupby": true,
"id": 0,
"is_active": true,
"is_dttm": true,
"python_date_format": "string",
"type": "string",
"uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"verbose_name": "string"
}
],
"database_id": 0,
"default_endpoint": "string",
"description": "string",
"external_url": "string",
"extra": "string",
"fetch_values_predicate": "string",
"filter_select_enabled": true,
"is_managed_externally": true,
"is_sqllab_view": true,
"main_dttm_col": "string",
"metrics": [
{
"currency": "string",
"d3format": "string",
"description": "string",
"expression": "string",
"extra": "string",
"id": 0,
"metric_name": "string",
"metric_type": "string",
"uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"verbose_name": "string",
"warning_text": "string"
}
],
"normalize_columns": true,
"offset": 0,
"owners": [
0
],
"schema": "string",
"sql": "string",
"table_name": "string",
"template_params": "string"
}

63
test_update_yamls.py Normal file
View File

@@ -0,0 +1,63 @@
# [DEF:test_update_yamls:Module]
#
# @SEMANTICS: test, yaml, update, script
# @PURPOSE: Test script to verify update_yamls behavior.
# @LAYER: Test
# @RELATION: DEPENDS_ON -> superset_tool.utils.fileio
# @PUBLIC_API: main
# [SECTION: IMPORTS]
import tempfile
import os
from pathlib import Path
import yaml
from superset_tool.utils.fileio import update_yamls
# [/SECTION]
# [DEF:main:Function]
# @PURPOSE: Main test function.
# @RELATION: CALLS -> update_yamls
def main():
# Create a temporary directory structure
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
# Create a mock dashboard directory structure
dash_dir = tmp_path / "dashboard"
dash_dir.mkdir()
# Create a mock metadata.yaml file
metadata_file = dash_dir / "metadata.yaml"
metadata_content = {
"dashboard_uuid": "12345",
"database_name": "Prod Clickhouse",
"slug": "test-dashboard"
}
with open(metadata_file, 'w') as f:
yaml.dump(metadata_content, f)
print("Original metadata.yaml:")
with open(metadata_file, 'r') as f:
print(f.read())
# Test update_yamls
db_configs = [
{
"old": {"database_name": "Prod Clickhouse"},
"new": {"database_name": "DEV Clickhouse"}
}
]
update_yamls(db_configs=db_configs, path=str(dash_dir))
print("\nAfter update_yamls:")
with open(metadata_file, 'r') as f:
print(f.read())
print("Test completed.")
# [/DEF:main]
if __name__ == "__main__":
main()
# [/DEF:test_update_yamls]