001-fix-ui-ws-validation #2
@@ -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
70
debug_db_api.py
Normal 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]
|
||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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 case‑insensitive 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]
|
||||||
|
|||||||
@@ -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">
|
|
||||||
|
|||||||
342
search_script.py
342
search_script.py
@@ -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">
|
|
||||||
|
|||||||
@@ -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` и переходит к конкретным функциям по графу.
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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">
|
|
||||||
|
|||||||
@@ -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">
|
|
||||||
@@ -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">
|
|
||||||
|
|||||||
5
superset_tool/utils/__init__.py
Normal file
5
superset_tool/utils/__init__.py
Normal 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]
|
||||||
@@ -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">
|
|
||||||
|
|||||||
@@ -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">
|
|
||||||
|
|||||||
@@ -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">
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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">
|
|
||||||
|
|||||||
@@ -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">
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
28200
tech_spec/openapi.json
28200
tech_spec/openapi.json
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
63
test_update_yamls.py
Normal 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]
|
||||||
Reference in New Issue
Block a user