refactor, add db search

This commit is contained in:
2025-12-15 19:18:17 +03:00
parent e6346612c4
commit d3395d55c3
24 changed files with 1582 additions and 32542 deletions

View File

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

Binary file not shown.

70
debug_db_api.py Normal file
View File

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

View File

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

View File

@@ -1,14 +1,18 @@
# <GRACE_MODULE id="migration_script" name="migration_script.py">
# [DEF:migration_script:Module]
#
# @SEMANTICS: migration, cli, superset, ui, logging, error-recovery, batch-delete
# @PURPOSE: Предоставляет интерактивный CLI для миграции дашбордов Superset между окружениями с возможностью восстановления после ошибок.
# @DEPENDS_ON: superset_tool.client -> Для взаимодействия с API 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 logging
import sys
import zipfile
import re
from pathlib import Path
from typing import List, Optional, Tuple, Dict
from superset_tool.client import SupersetClient
@@ -16,11 +20,9 @@ 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.whiptail_fallback import menu, checklist, yesno, msgbox, inputbox, gauge
from superset_tool.utils.logger import SupersetLogger
# </IMPORTS>
# [/SECTION]
# --- Начало кода модуля ---
# <ANCHOR id="Migration" type="Class">
# [DEF:Migration:Class]
# @PURPOSE: Инкапсулирует логику интерактивной миграции дашбордов с возможностью «удалить‑и‑перезаписать» при ошибке импорта.
# @RELATION: CREATES_INSTANCE_OF -> SupersetLogger
# @RELATION: USES -> SupersetClient
@@ -28,10 +30,10 @@ class Migration:
"""
Интерактивный процесс миграции дашбордов.
"""
def __init__(self) -> None:
# <ANCHOR id="Migration.__init__" type="Function">
# [DEF:Migration.__init__:Function]
# @PURPOSE: Инициализирует сервис миграции, настраивает логгер и начальные состояния.
# @POST: `self.logger` готов к использованию; `enable_delete_on_failure` = `False`.
def __init__(self) -> None:
default_log_dir = Path.cwd() / "logs"
self.logger = SupersetLogger(
name="migration_script",
@@ -46,9 +48,9 @@ class Migration:
self.db_config_replacement: Optional[dict] = None
self._failed_imports: List[dict] = []
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: Точка входа последовательный запуск всех шагов миграции.
# @PRE: Логгер готов.
# @POST: Скрипт завершён, пользователю выведено сообщение.
@@ -65,9 +67,9 @@ class Migration:
self.confirm_db_config_replacement()
self.execute_migration()
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: Запрашивает у пользователя, следует ли удалять дашборд при ошибке импорта.
# @POST: `self.enable_delete_on_failure` установлен.
# @RELATION: CALLS -> yesno
@@ -80,9 +82,9 @@ class Migration:
"[ask_delete_on_failure][State] Delete-on-failure = %s",
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.
# @PRE: `setup_clients` успешно инициализирует все клиенты.
# @POST: `self.from_c` и `self.to_c` установлены.
@@ -119,9 +121,9 @@ class Migration:
self.to_c = all_clients[to_env_name]
self.logger.info("[select_environments][State] to = %s", to_env_name)
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: Позволяет пользователю выбрать набор дашбордов для миграции.
# @PRE: `self.from_c` инициализирован.
# @POST: `self.dashboards_to_migrate` заполнен.
@@ -136,8 +138,17 @@ class Migration:
msgbox("Информация", "В исходном окружении нет дашбордов.")
return
rc, regex = inputbox("Поиск", "Введите регулярное выражение для поиска дашбордов:")
if rc != 0:
return
# Ensure regex is a string and perform caseinsensitive search
regex_str = str(regex)
filtered_dashboards = [
d for d in all_dashboards if re.search(regex_str, d["dashboard_title"], re.IGNORECASE)
]
options = [("ALL", "Все дашборды")] + [
(str(d["id"]), d["dashboard_title"]) for d in all_dashboards
(str(d["id"]), d["dashboard_title"]) for d in filtered_dashboards
]
rc, selected = checklist(
@@ -149,10 +160,10 @@ class Migration:
return
if "ALL" in selected:
self.dashboards_to_migrate = list(all_dashboards)
self.dashboards_to_migrate = filtered_dashboards
else:
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(
@@ -163,32 +174,106 @@ class Migration:
self.logger.error("[select_dashboards][Failure] %s", e, exc_info=True)
msgbox("Ошибка", "Не удалось получить список дашбордов.")
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-файлах.
# @POST: `self.db_config_replacement` либо `None`, либо заполнен.
# @RELATION: CALLS -> yesno
# @RELATION: CALLS -> inputbox
# @RELATION: CALLS -> self._select_databases
def confirm_db_config_replacement(self) -> None:
if yesno("Замена БД", "Заменить конфигурацию БД в YAMLфайлах?"):
rc, old_name = inputbox("Замена БД", "Старое имя БД (например, db_dev):")
if rc != 0: return
rc, new_name = inputbox("Замена БД", "Новое имя БД (например, db_prod):")
if rc != 0: return
old_db, new_db = self._select_databases()
if not old_db or not new_db:
self.logger.info("[confirm_db_config_replacement][State] Selection cancelled.")
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)
else:
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: Позволяет пользователю выбрать исходную и целевую БД через API.
# @POST: Возвращает кортеж (старая БД, новая БД) или (None, None) при отмене.
# @RELATION: CALLS -> self.from_c.get_databases
# @RELATION: CALLS -> self.to_c.get_databases
# @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: Все указанные дашборды удалены (если они существовали).
# @PARAM: ids: List[int] - Список ID дашбордов для удаления.
# @RELATION: CALLS -> self.to_c.network.request
# @PARAM: ids (List[int]) - Список ID дашбордов для удаления.
def _batch_delete_by_ids(self, ids: List[int]) -> None:
if not ids:
self.logger.debug("[_batch_delete_by_ids][Skip] Empty ID list nothing to delete.")
@@ -202,9 +287,9 @@ class Migration:
self.logger.warning("[_batch_delete_by_ids][Warning] Unexpected delete response: %s", response)
else:
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: Выполняет экспорт-импорт дашбордов, обрабатывает ошибки и, при необходимости, выполняет процедуру восстановления.
# @PRE: `self.dashboards_to_migrate` не пуст; `self.from_c` и `self.to_c` инициализированы.
# @POST: Успешные дашборды импортированы; неудачные - восстановлены или залогированы.
@@ -269,13 +354,11 @@ class Migration:
self.logger.info("[execute_migration][Exit] Migration finished.")
msgbox("Информация", "Миграция завершена!")
# </ANCHOR id="Migration.execute_migration">
# [/DEF:Migration.execute_migration]
# </ANCHOR id="Migration">
# --- Конец кода модуля ---
# [/DEF:Migration]
if __name__ == "__main__":
Migration().run()
# </GRACE_MODULE id="migration_script">
# [/DEF:migration_script]

View File

@@ -1,20 +1,21 @@
# <GRACE_MODULE id="run_mapper" name="run_mapper.py">
# [DEF:run_mapper:Module]
#
# @SEMANTICS: runner, configuration, cli, main
# @PURPOSE: Этот модуль является CLI-точкой входа для запуска процесса меппинга метаданных датасетов.
# @DEPENDS_ON: dataset_mapper -> Использует DatasetMapper для выполнения основной логики.
# @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 keyring
from superset_tool.utils.init_clients import setup_clients
from superset_tool.utils.logger import SupersetLogger
from superset_tool.utils.dataset_mapper import DatasetMapper
# </IMPORTS>
# [/SECTION]
# --- Начало кода модуля ---
# <ANCHOR id="main" type="Function">
# [DEF:main:Function]
# @PURPOSE: Парсит аргументы командной строки и запускает процесс меппинга.
# @RELATION: CREATES_INSTANCE_OF -> DatasetMapper
# @RELATION: CALLS -> setup_clients
@@ -63,11 +64,9 @@ def main():
except Exception as main_exc:
logger.error("[main][Failure] An unexpected error occurred: %s", main_exc, exc_info=True)
# </ANCHOR id="main">
# [/DEF:main]
if __name__ == '__main__':
main()
# --- Конец кода модуля ---
# </GRACE_MODULE id="run_mapper">
# [/DEF:run_mapper]

View File

@@ -1,10 +1,13 @@
# <GRACE_MODULE id="search_script" name="search_script.py">
# [DEF:search_script:Module]
#
# @SEMANTICS: search, superset, dataset, regex, file_output
# @PURPOSE: Предоставляет утилиты для поиска по текстовым паттернам в метаданных датасетов Superset.
# @DEPENDS_ON: superset_tool.client -> Для взаимодействия с API Superset.
# @DEPENDS_ON: superset_tool.utils -> Для логирования и инициализации клиентов.
# @LAYER: App
# @RELATION: DEPENDS_ON -> superset_tool.client
# @RELATION: DEPENDS_ON -> superset_tool.utils
# @PUBLIC_API: search_datasets, save_results_to_file, print_search_results, main
# <IMPORTS>
# [SECTION: IMPORTS]
import logging
import re
import os
@@ -14,22 +17,20 @@ from superset_tool.client import SupersetClient
from superset_tool.exceptions import SupersetAPIError
from superset_tool.utils.logger import SupersetLogger
from superset_tool.utils.init_clients import setup_clients
# </IMPORTS>
# [/SECTION]
# --- Начало кода модуля ---
# <ANCHOR id="search_datasets" type="Function">
# [DEF:search_datasets:Function]
# @PURPOSE: Выполняет поиск по строковому паттерну в метаданных всех датасетов.
# @PRE: `client` должен быть инициализированным экземпляром `SupersetClient`.
# @PRE: `search_pattern` должен быть валидной строкой регулярного выражения.
# @POST: Возвращает словарь с результатами поиска, где ключ - ID датасета, значение - список совпадений.
# @PARAM: client: SupersetClient - Клиент для доступа к API Superset.
# @PARAM: search_pattern: str - Регулярное выражение для поиска.
# @PARAM: logger: Optional[SupersetLogger] - Инстанс логгера.
# @RETURN: Optional[Dict] - Словарь с результатами или None, если ничего не найдено.
# @RELATION: CALLS -> client.get_datasets
# @THROW: re.error - Если паттерн регулярного выражения невалиден.
# @THROW: SupersetAPIError, RequestException - При критических ошибках API.
# @RELATION: CALLS -> client.get_datasets
# @PARAM: client (SupersetClient) - Клиент для доступа к API Superset.
# @PARAM: search_pattern (str) - Регулярное выражение для поиска.
# @PARAM: logger (Optional[SupersetLogger]) - Инстанс логгера.
# @RETURN: Optional[Dict] - Словарь с результатами или None, если ничего не найдено.
def search_datasets(
client: SupersetClient,
search_pattern: str,
@@ -75,16 +76,16 @@ def search_datasets(
except (SupersetAPIError, RequestException) as e:
logger.critical(f"[search_datasets][Failure] Critical error during search: {e}", exc_info=True)
raise
# </ANCHOR id="search_datasets">
# [/DEF:search_datasets]
# <ANCHOR id="save_results_to_file" type="Function">
# [DEF:save_results_to_file:Function]
# @PURPOSE: Сохраняет результаты поиска в текстовый файл.
# @PRE: `results` является словарем, возвращенным `search_datasets`, или `None`.
# @PRE: `filename` должен быть допустимым путем к файлу.
# @POST: Записывает отформатированные результаты в указанный файл.
# @PARAM: results: Optional[Dict] - Словарь с результатами поиска.
# @PARAM: filename: str - Имя файла для сохранения результатов.
# @PARAM: logger: Optional[SupersetLogger] - Инстанс логгера.
# @PARAM: results (Optional[Dict]) - Словарь с результатами поиска.
# @PARAM: filename (str) - Имя файла для сохранения результатов.
# @PARAM: logger (Optional[SupersetLogger]) - Инстанс логгера.
# @RETURN: bool - Успешно ли выполнено сохранение.
def save_results_to_file(results: Optional[Dict], filename: str, logger: Optional[SupersetLogger] = None) -> bool:
logger = logger or SupersetLogger(name="file_writer")
@@ -98,14 +99,14 @@ def save_results_to_file(results: Optional[Dict], filename: str, logger: Optiona
except Exception as e:
logger.error(f"[save_results_to_file][Failure] Failed to save results to file: {e}", exc_info=True)
return False
# </ANCHOR id="save_results_to_file">
# [/DEF:save_results_to_file]
# <ANCHOR id="print_search_results" type="Function">
# [DEF:print_search_results:Function]
# @PURPOSE: Форматирует результаты поиска для читаемого вывода в консоль.
# @PRE: `results` является словарем, возвращенным `search_datasets`, или `None`.
# @POST: Возвращает отформатированную строку с результатами.
# @PARAM: results: Optional[Dict] - Словарь с результатами поиска.
# @PARAM: context_lines: int - Количество строк контекста для вывода до и после совпадения.
# @PARAM: results (Optional[Dict]) - Словарь с результатами поиска.
# @PARAM: context_lines (int) - Количество строк контекста для вывода до и после совпадения.
# @RETURN: str - Отформатированный отчет.
def print_search_results(results: Optional[Dict], context_lines: int = 3) -> str:
if not results:
@@ -159,9 +160,9 @@ def print_search_results(results: Optional[Dict], context_lines: int = 3) -> str
output.append(f" {prefix}{line_content}")
output.append("-" * 25)
return "\n".join(output)
# </ANCHOR id="print_search_results">
# [/DEF:print_search_results]
# <ANCHOR id="main" type="Function">
# [DEF:main:Function]
# @PURPOSE: Основная точка входа для запуска скрипта поиска.
# @RELATION: CALLS -> setup_clients
# @RELATION: CALLS -> search_datasets
@@ -171,8 +172,8 @@ def main():
logger = SupersetLogger(level=logging.INFO, console=True)
clients = setup_clients(logger)
target_client = clients['prod']
search_query = r"from dm_view.[a-z_]*"
target_client = clients['dev5']
search_query = r"from dm(_view)*.account_debt"
# Генерируем имя файла на основе времени
import datetime
@@ -195,12 +196,9 @@ def main():
logger.info(f"[main][Success] Results also saved to file: {output_filename}")
else:
logger.error(f"[main][Failure] Failed to save results to file: {output_filename}")
# </ANCHOR id="main">
# [/DEF:main]
if __name__ == "__main__":
main()
# --- Конец кода модуля ---
# </GRACE_MODULE id="search_script">
# [/DEF:search_script]

View File

@@ -1,120 +1,144 @@
### **Протокол GRACE-Py: Семантическая Разметка для AI-Агентов на Python**
# 📁 BUNDLE: Engineering Prompting & GRACE Methodology
**Context Transfer Protocol for LLM Agents**
**Версия: 2.2 (Hybrid)**
## 1. Фундаментальная Парадигма (The "Physics" of LLMs)
Мы отказываемся от антропоморфного подхода ("диалог с помощником") в пользу инженерного подхода ("программирование семантического процессора").
#### **I. Философия и Основные Принципы**
* **Трансформер = GNN (Graph Neural Network):** LLM обрабатывает токены как узлы в полносвязном графе. Чтобы модель работала эффективно, мы должны явно задавать топологию этого графа через семантические связи.
* **Мышление = Навигация по Состояниям (FSM):** Генерация — это переход между "состояниями веры" (Belief States). Мы управляем этими переходами через Якоря и Контракты.
* **Causal Attention & KV Cache:** Модель читает слева-направо. Смысл, обработанный в начале, "замораживается". **Правило:** Контекст и Контракты всегда строго *до* реализации.
* **Sparse Attention & Block Processing:** На больших контекстах (100k+) модель работает не с отдельными токенами, а с семантическими сжатиями блоков (чанков). Наша разметка создает идеальные границы для этих блоков, помогая механизму Top-K retrieval.
* **Проблема "Семантического Казино":** Без жесткой структуры модель играет в рулетку вероятностей. Мы устраняем это через детерминированные структуры (графы, схемы).
* **Проблема "Нейронного Воя" (Neural Howlround):** Самоусиливающиеся ошибки в длинных сессиях. **Решение:** Разделение сессий, жесткие инварианты и использование "суперпозиции" (анализ вариантов перед решением).
Этот протокол является **единственным источником истины** для правил семантического обогащения кода. Его цель — превратить процесс разработки с LLM-агентами из непредсказуемого "диалога" в управляемую **инженерную дисциплину**.
---
* **Аксиома 1: Код Вторичен.** Первична его семантическая модель (графы, контракты, якоря).
* **Аксиома 2: Когерентность Абсолютна.** Все артефакты (ТЗ, граф, контракты, код) должны быть на 100% семантически согласованы.
* **Аксиома 3: Архитектура GPT — Закон.** Протокол построен на фундаментальных принципах работы трансформеров (Causal Attention, KV Cache, Sparse Attention).
## 2. Методология GRACE (Framework)
Целостная система управления жизненным циклом генерации.
#### **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
# <GRACE_MODULE id="my_module" name="my_module.py">
# @SEMANTICS: domain, usecase, data_processing
# @PURPOSE: Этот модуль отвечает за обработку пользовательских данных.
# @DEPENDS_ON: utils_module -> Использует утилиты для валидации.
# [DEF:module_name:Module]
#
# @SEMANTICS: [keywords for vector search]
# @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>
import os
from typing import List
# </IMPORTS>
# [SECTION: IMPORTS]
...
# [/SECTION]
# --- Начало кода модуля ---
# ... IMPLEMENTATION ...
# ... (классы, функции, константы) ...
# --- Конец кода модуля ---
# </GRACE_MODULE id="my_module">
# [/DEF:module_name]
```
#### **III. Компоненты Разметки (Детализация GRACE-Py)**
---
##### **A. Anchors (Якоря): Навигация и Консолидация**
## IV. FUNCTION & CLASS CONTRACTS (DbC)
1. **Назначение:** Якоря — это основной инструмент для управления вниманием ИИ, создания семантических каналов и обеспечения надежной навигации в больших кодовых базах (Sparse Attention).
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`.
Contracts are the **Source of Truth**.
##### **C. Contracts (Контракты): Тактические Спецификации**
1. **Назначение:** Предоставление ИИ точных инструкций для генерации и валидации кода.
2. **Расположение:** Контракт всегда располагается **внутри открывающего якоря**, ДО декларации кода (`def` или `class`).
3. **Синтаксис:** JSDoc-подобный стиль с `@tag` для лаконичности и читаемости.
**Required Template:**
```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)**
```python
# <ANCHOR id="process_data" type="Function">
# @PURPOSE: Валидирует и обрабатывает входящие данные пользователя.
# @SPEC_LINK: tz-req-005
# @PRE: `raw_data` не должен быть пустым.
# @PARAM: raw_data: Dict[str, any] - Сырые данные от пользователя.
# @RETURN: Dict[str, any] - Обработанные и валидированные данные.
# @TEST: input='{}', expected_exception='AssertionError'
# @RELATION: CALLS -> some_helper_function
def process_data(raw_data: dict) -> dict:
"""
Docstring для стандартных инструментов Python.
Не является источником истины для ИИ-агентов.
"""
logger.debug(f"[process_data][Entry] Начало обработки данных.")
# Реализация контракта
assert raw_data, "Precondition failed: raw_data must not be empty."
# ... Основная логика ...
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">
# [DEF:func_name:Function]
# @PURPOSE: [Description]
# @SPEC_LINK: [Requirement ID]
#
# @PRE: [Condition required before execution]
# @POST: [Condition guaranteed after execution]
# @PARAM: [name] ([type]) - [desc]
# @RETURN: [type] - [desc]
# @THROW: [Exception] - [Reason]
#
# @RELATION: [Graph connections]
def func_name(...):
# 1. Runtime check of @PRE (Assertions)
# 2. Logic implementation
# 3. Runtime check of @POST
pass
# [/DEF:func_name]
```
---
## V. LOGGING STANDARD (BELIEF STATE)
Logs define the agent's internal state for debugging and coherence checks.
**Format:** `logger.level(f"[{ANCHOR_ID}][{STATE}] {MESSAGE} context={...}")`
**States:** `Entry`, `Validation`, `Action`, `Coherence:OK`, `Coherence:Failed`, `Exit`.
---
## VI. GENERATION WORKFLOW
1. **Analyze Request:** Identify target module and graph position.
2. **Define Structure:** Generate `[DEF]` anchors and Contracts FIRST.
3. **Implement Logic:** Write code satisfying Contracts.
4. **Validate:** If logic conflicts with Contract -> Stop -> Report Error.
```
---
## 4. Интеграция с RAG (GraphRAG)
Как этот код используется инструментами (например, Cursor):
1. **Индексация:** RAG-система парсит теги `[DEF]`, `[/DEF]` и `@RELATION`.
2. **Построение Графа:** На основе `@RELATION` и `@DEPENDS_ON` строится граф знаний проекта.
3. **Вектор-Аккумулятор:** Замыкающий тег `[/DEF:func_name]` используется как точка для создания эмбеддинга всего блока. Это позволяет находить функцию не только по имени, но и по её внутренней логике.
4. **Поиск:** При запросе "Где логика авторизации?" система находит модуль по тегу `@SEMANTICS: auth` и переходит к конкретным функциям по графу.

View File

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

View File

@@ -1,36 +1,41 @@
# <GRACE_MODULE id="superset_tool.client" name="client.py">
# [DEF:superset_tool.client:Module]
#
# @SEMANTICS: superset, api, client, rest, http, dashboard, dataset, import, export
# @PURPOSE: Предоставляет высокоуровневый клиент для взаимодействия с Superset REST API, инкапсулируя логику запросов, обработку ошибок и пагинацию.
# @DEPENDS_ON: superset_tool.models -> Использует SupersetConfig для конфигурации.
# @DEPENDS_ON: superset_tool.exceptions -> Выбрасывает специализированные исключения.
# @DEPENDS_ON: superset_tool.utils -> Использует утилиты для сети, логгирования и работы с файлами.
# @LAYER: Domain
# @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 zipfile
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 superset_tool.models import SupersetConfig
from superset_tool.exceptions import ExportError, InvalidZipFormatError
from superset_tool.utils.fileio import get_filename_from_headers
from superset_tool.utils.logger import SupersetLogger
from superset_tool.utils.network import APIClient
# </IMPORTS>
# [/SECTION]
# --- Начало кода модуля ---
# <ANCHOR id="SupersetClient" type="Class">
# [DEF:SupersetClient:Class]
# @PURPOSE: Класс-обёртка над Superset REST API, предоставляющий методы для работы с дашбордами и датасетами.
# @RELATION: CREATES_INSTANCE_OF -> APIClient
# @RELATION: USES -> SupersetConfig
class SupersetClient:
def __init__(self, config: SupersetConfig, logger: Optional[SupersetLogger] = None):
# <ANCHOR id="SupersetClient.__init__" type="Function">
# [DEF:SupersetClient.__init__:Function]
# @PURPOSE: Инициализирует клиент, проверяет конфигурацию и создает сетевой клиент.
# @PARAM: config: SupersetConfig - Конфигурация подключения.
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
# @POST: Атрибуты `logger`, `config`, и `network` созданы.
# @PRE: `config` должен быть валидным объектом SupersetConfig.
# @POST: Атрибуты `logger`, `config`, и `network` созданы и готовы к работе.
# @PARAM: config (SupersetConfig) - Конфигурация подключения.
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
def __init__(self, config: SupersetConfig, logger: Optional[SupersetLogger] = None):
self.logger = logger or SupersetLogger(name="SupersetClient")
self.logger.info("[SupersetClient.__init__][Enter] Initializing SupersetClient.")
self._validate_config(config)
@@ -43,32 +48,40 @@ class SupersetClient:
)
self.delete_before_reimport: bool = False
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: Проверяет, что переданный объект конфигурации имеет корректный тип.
# @PARAM: config: SupersetConfig - Объект для проверки.
# @PRE: `config` должен быть передан.
# @POST: Если проверка пройдена, выполнение продолжается.
# @THROW: TypeError - Если `config` не является экземпляром `SupersetConfig`.
# @PARAM: config (SupersetConfig) - Объект для проверки.
def _validate_config(self, config: SupersetConfig) -> None:
self.logger.debug("[_validate_config][Enter] Validating SupersetConfig.")
assert isinstance(config, SupersetConfig), "Конфигурация должна быть экземпляром SupersetConfig"
self.logger.debug("[_validate_config][Exit] Config is valid.")
# </ANCHOR id="SupersetClient._validate_config">
# [/DEF:SupersetClient._validate_config]
@property
def headers(self) -> dict:
# <ANCHOR id="SupersetClient.headers" type="Property">
# [DEF:SupersetClient.headers:Function]
# @PURPOSE: Возвращает базовые HTTP-заголовки, используемые сетевым клиентом.
# @PRE: self.network должен быть инициализирован.
# @POST: Возвращаемый словарь содержит актуальные заголовки, включая токен авторизации.
return self.network.headers
# </ANCHOR id="SupersetClient.headers">
# [/DEF:SupersetClient.headers]
# <ANCHOR id="SupersetClient.get_dashboards" type="Function">
# [DEF:SupersetClient.get_dashboards:Function]
# @PURPOSE: Получает полный список дашбордов, автоматически обрабатывая пагинацию.
# @PARAM: query: Optional[Dict] - Дополнительные параметры запроса для API.
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список дашбордов).
# @RELATION: CALLS -> self._fetch_total_object_count
# @RELATION: CALLS -> self._fetch_all_pages
# @PRE: self.network должен быть инициализирован.
# @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]]:
assert self.network, "[get_dashboards][PRE] Network client must be initialized."
self.logger.info("[get_dashboards][Enter] Fetching dashboards.")
validated_query = self._validate_query_params(query)
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)
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-архива.
# @PARAM: dashboard_id: int - ID дашборда для экспорта.
# @RETURN: Tuple[bytes, str] - Бинарное содержимое ZIP-архива и имя файла.
# @THROW: ExportError - Если экспорт завершился неудачей.
# @RELATION: CALLS -> self.network.request
# @PRE: dashboard_id должен быть положительным целым числом.
# @POST: Возвращает бинарное содержимое ZIP-архива и имя файла.
# @THROW: ExportError - Если экспорт завершился неудачей.
# @PARAM: dashboard_id (int) - ID дашборда для экспорта.
# @RETURN: Tuple[bytes, str] - Бинарное содержимое ZIP-архива и имя файла.
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)
response = self.network.request(
method="GET",
@@ -95,22 +111,28 @@ class SupersetClient:
stream=True,
raw_response=True,
)
response = cast(Response, response)
self._validate_export_response(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)
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-файла с возможностью автоматического удаления и повторной попытки при ошибке.
# @PARAM: file_name: Union[str, Path] - Путь к ZIP-архиву.
# @PARAM: dash_id: Optional[int] - ID дашборда для удаления при сбое.
# @PARAM: dash_slug: Optional[str] - Slug дашборда для поиска ID, если ID не предоставлен.
# @RETURN: Dict - Ответ API в случае успеха.
# @RELATION: CALLS -> self._do_import
# @RELATION: CALLS -> self.delete_dashboard
# @RELATION: CALLS -> self.get_dashboards
# @PRE: Файл, указанный в `file_name`, должен существовать и быть валидным ZIP-архивом Superset.
# @POST: Дашборд успешно импортирован, возвращен ответ API.
# @THROW: FileNotFoundError - Если файл не найден.
# @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:
assert file_name, "[import_dashboard][PRE] file_name must be provided."
file_path = str(file_name)
self._validate_import_file(file_path)
try:
@@ -128,12 +150,18 @@ class SupersetClient:
self.delete_dashboard(target_id)
self.logger.info("[import_dashboard][State] Deleted dashboard ID %s, retrying import.", target_id)
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.
# @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]:
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:
return dash_id
if dash_slug is not None:
@@ -147,37 +175,58 @@ class SupersetClient:
except Exception as e:
self.logger.warning("[_resolve_target_id_for_delete][Warning] Could not resolve slug '%s' to ID: %s", dash_slug, e)
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: Выполняет один запрос на импорт без обработки исключений.
# @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:
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(
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"},
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.
# @PARAM: dashboard_id: Union[int, str] - ID или slug дашборда.
# @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:
assert dashboard_id, "[delete_dashboard][PRE] dashboard_id must be provided."
self.logger.info("[delete_dashboard][Enter] Deleting dashboard %s.", 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:
self.logger.info("[delete_dashboard][Success] Dashboard %s deleted.", dashboard_id)
else:
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-архива.
# @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]:
assert zipfile.is_zipfile(file_name), "[_extract_dashboard_id_from_zip][PRE] file_name must be a valid zip file."
try:
import yaml
with zipfile.ZipFile(file_name, "r") as zf:
@@ -190,12 +239,17 @@ class SupersetClient:
except Exception as exc:
self.logger.error("[_extract_dashboard_id_from_zip][Failure] %s", exc, exc_info=True)
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-архива.
# @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]:
assert zipfile.is_zipfile(file_name), "[_extract_dashboard_slug_from_zip][PRE] file_name must be a valid zip file."
try:
import yaml
with zipfile.ZipFile(file_name, "r") as zf:
@@ -208,79 +262,111 @@ class SupersetClient:
except Exception as exc:
self.logger.error("[_extract_dashboard_slug_from_zip][Failure] %s", exc, exc_info=True)
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-архивом.
# @INTERNAL
# @PRE: response должен быть объектом requests.Response.
# @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:
assert isinstance(response, Response), "[_validate_export_response][PRE] response must be a requests.Response object."
content_type = response.headers.get("Content-Type", "")
if "application/zip" not in content_type:
raise ExportError(f"Получен не ZIP-архив (Content-Type: {content_type})")
if not response.content:
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: Определяет имя файла для экспорта из заголовков или генерирует его.
# @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:
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:
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip"
self.logger.warning("[_resolve_export_filename][Warning] Generated filename: %s", 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: Формирует корректный набор параметров запроса с пагинацией.
# @INTERNAL
# @PARAM: query (Optional[Dict]) - Исходные параметры.
# @PRE: query, если предоставлен, должен быть словарем.
# @POST: Возвращает словарь, содержащий базовые параметры пагинации, объединенные с `query`.
# @RETURN: 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}
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: Получает общее количество объектов по указанному эндпоинту для пагинации.
# @INTERNAL
# @PARAM: endpoint (str) - API эндпоинт.
# @PRE: endpoint должен быть непустой строкой.
# @POST: Возвращает общее количество объектов (>= 0).
# @THROW: APIError - В случае ошибки сетевого запроса.
# @RETURN: 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(
endpoint=endpoint,
query_params={"page": 0, "page_size": 1},
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 и собирает все данные.
# @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]:
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)
# </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`.
# @INTERNAL
# @PRE: zip_path должен быть предоставлен.
# @POST: Проверка пройдена, если файл существует, является 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:
assert zip_path, "[_validate_import_file][PRE] zip_path must be provided."
path = Path(zip_path)
assert path.exists(), f"Файл {zip_path} не существует"
assert zipfile.is_zipfile(path), f"Файл {zip_path} не является ZIP-архивом"
with zipfile.ZipFile(path, "r") as zf:
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: Получает полный список датасетов, автоматически обрабатывая пагинацию.
# @PARAM: query: Optional[Dict] - Дополнительные параметры запроса для API.
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список датасетов).
# @RELATION: CALLS -> self._fetch_total_object_count
# @RELATION: CALLS -> self._fetch_all_pages
# @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса.
# @PRE: self.network должен быть инициализирован.
# @POST: Возвращаемый список содержит все датасеты, доступные по API.
# @THROW: APIError - В случае ошибки сетевого запроса.
# @RETURN: 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.")
validated_query = self._validate_query_params(query)
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)
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: Получает полный список баз данных, автоматически обрабатывая пагинацию.
# @RELATION: CALLS -> self._fetch_total_object_count
# @RELATION: CALLS -> self._fetch_all_pages
# @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.
# @PARAM: dataset_id: int - ID датасета.
# @RETURN: Dict - Словарь с информацией о датасете.
# @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:
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)
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)
return response
# </ANCHOR id="SupersetClient.get_dataset">
# [/DEF:SupersetClient.get_dataset]
# <ANCHOR id="SupersetClient.update_dataset" type="Function">
# @PURPOSE: Обновляет данные датасета по его ID.
# @PARAM: dataset_id: int - ID датасета для обновления.
# @PARAM: data: Dict - Словарь с данными для обновления.
# @RETURN: Dict - Ответ API.
# [DEF:SupersetClient.get_database:Function]
# @PURPOSE: Получает информацию о конкретной базе данных по её ID.
# @RELATION: CALLS -> self.network.request
# @PARAM: database_id (int) - ID базы данных.
# @PRE: database_id должен быть положительным целым числом.
# @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:
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)
response = self.network.request(
method="PUT",
@@ -318,12 +453,11 @@ class SupersetClient:
data=json.dumps(data),
headers={'Content-Type': 'application/json'}
)
response = cast(Dict, response)
self.logger.info("[update_dataset][Exit] Updated dataset %s.", dataset_id)
return response
# </ANCHOR id="SupersetClient.update_dataset">
# [/DEF:SupersetClient.update_dataset]
# </ANCHOR id="SupersetClient">
# [/DEF:SupersetClient]
# --- Конец кода модуля ---
# </GRACE_MODULE id="superset_tool.client">
# [/DEF:superset_tool.client]

View File

@@ -1,110 +1,128 @@
# <GRACE_MODULE id="superset_tool.exceptions" name="exceptions.py">
# @SEMANTICS: exception, error, hierarchy
# [DEF:superset_tool.exceptions:Module]
# @PURPOSE: Определяет иерархию пользовательских исключений для всего инструмента, обеспечивая единую точку обработки ошибок.
# @RELATION: ALL_CLASSES -> INHERITS_FROM -> SupersetToolError (or other exception in this module)
# @SEMANTICS: exception, error, hierarchy
# @LAYER: Infra
# <IMPORTS>
# [SECTION: IMPORTS]
from pathlib import Path
from typing import Optional, Dict, Any, Union
# </IMPORTS>
# [/SECTION]
# --- Начало кода модуля ---
# <ANCHOR id="SupersetToolError" type="Class">
# [DEF:SupersetToolError:Class]
# @PURPOSE: Базовый класс для всех ошибок, генерируемых инструментом.
# @INHERITS_FROM: Exception
# @RELATION: INHERITS_FROM -> Exception
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: context (Optional[Dict[str, Any]]) - Дополнительный контекст ошибки.
class SupersetToolError(Exception):
def __init__(self, message: str, context: Optional[Dict[str, Any]] = None):
self.context = context or {}
super().__init__(f"{message} | Context: {self.context}")
# </ANCHOR id="SupersetToolError">
# [/DEF:SupersetToolError]
# <ANCHOR id="AuthenticationError" type="Class">
# [DEF:AuthenticationError:Class]
# @PURPOSE: Ошибки, связанные с аутентификацией или авторизацией.
# @INHERITS_FROM: SupersetToolError
# @RELATION: INHERITS_FROM -> SupersetToolError
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class AuthenticationError(SupersetToolError):
def __init__(self, message: str = "Authentication failed", **context: Any):
super().__init__(f"[AUTH_FAILURE] {message}", context={"type": "authentication", **context})
# </ANCHOR id="AuthenticationError">
# [/DEF:AuthenticationError]
# <ANCHOR id="PermissionDeniedError" type="Class">
# [DEF:PermissionDeniedError:Class]
# @PURPOSE: Ошибка, возникающая при отказе в доступе к ресурсу.
# @INHERITS_FROM: AuthenticationError
# @RELATION: INHERITS_FROM -> AuthenticationError
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: required_permission (Optional[str]) - Требуемое разрешение.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class PermissionDeniedError(AuthenticationError):
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
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.
# @INHERITS_FROM: SupersetToolError
# @RELATION: INHERITS_FROM -> SupersetToolError
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class SupersetAPIError(SupersetToolError):
def __init__(self, message: str = "Superset API error", **context: Any):
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: Ошибки, специфичные для операций экспорта.
# @INHERITS_FROM: SupersetAPIError
# @RELATION: INHERITS_FROM -> SupersetAPIError
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class ExportError(SupersetAPIError):
def __init__(self, message: str = "Dashboard export failed", **context: Any):
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).
# @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):
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})
# </ANCHOR id="DashboardNotFoundError">
# [/DEF:DashboardNotFoundError]
# <ANCHOR id="DatasetNotFoundError" type="Class">
# [DEF:DatasetNotFoundError:Class]
# @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):
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})
# </ANCHOR id="DatasetNotFoundError">
# [/DEF:DatasetNotFoundError]
# <ANCHOR id="InvalidZipFormatError" type="Class">
# [DEF:InvalidZipFormatError:Class]
# @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):
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})
# </ANCHOR id="InvalidZipFormatError">
# [/DEF:InvalidZipFormatError]
# <ANCHOR id="NetworkError" type="Class">
# [DEF:NetworkError:Class]
# @PURPOSE: Ошибки, связанные с сетевым соединением.
# @INHERITS_FROM: SupersetToolError
# @RELATION: INHERITS_FROM -> SupersetToolError
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class NetworkError(SupersetToolError):
def __init__(self, message: str = "Network connection failed", **context: Any):
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).
# @INHERITS_FROM: SupersetToolError
# @RELATION: INHERITS_FROM -> SupersetToolError
class FileOperationError(SupersetToolError):
pass
# </ANCHOR id="FileOperationError">
# [/DEF:FileOperationError]
# <ANCHOR id="InvalidFileStructureError" type="Class">
# [DEF:InvalidFileStructureError:Class]
# @PURPOSE: Ошибка, указывающая на некорректную структуру файлов или директорий.
# @INHERITS_FROM: FileOperationError
# @RELATION: INHERITS_FROM -> FileOperationError
class InvalidFileStructureError(FileOperationError):
pass
# </ANCHOR id="InvalidFileStructureError">
# [/DEF:InvalidFileStructureError]
# <ANCHOR id="ConfigurationError" type="Class">
# [DEF:ConfigurationError:Class]
# @PURPOSE: Ошибки, связанные с неверной конфигурацией инструмента.
# @INHERITS_FROM: SupersetToolError
# @RELATION: INHERITS_FROM -> SupersetToolError
class ConfigurationError(SupersetToolError):
pass
# </ANCHOR id="ConfigurationError">
# [/DEF:ConfigurationError]
# --- Конец кода модуля ---
# </GRACE_MODULE id="superset_tool.exceptions">
# [/DEF:superset_tool.exceptions]

View File

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

View File

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

View File

@@ -1,37 +1,38 @@
# <GRACE_MODULE id="dataset_mapper" name="dataset_mapper.py">
# [DEF:superset_tool.utils.dataset_mapper:Module]
#
# @SEMANTICS: dataset, mapping, postgresql, xlsx, superset
# @PURPOSE: Этот модуль отвечает за обновление метаданных (verbose_map) в датасетах Superset, извлекая их из PostgreSQL или XLSX-файлов.
# @DEPENDS_ON: superset_tool.client -> Использует SupersetClient для взаимодействия с API.
# @DEPENDS_ON: pandas -> для чтения XLSX-файлов.
# @DEPENDS_ON: psycopg2 -> для подключения к PostgreSQL.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> superset_tool.client
# @RELATION: DEPENDS_ON -> pandas
# @RELATION: DEPENDS_ON -> psycopg2
# @PUBLIC_API: DatasetMapper
# <IMPORTS>
import pandas as pd
import psycopg2
# [SECTION: IMPORTS]
import pandas as pd # type: ignore
import psycopg2 # type: ignore
from superset_tool.client import SupersetClient
from superset_tool.utils.init_clients import setup_clients
from superset_tool.utils.logger import SupersetLogger
from typing import Dict, List, Optional, Any
# </IMPORTS>
# [/SECTION]
# --- Начало кода модуля ---
# <ANCHOR id="DatasetMapper" type="Class">
# [DEF:DatasetMapper:Class]
# @PURPOSE: Класс для меппинга и обновления verbose_map в датасетах Superset.
class DatasetMapper:
def __init__(self, logger: SupersetLogger):
self.logger = logger
# <ANCHOR id="DatasetMapper.get_postgres_comments" type="Function">
# [DEF:DatasetMapper.get_postgres_comments:Function]
# @PURPOSE: Извлекает комментарии к колонкам из системного каталога PostgreSQL.
# @PRE: `db_config` должен содержать валидные креды для подключения к PostgreSQL.
# @PRE: `table_name` и `table_schema` должны быть строками.
# @POST: Возвращается словарь с меппингом `column_name` -> `column_comment`.
# @PARAM: db_config: Dict - Конфигурация для подключения к БД.
# @PARAM: table_name: str - Имя таблицы.
# @PARAM: table_schema: str - Схема таблицы.
# @RETURN: Dict[str, str] - Словарь с комментариями к колонкам.
# @THROW: Exception - При ошибках подключения или выполнения запроса к БД.
# @PARAM: db_config (Dict) - Конфигурация для подключения к БД.
# @PARAM: table_name (str) - Имя таблицы.
# @PARAM: table_schema (str) - Схема таблицы.
# @RETURN: 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)
query = f"""
@@ -84,15 +85,15 @@ class DatasetMapper:
self.logger.error("[get_postgres_comments][Failure] %s", e, exc_info=True)
raise
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 файла.
# @PRE: `file_path` должен быть валидным путем к XLSX файлу с колонками 'column_name' и 'column_comment'.
# @POST: Возвращается словарь с меппингами.
# @PARAM: file_path: str - Путь к XLSX файлу.
# @RETURN: Dict[str, str] - Словарь с меппингами.
# @THROW: Exception - При ошибках чтения файла или парсинга.
# @PARAM: file_path (str) - Путь к XLSX файлу.
# @RETURN: 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)
try:
@@ -103,21 +104,21 @@ class DatasetMapper:
except Exception as e:
self.logger.error("[load_excel_mappings][Failure] %s", e, exc_info=True)
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.
# @PARAM: superset_client: SupersetClient - Клиент Superset.
# @PARAM: dataset_id: int - ID датасета для обновления.
# @PARAM: source: str - Источник данных ('postgres', 'excel', 'both').
# @PARAM: postgres_config: Optional[Dict] - Конфигурация для подключения к PostgreSQL.
# @PARAM: excel_path: Optional[str] - Путь к XLSX файлу.
# @PARAM: table_name: Optional[str] - Имя таблицы в PostgreSQL.
# @PARAM: table_schema: Optional[str] - Схема таблицы в PostgreSQL.
# @RELATION: CALLS -> self.get_postgres_comments
# @RELATION: CALLS -> self.load_excel_mappings
# @RELATION: CALLS -> superset_client.get_dataset
# @RELATION: CALLS -> superset_client.update_dataset
# @PARAM: superset_client (SupersetClient) - Клиент Superset.
# @PARAM: dataset_id (int) - ID датасета для обновления.
# @PARAM: source (str) - Источник данных ('postgres', 'excel', 'both').
# @PARAM: postgres_config (Optional[Dict]) - Конфигурация для подключения к PostgreSQL.
# @PARAM: excel_path (Optional[str]) - Путь к XLSX файлу.
# @PARAM: table_name (Optional[str]) - Имя таблицы в PostgreSQL.
# @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):
self.logger.info("[run_mapping][Enter] Starting dataset mapping for ID %d from source '%s'.", dataset_id, source)
mappings: Dict[str, str] = {}
@@ -222,9 +223,7 @@ class DatasetMapper:
except (AssertionError, FileNotFoundError, Exception) as e:
self.logger.error("[run_mapping][Failure] %s", e, exc_info=True)
return
# </ANCHOR id="DatasetMapper.run_mapping">
# </ANCHOR id="DatasetMapper">
# [/DEF:DatasetMapper.run_mapping]
# [/DEF:DatasetMapper]
# --- Конец кода модуля ---
# </GRACE_MODULE id="dataset_mapper">
# [/DEF:superset_tool.utils.dataset_mapper]

View File

@@ -1,16 +1,19 @@
# <GRACE_MODULE id="superset_tool.utils.fileio" name="fileio.py">
# [DEF:superset_tool.utils.fileio:Module]
#
# @SEMANTICS: file, io, zip, yaml, temp, archive, utility
# @PURPOSE: Предоставляет набор утилит для управления файловыми операциями, включая работу с временными файлами, архивами ZIP, файлами YAML и очистку директорий.
# @DEPENDS_ON: superset_tool.exceptions -> Для генерации специфичных ошибок.
# @DEPENDS_ON: superset_tool.utils.logger -> Для логирования операций.
# @DEPENDS_ON: pyyaml -> Для работы с YAML файлами.
# @LAYER: Infra
# @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 re
import zipfile
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
import tempfile
from datetime import date, datetime
@@ -21,20 +24,18 @@ from dataclasses import dataclass
import yaml
from superset_tool.exceptions import InvalidZipFormatError
from superset_tool.utils.logger import SupersetLogger
# </IMPORTS>
# [/SECTION]
# --- Начало кода модуля ---
# <ANCHOR id="create_temp_file" type="Function">
# [DEF:create_temp_file:Function]
# @PURPOSE: Контекстный менеджер для создания временного файла или директории с гарантированным удалением.
# @PARAM: content: Optional[bytes] - Бинарное содержимое для записи во временный файл.
# @PARAM: suffix: str - Суффикс ресурса. Если `.dir`, создается директория.
# @PARAM: mode: str - Режим записи в файл (e.g., 'wb').
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
# @PARAM: content (Optional[bytes]) - Бинарное содержимое для записи во временный файл.
# @PARAM: suffix (str) - Суффикс ресурса. Если `.dir`, создается директория.
# @PARAM: mode (str) - Режим записи в файл (e.g., 'wb').
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
# @YIELDS: Path - Путь к временному ресурсу.
# @THROW: IOError - При ошибках создания ресурса.
@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")
resource_path = None
is_dir = suffix.startswith('.dir')
@@ -63,12 +64,12 @@ def create_temp_file(content: Optional[bytes] = None, suffix: str = ".zip", mode
logger.debug("[create_temp_file][Cleanup] Removed temporary file: %s", resource_path)
except OSError as 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: Рекурсивно удаляет все пустые поддиректории, начиная с указанного пути.
# @PARAM: root_dir: str - Путь к корневой директории для очистки.
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
# @PARAM: root_dir (str) - Путь к корневой директории для очистки.
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
# @RETURN: int - Количество удаленных директорий.
def remove_empty_directories(root_dir: str, logger: Optional[SupersetLogger] = None) -> int:
logger = logger or SupersetLogger(name="fileio")
@@ -87,12 +88,12 @@ 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.info("[remove_empty_directories][Exit] Removed %d empty directories.", 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: Читает бинарное содержимое файла с диска.
# @PARAM: file_path: str - Путь к файлу.
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
# @PARAM: file_path (str) - Путь к файлу.
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
# @RETURN: Tuple[bytes, str] - Кортеж (содержимое, имя файла).
# @THROW: FileNotFoundError - Если файл не найден.
def read_dashboard_from_disk(file_path: str, logger: Optional[SupersetLogger] = None) -> Tuple[bytes, str]:
@@ -104,36 +105,36 @@ def read_dashboard_from_disk(file_path: str, logger: Optional[SupersetLogger] =
if not content:
logger.warning("[read_dashboard_from_disk][Warning] File is empty: %s", file_path)
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 для файла.
# @PARAM: file_path: Path - Путь к файлу.
# @PARAM: file_path (Path) - Путь к файлу.
# @RETURN: str - 8-значное шестнадцатеричное представление CRC32.
# @THROW: IOError - При ошибках чтения файла.
def calculate_crc32(file_path: Path) -> str:
with open(file_path, 'rb') as f:
crc32_value = zlib.crc32(f.read())
return f"{crc32_value:08x}"
# </ANCHOR id="calculate_crc32">
# [/DEF:calculate_crc32]
# <ANCHOR id="RetentionPolicy" type="DataClass">
# [DEF:RetentionPolicy:DataClass]
# @PURPOSE: Определяет политику хранения для архивов (ежедневные, еженедельные, ежемесячные).
@dataclass
class RetentionPolicy:
daily: int = 7
weekly: int = 4
monthly: int = 12
# </ANCHOR id="RetentionPolicy">
# [/DEF:RetentionPolicy]
# <ANCHOR id="archive_exports" type="Function">
# [DEF:archive_exports:Function]
# @PURPOSE: Управляет архивом экспортированных файлов, применяя политику хранения и дедупликацию.
# @PARAM: output_dir: str - Директория с архивами.
# @PARAM: policy: RetentionPolicy - Политика хранения.
# @PARAM: deduplicate: bool - Флаг для включения удаления дубликатов по CRC32.
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
# @RELATION: CALLS -> apply_retention_policy
# @RELATION: CALLS -> calculate_crc32
# @PARAM: output_dir (str) - Директория с архивами.
# @PARAM: policy (RetentionPolicy) - Политика хранения.
# @PARAM: deduplicate (bool) - Флаг для включения удаления дубликатов по CRC32.
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
def archive_exports(output_dir: str, policy: RetentionPolicy, deduplicate: bool = False, logger: Optional[SupersetLogger] = None) -> None:
logger = logger or SupersetLogger(name="fileio")
output_path = Path(output_dir)
@@ -142,15 +143,77 @@ def archive_exports(output_dir: str, policy: RetentionPolicy, deduplicate: bool
return
logger.info("[archive_exports][Enter] Managing archive in %s", output_dir)
# ... (логика дедупликации и политики хранения) ...
# </ANCHOR id="archive_exports">
# <ANCHOR id="apply_retention_policy" type="Function">
# 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
# 2. Deduplication
if deduplicate:
logger.info("[archive_exports][State] Starting deduplication...")
checksums = {}
files_to_remove = []
# 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) Применяет политику хранения к списку файлов, возвращая те, что нужно сохранить.
# @INTERNAL
# @PARAM: files_with_dates: List[Tuple[Path, date]] - Список файлов с датами.
# @PARAM: policy: RetentionPolicy - Политика хранения.
# @PARAM: logger: SupersetLogger - Логгер.
# @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:
# Сортируем по дате (от новой к старой)
@@ -177,15 +240,15 @@ def apply_retention_policy(files_with_dates: List[Tuple[Path, date]], policy: Re
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))
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-архива на диск и опционально распаковывает его.
# @PARAM: zip_content: bytes - Содержимое ZIP-архива.
# @PARAM: output_dir: Union[str, Path] - Директория для сохранения.
# @PARAM: unpack: bool - Флаг, нужно ли распаковывать архив.
# @PARAM: original_filename: Optional[str] - Исходное имя файла для сохранения.
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
# @PARAM: zip_content (bytes) - Содержимое ZIP-архива.
# @PARAM: output_dir (Union[str, Path]) - Директория для сохранения.
# @PARAM: unpack (bool) - Флаг, нужно ли распаковывать архив.
# @PARAM: original_filename (Optional[str]) - Исходное имя файла для сохранения.
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
# @RETURN: Tuple[Path, Optional[Path]] - Путь к 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]]:
@@ -207,17 +270,17 @@ def save_and_unpack_dashboard(zip_content: bytes, output_dir: Union[str, Path],
except zipfile.BadZipFile as e:
logger.error("[save_and_unpack_dashboard][Failure] Invalid ZIP archive: %s", 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.
# @PARAM: db_configs: Optional[List[Dict]] - Список конфигураций для замены.
# @PARAM: path: str - Путь к директории с YAML файлами.
# @PARAM: regexp_pattern: Optional[LiteralString] - Паттерн для поиска.
# @PARAM: replace_string: Optional[LiteralString] - Строка для замены.
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
# @THROW: FileNotFoundError - Если `path` не существует.
# @RELATION: CALLS -> _update_yaml_file
# @THROW: FileNotFoundError - Если `path` не существует.
# @PARAM: db_configs (Optional[List[Dict]]) - Список конфигураций для замены.
# @PARAM: path (str) - Путь к директории с YAML файлами.
# @PARAM: regexp_pattern (Optional[LiteralString]) - Паттерн для поиска.
# @PARAM: replace_string (Optional[LiteralString]) - Строка для замены.
# @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:
logger = logger or SupersetLogger(name="fileio")
logger.info("[update_yamls][Enter] Starting YAML configuration update.")
@@ -228,11 +291,15 @@ def update_yamls(db_configs: Optional[List[Dict]] = None, path: str = "dashboard
for file_path in dir_path.rglob("*.yaml"):
_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 файл.
# @INTERNAL
# @PARAM: file_path (Path) - Путь к файлу.
# @PARAM: db_configs (List[Dict]) - Конфигурации.
# @PARAM: regexp_pattern (Optional[str]) - Паттерн.
# @PARAM: replace_string (Optional[str]) - Замена.
# @PARAM: logger (SupersetLogger) - Логгер.
def _update_yaml_file(file_path: Path, db_configs: List[Dict], regexp_pattern: Optional[str], replace_string: Optional[str], logger: SupersetLogger) -> None:
# Читаем содержимое файла
try:
@@ -251,33 +318,36 @@ def _update_yaml_file(file_path: Path, db_configs: List[Dict], regexp_pattern: O
logger.info("[_update_yaml_file][State] Updated %s using regex pattern", file_path)
except Exception as e:
logger.error("[_update_yaml_file][Failure] Error applying regex to %s: %s", file_path, e)
# Если заданы конфигурации, заменяем значения
# Если заданы конфигурации, заменяем значения (поддержка old/new)
if db_configs:
try:
parsed_data = yaml.safe_load(content)
if not isinstance(parsed_data, dict):
logger.warning("[_update_yaml_file][Warning] YAML content is not a dictionary in %s", file_path)
return
# Обновляем данные
for config in db_configs:
for key, value in config.items():
if key in parsed_data:
old_value = parsed_data[key]
parsed_data[key] = value
logger.info("[_update_yaml_file][State] Changed %s.%s from %s to %s", file_path, key, old_value, value)
# Записываем обратно
# Прямой текстовый заменитель для старых/новых значений, чтобы сохранить структуру файла
modified_content = content
for cfg in db_configs:
# Ожидаем структуру: {'old': {...}, 'new': {...}}
old_cfg = cfg.get('old', {})
new_cfg = cfg.get('new', {})
for key, old_val in old_cfg.items():
if key in new_cfg:
new_val = new_cfg[key]
# Заменяем только точные совпадения старого значения в тексте YAML
if isinstance(old_val, str):
escaped_old = re.escape(old_val)
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:
yaml.dump(parsed_data, f, default_flow_style=False, allow_unicode=True)
f.write(modified_content)
except Exception as e:
logger.error("[_update_yaml_file][Failure] Error updating YAML in %s: %s", file_path, e)
# </ANCHOR id="_update_yaml_file">
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-архив из указанных исходных путей.
# @PARAM: zip_path: Union[str, Path] - Путь для сохранения ZIP архива.
# @PARAM: source_paths: List[Union[str, Path]] - Список исходных путей для архивации.
# @PARAM: exclude_extensions: Optional[List[str]] - Список расширений для исключения.
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
# @PARAM: zip_path (Union[str, Path]) - Путь для сохранения ZIP архива.
# @PARAM: source_paths (List[Union[str, Path]]) - Список исходных путей для архивации.
# @PARAM: exclude_extensions (Optional[List[str]]) - Список расширений для исключения.
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
# @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:
logger = logger or SupersetLogger(name="fileio")
@@ -297,32 +367,32 @@ def create_dashboard_export(zip_path: Union[str, Path], source_paths: List[Union
except (IOError, zipfile.BadZipFile, AssertionError) as e:
logger.error("[create_dashboard_export][Failure] Error: %s", e, exc_info=True)
return False
# </ANCHOR id="create_dashboard_export">
# [/DEF:create_dashboard_export]
# <ANCHOR id="sanitize_filename" type="Function">
# [DEF:sanitize_filename:Function]
# @PURPOSE: Очищает строку от символов, недопустимых в именах файлов.
# @PARAM: filename: str - Исходное имя файла.
# @PARAM: filename (str) - Исходное имя файла.
# @RETURN: str - Очищенная строка.
def sanitize_filename(filename: str) -> str:
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'.
# @PARAM: headers: dict - Словарь HTTP заголовков.
# @PARAM: headers (dict) - Словарь HTTP заголовков.
# @RETURN: Optional[str] - Имя файла или `None`.
def get_filename_from_headers(headers: dict) -> Optional[str]:
content_disposition = headers.get("Content-Disposition", "")
if match := re.search(r'filename="?([^"]+)"?', content_disposition):
return match.group(1).strip()
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: Консолидирует директории архивов на основе общего слага в имени.
# @PARAM: root_directory: Path - Корневая директория для консолидации.
# @PARAM: logger: Optional[SupersetLogger] - Экземпляр логгера.
# @THROW: TypeError, ValueError - Если `root_directory` невалиден.
# @PARAM: root_directory (Path) - Корневая директория для консолидации.
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
def consolidate_archive_folders(root_directory: Path, logger: Optional[SupersetLogger] = None) -> None:
logger = logger or SupersetLogger(name="fileio")
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)
except Exception as 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]
# --- Конец кода модуля ---
# </GRACE_MODULE id="superset_tool.utils.fileio">
# [/DEF:superset_tool.utils.fileio]

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

63
test_update_yamls.py Normal file
View File

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